up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-14 15:50:38 +02:00
parent f1a39c4ce3
commit 233873f620
249 changed files with 29746 additions and 154 deletions

View File

@@ -0,0 +1,427 @@
using StellaOps.Symbols.Bundle.Models;
namespace StellaOps.Symbols.Bundle.Abstractions;
/// <summary>
/// SYMS-BUNDLE-401-014: Builds deterministic symbol bundles for air-gapped installations.
/// </summary>
public interface IBundleBuilder
{
/// <summary>
/// Creates a symbol bundle from the specified options.
/// </summary>
Task<BundleBuildResult> BuildAsync(
BundleBuildOptions options,
CancellationToken cancellationToken = default);
/// <summary>
/// Verifies a bundle's integrity and signatures.
/// </summary>
Task<BundleVerifyResult> VerifyAsync(
string bundlePath,
BundleVerifyOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Extracts a bundle to target directory.
/// </summary>
Task<BundleExtractResult> ExtractAsync(
string bundlePath,
string outputDir,
BundleExtractOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists contents of a bundle without extracting.
/// </summary>
Task<BundleManifest?> InspectAsync(
string bundlePath,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Options for building a symbol bundle.
/// </summary>
public sealed record BundleBuildOptions
{
/// <summary>
/// Bundle name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Bundle version (SemVer).
/// </summary>
public required string Version { get; init; }
/// <summary>
/// Source directory containing symbol manifests and blobs.
/// </summary>
public required string SourceDir { get; init; }
/// <summary>
/// Output directory for the bundle archive.
/// </summary>
public required string OutputDir { get; init; }
/// <summary>
/// Platform filter (e.g., "linux-x64"). Null means all platforms.
/// </summary>
public string? Platform { get; init; }
/// <summary>
/// Tenant ID filter. Null means all tenants.
/// </summary>
public string? TenantId { get; init; }
/// <summary>
/// Sign the bundle with DSSE.
/// </summary>
public bool Sign { get; init; }
/// <summary>
/// Path to signing key (PEM-encoded private key).
/// </summary>
public string? SigningKeyPath { get; init; }
/// <summary>
/// Key ID for DSSE signature.
/// </summary>
public string? KeyId { get; init; }
/// <summary>
/// Signing algorithm to use.
/// </summary>
public string SigningAlgorithm { get; init; } = "ecdsa-p256";
/// <summary>
/// Submit to Rekor transparency log.
/// </summary>
public bool SubmitRekor { get; init; }
/// <summary>
/// Rekor server URL.
/// </summary>
public string RekorUrl { get; init; } = "https://rekor.sigstore.dev";
/// <summary>
/// Include Rekor log public key for offline verification.
/// </summary>
public bool IncludeRekorPublicKey { get; init; } = true;
/// <summary>
/// Include public key in manifest for offline verification.
/// </summary>
public bool IncludePublicKey { get; init; } = true;
/// <summary>
/// Bundle format (zip or tar.gz).
/// </summary>
public BundleFormat Format { get; init; } = BundleFormat.Zip;
/// <summary>
/// Compression level (0-9).
/// </summary>
public int CompressionLevel { get; init; } = 6;
/// <summary>
/// Additional metadata to include in manifest.
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
/// <summary>
/// Maximum bundle size in bytes (0 = unlimited).
/// </summary>
public long MaxSizeBytes { get; init; }
/// <summary>
/// If true, create multiple bundles if size limit exceeded.
/// </summary>
public bool AllowSplit { get; init; }
}
/// <summary>
/// Bundle archive format.
/// </summary>
public enum BundleFormat
{
/// <summary>
/// ZIP archive format.
/// </summary>
Zip = 0,
/// <summary>
/// Gzipped TAR archive format.
/// </summary>
TarGz = 1
}
/// <summary>
/// Result of bundle build operation.
/// </summary>
public sealed record BundleBuildResult
{
/// <summary>
/// Whether the build succeeded.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Path to the created bundle archive.
/// </summary>
public string? BundlePath { get; init; }
/// <summary>
/// Path to the manifest JSON file.
/// </summary>
public string? ManifestPath { get; init; }
/// <summary>
/// The bundle manifest.
/// </summary>
public BundleManifest? Manifest { get; init; }
/// <summary>
/// Error message if build failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Warnings during build.
/// </summary>
public IReadOnlyList<string> Warnings { get; init; } = [];
/// <summary>
/// Build duration.
/// </summary>
public TimeSpan Duration { get; init; }
}
/// <summary>
/// Options for verifying a bundle.
/// </summary>
public sealed record BundleVerifyOptions
{
/// <summary>
/// Path to public key for signature verification.
/// If null, uses embedded public key.
/// </summary>
public string? PublicKeyPath { get; init; }
/// <summary>
/// Verify Rekor inclusion proof offline.
/// </summary>
public bool VerifyRekorOffline { get; init; } = true;
/// <summary>
/// Path to Rekor public key for offline verification.
/// If null, uses embedded key.
/// </summary>
public string? RekorPublicKeyPath { get; init; }
/// <summary>
/// Verify all blob hashes.
/// </summary>
public bool VerifyBlobHashes { get; init; } = true;
/// <summary>
/// Verify manifest hashes.
/// </summary>
public bool VerifyManifestHashes { get; init; } = true;
}
/// <summary>
/// Result of bundle verification.
/// </summary>
public sealed record BundleVerifyResult
{
/// <summary>
/// Overall verification status.
/// </summary>
public required bool Valid { get; init; }
/// <summary>
/// Signature verification status.
/// </summary>
public required SignatureStatus SignatureStatus { get; init; }
/// <summary>
/// Rekor verification status.
/// </summary>
public RekorVerifyStatus? RekorStatus { get; init; }
/// <summary>
/// Hash verification status.
/// </summary>
public required HashVerifyStatus HashStatus { get; init; }
/// <summary>
/// Verification errors.
/// </summary>
public IReadOnlyList<string> Errors { get; init; } = [];
/// <summary>
/// Verification warnings.
/// </summary>
public IReadOnlyList<string> Warnings { get; init; } = [];
/// <summary>
/// Verified manifest (if valid).
/// </summary>
public BundleManifest? Manifest { get; init; }
}
/// <summary>
/// Signature verification status.
/// </summary>
public enum SignatureStatus
{
/// <summary>
/// Bundle is not signed.
/// </summary>
Unsigned = 0,
/// <summary>
/// Signature is valid.
/// </summary>
Valid = 1,
/// <summary>
/// Signature verification failed.
/// </summary>
Invalid = 2,
/// <summary>
/// Could not verify (missing key, etc.).
/// </summary>
Unknown = 3
}
/// <summary>
/// Rekor verification status.
/// </summary>
public enum RekorVerifyStatus
{
/// <summary>
/// No Rekor checkpoint present.
/// </summary>
NotPresent = 0,
/// <summary>
/// Inclusion proof verified offline.
/// </summary>
VerifiedOffline = 1,
/// <summary>
/// Verified against live Rekor.
/// </summary>
VerifiedOnline = 2,
/// <summary>
/// Verification failed.
/// </summary>
Invalid = 3
}
/// <summary>
/// Hash verification status.
/// </summary>
public sealed record HashVerifyStatus
{
/// <summary>
/// Bundle hash valid.
/// </summary>
public required bool BundleHashValid { get; init; }
/// <summary>
/// Number of entries with valid hashes.
/// </summary>
public required int ValidEntries { get; init; }
/// <summary>
/// Number of entries with invalid hashes.
/// </summary>
public required int InvalidEntries { get; init; }
/// <summary>
/// Total entries checked.
/// </summary>
public required int TotalEntries { get; init; }
/// <summary>
/// Entries with hash mismatches.
/// </summary>
public IReadOnlyList<string> InvalidEntryIds { get; init; } = [];
}
/// <summary>
/// Options for extracting a bundle.
/// </summary>
public sealed record BundleExtractOptions
{
/// <summary>
/// Verify bundle before extracting.
/// </summary>
public bool VerifyFirst { get; init; } = true;
/// <summary>
/// Verification options if VerifyFirst is true.
/// </summary>
public BundleVerifyOptions? VerifyOptions { get; init; }
/// <summary>
/// Platform filter for extraction.
/// </summary>
public string? Platform { get; init; }
/// <summary>
/// Overwrite existing files.
/// </summary>
public bool Overwrite { get; init; }
/// <summary>
/// Only extract manifest files (not blobs).
/// </summary>
public bool ManifestsOnly { get; init; }
}
/// <summary>
/// Result of bundle extraction.
/// </summary>
public sealed record BundleExtractResult
{
/// <summary>
/// Whether extraction succeeded.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Verification result (if verification was performed).
/// </summary>
public BundleVerifyResult? VerifyResult { get; init; }
/// <summary>
/// Number of entries extracted.
/// </summary>
public int ExtractedCount { get; init; }
/// <summary>
/// Number of entries skipped.
/// </summary>
public int SkippedCount { get; init; }
/// <summary>
/// Total bytes extracted.
/// </summary>
public long TotalBytesExtracted { get; init; }
/// <summary>
/// Error message if failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Extraction duration.
/// </summary>
public TimeSpan Duration { get; init; }
}

View File

@@ -0,0 +1,711 @@
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;
/// <summary>
/// SYMS-BUNDLE-401-014: Default implementation of IBundleBuilder.
/// Produces deterministic symbol bundles with DSSE signing and Rekor integration.
/// </summary>
public sealed class BundleBuilder : IBundleBuilder
{
private readonly ILogger<BundleBuilder> _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<BundleBuilder> logger)
{
_logger = logger;
}
/// <inheritdoc />
public async Task<BundleBuildResult> BuildAsync(
BundleBuildOptions options,
CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
var warnings = new List<string>();
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<BundleEntry>();
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
};
}
}
/// <inheritdoc />
public async Task<BundleVerifyResult> VerifyAsync(
string bundlePath,
BundleVerifyOptions? options = null,
CancellationToken cancellationToken = default)
{
options ??= new BundleVerifyOptions();
var errors = new List<string>();
var warnings = new List<string>();
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]
};
}
}
/// <inheritdoc />
public async Task<BundleExtractResult> 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
};
}
}
/// <inheritdoc />
public async Task<BundleManifest?> 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<BundleManifest>(stream, PrettyJsonOptions, cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to inspect bundle: {Path}", bundlePath);
return null;
}
}
private static async Task<SymbolManifest?> LoadManifestAsync(string path, CancellationToken cancellationToken)
{
try
{
await using var stream = File.OpenRead(path);
return await JsonSerializer.DeserializeAsync<SymbolManifest>(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<string> 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<BundleSignature> 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<RekorCheckpoint?> SubmitToRekorAsync(
BundleManifest manifest,
BundleBuildOptions options,
CancellationToken cancellationToken)
{
// TODO: Implement actual Rekor submission
// For now, return placeholder checkpoint structure
if (!options.SubmitRekor)
return Task.FromResult<RekorCheckpoint?>(null);
return Task.FromResult<RekorCheckpoint?>(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<SignatureStatus> 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<RekorVerifyStatus> 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<HashVerifyStatus> VerifyHashesAsync(
string bundlePath,
BundleManifest manifest,
BundleVerifyOptions options,
CancellationToken cancellationToken)
{
var validEntries = 0;
var invalidEntries = new List<string>();
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
};
}
}

View File

@@ -0,0 +1,313 @@
using System.Text.Json.Serialization;
namespace StellaOps.Symbols.Bundle.Models;
/// <summary>
/// SYMS-BUNDLE-401-014: Symbol bundle manifest for air-gapped installations.
/// Contains deterministic ordering of symbol entries with DSSE signatures
/// and Rekor checkpoint references.
/// </summary>
public sealed record BundleManifest
{
/// <summary>
/// Schema version for bundle manifest format.
/// </summary>
[JsonPropertyName("schemaVersion")]
public string SchemaVersion { get; init; } = "stellaops.symbols.bundle/v1";
/// <summary>
/// Unique bundle identifier (BLAKE3 hash of canonical manifest content).
/// </summary>
[JsonPropertyName("bundleId")]
public required string BundleId { get; init; }
/// <summary>
/// Human-readable bundle name.
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// Bundle version (SemVer).
/// </summary>
[JsonPropertyName("version")]
public required string Version { get; init; }
/// <summary>
/// Bundle creation timestamp (UTC ISO-8601).
/// </summary>
[JsonPropertyName("createdAt")]
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Platform/architecture filter for included symbols (e.g., "linux-x64").
/// Null means all platforms.
/// </summary>
[JsonPropertyName("platform")]
public string? Platform { get; init; }
/// <summary>
/// Tenant ID for multi-tenant isolation. Null means system-wide bundle.
/// </summary>
[JsonPropertyName("tenantId")]
public string? TenantId { get; init; }
/// <summary>
/// Symbol entries included in this bundle (deterministically sorted).
/// </summary>
[JsonPropertyName("entries")]
public required IReadOnlyList<BundleEntry> Entries { get; init; }
/// <summary>
/// Total size of all blob data in bytes.
/// </summary>
[JsonPropertyName("totalSizeBytes")]
public long TotalSizeBytes { get; init; }
/// <summary>
/// DSSE signature information.
/// </summary>
[JsonPropertyName("signature")]
public BundleSignature? Signature { get; init; }
/// <summary>
/// Rekor transparency log checkpoint.
/// </summary>
[JsonPropertyName("rekorCheckpoint")]
public RekorCheckpoint? RekorCheckpoint { get; init; }
/// <summary>
/// Hash algorithm used for all hashes in this manifest.
/// </summary>
[JsonPropertyName("hashAlgorithm")]
public string HashAlgorithm { get; init; } = "blake3";
/// <summary>
/// Additional metadata for offline verification.
/// </summary>
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Individual entry in a symbol bundle.
/// </summary>
public sealed record BundleEntry
{
/// <summary>
/// Debug ID for symbol lookup.
/// </summary>
[JsonPropertyName("debugId")]
public required string DebugId { get; init; }
/// <summary>
/// Code ID (GNU build-id, PE checksum) if available.
/// </summary>
[JsonPropertyName("codeId")]
public string? CodeId { get; init; }
/// <summary>
/// Original binary name.
/// </summary>
[JsonPropertyName("binaryName")]
public required string BinaryName { get; init; }
/// <summary>
/// Platform/architecture (e.g., linux-x64, win-x64).
/// </summary>
[JsonPropertyName("platform")]
public string? Platform { get; init; }
/// <summary>
/// Binary format (ELF, PE, Mach-O, WASM).
/// </summary>
[JsonPropertyName("format")]
public string Format { get; init; } = "unknown";
/// <summary>
/// BLAKE3 hash of the manifest content.
/// </summary>
[JsonPropertyName("manifestHash")]
public required string ManifestHash { get; init; }
/// <summary>
/// BLAKE3 hash of the symbol blob content.
/// </summary>
[JsonPropertyName("blobHash")]
public required string BlobHash { get; init; }
/// <summary>
/// Size of the blob in bytes.
/// </summary>
[JsonPropertyName("blobSizeBytes")]
public long BlobSizeBytes { get; init; }
/// <summary>
/// Relative path within the bundle archive.
/// Format: "symbols/{debugId}/{binaryName}.symbols"
/// </summary>
[JsonPropertyName("archivePath")]
public required string ArchivePath { get; init; }
/// <summary>
/// Number of symbols in the manifest.
/// </summary>
[JsonPropertyName("symbolCount")]
public int SymbolCount { get; init; }
/// <summary>
/// DSSE envelope digest for individual manifest signing.
/// </summary>
[JsonPropertyName("dsseDigest")]
public string? DsseDigest { get; init; }
/// <summary>
/// Rekor log index if individually published.
/// </summary>
[JsonPropertyName("rekorLogIndex")]
public long? RekorLogIndex { get; init; }
}
/// <summary>
/// DSSE signature information for the bundle.
/// </summary>
public sealed record BundleSignature
{
/// <summary>
/// Whether the bundle is signed.
/// </summary>
[JsonPropertyName("signed")]
public bool Signed { get; init; }
/// <summary>
/// Signing algorithm (e.g., "ecdsa-p256", "ed25519", "rsa-pss-sha256").
/// </summary>
[JsonPropertyName("algorithm")]
public string? Algorithm { get; init; }
/// <summary>
/// Key ID used for signing.
/// </summary>
[JsonPropertyName("keyId")]
public string? KeyId { get; init; }
/// <summary>
/// DSSE envelope digest.
/// </summary>
[JsonPropertyName("dsseDigest")]
public string? DsseDigest { get; init; }
/// <summary>
/// Signing timestamp (UTC ISO-8601).
/// </summary>
[JsonPropertyName("signedAt")]
public DateTimeOffset? SignedAt { get; init; }
/// <summary>
/// Certificate chain for verification (PEM-encoded).
/// </summary>
[JsonPropertyName("certificateChain")]
public IReadOnlyList<string>? CertificateChain { get; init; }
/// <summary>
/// Public key for offline verification (PEM-encoded).
/// </summary>
[JsonPropertyName("publicKey")]
public string? PublicKey { get; init; }
}
/// <summary>
/// Rekor transparency log checkpoint for offline verification.
/// </summary>
public sealed record RekorCheckpoint
{
/// <summary>
/// Rekor server URL where this checkpoint was created.
/// </summary>
[JsonPropertyName("rekorUrl")]
public required string RekorUrl { get; init; }
/// <summary>
/// Log entry ID (UUID or log index).
/// </summary>
[JsonPropertyName("logEntryId")]
public required string LogEntryId { get; init; }
/// <summary>
/// Log index (monotonic sequence number).
/// </summary>
[JsonPropertyName("logIndex")]
public required long LogIndex { get; init; }
/// <summary>
/// Signed entry timestamp from Rekor.
/// </summary>
[JsonPropertyName("integratedTime")]
public required DateTimeOffset IntegratedTime { get; init; }
/// <summary>
/// Root hash of the Merkle tree at time of inclusion.
/// </summary>
[JsonPropertyName("rootHash")]
public required string RootHash { get; init; }
/// <summary>
/// Tree size at time of inclusion.
/// </summary>
[JsonPropertyName("treeSize")]
public required long TreeSize { get; init; }
/// <summary>
/// Inclusion proof for offline verification.
/// </summary>
[JsonPropertyName("inclusionProof")]
public InclusionProof? InclusionProof { get; init; }
/// <summary>
/// Signed checkpoint from the log.
/// </summary>
[JsonPropertyName("signedCheckpoint")]
public string? SignedCheckpoint { get; init; }
/// <summary>
/// Public key of the Rekor log for verification.
/// </summary>
[JsonPropertyName("logPublicKey")]
public string? LogPublicKey { get; init; }
}
/// <summary>
/// Merkle tree inclusion proof for offline verification.
/// </summary>
public sealed record InclusionProof
{
/// <summary>
/// Log index of the entry.
/// </summary>
[JsonPropertyName("logIndex")]
public required long LogIndex { get; init; }
/// <summary>
/// Root hash of the Merkle tree.
/// </summary>
[JsonPropertyName("rootHash")]
public required string RootHash { get; init; }
/// <summary>
/// Tree size at time of proof.
/// </summary>
[JsonPropertyName("treeSize")]
public required long TreeSize { get; init; }
/// <summary>
/// Hashes forming the Merkle proof path.
/// </summary>
[JsonPropertyName("hashes")]
public required IReadOnlyList<string> Hashes { get; init; }
/// <summary>
/// Checkpoint signature.
/// </summary>
[JsonPropertyName("checkpoint")]
public string? Checkpoint { get; init; }
}

View File

@@ -0,0 +1,19 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Symbols.Bundle.Abstractions;
namespace StellaOps.Symbols.Bundle;
/// <summary>
/// Extension methods for registering Symbol Bundle services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds symbol bundle services to the service collection.
/// </summary>
public static IServiceCollection AddSymbolBundle(this IServiceCollection services)
{
services.AddSingleton<IBundleBuilder, BundleBuilder>();
return services;
}
}

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<Description>StellaOps Symbol Bundle - Deterministic symbol bundles for air-gapped installs with DSSE manifests and Rekor checkpoints</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Symbols.Core\StellaOps.Symbols.Core.csproj" />
</ItemGroup>
</Project>