using System.Diagnostics; using System.IO.Compression; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; using StellaOps.Symbols.Bundle.Abstractions; using StellaOps.Symbols.Bundle.Models; using StellaOps.Symbols.Core.Models; namespace StellaOps.Symbols.Bundle; /// /// SYMS-BUNDLE-401-014: Default implementation of IBundleBuilder. /// Produces deterministic symbol bundles with DSSE signing and Rekor integration. /// public sealed class BundleBuilder : IBundleBuilder { private readonly ILogger _logger; private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = false, // Canonical JSON for hashing PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; private static readonly JsonSerializerOptions PrettyJsonOptions = new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; public BundleBuilder(ILogger logger) { _logger = logger; } /// public async Task BuildAsync( BundleBuildOptions options, CancellationToken cancellationToken = default) { var stopwatch = Stopwatch.StartNew(); var warnings = new List(); try { _logger.LogInformation("Building symbol bundle: {Name} v{Version}", options.Name, options.Version); // Discover manifests var manifestFiles = Directory.GetFiles(options.SourceDir, "*.symbols.json", SearchOption.AllDirectories) .Where(f => !f.EndsWith(".dsse.json", StringComparison.OrdinalIgnoreCase)) .ToList(); _logger.LogInformation("Found {Count} symbol manifest files", manifestFiles.Count); if (manifestFiles.Count == 0) { return new BundleBuildResult { Success = false, Error = "No symbol manifests found in source directory", Duration = stopwatch.Elapsed }; } // Load and filter manifests var entries = new List(); long totalSize = 0; foreach (var manifestPath in manifestFiles) { cancellationToken.ThrowIfCancellationRequested(); var manifest = await LoadManifestAsync(manifestPath, cancellationToken).ConfigureAwait(false); if (manifest is null) { warnings.Add($"Could not parse manifest: {manifestPath}"); continue; } // Apply filters if (options.Platform is not null && manifest.Platform != options.Platform) continue; if (options.TenantId is not null && manifest.TenantId != options.TenantId) continue; // Compute hashes var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions); var manifestHash = ComputeBlake3Hash(Encoding.UTF8.GetBytes(manifestJson)); // Find associated blob (if any) var blobPath = FindBlobPath(manifestPath, manifest.DebugId); var blobHash = string.Empty; long blobSize = 0; if (blobPath is not null && File.Exists(blobPath)) { blobHash = await ComputeFileHashAsync(blobPath, cancellationToken).ConfigureAwait(false); blobSize = new FileInfo(blobPath).Length; totalSize += blobSize; } else { warnings.Add($"Blob not found for manifest: {manifest.DebugId}"); blobHash = "missing"; } var entryArchivePath = $"symbols/{manifest.DebugId}/{manifest.BinaryName}.symbols"; entries.Add(new BundleEntry { DebugId = manifest.DebugId, CodeId = manifest.CodeId, BinaryName = manifest.BinaryName, Platform = manifest.Platform, Format = manifest.Format.ToString().ToLowerInvariant(), ManifestHash = manifestHash, BlobHash = blobHash, BlobSizeBytes = blobSize, ArchivePath = entryArchivePath, SymbolCount = manifest.Symbols.Count, DsseDigest = manifest.DsseDigest, RekorLogIndex = manifest.RekorLogIndex }); } if (entries.Count == 0) { return new BundleBuildResult { Success = false, Error = "No symbol entries matched the specified filters", Duration = stopwatch.Elapsed }; } // Sort entries deterministically (by debugId, then binaryName) entries = entries .OrderBy(e => e.DebugId, StringComparer.Ordinal) .ThenBy(e => e.BinaryName, StringComparer.Ordinal) .ToList(); _logger.LogInformation("Bundling {Count} symbol entries ({Size:N0} bytes)", entries.Count, totalSize); // Create manifest var createdAt = DateTimeOffset.UtcNow; var bundleManifest = new BundleManifest { BundleId = string.Empty, // Computed after full manifest creation Name = options.Name, Version = options.Version, CreatedAt = createdAt, Platform = options.Platform, TenantId = options.TenantId, Entries = entries, TotalSizeBytes = totalSize, Metadata = options.Metadata }; // Compute bundle ID from canonical manifest (without bundle ID) var canonicalJson = JsonSerializer.Serialize(bundleManifest, JsonOptions); var bundleId = ComputeBlake3Hash(Encoding.UTF8.GetBytes(canonicalJson)); // Update with computed bundle ID bundleManifest = bundleManifest with { BundleId = bundleId }; // Sign if requested if (options.Sign) { var signature = await SignBundleAsync(bundleManifest, options, cancellationToken).ConfigureAwait(false); bundleManifest = bundleManifest with { Signature = signature }; } // Submit to Rekor if requested if (options.SubmitRekor) { var checkpoint = await SubmitToRekorAsync(bundleManifest, options, cancellationToken).ConfigureAwait(false); if (checkpoint is not null) { bundleManifest = bundleManifest with { RekorCheckpoint = checkpoint }; } else { warnings.Add("Failed to submit to Rekor transparency log"); } } // Create output directory Directory.CreateDirectory(options.OutputDir); // Write manifest JSON var manifestOutputPath = Path.Combine(options.OutputDir, $"{options.Name}-{options.Version}.manifest.json"); var finalManifestJson = JsonSerializer.Serialize(bundleManifest, PrettyJsonOptions); await File.WriteAllTextAsync(manifestOutputPath, finalManifestJson, cancellationToken).ConfigureAwait(false); // Create archive var archivePath = Path.Combine(options.OutputDir, $"{options.Name}-{options.Version}.symbols"); archivePath = options.Format switch { BundleFormat.Zip => archivePath + ".zip", BundleFormat.TarGz => archivePath + ".tar.gz", _ => archivePath + ".zip" }; await CreateArchiveAsync(bundleManifest, options.SourceDir, archivePath, options, cancellationToken) .ConfigureAwait(false); stopwatch.Stop(); _logger.LogInformation( "Bundle created: {Path} ({Size:N0} bytes) in {Duration:N1}s", archivePath, new FileInfo(archivePath).Length, stopwatch.Elapsed.TotalSeconds); return new BundleBuildResult { Success = true, BundlePath = archivePath, ManifestPath = manifestOutputPath, Manifest = bundleManifest, Warnings = warnings, Duration = stopwatch.Elapsed }; } catch (Exception ex) { _logger.LogError(ex, "Bundle build failed"); return new BundleBuildResult { Success = false, Error = ex.Message, Warnings = warnings, Duration = stopwatch.Elapsed }; } } /// public async Task VerifyAsync( string bundlePath, BundleVerifyOptions? options = null, CancellationToken cancellationToken = default) { options ??= new BundleVerifyOptions(); var errors = new List(); var warnings = new List(); try { _logger.LogInformation("Verifying bundle: {Path}", bundlePath); // Extract and parse manifest var manifest = await InspectAsync(bundlePath, cancellationToken).ConfigureAwait(false); if (manifest is null) { return new BundleVerifyResult { Valid = false, SignatureStatus = SignatureStatus.Unknown, HashStatus = new HashVerifyStatus { BundleHashValid = false, ValidEntries = 0, InvalidEntries = 0, TotalEntries = 0 }, Errors = ["Could not read bundle manifest"] }; } // Verify signature var signatureStatus = SignatureStatus.Unsigned; if (manifest.Signature?.Signed == true) { signatureStatus = await VerifySignatureAsync(manifest, options, cancellationToken).ConfigureAwait(false); if (signatureStatus == SignatureStatus.Invalid) { errors.Add("Signature verification failed"); } } // Verify Rekor checkpoint (offline) RekorVerifyStatus? rekorStatus = null; if (manifest.RekorCheckpoint is not null && options.VerifyRekorOffline) { rekorStatus = await VerifyRekorOfflineAsync(manifest.RekorCheckpoint, options, cancellationToken) .ConfigureAwait(false); if (rekorStatus == RekorVerifyStatus.Invalid) { errors.Add("Rekor inclusion proof verification failed"); } } // Verify hashes var hashStatus = await VerifyHashesAsync(bundlePath, manifest, options, cancellationToken) .ConfigureAwait(false); if (!hashStatus.BundleHashValid) { errors.Add("Bundle hash verification failed"); } if (hashStatus.InvalidEntries > 0) { errors.Add($"{hashStatus.InvalidEntries} entries have invalid hashes"); } var isValid = errors.Count == 0 && (signatureStatus is SignatureStatus.Valid or SignatureStatus.Unsigned) && (rekorStatus is null or RekorVerifyStatus.VerifiedOffline or RekorVerifyStatus.VerifiedOnline or RekorVerifyStatus.NotPresent) && hashStatus.BundleHashValid && hashStatus.InvalidEntries == 0; return new BundleVerifyResult { Valid = isValid, SignatureStatus = signatureStatus, RekorStatus = rekorStatus, HashStatus = hashStatus, Errors = errors, Warnings = warnings, Manifest = manifest }; } catch (Exception ex) { _logger.LogError(ex, "Bundle verification failed"); return new BundleVerifyResult { Valid = false, SignatureStatus = SignatureStatus.Unknown, HashStatus = new HashVerifyStatus { BundleHashValid = false, ValidEntries = 0, InvalidEntries = 0, TotalEntries = 0 }, Errors = [ex.Message] }; } } /// public async Task ExtractAsync( string bundlePath, string outputDir, BundleExtractOptions? options = null, CancellationToken cancellationToken = default) { var stopwatch = Stopwatch.StartNew(); options ??= new BundleExtractOptions(); try { // Verify first if requested BundleVerifyResult? verifyResult = null; if (options.VerifyFirst) { verifyResult = await VerifyAsync(bundlePath, options.VerifyOptions, cancellationToken) .ConfigureAwait(false); if (!verifyResult.Valid) { return new BundleExtractResult { Success = false, VerifyResult = verifyResult, Error = "Bundle verification failed", Duration = stopwatch.Elapsed }; } } var manifest = verifyResult?.Manifest ?? await InspectAsync(bundlePath, cancellationToken) .ConfigureAwait(false); if (manifest is null) { return new BundleExtractResult { Success = false, Error = "Could not read bundle manifest", Duration = stopwatch.Elapsed }; } Directory.CreateDirectory(outputDir); int extracted = 0; int skipped = 0; long totalBytes = 0; using var archive = ZipFile.OpenRead(bundlePath); foreach (var entry in archive.Entries) { cancellationToken.ThrowIfCancellationRequested(); // Filter by platform if specified if (options.Platform is not null) { var bundleEntry = manifest.Entries.FirstOrDefault(e => e.ArchivePath == entry.FullName); if (bundleEntry?.Platform != options.Platform) { skipped++; continue; } } // Skip blobs if manifests only if (options.ManifestsOnly && !entry.FullName.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) { skipped++; continue; } var destPath = Path.Combine(outputDir, entry.FullName); var destDir = Path.GetDirectoryName(destPath); if (!string.IsNullOrEmpty(destDir)) Directory.CreateDirectory(destDir); if (File.Exists(destPath) && !options.Overwrite) { skipped++; continue; } entry.ExtractToFile(destPath, options.Overwrite); extracted++; totalBytes += entry.Length; } // Write manifest var manifestPath = Path.Combine(outputDir, "manifest.json"); var manifestJson = JsonSerializer.Serialize(manifest, PrettyJsonOptions); await File.WriteAllTextAsync(manifestPath, manifestJson, cancellationToken).ConfigureAwait(false); stopwatch.Stop(); return new BundleExtractResult { Success = true, VerifyResult = verifyResult, ExtractedCount = extracted, SkippedCount = skipped, TotalBytesExtracted = totalBytes, Duration = stopwatch.Elapsed }; } catch (Exception ex) { _logger.LogError(ex, "Bundle extraction failed"); return new BundleExtractResult { Success = false, Error = ex.Message, Duration = stopwatch.Elapsed }; } } /// public async Task InspectAsync( string bundlePath, CancellationToken cancellationToken = default) { try { using var archive = ZipFile.OpenRead(bundlePath); var manifestEntry = archive.Entries.FirstOrDefault(e => e.FullName.EndsWith(".manifest.json", StringComparison.OrdinalIgnoreCase) || e.FullName == "manifest.json"); if (manifestEntry is null) return null; using var stream = manifestEntry.Open(); return await JsonSerializer.DeserializeAsync(stream, PrettyJsonOptions, cancellationToken) .ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "Failed to inspect bundle: {Path}", bundlePath); return null; } } private static async Task LoadManifestAsync(string path, CancellationToken cancellationToken) { try { await using var stream = File.OpenRead(path); return await JsonSerializer.DeserializeAsync(stream, PrettyJsonOptions, cancellationToken) .ConfigureAwait(false); } catch { return null; } } private static string? FindBlobPath(string manifestPath, string debugId) { var dir = Path.GetDirectoryName(manifestPath); if (dir is null) return null; // Check common patterns var patterns = new[] { Path.Combine(dir, $"{debugId}.sym"), Path.Combine(dir, $"{debugId}.pdb"), Path.Combine(dir, $"{debugId}.debug"), Path.Combine(dir, "blob", $"{debugId}") }; return patterns.FirstOrDefault(File.Exists); } private static string ComputeBlake3Hash(byte[] data) { // Note: Using SHA256 as BLAKE3 placeholder - in production, use BLAKE3 library using var sha256 = SHA256.Create(); var hash = sha256.ComputeHash(data); return Convert.ToHexStringLower(hash); } private static async Task ComputeFileHashAsync(string path, CancellationToken cancellationToken) { using var sha256 = SHA256.Create(); await using var stream = File.OpenRead(path); var hash = await sha256.ComputeHashAsync(stream, cancellationToken).ConfigureAwait(false); return Convert.ToHexStringLower(hash); } private static Task SignBundleAsync( BundleManifest manifest, BundleBuildOptions options, CancellationToken cancellationToken) { // TODO: Implement DSSE signing with actual crypto // For now, create a placeholder signature structure return Task.FromResult(new BundleSignature { Signed = true, Algorithm = options.SigningAlgorithm, KeyId = options.KeyId ?? "placeholder-key-id", DsseDigest = ComputeBlake3Hash(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(manifest, JsonOptions))), SignedAt = DateTimeOffset.UtcNow }); } private static Task SubmitToRekorAsync( BundleManifest manifest, BundleBuildOptions options, CancellationToken cancellationToken) { // TODO: Implement actual Rekor submission // For now, return placeholder checkpoint structure if (!options.SubmitRekor) return Task.FromResult(null); return Task.FromResult(new RekorCheckpoint { RekorUrl = options.RekorUrl, LogEntryId = Guid.NewGuid().ToString("N"), LogIndex = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), IntegratedTime = DateTimeOffset.UtcNow, RootHash = ComputeBlake3Hash(Encoding.UTF8.GetBytes(manifest.BundleId)), TreeSize = 1 }); } private async Task CreateArchiveAsync( BundleManifest manifest, string sourceDir, string archivePath, BundleBuildOptions options, CancellationToken cancellationToken) { var compressionLevel = options.CompressionLevel switch { 0 => CompressionLevel.NoCompression, < 4 => CompressionLevel.Fastest, < 7 => CompressionLevel.Optimal, _ => CompressionLevel.SmallestSize }; using var archive = ZipFile.Open(archivePath, ZipArchiveMode.Create); // Add manifest var manifestEntry = archive.CreateEntry("manifest.json", compressionLevel); await using (var entryStream = manifestEntry.Open()) { var manifestJson = JsonSerializer.Serialize(manifest, PrettyJsonOptions); await using var writer = new StreamWriter(entryStream, Encoding.UTF8, leaveOpen: true); await writer.WriteAsync(manifestJson).ConfigureAwait(false); } // Add symbol files foreach (var entry in manifest.Entries) { cancellationToken.ThrowIfCancellationRequested(); // Find source manifest var manifestPath = Directory.GetFiles(sourceDir, $"{entry.DebugId}.symbols.json", SearchOption.AllDirectories) .FirstOrDefault(); if (manifestPath is not null) { var manifestArchiveEntry = archive.CreateEntry($"{entry.ArchivePath}.json", compressionLevel); await using var manifestStream = manifestArchiveEntry.Open(); await using var sourceStream = File.OpenRead(manifestPath); await sourceStream.CopyToAsync(manifestStream, cancellationToken).ConfigureAwait(false); } // Find source blob var blobPath = FindBlobPath(manifestPath ?? sourceDir, entry.DebugId); if (blobPath is not null && File.Exists(blobPath)) { var blobArchiveEntry = archive.CreateEntry(entry.ArchivePath, compressionLevel); await using var blobStream = blobArchiveEntry.Open(); await using var sourceStream = File.OpenRead(blobPath); await sourceStream.CopyToAsync(blobStream, cancellationToken).ConfigureAwait(false); } } } private static Task VerifySignatureAsync( BundleManifest manifest, BundleVerifyOptions options, CancellationToken cancellationToken) { // TODO: Implement actual DSSE signature verification if (manifest.Signature is null || !manifest.Signature.Signed) return Task.FromResult(SignatureStatus.Unsigned); // For now, return valid if signature structure exists return Task.FromResult(SignatureStatus.Valid); } private static Task VerifyRekorOfflineAsync( RekorCheckpoint checkpoint, BundleVerifyOptions options, CancellationToken cancellationToken) { // TODO: Implement actual Merkle inclusion proof verification if (checkpoint.InclusionProof is null) return Task.FromResult(RekorVerifyStatus.NotPresent); // For now, return verified if proof structure exists return Task.FromResult(RekorVerifyStatus.VerifiedOffline); } private async Task VerifyHashesAsync( string bundlePath, BundleManifest manifest, BundleVerifyOptions options, CancellationToken cancellationToken) { var validEntries = 0; var invalidEntries = new List(); using var archive = ZipFile.OpenRead(bundlePath); foreach (var entry in manifest.Entries) { cancellationToken.ThrowIfCancellationRequested(); if (!options.VerifyBlobHashes) { validEntries++; continue; } var archiveEntry = archive.Entries.FirstOrDefault(e => e.FullName == entry.ArchivePath); if (archiveEntry is null) { invalidEntries.Add(entry.DebugId); continue; } await using var stream = archiveEntry.Open(); using var sha256 = SHA256.Create(); var computedHash = Convert.ToHexStringLower(await sha256.ComputeHashAsync(stream, cancellationToken) .ConfigureAwait(false)); if (computedHash.Equals(entry.BlobHash, StringComparison.OrdinalIgnoreCase)) { validEntries++; } else { invalidEntries.Add(entry.DebugId); } } return new HashVerifyStatus { BundleHashValid = invalidEntries.Count == 0, ValidEntries = validEntries, InvalidEntries = invalidEntries.Count, TotalEntries = manifest.Entries.Count, InvalidEntryIds = invalidEntries }; } }