// ----------------------------------------------------------------------------- // SnapshotBundleWriter.cs // Sprint: SPRINT_4300_0003_0001 (Sealed Knowledge Snapshot Export/Import) // Task: SEAL-003 - Create SnapshotBundleWriter // Description: Writes sealed knowledge snapshots to tar.gz bundles. // ----------------------------------------------------------------------------- using System.Formats.Tar; using System.IO.Compression; using System.Security.Cryptography; using System.Text; using System.Text.Json; using StellaOps.AirGap.Bundle.Models; using PolicySnapshotEntry = StellaOps.AirGap.Bundle.Models.PolicySnapshotEntry; namespace StellaOps.AirGap.Bundle.Services; /// /// Writes sealed knowledge snapshots to tar.gz bundles with manifest and merkle root. /// public sealed class SnapshotBundleWriter : ISnapshotBundleWriter { private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; /// /// Creates a knowledge snapshot bundle from the specified contents. /// public async Task WriteAsync( SnapshotBundleRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); ArgumentException.ThrowIfNullOrWhiteSpace(request.OutputPath); var tempDir = Path.Combine(Path.GetTempPath(), $"snapshot-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDir); try { var entries = new List(); var manifest = new KnowledgeSnapshotManifest { BundleId = request.BundleId ?? Guid.NewGuid().ToString("N"), Name = request.Name ?? $"knowledge-{DateTime.UtcNow:yyyy-MM-dd}", Version = request.Version ?? "1.0.0", CreatedAt = DateTimeOffset.UtcNow, SchemaVersion = "1.0.0" }; // Write advisories if (request.Advisories is { Count: > 0 }) { var advisoriesDir = Path.Combine(tempDir, "advisories"); Directory.CreateDirectory(advisoriesDir); foreach (var advisory in request.Advisories) { var feedDir = Path.Combine(advisoriesDir, advisory.FeedId); Directory.CreateDirectory(feedDir); var filePath = Path.Combine(feedDir, advisory.FileName); await File.WriteAllBytesAsync(filePath, advisory.Content, cancellationToken); var relativePath = $"advisories/{advisory.FeedId}/{advisory.FileName}"; var digest = ComputeSha256(advisory.Content); entries.Add(new BundleEntry(relativePath, digest, advisory.Content.Length)); manifest.Advisories.Add(new AdvisorySnapshotEntry { FeedId = advisory.FeedId, RelativePath = relativePath, Digest = digest, SizeBytes = advisory.Content.Length, SnapshotAt = advisory.SnapshotAt ?? DateTimeOffset.UtcNow, RecordCount = advisory.RecordCount }); } } // Write VEX statements if (request.VexStatements is { Count: > 0 }) { var vexDir = Path.Combine(tempDir, "vex"); Directory.CreateDirectory(vexDir); foreach (var vex in request.VexStatements) { var sourceDir = Path.Combine(vexDir, vex.SourceId); Directory.CreateDirectory(sourceDir); var filePath = Path.Combine(sourceDir, vex.FileName); await File.WriteAllBytesAsync(filePath, vex.Content, cancellationToken); var relativePath = $"vex/{vex.SourceId}/{vex.FileName}"; var digest = ComputeSha256(vex.Content); entries.Add(new BundleEntry(relativePath, digest, vex.Content.Length)); manifest.VexStatements.Add(new VexSnapshotEntry { SourceId = vex.SourceId, RelativePath = relativePath, Digest = digest, SizeBytes = vex.Content.Length, SnapshotAt = vex.SnapshotAt ?? DateTimeOffset.UtcNow, StatementCount = vex.StatementCount }); } } // Write policies if (request.Policies is { Count: > 0 }) { var policiesDir = Path.Combine(tempDir, "policies"); Directory.CreateDirectory(policiesDir); foreach (var policy in request.Policies) { var filePath = Path.Combine(policiesDir, policy.FileName); await File.WriteAllBytesAsync(filePath, policy.Content, cancellationToken); var relativePath = $"policies/{policy.FileName}"; var digest = ComputeSha256(policy.Content); entries.Add(new BundleEntry(relativePath, digest, policy.Content.Length)); manifest.Policies.Add(new PolicySnapshotEntry { PolicyId = policy.PolicyId, Name = policy.Name, Version = policy.Version, RelativePath = relativePath, Digest = digest, SizeBytes = policy.Content.Length, Type = policy.Type }); } } // Write trust roots if (request.TrustRoots is { Count: > 0 }) { var trustDir = Path.Combine(tempDir, "trust"); Directory.CreateDirectory(trustDir); foreach (var trustRoot in request.TrustRoots) { var filePath = Path.Combine(trustDir, trustRoot.FileName); await File.WriteAllBytesAsync(filePath, trustRoot.Content, cancellationToken); var relativePath = $"trust/{trustRoot.FileName}"; var digest = ComputeSha256(trustRoot.Content); entries.Add(new BundleEntry(relativePath, digest, trustRoot.Content.Length)); manifest.TrustRoots.Add(new TrustRootSnapshotEntry { KeyId = trustRoot.KeyId, RelativePath = relativePath, Digest = digest, SizeBytes = trustRoot.Content.Length, Algorithm = trustRoot.Algorithm, ExpiresAt = trustRoot.ExpiresAt }); } } // Write time anchor if (request.TimeAnchor is not null) { var timeAnchorPath = Path.Combine(tempDir, "time-anchor.json"); var timeAnchorJson = JsonSerializer.SerializeToUtf8Bytes(request.TimeAnchor, JsonOptions); await File.WriteAllBytesAsync(timeAnchorPath, timeAnchorJson, cancellationToken); var digest = ComputeSha256(timeAnchorJson); entries.Add(new BundleEntry("time-anchor.json", digest, timeAnchorJson.Length)); manifest.TimeAnchor = new TimeAnchorEntry { AnchorTime = request.TimeAnchor.AnchorTime, Source = request.TimeAnchor.Source, Digest = digest }; } // Compute merkle root manifest.MerkleRoot = ComputeMerkleRoot(entries); manifest.TotalSizeBytes = entries.Sum(e => e.SizeBytes); manifest.EntryCount = entries.Count; // Write manifest var manifestJson = JsonSerializer.SerializeToUtf8Bytes(manifest, JsonOptions); var manifestPath = Path.Combine(tempDir, "manifest.json"); await File.WriteAllBytesAsync(manifestPath, manifestJson, cancellationToken); // Sign manifest if requested string? signingKeyId = null; string? signingAlgorithm = null; var signed = false; if (request.Sign) { var signer = new SnapshotManifestSigner(); var signResult = await signer.SignAsync(new ManifestSigningRequest { ManifestBytes = manifestJson, KeyFilePath = request.SigningKeyPath, KeyPassword = request.SigningKeyPassword }, cancellationToken); if (signResult.Success && signResult.Envelope is not null) { var signaturePath = Path.Combine(tempDir, "manifest.sig"); await File.WriteAllBytesAsync(signaturePath, signResult.Envelope, cancellationToken); signingKeyId = signResult.KeyId; signingAlgorithm = signResult.Algorithm; signed = true; } } // Create tar.gz bundle var outputPath = request.OutputPath; if (!outputPath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase)) { outputPath = $"{outputPath}.tar.gz"; } await CreateTarGzAsync(tempDir, outputPath, cancellationToken); var bundleDigest = await ComputeFileDigestAsync(outputPath, cancellationToken); return new SnapshotBundleResult { Success = true, OutputPath = outputPath, BundleId = manifest.BundleId, MerkleRoot = manifest.MerkleRoot, BundleDigest = bundleDigest, TotalSizeBytes = new FileInfo(outputPath).Length, EntryCount = entries.Count, CreatedAt = manifest.CreatedAt, Signed = signed, SigningKeyId = signingKeyId, SigningAlgorithm = signingAlgorithm }; } finally { // Clean up temp directory try { if (Directory.Exists(tempDir)) { Directory.Delete(tempDir, recursive: true); } } catch { // Ignore cleanup errors } } } private static string ComputeSha256(byte[] content) { var hash = SHA256.HashData(content); return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; } private static async Task ComputeFileDigestAsync(string filePath, CancellationToken ct) { await using var stream = File.OpenRead(filePath); var hash = await SHA256.HashDataAsync(stream, ct); return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; } private static string ComputeMerkleRoot(List entries) { if (entries.Count == 0) { return string.Empty; } var leaves = entries .OrderBy(e => e.Path, StringComparer.Ordinal) .Select(e => SHA256.HashData(Encoding.UTF8.GetBytes($"{e.Path}:{e.Digest}"))) .ToArray(); while (leaves.Length > 1) { leaves = PairwiseHash(leaves).ToArray(); } return Convert.ToHexString(leaves[0]).ToLowerInvariant(); } private static IEnumerable PairwiseHash(byte[][] nodes) { for (var i = 0; i < nodes.Length; i += 2) { if (i + 1 >= nodes.Length) { yield return SHA256.HashData(nodes[i]); continue; } var combined = new byte[nodes[i].Length + nodes[i + 1].Length]; Buffer.BlockCopy(nodes[i], 0, combined, 0, nodes[i].Length); Buffer.BlockCopy(nodes[i + 1], 0, combined, nodes[i].Length, nodes[i + 1].Length); yield return SHA256.HashData(combined); } } private static async Task CreateTarGzAsync(string sourceDir, string outputPath, CancellationToken ct) { var outputDir = Path.GetDirectoryName(outputPath); if (!string.IsNullOrEmpty(outputDir) && !Directory.Exists(outputDir)) { Directory.CreateDirectory(outputDir); } await using var fileStream = File.Create(outputPath); await using var gzipStream = new GZipStream(fileStream, CompressionLevel.Optimal); await TarFile.CreateFromDirectoryAsync(sourceDir, gzipStream, includeBaseDirectory: false, ct); } private sealed record BundleEntry(string Path, string Digest, long SizeBytes); } /// /// Interface for snapshot bundle writing. /// public interface ISnapshotBundleWriter { Task WriteAsync( SnapshotBundleRequest request, CancellationToken cancellationToken = default); } #region Request and Result Models /// /// Request for creating a knowledge snapshot bundle. /// public sealed record SnapshotBundleRequest { public required string OutputPath { get; init; } public string? BundleId { get; init; } public string? Name { get; init; } public string? Version { get; init; } public List Advisories { get; init; } = []; public List VexStatements { get; init; } = []; public List Policies { get; init; } = []; public List TrustRoots { get; init; } = []; public TimeAnchorContent? TimeAnchor { get; init; } /// /// Whether to sign the manifest. /// public bool Sign { get; init; } = true; /// /// Path to signing key file (PEM format). /// If null and Sign is true, an ephemeral key will be used. /// public string? SigningKeyPath { get; init; } /// /// Password for encrypted signing key. /// public string? SigningKeyPassword { get; init; } } public sealed record AdvisoryContent { public required string FeedId { get; init; } public required string FileName { get; init; } public required byte[] Content { get; init; } public DateTimeOffset? SnapshotAt { get; init; } public int RecordCount { get; init; } } public sealed record VexContent { public required string SourceId { get; init; } public required string FileName { get; init; } public required byte[] Content { get; init; } public DateTimeOffset? SnapshotAt { get; init; } public int StatementCount { get; init; } } public sealed record PolicyContent { public required string PolicyId { get; init; } public required string Name { get; init; } public required string Version { get; init; } public required string FileName { get; init; } public required byte[] Content { get; init; } public string Type { get; init; } = "OpaRego"; } public sealed record TrustRootContent { public required string KeyId { get; init; } public required string FileName { get; init; } public required byte[] Content { get; init; } public string Algorithm { get; init; } = "ES256"; public DateTimeOffset? ExpiresAt { get; init; } } public sealed record TimeAnchorContent { public required DateTimeOffset AnchorTime { get; init; } public required string Source { get; init; } public string? TokenDigest { get; init; } } /// /// Result of creating a knowledge snapshot bundle. /// public sealed record SnapshotBundleResult { public bool Success { get; init; } public string? OutputPath { get; init; } public string? BundleId { get; init; } public string? MerkleRoot { get; init; } public string? BundleDigest { get; init; } public long TotalSizeBytes { get; init; } public int EntryCount { get; init; } public DateTimeOffset CreatedAt { get; init; } public string? Error { get; init; } /// /// Whether the manifest was signed. /// public bool Signed { get; init; } /// /// Key ID used for signing. /// public string? SigningKeyId { get; init; } /// /// Algorithm used for signing. /// public string? SigningAlgorithm { get; init; } public static SnapshotBundleResult Failed(string error) => new() { Success = false, Error = error }; } #endregion