// SPDX-License-Identifier: BUSL-1.1 // Sprint: SPRINT_5200_0001_0001 - Starter Policy Template // Task: T7 - Policy Pack Distribution using System.Collections.Immutable; using System.IO.Compression; using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging; namespace StellaOps.Policy.Registry.Distribution; /// /// Service for exporting and importing policy packs as offline bundles. /// Supports air-gapped environments where OCI registries are not available. /// public sealed class PolicyPackOfflineBundleService { private const string SchemaVersion = "1.0.0"; private const string BlobsDirectory = "blobs/sha256"; private const string ManifestFile = "index.json"; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; public PolicyPackOfflineBundleService( ILogger? logger = null, TimeProvider? timeProvider = null) { _logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; _timeProvider = timeProvider ?? TimeProvider.System; } /// /// Exports a policy pack to an offline bundle. /// public async Task ExportAsync( PolicyPackBundleExportRequest request, string outputPath, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); ArgumentException.ThrowIfNullOrWhiteSpace(outputPath); if (request.PackContent.Length == 0) { return new PolicyPackBundleExportResult { Success = false, Error = "Pack content cannot be empty" }; } try { _logger.LogInformation( "Exporting policy pack {PackName}:{PackVersion} to {OutputPath}", request.PackName, request.PackVersion, outputPath); // Create temp directory for bundle layout var tempDir = Path.Combine(Path.GetTempPath(), $"stellaops-policy-bundle-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDir); var blobsDir = Path.Combine(tempDir, BlobsDirectory); Directory.CreateDirectory(blobsDir); try { var artifacts = new List(); // Export main pack content var packDigest = ComputeDigest(request.PackContent); var packPath = Path.Combine(blobsDir, packDigest); await File.WriteAllBytesAsync(packPath, request.PackContent, cancellationToken).ConfigureAwait(false); artifacts.Add(new PolicyPackBundleArtifact { Digest = $"sha256:{packDigest}", MediaType = "application/vnd.stellaops.policy-pack.yaml.v1", Size = request.PackContent.Length, Path = $"{BlobsDirectory}/{packDigest}", Annotations = ImmutableDictionary.Empty .Add("stellaops.policy.pack.name", request.PackName) .Add("stellaops.policy.pack.version", request.PackVersion) .Add("org.opencontainers.image.title", $"{request.PackName}.yaml") }); // Export overrides if (request.Overrides?.Count > 0) { foreach (var (env, content) in request.Overrides) { var overrideDigest = ComputeDigest(content); var overridePath = Path.Combine(blobsDir, overrideDigest); await File.WriteAllBytesAsync(overridePath, content, cancellationToken).ConfigureAwait(false); artifacts.Add(new PolicyPackBundleArtifact { Digest = $"sha256:{overrideDigest}", MediaType = "application/vnd.stellaops.policy-pack.override.v1+json", Size = content.Length, Path = $"{BlobsDirectory}/{overrideDigest}", Annotations = ImmutableDictionary.Empty .Add("stellaops.policy.pack.override.env", env) .Add("org.opencontainers.image.title", $"overrides/{env}.yaml") }); } } // Export attestation if provided if (request.Attestation?.Length > 0) { var attestDigest = ComputeDigest(request.Attestation); var attestPath = Path.Combine(blobsDir, attestDigest); await File.WriteAllBytesAsync(attestPath, request.Attestation, cancellationToken).ConfigureAwait(false); artifacts.Add(new PolicyPackBundleArtifact { Digest = $"sha256:{attestDigest}", MediaType = "application/vnd.stellaops.policy-pack.attestation.v1+json", Size = request.Attestation.Length, Path = $"{BlobsDirectory}/{attestDigest}", Annotations = ImmutableDictionary.Empty .Add("org.opencontainers.image.title", "attestation.dsse.json") }); } // Create manifest var manifest = new PolicyPackBundleManifest { SchemaVersion = SchemaVersion, CreatedAt = _timeProvider.GetUtcNow(), PackName = request.PackName, PackVersion = request.PackVersion, Artifacts = artifacts.ToImmutableArray(), Metrics = new PolicyPackBundleMetrics { ArtifactCount = artifacts.Count, OverrideCount = request.Overrides?.Count ?? 0, HasAttestation = request.Attestation?.Length > 0, TotalSize = artifacts.Sum(a => a.Size) }, ManifestDigest = "" // Will be set after serialization }; var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions); var manifestDigest = ComputeDigest(Encoding.UTF8.GetBytes(manifestJson)); manifest = manifest with { ManifestDigest = $"sha256:{manifestDigest}" }; manifestJson = JsonSerializer.Serialize(manifest, JsonOptions); await File.WriteAllTextAsync( Path.Combine(tempDir, ManifestFile), manifestJson, cancellationToken).ConfigureAwait(false); // Create tar.gz using (var fs = File.Create(outputPath)) using (var gzip = new GZipStream(fs, CompressionLevel.Optimal)) { await CreateTarAsync(tempDir, gzip, cancellationToken).ConfigureAwait(false); } var bundleDigest = ComputeFileDigest(outputPath); _logger.LogInformation( "Bundle exported: {ArtifactCount} artifacts, {TotalSize:N0} bytes", manifest.Metrics.ArtifactCount, manifest.Metrics.TotalSize); return new PolicyPackBundleExportResult { Success = true, BundlePath = outputPath, BundleDigest = $"sha256:{bundleDigest}", Metrics = manifest.Metrics }; } finally { // Cleanup temp directory try { Directory.Delete(tempDir, true); } catch { /* Ignore cleanup errors */ } } } catch (Exception ex) { _logger.LogError(ex, "Failed to export policy pack bundle"); return new PolicyPackBundleExportResult { Success = false, Error = ex.Message }; } } /// /// Imports a policy pack from an offline bundle. /// public async Task ImportAsync( string bundlePath, bool verifyIntegrity = true, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(bundlePath); if (!File.Exists(bundlePath)) { return new PolicyPackBundleImportResult { Success = false, Error = $"Bundle not found: {bundlePath}" }; } try { _logger.LogInformation("Importing policy pack bundle from {BundlePath}", bundlePath); // Extract to temp directory var tempDir = Path.Combine(Path.GetTempPath(), $"stellaops-policy-import-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDir); try { // Extract tar.gz await using (var fs = File.OpenRead(bundlePath)) await using (var gzip = new GZipStream(fs, CompressionMode.Decompress)) { await ExtractTarAsync(gzip, tempDir, cancellationToken).ConfigureAwait(false); } // Read manifest var manifestPath = Path.Combine(tempDir, ManifestFile); if (!File.Exists(manifestPath)) { return new PolicyPackBundleImportResult { Success = false, Error = "Bundle manifest not found" }; } var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false); var manifest = JsonSerializer.Deserialize(manifestJson, JsonOptions); if (manifest is null) { return new PolicyPackBundleImportResult { Success = false, Error = "Failed to parse bundle manifest" }; } // Verify integrity if requested bool integrityVerified = false; if (verifyIntegrity) { integrityVerified = await VerifyBundleIntegrityAsync(tempDir, manifest, cancellationToken) .ConfigureAwait(false); if (!integrityVerified) { return new PolicyPackBundleImportResult { Success = false, Error = "Bundle integrity verification failed" }; } } // Read artifacts byte[]? packContent = null; var overrides = new Dictionary(); byte[]? attestation = null; foreach (var artifact in manifest.Artifacts) { var artifactPath = Path.Combine(tempDir, artifact.Path); if (!File.Exists(artifactPath)) { _logger.LogWarning("Artifact not found in bundle: {Path}", artifact.Path); continue; } var content = await File.ReadAllBytesAsync(artifactPath, cancellationToken).ConfigureAwait(false); if (artifact.MediaType.Contains("policy-pack.yaml")) { packContent = content; } else if (artifact.MediaType.Contains("override")) { var env = artifact.Annotations?.GetValueOrDefault("stellaops.policy.pack.override.env"); if (!string.IsNullOrEmpty(env)) { overrides[env] = content; } } else if (artifact.MediaType.Contains("attestation")) { attestation = content; } } if (packContent is null) { return new PolicyPackBundleImportResult { Success = false, Error = "No policy pack content found in bundle" }; } _logger.LogInformation( "Bundle imported: {PackName}:{PackVersion}, {OverrideCount} overrides", manifest.PackName, manifest.PackVersion, overrides.Count); return new PolicyPackBundleImportResult { Success = true, PackName = manifest.PackName, PackVersion = manifest.PackVersion, PackContent = packContent, Overrides = overrides.Count > 0 ? overrides : null, Attestation = attestation, IntegrityVerified = integrityVerified }; } finally { // Cleanup temp directory try { Directory.Delete(tempDir, true); } catch { /* Ignore cleanup errors */ } } } catch (Exception ex) { _logger.LogError(ex, "Failed to import policy pack bundle from {BundlePath}", bundlePath); return new PolicyPackBundleImportResult { Success = false, Error = ex.Message }; } } private async Task VerifyBundleIntegrityAsync( string tempDir, PolicyPackBundleManifest manifest, CancellationToken cancellationToken) { foreach (var artifact in manifest.Artifacts) { var artifactPath = Path.Combine(tempDir, artifact.Path); if (!File.Exists(artifactPath)) { _logger.LogWarning("Missing artifact: {Path}", artifact.Path); return false; } var data = await File.ReadAllBytesAsync(artifactPath, cancellationToken).ConfigureAwait(false); var actualDigest = $"sha256:{ComputeDigest(data)}"; if (!string.Equals(actualDigest, artifact.Digest, StringComparison.OrdinalIgnoreCase)) { _logger.LogWarning( "Digest mismatch for {Path}: expected {Expected}, got {Actual}", artifact.Path, artifact.Digest, actualDigest); return false; } } return true; } private static string ComputeDigest(byte[] data) { var hash = SHA256.HashData(data); return Convert.ToHexString(hash).ToLowerInvariant(); } private static string ComputeFileDigest(string path) { using var fs = File.OpenRead(path); var hash = SHA256.HashData(fs); return Convert.ToHexString(hash).ToLowerInvariant(); } private static async Task CreateTarAsync(string sourceDir, Stream output, CancellationToken cancellationToken) { // Simplified tar creation - in production, use a proper tar library var files = Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories); using var writer = new BinaryWriter(output, Encoding.UTF8, leaveOpen: true); foreach (var file in files) { var relativePath = Path.GetRelativePath(sourceDir, file).Replace('\\', '/'); var content = await File.ReadAllBytesAsync(file, cancellationToken).ConfigureAwait(false); // Write simple header var header = Encoding.UTF8.GetBytes($"FILE:{relativePath}:{content.Length}\n"); writer.Write(header); writer.Write(content); } } private static async Task ExtractTarAsync(Stream input, string targetDir, CancellationToken cancellationToken) { // Simplified tar extraction - in production, use a proper tar library using var memoryStream = new MemoryStream(); await input.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false); memoryStream.Position = 0; var textReader = new StreamReader(memoryStream, Encoding.UTF8, leaveOpen: true); while (memoryStream.Position < memoryStream.Length) { var headerLine = textReader.ReadLine(); if (string.IsNullOrEmpty(headerLine) || !headerLine.StartsWith("FILE:")) break; var parts = headerLine[5..].Split(':'); if (parts.Length != 2 || !int.TryParse(parts[1], out var size)) break; var relativePath = parts[0]; var fullPath = Path.Combine(targetDir, relativePath); Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); var content = new byte[size]; _ = memoryStream.Read(content, 0, size); await File.WriteAllBytesAsync(fullPath, content, cancellationToken).ConfigureAwait(false); } } } /// /// Request to export a policy pack to offline bundle. /// public sealed record PolicyPackBundleExportRequest { public required string PackName { get; init; } public required string PackVersion { get; init; } public required byte[] PackContent { get; init; } public IReadOnlyDictionary? Overrides { get; init; } public byte[]? Attestation { get; init; } } /// /// Result of policy pack bundle export. /// public sealed record PolicyPackBundleExportResult { public required bool Success { get; init; } public string? BundlePath { get; init; } public string? BundleDigest { get; init; } public PolicyPackBundleMetrics? Metrics { get; init; } public string? Error { get; init; } } /// /// Result of policy pack bundle import. /// public sealed record PolicyPackBundleImportResult { public required bool Success { get; init; } public string? PackName { get; init; } public string? PackVersion { get; init; } public byte[]? PackContent { get; init; } public IReadOnlyDictionary? Overrides { get; init; } public byte[]? Attestation { get; init; } public bool IntegrityVerified { get; init; } public string? Error { get; init; } } /// /// Bundle manifest for policy pack. /// public sealed record PolicyPackBundleManifest { public required string SchemaVersion { get; init; } public required DateTimeOffset CreatedAt { get; init; } public required string PackName { get; init; } public required string PackVersion { get; init; } public required ImmutableArray Artifacts { get; init; } public required PolicyPackBundleMetrics Metrics { get; init; } public required string ManifestDigest { get; init; } } /// /// Artifact entry in bundle manifest. /// public sealed record PolicyPackBundleArtifact { public required string Digest { get; init; } public required string MediaType { get; init; } public required long Size { get; init; } public required string Path { get; init; } public ImmutableDictionary? Annotations { get; init; } } /// /// Metrics about bundle contents. /// public sealed record PolicyPackBundleMetrics { public int ArtifactCount { get; init; } public int OverrideCount { get; init; } public bool HasAttestation { get; init; } public long TotalSize { get; init; } }