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
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:
@@ -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; }
|
||||
}
|
||||
711
src/Symbols/StellaOps.Symbols.Bundle/BundleBuilder.cs
Normal file
711
src/Symbols/StellaOps.Symbols.Bundle/BundleBuilder.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
313
src/Symbols/StellaOps.Symbols.Bundle/Models/BundleManifest.cs
Normal file
313
src/Symbols/StellaOps.Symbols.Bundle/Models/BundleManifest.cs
Normal 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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user