Files
git.stella-ops.org/src/Scanner/__Libraries/StellaOps.Scanner.Storage.Oci/FuncProofOciPublisher.cs

340 lines
12 KiB
C#

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;
/// <summary>
/// Service for publishing FuncProof documents to OCI registries as referrer artifacts.
/// Follows the OCI referrer pattern to link FuncProof evidence to the original image.
/// </summary>
public interface IFuncProofOciPublisher
{
/// <summary>
/// Publishes a FuncProof document to an OCI registry as a referrer artifact.
/// </summary>
/// <param name="request">The publish request containing FuncProof and target details.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Result containing the pushed manifest digest and reference.</returns>
Task<FuncProofOciPublishResult> PublishAsync(FuncProofOciPublishRequest request, CancellationToken ct = default);
}
/// <summary>
/// Request to publish a FuncProof document to OCI registry.
/// </summary>
public sealed record FuncProofOciPublishRequest
{
/// <summary>
/// The FuncProof document to publish.
/// </summary>
public required FuncProof FuncProof { get; init; }
/// <summary>
/// Optional DSSE envelope containing the signed FuncProof.
/// If provided, this is published instead of the raw FuncProof.
/// </summary>
public DsseEnvelope? DsseEnvelope { get; init; }
/// <summary>
/// Target OCI registry reference (e.g., "registry.example.com/repo:tag").
/// </summary>
public required string RegistryReference { get; init; }
/// <summary>
/// Digest of the subject image this FuncProof refers to.
/// Used to create a referrer relationship (OCI referrer pattern).
/// </summary>
public required string SubjectDigest { get; init; }
/// <summary>
/// Optional tag for the FuncProof artifact. If null, uses the proof ID.
/// </summary>
public string? Tag { get; init; }
/// <summary>
/// Additional annotations to include in the OCI manifest.
/// </summary>
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
}
/// <summary>
/// Result of publishing a FuncProof document to OCI registry.
/// </summary>
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
};
}
/// <summary>
/// Configuration options for FuncProof OCI publishing.
/// </summary>
public sealed class FuncProofOciOptions
{
public const string SectionName = "Scanner:FuncProof:Oci";
/// <summary>
/// Whether to publish FuncProof as a referrer artifact.
/// </summary>
public bool EnableReferrerPublish { get; set; } = true;
/// <summary>
/// Whether to include the DSSE envelope as a separate layer.
/// </summary>
public bool IncludeDsseLayer { get; set; } = true;
/// <summary>
/// Whether to compress the FuncProof content before publishing.
/// </summary>
public bool CompressContent { get; set; } = false;
}
/// <summary>
/// Default implementation of FuncProof OCI publisher.
/// </summary>
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<FuncProofOciOptions> _options;
private readonly ILogger<FuncProofOciPublisher> _logger;
public FuncProofOciPublisher(
IOciPushService ociPushService,
IOptions<FuncProofOciOptions> options,
ILogger<FuncProofOciPublisher> 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<FuncProofOciPublishResult> 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<OciLayerContent> BuildLayers(FuncProofOciPublishRequest request)
{
var layers = new List<OciLayerContent>();
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<string, string>(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<string, string>(StringComparer.Ordinal)
{
[OciAnnotations.Title] = $"funcproof-raw-{request.FuncProof.ProofId}"
}
});
}
return layers;
}
private SortedDictionary<string, string> BuildAnnotations(FuncProofOciPublishRequest request)
{
var annotations = new SortedDictionary<string, string>(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();
}
}
/// <summary>
/// OCI media types for FuncProof artifacts.
/// </summary>
public static class FuncProofOciMediaTypes
{
/// <summary>
/// Artifact type for FuncProof OCI artifacts.
/// </summary>
public const string ArtifactType = "application/vnd.stellaops.funcproof";
/// <summary>
/// Media type for the FuncProof JSON layer.
/// </summary>
public const string ProofLayer = "application/vnd.stellaops.funcproof+json";
/// <summary>
/// Media type for the DSSE envelope layer containing signed FuncProof.
/// </summary>
public const string DsseLayer = "application/vnd.stellaops.funcproof.dsse+json";
}
/// <summary>
/// Custom OCI annotations for FuncProof artifacts.
/// </summary>
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";
}