using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Replay.Core; using StellaOps.Scanner.Evidence.Models; namespace StellaOps.Scanner.Storage.Oci; /// /// Service for publishing FuncProof documents to OCI registries as referrer artifacts. /// Follows the OCI referrer pattern to link FuncProof evidence to the original image. /// public interface IFuncProofOciPublisher { /// /// Publishes a FuncProof document to an OCI registry as a referrer artifact. /// /// The publish request containing FuncProof and target details. /// Cancellation token. /// Result containing the pushed manifest digest and reference. Task PublishAsync(FuncProofOciPublishRequest request, CancellationToken ct = default); } /// /// Request to publish a FuncProof document to OCI registry. /// public sealed record FuncProofOciPublishRequest { /// /// The FuncProof document to publish. /// public required FuncProof FuncProof { get; init; } /// /// Optional DSSE envelope containing the signed FuncProof. /// If provided, this is published instead of the raw FuncProof. /// public DsseEnvelope? DsseEnvelope { get; init; } /// /// Target OCI registry reference (e.g., "registry.example.com/repo:tag"). /// public required string RegistryReference { get; init; } /// /// Digest of the subject image this FuncProof refers to. /// Used to create a referrer relationship (OCI referrer pattern). /// public required string SubjectDigest { get; init; } /// /// Optional tag for the FuncProof artifact. If null, uses the proof ID. /// public string? Tag { get; init; } /// /// Additional annotations to include in the OCI manifest. /// public IReadOnlyDictionary? Annotations { get; init; } } /// /// Result of publishing a FuncProof document to OCI registry. /// public sealed record FuncProofOciPublishResult { public required bool Success { get; init; } public string? ManifestDigest { get; init; } public string? ManifestReference { get; init; } public string? ProofLayerDigest { get; init; } public string? Error { get; init; } public static FuncProofOciPublishResult Failed(string error) => new() { Success = false, Error = error }; } /// /// Configuration options for FuncProof OCI publishing. /// public sealed class FuncProofOciOptions { public const string SectionName = "Scanner:FuncProof:Oci"; /// /// Whether to publish FuncProof as a referrer artifact. /// public bool EnableReferrerPublish { get; set; } = true; /// /// Whether to include the DSSE envelope as a separate layer. /// public bool IncludeDsseLayer { get; set; } = true; /// /// Whether to compress the FuncProof content before publishing. /// public bool CompressContent { get; set; } = false; } /// /// Default implementation of FuncProof OCI publisher. /// public sealed class FuncProofOciPublisher : IFuncProofOciPublisher { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, WriteIndented = false }; private readonly IOciPushService _ociPushService; private readonly IOptions _options; private readonly ILogger _logger; public FuncProofOciPublisher( IOciPushService ociPushService, IOptions options, ILogger logger) { _ociPushService = ociPushService ?? throw new ArgumentNullException(nameof(ociPushService)); _options = options ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task PublishAsync( FuncProofOciPublishRequest request, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request.FuncProof); ArgumentException.ThrowIfNullOrWhiteSpace(request.RegistryReference); ArgumentException.ThrowIfNullOrWhiteSpace(request.SubjectDigest); ct.ThrowIfCancellationRequested(); if (string.IsNullOrEmpty(request.FuncProof.ProofId)) { return FuncProofOciPublishResult.Failed("FuncProof must have a valid ProofId before publishing."); } _logger.LogDebug( "Publishing FuncProof {ProofId} to OCI registry {Reference}", request.FuncProof.ProofId, request.RegistryReference); try { var layers = BuildLayers(request); var annotations = BuildAnnotations(request); var pushRequest = new OciArtifactPushRequest { Reference = request.RegistryReference, ArtifactType = FuncProofOciMediaTypes.ArtifactType, Layers = layers, SubjectDigest = request.SubjectDigest, Annotations = annotations }; var result = await _ociPushService.PushAsync(pushRequest, ct).ConfigureAwait(false); if (!result.Success) { _logger.LogWarning( "Failed to publish FuncProof {ProofId}: {Error}", request.FuncProof.ProofId, result.Error); return FuncProofOciPublishResult.Failed(result.Error ?? "Unknown OCI push failure"); } _logger.LogInformation( "Published FuncProof {ProofId} to {Reference} with digest {Digest}", request.FuncProof.ProofId, result.ManifestReference, result.ManifestDigest); return new FuncProofOciPublishResult { Success = true, ManifestDigest = result.ManifestDigest, ManifestReference = result.ManifestReference, ProofLayerDigest = result.LayerDigests?.FirstOrDefault() }; } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogError(ex, "Error publishing FuncProof {ProofId}", request.FuncProof.ProofId); return FuncProofOciPublishResult.Failed($"Publish error: {ex.Message}"); } } private List BuildLayers(FuncProofOciPublishRequest request) { var layers = new List(); var opts = _options.Value; // Primary FuncProof layer byte[] proofContent; string proofMediaType; if (request.DsseEnvelope is not null && opts.IncludeDsseLayer) { // Use DSSE envelope as primary layer proofContent = JsonSerializer.SerializeToUtf8Bytes(request.DsseEnvelope, JsonOptions); proofMediaType = FuncProofOciMediaTypes.DsseLayer; } else { // Use raw FuncProof proofContent = JsonSerializer.SerializeToUtf8Bytes(request.FuncProof, JsonOptions); proofMediaType = FuncProofOciMediaTypes.ProofLayer; } if (opts.CompressContent) { proofContent = CompressGzip(proofContent); proofMediaType += "+gzip"; } layers.Add(new OciLayerContent { Content = proofContent, MediaType = proofMediaType, Annotations = new SortedDictionary(StringComparer.Ordinal) { [OciAnnotations.Title] = $"funcproof-{request.FuncProof.ProofId}", [FuncProofOciAnnotations.ProofId] = request.FuncProof.ProofId, [FuncProofOciAnnotations.BuildId] = request.FuncProof.BuildId ?? string.Empty, [FuncProofOciAnnotations.FunctionCount] = request.FuncProof.Functions.Length.ToString() } }); // Add raw FuncProof as secondary layer if DSSE was primary if (request.DsseEnvelope is not null && opts.IncludeDsseLayer) { var rawContent = JsonSerializer.SerializeToUtf8Bytes(request.FuncProof, JsonOptions); if (opts.CompressContent) { rawContent = CompressGzip(rawContent); } layers.Add(new OciLayerContent { Content = rawContent, MediaType = opts.CompressContent ? FuncProofOciMediaTypes.ProofLayer + "+gzip" : FuncProofOciMediaTypes.ProofLayer, Annotations = new SortedDictionary(StringComparer.Ordinal) { [OciAnnotations.Title] = $"funcproof-raw-{request.FuncProof.ProofId}" } }); } return layers; } private SortedDictionary BuildAnnotations(FuncProofOciPublishRequest request) { var annotations = new SortedDictionary(StringComparer.Ordinal) { [OciAnnotations.Title] = $"FuncProof for {request.FuncProof.BuildId ?? request.FuncProof.ProofId}", [FuncProofOciAnnotations.ProofId] = request.FuncProof.ProofId, [FuncProofOciAnnotations.SchemaVersion] = FuncProofConstants.SchemaVersion }; if (!string.IsNullOrEmpty(request.FuncProof.BuildId)) { annotations[FuncProofOciAnnotations.BuildId] = request.FuncProof.BuildId; } if (!string.IsNullOrEmpty(request.FuncProof.FileSha256)) { annotations[FuncProofOciAnnotations.FileSha256] = request.FuncProof.FileSha256; } if (request.FuncProof.Meta?.BuildTime is not null) { annotations[OciAnnotations.Created] = request.FuncProof.Meta.BuildTime.Value.ToString("o"); } // Merge user-provided annotations if (request.Annotations is not null) { foreach (var (key, value) in request.Annotations) { annotations[key] = value; } } return annotations; } private static byte[] CompressGzip(byte[] data) { using var output = new System.IO.MemoryStream(); using (var gzip = new System.IO.Compression.GZipStream(output, System.IO.Compression.CompressionLevel.Optimal)) { gzip.Write(data, 0, data.Length); } return output.ToArray(); } } /// /// OCI media types for FuncProof artifacts. /// public static class FuncProofOciMediaTypes { /// /// Artifact type for FuncProof OCI artifacts. /// public const string ArtifactType = "application/vnd.stellaops.funcproof"; /// /// Media type for the FuncProof JSON layer. /// public const string ProofLayer = "application/vnd.stellaops.funcproof+json"; /// /// Media type for the DSSE envelope layer containing signed FuncProof. /// public const string DsseLayer = "application/vnd.stellaops.funcproof.dsse+json"; } /// /// Custom OCI annotations for FuncProof artifacts. /// public static class FuncProofOciAnnotations { public const string ProofId = "io.stellaops.funcproof.id"; public const string BuildId = "io.stellaops.funcproof.build-id"; public const string FileSha256 = "io.stellaops.funcproof.file-sha256"; public const string FunctionCount = "io.stellaops.funcproof.function-count"; public const string SchemaVersion = "io.stellaops.funcproof.schema-version"; }