using System.Buffers.Binary; using System.Collections.Immutable; using System.Formats.Tar; using System.IO.Compression; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; namespace StellaOps.ExportCenter.RiskBundles; public sealed class RiskBundleBuilder { private const string ManifestVersion = "1"; private static readonly UnixFileMode DefaultFileMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead; private static readonly DateTimeOffset FixedTimestamp = new(2024, 01, 01, 0, 0, 0, TimeSpan.Zero); private static readonly JsonSerializerOptions SerializerOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; public RiskBundleBuildResult Build(RiskBundleBuildRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); if (request.BundleId == Guid.Empty) { throw new ArgumentException("Bundle identifier must be provided.", nameof(request)); } if (request.Providers is not { Count: > 0 }) { throw new ArgumentException("At least one provider input is required.", nameof(request)); } var providers = CollectProviders(request, cancellationToken); var manifest = BuildManifest(request.BundleId, providers); var manifestJson = JsonSerializer.Serialize(manifest, SerializerOptions); var rootHash = ComputeSha256(manifestJson); var bundleStream = CreateBundleStream( providers, request.ManifestFileName, manifestJson, request.BundleFileName ?? "risk-bundle.tar.gz"); bundleStream.Position = 0; return new RiskBundleBuildResult(manifest, manifestJson, rootHash, bundleStream); } private static List CollectProviders(RiskBundleBuildRequest request, CancellationToken cancellationToken) { var entries = new List(request.Providers.Count); foreach (var provider in request.Providers.OrderBy(p => p.ProviderId, StringComparer.Ordinal)) { cancellationToken.ThrowIfCancellationRequested(); if (provider is null) { throw new ArgumentException("Provider list cannot contain null entries.", nameof(request)); } if (string.IsNullOrWhiteSpace(provider.ProviderId)) { throw new ArgumentException("ProviderId cannot be empty.", nameof(provider.ProviderId)); } if (string.IsNullOrWhiteSpace(provider.SourcePath)) { throw new ArgumentException("SourcePath cannot be empty.", nameof(provider.SourcePath)); } var fullPath = Path.GetFullPath(provider.SourcePath); if (!File.Exists(fullPath)) { if (provider.Optional && request.AllowMissingOptional) { continue; } throw new FileNotFoundException($"Provider source file '{fullPath}' not found.", fullPath); } var sha256 = ComputeSha256FromFile(fullPath); var size = new FileInfo(fullPath).Length; var bundlePath = $"providers/{provider.ProviderId}/snapshot"; entries.Add(new RiskBundleProviderEntry( provider.ProviderId, provider.Source, provider.SnapshotDate, sha256, size, provider.Optional, bundlePath, fullPath, SignaturePath: null)); } if (entries.Count == 0) { throw new InvalidOperationException("No provider artefacts collected. Provide at least one valid provider input."); } return entries; } private static RiskBundleManifest BuildManifest(Guid bundleId, List providers) { var inputsHash = ComputeInputsHash(providers); return new RiskBundleManifest( ManifestVersion, bundleId, FixedTimestamp, providers.ToImmutableArray(), inputsHash); } private static string ComputeInputsHash(IEnumerable providers) { using var sha = SHA256.Create(); var builder = new StringBuilder(); foreach (var entry in providers.OrderBy(e => e.ProviderId, StringComparer.Ordinal)) { builder.Append(entry.ProviderId) .Append('\0') .Append(entry.Sha256) .Append('\0') .Append(entry.Source) .Append('\0') .Append(entry.Optional ? '1' : '0') .Append('\0') .Append(entry.SnapshotDate?.ToString("yyyy-MM-dd") ?? string.Empty) .Append('\0'); } var bytes = Encoding.UTF8.GetBytes(builder.ToString()); var hash = sha.ComputeHash(bytes); return ToHex(hash); } private static string ComputeSha256(string content) { var bytes = Encoding.UTF8.GetBytes(content); using var sha = SHA256.Create(); var hash = sha.ComputeHash(bytes); return ToHex(hash); } private static string ComputeSha256FromFile(string filePath) { using var stream = File.OpenRead(filePath); using var sha = SHA256.Create(); var hash = sha.ComputeHash(stream); return ToHex(hash); } private static string ToHex(ReadOnlySpan bytes) { Span hex = stackalloc byte[bytes.Length * 2]; for (var i = 0; i < bytes.Length; i++) { var b = bytes[i]; hex[i * 2] = GetHexValue(b / 16); hex[i * 2 + 1] = GetHexValue(b % 16); } return Encoding.ASCII.GetString(hex); } private static byte GetHexValue(int i) => (byte)(i < 10 ? i + 48 : i - 10 + 97); private static MemoryStream CreateBundleStream( IReadOnlyList providers, string manifestFileName, string manifestJson, string bundleFileName) { var stream = new MemoryStream(); using (var gzip = new GZipStream(stream, CompressionLevel.SmallestSize, leaveOpen: true)) using (var tar = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: true)) { WriteTextEntry(tar, $"manifests/{manifestFileName}", manifestJson, DefaultFileMode); foreach (var provider in providers) { WriteProviderFile(tar, provider); } } ApplyDeterministicGzipHeader(stream); stream.Position = 0; return stream; } private static void WriteProviderFile(TarWriter writer, RiskBundleProviderEntry entry) { var filePath = entry.SourceFilePath ?? throw new InvalidOperationException("Source file path missing for provider entry."); using var dataStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 128 * 1024, FileOptions.SequentialScan); var tarEntry = new PaxTarEntry(TarEntryType.RegularFile, entry.BundlePath) { Mode = DefaultFileMode, ModificationTime = FixedTimestamp, DataStream = dataStream }; writer.WriteEntry(tarEntry); } private static void WriteTextEntry(TarWriter writer, string path, string content, UnixFileMode mode) { var bytes = Encoding.UTF8.GetBytes(content); using var dataStream = new MemoryStream(bytes); var entry = new PaxTarEntry(TarEntryType.RegularFile, path) { Mode = mode, ModificationTime = FixedTimestamp, DataStream = dataStream }; writer.WriteEntry(entry); } private static void ApplyDeterministicGzipHeader(MemoryStream stream) { if (stream.Length < 10) { throw new InvalidOperationException("GZip header not fully written for risk bundle."); } var seconds = checked((int)(FixedTimestamp - DateTimeOffset.UnixEpoch).TotalSeconds); Span buffer = stackalloc byte[4]; BinaryPrimitives.WriteInt32LittleEndian(buffer, seconds); var originalPosition = stream.Position; stream.Position = 4; stream.Write(buffer); stream.Position = originalPosition; } }