using System.Collections.Immutable; using System.Security.Cryptography; using System.Text; using StellaOps.AirGap.Bundle.Models; using StellaOps.AirGap.Bundle.Serialization; namespace StellaOps.AirGap.Bundle.Services; public sealed class BundleBuilder : IBundleBuilder { public async Task BuildAsync( BundleBuildRequest request, string outputPath, CancellationToken ct = default) { Directory.CreateDirectory(outputPath); var feeds = new List(); var policies = new List(); var cryptoMaterials = new List(); foreach (var feedConfig in request.Feeds) { var component = await CopyComponentAsync(feedConfig, outputPath, ct).ConfigureAwait(false); feeds.Add(new FeedComponent( feedConfig.FeedId, feedConfig.Name, feedConfig.Version, component.RelativePath, component.Digest, component.SizeBytes, feedConfig.SnapshotAt, feedConfig.Format)); } foreach (var policyConfig in request.Policies) { var component = await CopyComponentAsync(policyConfig, outputPath, ct).ConfigureAwait(false); policies.Add(new PolicyComponent( policyConfig.PolicyId, policyConfig.Name, policyConfig.Version, component.RelativePath, component.Digest, component.SizeBytes, policyConfig.Type)); } foreach (var cryptoConfig in request.CryptoMaterials) { var component = await CopyComponentAsync(cryptoConfig, outputPath, ct).ConfigureAwait(false); cryptoMaterials.Add(new CryptoComponent( cryptoConfig.ComponentId, cryptoConfig.Name, component.RelativePath, component.Digest, component.SizeBytes, cryptoConfig.Type, cryptoConfig.ExpiresAt)); } var totalSize = feeds.Sum(f => f.SizeBytes) + policies.Sum(p => p.SizeBytes) + cryptoMaterials.Sum(c => c.SizeBytes); var manifest = new BundleManifest { BundleId = Guid.NewGuid().ToString(), SchemaVersion = "1.0.0", Name = request.Name, Version = request.Version, CreatedAt = DateTimeOffset.UtcNow, ExpiresAt = request.ExpiresAt, Feeds = feeds.ToImmutableArray(), Policies = policies.ToImmutableArray(), CryptoMaterials = cryptoMaterials.ToImmutableArray(), TotalSizeBytes = totalSize }; return BundleManifestSerializer.WithDigest(manifest); } private static async Task CopyComponentAsync( BundleComponentSource source, string outputPath, CancellationToken ct) { var targetPath = Path.Combine(outputPath, source.RelativePath); Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? outputPath); await using var input = File.OpenRead(source.SourcePath); await using var output = File.Create(targetPath); await input.CopyToAsync(output, ct).ConfigureAwait(false); await using var digestStream = File.OpenRead(targetPath); var hash = await SHA256.HashDataAsync(digestStream, ct).ConfigureAwait(false); var digest = Convert.ToHexString(hash).ToLowerInvariant(); var info = new FileInfo(targetPath); return new CopiedComponent(source.RelativePath, digest, info.Length); } private sealed record CopiedComponent(string RelativePath, string Digest, long SizeBytes); } public interface IBundleBuilder { Task BuildAsync(BundleBuildRequest request, string outputPath, CancellationToken ct = default); } public sealed record BundleBuildRequest( string Name, string Version, DateTimeOffset? ExpiresAt, IReadOnlyList Feeds, IReadOnlyList Policies, IReadOnlyList CryptoMaterials); public abstract record BundleComponentSource(string SourcePath, string RelativePath); public sealed record FeedBuildConfig( string FeedId, string Name, string Version, string SourcePath, string RelativePath, DateTimeOffset SnapshotAt, FeedFormat Format) : BundleComponentSource(SourcePath, RelativePath); public sealed record PolicyBuildConfig( string PolicyId, string Name, string Version, string SourcePath, string RelativePath, PolicyType Type) : BundleComponentSource(SourcePath, RelativePath); public sealed record CryptoBuildConfig( string ComponentId, string Name, string SourcePath, string RelativePath, CryptoComponentType Type, DateTimeOffset? ExpiresAt) : BundleComponentSource(SourcePath, RelativePath);