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
712 lines
26 KiB
C#
712 lines
26 KiB
C#
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
|
|
};
|
|
}
|
|
}
|