test fixes and new product advisories work

This commit is contained in:
master
2026-01-28 02:30:48 +02:00
parent 82caceba56
commit 644887997c
288 changed files with 69101 additions and 375 deletions

View File

@@ -98,7 +98,8 @@ public static class ExportAdapterServiceExtensions
services.AddSingleton<IExportAdapter>(sp =>
new MirrorAdapter(
sp.GetRequiredService<ILogger<MirrorAdapter>>(),
sp.GetRequiredService<ICryptoHash>()));
sp.GetRequiredService<ICryptoHash>(),
sp.GetService<IReferrerDiscoveryService>()));
// Register Trivy DB adapter
services.AddSingleton<IExportAdapter>(sp =>

View File

@@ -1,4 +1,5 @@
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using StellaOps.Cryptography;
using StellaOps.ExportCenter.Core.MirrorBundle;
@@ -8,18 +9,40 @@ namespace StellaOps.ExportCenter.Core.Adapters;
/// <summary>
/// Export adapter that produces mirror bundles with filesystem layout, indexes, and manifests.
/// Supports OCI referrer discovery to include SBOMs, attestations, and signatures linked to images.
/// </summary>
public sealed class MirrorAdapter : IExportAdapter
{
private const string DefaultBundleFileName = "export-mirror-bundle-v1.tgz";
// Regex to detect image references (registry/repo:tag or registry/repo@sha256:...)
private static readonly Regex ImageReferencePattern = new(
@"^(?<registry>[a-zA-Z0-9][-a-zA-Z0-9.]*[a-zA-Z0-9](:[0-9]+)?)/(?<repository>[a-zA-Z0-9][-a-zA-Z0-9._/]*)([:@])(?<reference>.+)$",
RegexOptions.Compiled | RegexOptions.ExplicitCapture);
// Regex to detect digest format
private static readonly Regex DigestPattern = new(
@"^sha256:[a-fA-F0-9]{64}$",
RegexOptions.Compiled);
private readonly ILogger<MirrorAdapter> _logger;
private readonly ICryptoHash _cryptoHash;
private readonly IReferrerDiscoveryService _referrerDiscovery;
public MirrorAdapter(ILogger<MirrorAdapter> logger, ICryptoHash cryptoHash)
/// <summary>
/// Creates a new MirrorAdapter with referrer discovery support.
/// </summary>
/// <param name="logger">Logger instance.</param>
/// <param name="cryptoHash">Crypto hash provider.</param>
/// <param name="referrerDiscovery">Optional referrer discovery service. If null, referrer discovery is disabled.</param>
public MirrorAdapter(
ILogger<MirrorAdapter> logger,
ICryptoHash cryptoHash,
IReferrerDiscoveryService? referrerDiscovery = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
_referrerDiscovery = referrerDiscovery ?? NullReferrerDiscoveryService.Instance;
}
/// <inheritdoc />
@@ -234,6 +257,7 @@ public sealed class MirrorAdapter : IExportAdapter
CancellationToken cancellationToken)
{
var dataSources = new List<MirrorBundleDataSource>();
var discoveredImageRefs = new List<string>();
foreach (var item in context.Items)
{
@@ -299,6 +323,12 @@ public sealed class MirrorAdapter : IExportAdapter
ContentHash = content.OriginalHash,
ProcessedAt = context.TimeProvider.GetUtcNow()
});
// Check if this item represents an image that might have referrers
if (IsImageReference(item.SourceRef))
{
discoveredImageRefs.Add(item.SourceRef);
}
}
catch (Exception ex)
{
@@ -307,9 +337,231 @@ public sealed class MirrorAdapter : IExportAdapter
}
}
// Discover and collect OCI referrer artifacts for all image references
if (discoveredImageRefs.Count > 0)
{
var referrerSources = await DiscoverAndCollectReferrersAsync(
discoveredImageRefs,
tempDir,
context,
cancellationToken);
dataSources.AddRange(referrerSources);
_logger.LogInformation(
"Discovered {ReferrerCount} referrer artifacts for {ImageCount} images",
referrerSources.Count,
discoveredImageRefs.Count);
}
return dataSources;
}
/// <summary>
/// Discovers OCI referrer artifacts for the given image references and collects their content.
/// </summary>
private async Task<List<MirrorBundleDataSource>> DiscoverAndCollectReferrersAsync(
IReadOnlyList<string> imageReferences,
string tempDir,
ExportAdapterContext context,
CancellationToken cancellationToken)
{
var referrerSources = new List<MirrorBundleDataSource>();
var processedDigests = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Extract unique registries and probe capabilities at export start
var uniqueRegistries = imageReferences
.Select(ExtractRegistry)
.Where(r => !string.IsNullOrEmpty(r))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (uniqueRegistries.Count > 0)
{
_logger.LogInformation(
"Probing {RegistryCount} registries for OCI referrer capabilities before export",
uniqueRegistries.Count);
foreach (var registry in uniqueRegistries)
{
cancellationToken.ThrowIfCancellationRequested();
// Probe capabilities - this will log the result and cache it
await _referrerDiscovery.ProbeRegistryCapabilitiesAsync(registry!, cancellationToken);
}
}
foreach (var imageRef in imageReferences)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
_logger.LogDebug("Discovering referrers for image: {ImageRef}", imageRef);
var discoveryResult = await _referrerDiscovery.DiscoverReferrersAsync(imageRef, cancellationToken);
if (!discoveryResult.IsSuccess)
{
_logger.LogWarning(
"Failed to discover referrers for {ImageRef}: {Error}",
imageRef,
discoveryResult.Error);
continue;
}
if (discoveryResult.Referrers.Count == 0)
{
_logger.LogDebug("No referrers found for image: {ImageRef}", imageRef);
continue;
}
_logger.LogInformation(
"Found {Count} referrers for {ImageRef} (API supported: {ApiSupported})",
discoveryResult.Referrers.Count,
imageRef,
discoveryResult.SupportsReferrersApi);
// Process each referrer
foreach (var referrer in discoveryResult.Referrers)
{
// Skip if we've already processed this digest (deduplication)
if (!processedDigests.Add(referrer.Digest))
{
_logger.LogDebug("Skipping duplicate referrer: {Digest}", referrer.Digest);
continue;
}
// Determine category for this referrer
var category = referrer.Category;
if (category is null)
{
_logger.LogDebug(
"Skipping referrer with unknown artifact type: {ArtifactType}",
referrer.ArtifactType);
continue;
}
// Fetch referrer content
var referrerContent = await FetchReferrerContentAsync(
discoveryResult.Registry,
discoveryResult.Repository,
referrer,
cancellationToken);
if (referrerContent is null)
{
_logger.LogWarning(
"Failed to fetch content for referrer {Digest}",
referrer.Digest);
continue;
}
// Write referrer to temp file
var referrerDir = Path.Combine(
tempDir,
"referrers",
SanitizeDigestForPath(discoveryResult.SubjectDigest));
Directory.CreateDirectory(referrerDir);
var referrerFileName = $"{SanitizeDigestForPath(referrer.Digest)}.json";
var referrerFilePath = Path.Combine(referrerDir, referrerFileName);
await File.WriteAllBytesAsync(referrerFilePath, referrerContent, cancellationToken);
referrerSources.Add(new MirrorBundleDataSource(
category.Value,
referrerFilePath,
IsNormalized: false,
SubjectId: discoveryResult.SubjectDigest));
_logger.LogDebug(
"Collected referrer {Digest} ({Category}) for subject {Subject}",
referrer.Digest,
category.Value,
discoveryResult.SubjectDigest);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error discovering referrers for {ImageRef}", imageRef);
// Continue with other images even if one fails
}
}
return referrerSources;
}
/// <summary>
/// Fetches the content of a referrer artifact.
/// </summary>
private async Task<byte[]?> FetchReferrerContentAsync(
string registry,
string repository,
DiscoveredReferrer referrer,
CancellationToken cancellationToken)
{
// If the referrer has layers, fetch the first layer content
if (referrer.Layers.Count > 0)
{
var layer = referrer.Layers[0];
return await _referrerDiscovery.GetReferrerContentAsync(
registry,
repository,
layer.Digest,
cancellationToken);
}
// Otherwise try to fetch by the referrer digest itself
return await _referrerDiscovery.GetReferrerContentAsync(
registry,
repository,
referrer.Digest,
cancellationToken);
}
/// <summary>
/// Checks if a source reference looks like an OCI image reference.
/// </summary>
private static bool IsImageReference(string? sourceRef)
{
if (string.IsNullOrWhiteSpace(sourceRef))
return false;
// Check if it matches the image reference pattern
if (ImageReferencePattern.IsMatch(sourceRef))
return true;
// Check if it contains a digest (sha256:...)
if (sourceRef.Contains("sha256:", StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
/// <summary>
/// Extracts the registry hostname from an image reference.
/// </summary>
private static string? ExtractRegistry(string? sourceRef)
{
if (string.IsNullOrWhiteSpace(sourceRef))
return null;
var match = ImageReferencePattern.Match(sourceRef);
if (!match.Success)
return null;
return match.Groups["registry"].Value;
}
/// <summary>
/// Sanitizes a digest for use as a filesystem path segment.
/// </summary>
private static string SanitizeDigestForPath(string digest)
{
// Replace colon with hyphen: sha256:abc... -> sha256-abc...
return digest.Replace(':', '-');
}
private static MirrorBundleDataCategory? MapKindToCategory(string kind)
{
return kind.ToLowerInvariant() switch
@@ -324,6 +576,17 @@ public sealed class MirrorAdapter : IExportAdapter
"vex-consensus" => MirrorBundleDataCategory.VexConsensus,
"findings" => MirrorBundleDataCategory.Findings,
"scan-report" => MirrorBundleDataCategory.Findings,
// Attestation types
"attestation" => MirrorBundleDataCategory.Attestation,
"dsse" => MirrorBundleDataCategory.Attestation,
"in-toto" => MirrorBundleDataCategory.Attestation,
"intoto" => MirrorBundleDataCategory.Attestation,
"provenance" => MirrorBundleDataCategory.Attestation,
"signature" => MirrorBundleDataCategory.Attestation,
"rva" => MirrorBundleDataCategory.Attestation,
// Image types (for referrer discovery)
"image" => MirrorBundleDataCategory.Referrer,
"container" => MirrorBundleDataCategory.Referrer,
_ => null
};
}

View File

@@ -0,0 +1,302 @@
namespace StellaOps.ExportCenter.Core.MirrorBundle;
/// <summary>
/// Service interface for discovering OCI referrer artifacts linked to images.
/// Used by MirrorAdapter to discover SBOMs, attestations, and signatures attached to images.
/// </summary>
public interface IReferrerDiscoveryService
{
/// <summary>
/// Probes registry capabilities to determine the best discovery strategy.
/// Results are cached per registry host.
/// </summary>
/// <param name="registry">Registry hostname.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Registry capabilities including referrers API support.</returns>
Task<RegistryCapabilitiesInfo> ProbeRegistryCapabilitiesAsync(
string registry,
CancellationToken cancellationToken = default);
/// <summary>
/// Discovers all referrer artifacts for a given image.
/// </summary>
/// <param name="imageReference">Full image reference (e.g., registry.example.com/repo:tag or registry.example.com/repo@sha256:...).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result containing discovered referrer artifacts.</returns>
Task<ReferrerDiscoveryResult> DiscoverReferrersAsync(
string imageReference,
CancellationToken cancellationToken = default);
/// <summary>
/// Downloads the content of a referrer artifact.
/// </summary>
/// <param name="registry">Registry hostname.</param>
/// <param name="repository">Repository name.</param>
/// <param name="digest">Artifact digest.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Artifact content as bytes, or null if not found.</returns>
Task<byte[]?> GetReferrerContentAsync(
string registry,
string repository,
string digest,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Registry capabilities information returned from capability probing.
/// </summary>
public sealed record RegistryCapabilitiesInfo
{
/// <summary>
/// Registry hostname.
/// </summary>
public required string Registry { get; init; }
/// <summary>
/// OCI Distribution spec version (e.g., "registry/2.0", "OCI 1.1").
/// </summary>
public string? DistributionVersion { get; init; }
/// <summary>
/// Whether the registry supports the native OCI 1.1 referrers API.
/// </summary>
public bool SupportsReferrersApi { get; init; }
/// <summary>
/// Whether the registry supports the artifactType field.
/// </summary>
public bool SupportsArtifactType { get; init; }
/// <summary>
/// When capabilities were probed.
/// </summary>
public DateTimeOffset ProbedAt { get; init; }
/// <summary>
/// Whether probing was successful.
/// </summary>
public bool IsSuccess { get; init; } = true;
/// <summary>
/// Error message if probing failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Creates a failed result.
/// </summary>
public static RegistryCapabilitiesInfo Failed(string registry, string error) =>
new()
{
Registry = registry,
IsSuccess = false,
Error = error,
ProbedAt = DateTimeOffset.UtcNow
};
}
/// <summary>
/// Result of referrer discovery for an image.
/// </summary>
public sealed record ReferrerDiscoveryResult
{
/// <summary>
/// Whether the discovery operation succeeded.
/// </summary>
public required bool IsSuccess { get; init; }
/// <summary>
/// The subject image digest that was queried.
/// </summary>
public required string SubjectDigest { get; init; }
/// <summary>
/// Registry hostname.
/// </summary>
public required string Registry { get; init; }
/// <summary>
/// Repository name.
/// </summary>
public required string Repository { get; init; }
/// <summary>
/// Discovered referrer artifacts.
/// </summary>
public IReadOnlyList<DiscoveredReferrer> Referrers { get; init; } = [];
/// <summary>
/// Whether the registry supports the native OCI 1.1 referrers API.
/// </summary>
public bool SupportsReferrersApi { get; init; }
/// <summary>
/// Error message if discovery failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Creates a failed result.
/// </summary>
public static ReferrerDiscoveryResult Failed(string error, string subjectDigest, string registry, string repository) =>
new()
{
IsSuccess = false,
SubjectDigest = subjectDigest,
Registry = registry,
Repository = repository,
Error = error
};
}
/// <summary>
/// A discovered referrer artifact.
/// </summary>
public sealed record DiscoveredReferrer
{
/// <summary>
/// Digest of the referrer manifest.
/// </summary>
public required string Digest { get; init; }
/// <summary>
/// Artifact type (e.g., application/vnd.cyclonedx+json for SBOM).
/// </summary>
public string? ArtifactType { get; init; }
/// <summary>
/// Media type of the manifest.
/// </summary>
public string? MediaType { get; init; }
/// <summary>
/// Size in bytes.
/// </summary>
public long Size { get; init; }
/// <summary>
/// Manifest annotations.
/// </summary>
public IReadOnlyDictionary<string, string> Annotations { get; init; } = new Dictionary<string, string>();
/// <summary>
/// Content layers (for fetching actual artifact data).
/// </summary>
public IReadOnlyList<ReferrerLayer> Layers { get; init; } = [];
/// <summary>
/// The category this referrer maps to in a mirror bundle.
/// </summary>
public MirrorBundleDataCategory? Category => MapArtifactTypeToCategory(ArtifactType);
private static MirrorBundleDataCategory? MapArtifactTypeToCategory(string? artifactType)
{
if (string.IsNullOrEmpty(artifactType))
return null;
// SBOM types
if (artifactType.Contains("cyclonedx", StringComparison.OrdinalIgnoreCase) &&
!artifactType.Contains("vex", StringComparison.OrdinalIgnoreCase))
return MirrorBundleDataCategory.Sbom;
if (artifactType.Contains("spdx", StringComparison.OrdinalIgnoreCase))
return MirrorBundleDataCategory.Sbom;
// VEX types
if (artifactType.Contains("vex", StringComparison.OrdinalIgnoreCase) ||
artifactType.Contains("openvex", StringComparison.OrdinalIgnoreCase) ||
artifactType.Contains("csaf", StringComparison.OrdinalIgnoreCase))
return MirrorBundleDataCategory.Vex;
// Attestation types (DSSE, in-toto, sigstore)
if (artifactType.Contains("dsse", StringComparison.OrdinalIgnoreCase) ||
artifactType.Contains("in-toto", StringComparison.OrdinalIgnoreCase) ||
artifactType.Contains("intoto", StringComparison.OrdinalIgnoreCase) ||
artifactType.Contains("sigstore", StringComparison.OrdinalIgnoreCase) ||
artifactType.Contains("provenance", StringComparison.OrdinalIgnoreCase) ||
artifactType.Contains("slsa", StringComparison.OrdinalIgnoreCase) ||
artifactType.Contains("rva", StringComparison.OrdinalIgnoreCase))
return MirrorBundleDataCategory.Attestation;
return null;
}
}
/// <summary>
/// A layer within a referrer manifest.
/// </summary>
public sealed record ReferrerLayer
{
/// <summary>
/// Layer digest.
/// </summary>
public required string Digest { get; init; }
/// <summary>
/// Layer media type.
/// </summary>
public required string MediaType { get; init; }
/// <summary>
/// Layer size in bytes.
/// </summary>
public long Size { get; init; }
/// <summary>
/// Layer annotations.
/// </summary>
public IReadOnlyDictionary<string, string> Annotations { get; init; } = new Dictionary<string, string>();
}
/// <summary>
/// Null implementation of IReferrerDiscoveryService for when referrer discovery is disabled.
/// </summary>
public sealed class NullReferrerDiscoveryService : IReferrerDiscoveryService
{
/// <summary>
/// Singleton instance.
/// </summary>
public static readonly NullReferrerDiscoveryService Instance = new();
private NullReferrerDiscoveryService() { }
/// <inheritdoc />
public Task<RegistryCapabilitiesInfo> ProbeRegistryCapabilitiesAsync(
string registry,
CancellationToken cancellationToken = default)
{
return Task.FromResult(new RegistryCapabilitiesInfo
{
Registry = registry,
SupportsReferrersApi = false,
SupportsArtifactType = false,
ProbedAt = DateTimeOffset.UtcNow,
IsSuccess = true
});
}
/// <inheritdoc />
public Task<ReferrerDiscoveryResult> DiscoverReferrersAsync(
string imageReference,
CancellationToken cancellationToken = default)
{
return Task.FromResult(new ReferrerDiscoveryResult
{
IsSuccess = true,
SubjectDigest = string.Empty,
Registry = string.Empty,
Repository = string.Empty,
Referrers = []
});
}
/// <inheritdoc />
public Task<byte[]?> GetReferrerContentAsync(
string registry,
string repository,
string digest,
CancellationToken cancellationToken = default)
{
return Task.FromResult<byte[]?>(null);
}
}

View File

@@ -191,6 +191,13 @@ public sealed class MirrorBundleBuilder
MirrorBundleDataCategory.PolicyEvaluations => $"data/policy/{fileName}",
MirrorBundleDataCategory.VexConsensus => $"data/consensus/{fileName}",
MirrorBundleDataCategory.Findings => $"data/findings/{fileName}",
// OCI referrer artifacts - stored under referrers/{subject-digest}/
MirrorBundleDataCategory.Attestation when !string.IsNullOrEmpty(source.SubjectId) =>
$"referrers/{SanitizeSegment(source.SubjectId)}/attestations/{fileName}",
MirrorBundleDataCategory.Attestation => $"data/attestations/{fileName}",
MirrorBundleDataCategory.Referrer when !string.IsNullOrEmpty(source.SubjectId) =>
$"referrers/{SanitizeSegment(source.SubjectId)}/{fileName}",
MirrorBundleDataCategory.Referrer => $"data/referrers/{fileName}",
_ => throw new ArgumentOutOfRangeException(nameof(source), $"Unknown data category: {source.Category}")
};
}
@@ -210,8 +217,10 @@ public sealed class MirrorBundleBuilder
var vex = files.Count(f => f.Category is MirrorBundleDataCategory.Vex or MirrorBundleDataCategory.VexConsensus);
var sboms = files.Count(f => f.Category == MirrorBundleDataCategory.Sbom);
var policyEvals = files.Count(f => f.Category == MirrorBundleDataCategory.PolicyEvaluations);
var attestations = files.Count(f => f.Category == MirrorBundleDataCategory.Attestation);
var referrers = files.Count(f => f.Category == MirrorBundleDataCategory.Referrer);
return new MirrorBundleManifestCounts(advisories, vex, sboms, policyEvals);
return new MirrorBundleManifestCounts(advisories, vex, sboms, policyEvals, attestations, referrers);
}
private MirrorBundleManifest BuildManifest(
@@ -355,6 +364,8 @@ public sealed class MirrorBundleBuilder
builder.Append("- VEX statements: ").AppendLine(manifest.Counts.Vex.ToString());
builder.Append("- SBOMs: ").AppendLine(manifest.Counts.Sboms.ToString());
builder.Append("- Policy evaluations: ").AppendLine(manifest.Counts.PolicyEvaluations.ToString());
builder.Append("- Attestations: ").AppendLine(manifest.Counts.Attestations.ToString());
builder.Append("- OCI referrers: ").AppendLine(manifest.Counts.Referrers.ToString());
builder.AppendLine();
if (manifest.Delta is not null)
@@ -441,6 +452,8 @@ public sealed class MirrorBundleBuilder
builder.Append(" vex: ").AppendLine(manifest.Counts.Vex.ToString());
builder.Append(" sboms: ").AppendLine(manifest.Counts.Sboms.ToString());
builder.Append(" policyEvaluations: ").AppendLine(manifest.Counts.PolicyEvaluations.ToString());
builder.Append(" attestations: ").AppendLine(manifest.Counts.Attestations.ToString());
builder.Append(" referrers: ").AppendLine(manifest.Counts.Referrers.ToString());
builder.AppendLine("artifacts:");
foreach (var artifact in manifest.Artifacts)
@@ -501,6 +514,8 @@ public sealed class MirrorBundleBuilder
WriteTextEntry(tar, "indexes/vex.index.json", "[]", DefaultFileMode);
WriteTextEntry(tar, "indexes/sbom.index.json", "[]", DefaultFileMode);
WriteTextEntry(tar, "indexes/findings.index.json", "[]", DefaultFileMode);
WriteTextEntry(tar, "indexes/attestations.index.json", "[]", DefaultFileMode);
WriteTextEntry(tar, "indexes/referrers.index.json", "[]", DefaultFileMode);
// Write data files
foreach (var file in files)

View File

@@ -60,7 +60,15 @@ public enum MirrorBundleDataCategory
PolicySnapshot = 4,
PolicyEvaluations = 5,
VexConsensus = 6,
Findings = 7
Findings = 7,
/// <summary>
/// Attestations discovered via OCI referrers (DSSE, in-toto, provenance, signatures).
/// </summary>
Attestation = 8,
/// <summary>
/// OCI referrer artifacts that don't fit other categories.
/// </summary>
Referrer = 9
}
/// <summary>
@@ -137,7 +145,9 @@ public sealed record MirrorBundleManifestCounts(
[property: JsonPropertyName("advisories")] int Advisories,
[property: JsonPropertyName("vex")] int Vex,
[property: JsonPropertyName("sboms")] int Sboms,
[property: JsonPropertyName("policyEvaluations")] int PolicyEvaluations);
[property: JsonPropertyName("policyEvaluations")] int PolicyEvaluations,
[property: JsonPropertyName("attestations")] int Attestations = 0,
[property: JsonPropertyName("referrers")] int Referrers = 0);
/// <summary>
/// Artifact entry in the manifest.
@@ -244,3 +254,217 @@ public sealed record MirrorBundleDsseSignature(
public sealed record MirrorBundleDsseSignatureEntry(
[property: JsonPropertyName("sig")] string Signature,
[property: JsonPropertyName("keyid")] string KeyId);
// ============================================================================
// OCI Referrer Discovery Models
// ============================================================================
/// <summary>
/// Referrer metadata section in the mirror bundle manifest.
/// Tracks OCI referrer artifacts (SBOMs, attestations, signatures) discovered for images.
/// </summary>
public sealed record MirrorBundleReferrersSection
{
/// <summary>
/// List of subject images and their discovered referrers.
/// </summary>
[JsonPropertyName("subjects")]
public IReadOnlyList<MirrorBundleSubjectReferrers> Subjects { get; init; } = [];
/// <summary>
/// Summary counts of referrer artifacts.
/// </summary>
[JsonPropertyName("counts")]
public MirrorBundleReferrerCounts Counts { get; init; } = new();
/// <summary>
/// Whether the source registry supports native OCI 1.1 referrers API.
/// </summary>
[JsonPropertyName("supportsReferrersApi")]
public bool SupportsReferrersApi { get; init; }
/// <summary>
/// Discovery method used (native or fallback).
/// </summary>
[JsonPropertyName("discoveryMethod")]
public string DiscoveryMethod { get; init; } = "native";
}
/// <summary>
/// Referrers for a specific subject image.
/// </summary>
public sealed record MirrorBundleSubjectReferrers
{
/// <summary>
/// Subject image digest (sha256:...).
/// </summary>
[JsonPropertyName("subject")]
public required string Subject { get; init; }
/// <summary>
/// Subject image reference (if available).
/// </summary>
[JsonPropertyName("reference")]
public string? Reference { get; init; }
/// <summary>
/// Registry hostname.
/// </summary>
[JsonPropertyName("registry")]
public required string Registry { get; init; }
/// <summary>
/// Repository name.
/// </summary>
[JsonPropertyName("repository")]
public required string Repository { get; init; }
/// <summary>
/// Referrer artifacts attached to this subject.
/// </summary>
[JsonPropertyName("artifacts")]
public IReadOnlyList<MirrorBundleReferrerArtifact> Artifacts { get; init; } = [];
}
/// <summary>
/// A referrer artifact in the mirror bundle.
/// </summary>
public sealed record MirrorBundleReferrerArtifact
{
/// <summary>
/// Artifact digest (sha256:...).
/// </summary>
[JsonPropertyName("digest")]
public required string Digest { get; init; }
/// <summary>
/// OCI artifact type (e.g., application/vnd.cyclonedx+json).
/// </summary>
[JsonPropertyName("artifactType")]
public string? ArtifactType { get; init; }
/// <summary>
/// Media type of the artifact manifest.
/// </summary>
[JsonPropertyName("mediaType")]
public string? MediaType { get; init; }
/// <summary>
/// Size in bytes.
/// </summary>
[JsonPropertyName("size")]
public long Size { get; init; }
/// <summary>
/// Category in the bundle (sbom, attestation, vex, etc.).
/// </summary>
[JsonPropertyName("category")]
public required string Category { get; init; }
/// <summary>
/// Relative path within the bundle.
/// </summary>
[JsonPropertyName("path")]
public required string Path { get; init; }
/// <summary>
/// SHA-256 hash of the artifact content in the bundle.
/// </summary>
[JsonPropertyName("sha256")]
public required string Sha256 { get; init; }
/// <summary>
/// Artifact annotations from the OCI manifest.
/// </summary>
[JsonPropertyName("annotations")]
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
/// <summary>
/// Timestamp when the artifact was created (from annotations).
/// </summary>
[JsonPropertyName("createdAt")]
public DateTimeOffset? CreatedAt { get; init; }
}
/// <summary>
/// Summary counts of referrer artifacts in the bundle.
/// </summary>
public sealed record MirrorBundleReferrerCounts
{
/// <summary>
/// Total number of subject images with referrers.
/// </summary>
[JsonPropertyName("subjects")]
public int Subjects { get; init; }
/// <summary>
/// Total referrer artifacts across all subjects.
/// </summary>
[JsonPropertyName("total")]
public int Total { get; init; }
/// <summary>
/// Number of SBOM referrers.
/// </summary>
[JsonPropertyName("sboms")]
public int Sboms { get; init; }
/// <summary>
/// Number of attestation referrers.
/// </summary>
[JsonPropertyName("attestations")]
public int Attestations { get; init; }
/// <summary>
/// Number of VEX referrers.
/// </summary>
[JsonPropertyName("vex")]
public int Vex { get; init; }
/// <summary>
/// Number of other/unknown referrers.
/// </summary>
[JsonPropertyName("other")]
public int Other { get; init; }
}
/// <summary>
/// Extended data source that includes referrer metadata.
/// </summary>
public sealed record MirrorBundleReferrerDataSource
{
/// <summary>
/// Base data source information.
/// </summary>
public required MirrorBundleDataSource DataSource { get; init; }
/// <summary>
/// Subject image digest this referrer is attached to.
/// </summary>
public required string SubjectDigest { get; init; }
/// <summary>
/// Referrer artifact digest.
/// </summary>
public required string ReferrerDigest { get; init; }
/// <summary>
/// OCI artifact type.
/// </summary>
public string? ArtifactType { get; init; }
/// <summary>
/// Artifact annotations.
/// </summary>
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
/// <summary>
/// Registry hostname.
/// </summary>
public required string Registry { get; init; }
/// <summary>
/// Repository name.
/// </summary>
public required string Repository { get; init; }
}

View File

@@ -28,11 +28,24 @@ public sealed record OfflineKitMirrorEntry(
[property: JsonPropertyName("rootHash")] string RootHash,
[property: JsonPropertyName("artifact")] string Artifact,
[property: JsonPropertyName("checksum")] string Checksum,
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt)
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
[property: JsonPropertyName("referrers")] OfflineKitReferrersSummary? Referrers = null)
{
public const string KindValue = "mirror-bundle";
}
/// <summary>
/// Summary of OCI referrer artifacts included in a mirror bundle.
/// </summary>
public sealed record OfflineKitReferrersSummary(
[property: JsonPropertyName("totalSubjects")] int TotalSubjects,
[property: JsonPropertyName("totalArtifacts")] int TotalArtifacts,
[property: JsonPropertyName("sbomCount")] int SbomCount,
[property: JsonPropertyName("attestationCount")] int AttestationCount,
[property: JsonPropertyName("vexCount")] int VexCount,
[property: JsonPropertyName("otherCount")] int OtherCount,
[property: JsonPropertyName("supportsReferrersApi")] bool SupportsReferrersApi);
/// <summary>
/// Manifest entry for a bootstrap pack in an offline kit.
/// </summary>
@@ -122,7 +135,8 @@ public sealed record OfflineKitMirrorRequest(
string Profile,
string RootHash,
byte[] BundleBytes,
DateTimeOffset CreatedAt);
DateTimeOffset CreatedAt,
OfflineKitReferrersSummary? Referrers = null);
/// <summary>
/// Request to add a bootstrap pack to an offline kit.

View File

@@ -245,7 +245,8 @@ public sealed class OfflineKitPackager
RootHash: $"sha256:{request.RootHash}",
Artifact: Path.Combine(MirrorsDir, MirrorBundleFileName).Replace('\\', '/'),
Checksum: Path.Combine(ChecksumsDir, MirrorsDir, $"{MirrorBundleFileName}.sha256").Replace('\\', '/'),
CreatedAt: request.CreatedAt);
CreatedAt: request.CreatedAt,
Referrers: request.Referrers);
}
/// <summary>

View File

@@ -0,0 +1,851 @@
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Cryptography;
using StellaOps.Determinism;
using StellaOps.ExportCenter.Core.Adapters;
using StellaOps.ExportCenter.Core.MirrorBundle;
using StellaOps.ExportCenter.Core.Planner;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.ExportCenter.Tests.Adapters;
/// <summary>
/// Tests for MirrorAdapter OCI referrer discovery integration.
/// </summary>
public sealed class MirrorAdapterReferrerDiscoveryTests : IDisposable
{
private readonly ICryptoHash _cryptoHash;
private readonly Mock<IReferrerDiscoveryService> _mockReferrerDiscovery;
private readonly MirrorAdapter _adapter;
private readonly string _tempDir;
private static readonly DateTimeOffset FixedTime = new(2025, 1, 27, 0, 0, 0, TimeSpan.Zero);
public MirrorAdapterReferrerDiscoveryTests()
{
_cryptoHash = new FakeCryptoHash();
_mockReferrerDiscovery = new Mock<IReferrerDiscoveryService>();
_adapter = new MirrorAdapter(
NullLogger<MirrorAdapter>.Instance,
_cryptoHash,
_mockReferrerDiscovery.Object);
_tempDir = Path.Combine(Path.GetTempPath(), $"mirror-referrer-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir);
}
public void Dispose()
{
if (Directory.Exists(_tempDir))
{
try { Directory.Delete(_tempDir, true); } catch { }
}
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AdapterId_IsMirrorStandard()
{
Assert.Equal("mirror:standard", _adapter.AdapterId);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Constructor_WithNullReferrerDiscovery_UsesNullImplementation()
{
// When no referrer discovery service is provided, adapter should use NullReferrerDiscoveryService
var adapter = new MirrorAdapter(
NullLogger<MirrorAdapter>.Instance,
_cryptoHash,
referrerDiscovery: null);
Assert.Equal("mirror:standard", adapter.AdapterId);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ProcessAsync_WithImageReference_DiscoverReferrers()
{
// Arrange
var imageRef = "registry.example.com/myapp@sha256:abc123def456";
var sbomContent = "{\"bomFormat\":\"CycloneDX\",\"specVersion\":\"1.5\"}"u8.ToArray();
_mockReferrerDiscovery
.Setup(x => x.DiscoverReferrersAsync(imageRef, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ReferrerDiscoveryResult
{
IsSuccess = true,
SubjectDigest = "sha256:abc123def456",
Registry = "registry.example.com",
Repository = "myapp",
SupportsReferrersApi = true,
Referrers =
[
new DiscoveredReferrer
{
Digest = "sha256:sbom111",
ArtifactType = "application/vnd.cyclonedx+json",
MediaType = "application/vnd.oci.image.manifest.v1+json",
Size = sbomContent.Length,
Layers =
[
new ReferrerLayer
{
Digest = "sha256:sbom-layer111",
MediaType = "application/vnd.cyclonedx+json",
Size = sbomContent.Length
}
]
}
]
});
_mockReferrerDiscovery
.Setup(x => x.GetReferrerContentAsync(
"registry.example.com",
"myapp",
"sha256:sbom-layer111",
It.IsAny<CancellationToken>()))
.ReturnsAsync(sbomContent);
var context = CreateContext(
items:
[
new ResolvedExportItem
{
ItemId = Guid.NewGuid(),
Kind = "sbom",
Name = "myapp-sbom",
SourceRef = imageRef,
CreatedAt = FixedTime
}
]);
// Act
var result = await _adapter.ProcessAsync(context);
// Assert
Assert.True(result.Success);
_mockReferrerDiscovery.Verify(
x => x.DiscoverReferrersAsync(imageRef, It.IsAny<CancellationToken>()),
Times.Once);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ProcessAsync_WithoutImageReference_SkipsReferrerDiscovery()
{
// Arrange - a regular VEX file without image reference
var context = CreateContext(
items:
[
new ResolvedExportItem
{
ItemId = Guid.NewGuid(),
Kind = "vex",
Name = "vex-document",
SourceRef = "local://vex-document.json",
CreatedAt = FixedTime
}
]);
// Act
var result = await _adapter.ProcessAsync(context);
// Assert
Assert.True(result.Success);
_mockReferrerDiscovery.Verify(
x => x.DiscoverReferrersAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ProcessAsync_ReferrerDiscoveryFails_ContinuesWithoutError()
{
// Arrange
var imageRef = "registry.example.com/myapp@sha256:abc123";
_mockReferrerDiscovery
.Setup(x => x.DiscoverReferrersAsync(imageRef, It.IsAny<CancellationToken>()))
.ReturnsAsync(ReferrerDiscoveryResult.Failed(
"Registry unavailable",
"sha256:abc123",
"registry.example.com",
"myapp"));
var context = CreateContext(
items:
[
new ResolvedExportItem
{
ItemId = Guid.NewGuid(),
Kind = "sbom",
Name = "myapp-sbom",
SourceRef = imageRef,
CreatedAt = FixedTime
}
]);
// Act
var result = await _adapter.ProcessAsync(context);
// Assert - should succeed even when referrer discovery fails
Assert.True(result.Success);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ProcessAsync_NoReferrersFound_ContinuesSuccessfully()
{
// Arrange
var imageRef = "registry.example.com/myapp@sha256:abc123";
_mockReferrerDiscovery
.Setup(x => x.DiscoverReferrersAsync(imageRef, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ReferrerDiscoveryResult
{
IsSuccess = true,
SubjectDigest = "sha256:abc123",
Registry = "registry.example.com",
Repository = "myapp",
SupportsReferrersApi = true,
Referrers = []
});
var context = CreateContext(
items:
[
new ResolvedExportItem
{
ItemId = Guid.NewGuid(),
Kind = "sbom",
Name = "myapp-sbom",
SourceRef = imageRef,
CreatedAt = FixedTime
}
]);
// Act
var result = await _adapter.ProcessAsync(context);
// Assert
Assert.True(result.Success);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ProcessAsync_MultipleImagesWithReferrers_CollectsAll()
{
// Arrange
var image1 = "registry.example.com/app1@sha256:111";
var image2 = "registry.example.com/app2@sha256:222";
var sbomContent1 = "{\"app\":\"app1\"}"u8.ToArray();
var sbomContent2 = "{\"app\":\"app2\"}"u8.ToArray();
_mockReferrerDiscovery
.Setup(x => x.DiscoverReferrersAsync(image1, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ReferrerDiscoveryResult
{
IsSuccess = true,
SubjectDigest = "sha256:111",
Registry = "registry.example.com",
Repository = "app1",
SupportsReferrersApi = true,
Referrers =
[
new DiscoveredReferrer
{
Digest = "sha256:sbom1",
ArtifactType = "application/vnd.cyclonedx+json",
Layers = [new ReferrerLayer { Digest = "sha256:layer1", MediaType = "application/json" }]
}
]
});
_mockReferrerDiscovery
.Setup(x => x.DiscoverReferrersAsync(image2, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ReferrerDiscoveryResult
{
IsSuccess = true,
SubjectDigest = "sha256:222",
Registry = "registry.example.com",
Repository = "app2",
SupportsReferrersApi = true,
Referrers =
[
new DiscoveredReferrer
{
Digest = "sha256:sbom2",
ArtifactType = "application/vnd.cyclonedx+json",
Layers = [new ReferrerLayer { Digest = "sha256:layer2", MediaType = "application/json" }]
}
]
});
_mockReferrerDiscovery
.Setup(x => x.GetReferrerContentAsync("registry.example.com", "app1", "sha256:layer1", It.IsAny<CancellationToken>()))
.ReturnsAsync(sbomContent1);
_mockReferrerDiscovery
.Setup(x => x.GetReferrerContentAsync("registry.example.com", "app2", "sha256:layer2", It.IsAny<CancellationToken>()))
.ReturnsAsync(sbomContent2);
var context = CreateContext(
items:
[
new ResolvedExportItem { ItemId = Guid.NewGuid(), Kind = "sbom", Name = "app1", SourceRef = image1, CreatedAt = FixedTime },
new ResolvedExportItem { ItemId = Guid.NewGuid(), Kind = "sbom", Name = "app2", SourceRef = image2, CreatedAt = FixedTime }
]);
// Act
var result = await _adapter.ProcessAsync(context);
// Assert
Assert.True(result.Success);
_mockReferrerDiscovery.Verify(
x => x.DiscoverReferrersAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Exactly(2));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ProcessAsync_DuplicateReferrers_Deduplicated()
{
// Arrange - same referrer for same image (e.g., discovered twice)
var imageRef = "registry.example.com/myapp@sha256:abc123";
var sbomContent = "{\"dedupe\":\"test\"}"u8.ToArray();
var sameReferrer = new DiscoveredReferrer
{
Digest = "sha256:same-sbom",
ArtifactType = "application/vnd.cyclonedx+json",
Layers = [new ReferrerLayer { Digest = "sha256:layer-same", MediaType = "application/json" }]
};
_mockReferrerDiscovery
.Setup(x => x.DiscoverReferrersAsync(imageRef, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ReferrerDiscoveryResult
{
IsSuccess = true,
SubjectDigest = "sha256:abc123",
Registry = "registry.example.com",
Repository = "myapp",
SupportsReferrersApi = true,
Referrers = [sameReferrer, sameReferrer] // Duplicate
});
_mockReferrerDiscovery
.Setup(x => x.GetReferrerContentAsync("registry.example.com", "myapp", "sha256:layer-same", It.IsAny<CancellationToken>()))
.ReturnsAsync(sbomContent);
var context = CreateContext(
items:
[
new ResolvedExportItem { ItemId = Guid.NewGuid(), Kind = "sbom", Name = "myapp", SourceRef = imageRef, CreatedAt = FixedTime }
]);
// Act
var result = await _adapter.ProcessAsync(context);
// Assert
Assert.True(result.Success);
// Should only fetch content once due to deduplication
_mockReferrerDiscovery.Verify(
x => x.GetReferrerContentAsync("registry.example.com", "myapp", "sha256:layer-same", It.IsAny<CancellationToken>()),
Times.Once);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ProcessAsync_AttestationReferrer_CategorizedCorrectly()
{
// Arrange
var imageRef = "registry.example.com/myapp@sha256:abc123";
var dsseContent = "{\"payloadType\":\"application/vnd.in-toto+json\"}"u8.ToArray();
_mockReferrerDiscovery
.Setup(x => x.DiscoverReferrersAsync(imageRef, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ReferrerDiscoveryResult
{
IsSuccess = true,
SubjectDigest = "sha256:abc123",
Registry = "registry.example.com",
Repository = "myapp",
SupportsReferrersApi = true,
Referrers =
[
new DiscoveredReferrer
{
Digest = "sha256:attestation1",
ArtifactType = "application/vnd.dsse.envelope.v1+json",
Layers = [new ReferrerLayer { Digest = "sha256:dsse-layer", MediaType = "application/vnd.dsse.envelope.v1+json" }]
}
]
});
_mockReferrerDiscovery
.Setup(x => x.GetReferrerContentAsync("registry.example.com", "myapp", "sha256:dsse-layer", It.IsAny<CancellationToken>()))
.ReturnsAsync(dsseContent);
var context = CreateContext(
items:
[
new ResolvedExportItem { ItemId = Guid.NewGuid(), Kind = "sbom", Name = "myapp", SourceRef = imageRef, CreatedAt = FixedTime }
]);
// Act
var result = await _adapter.ProcessAsync(context);
// Assert
Assert.True(result.Success);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ProcessAsync_VexReferrer_CategorizedCorrectly()
{
// Arrange
var imageRef = "registry.example.com/myapp@sha256:abc123";
var vexContent = "{\"document\":{\"category\":\"informational_advisory\"}}"u8.ToArray();
_mockReferrerDiscovery
.Setup(x => x.DiscoverReferrersAsync(imageRef, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ReferrerDiscoveryResult
{
IsSuccess = true,
SubjectDigest = "sha256:abc123",
Registry = "registry.example.com",
Repository = "myapp",
SupportsReferrersApi = true,
Referrers =
[
new DiscoveredReferrer
{
Digest = "sha256:vex1",
ArtifactType = "application/vnd.openvex+json",
Layers = [new ReferrerLayer { Digest = "sha256:vex-layer", MediaType = "application/vnd.openvex+json" }]
}
]
});
_mockReferrerDiscovery
.Setup(x => x.GetReferrerContentAsync("registry.example.com", "myapp", "sha256:vex-layer", It.IsAny<CancellationToken>()))
.ReturnsAsync(vexContent);
var context = CreateContext(
items:
[
new ResolvedExportItem { ItemId = Guid.NewGuid(), Kind = "sbom", Name = "myapp", SourceRef = imageRef, CreatedAt = FixedTime }
]);
// Act
var result = await _adapter.ProcessAsync(context);
// Assert
Assert.True(result.Success);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ProcessAsync_ReferrerContentFetchFails_ContinuesWithOthers()
{
// Arrange
var imageRef = "registry.example.com/myapp@sha256:abc123";
var goodContent = "{\"success\":true}"u8.ToArray();
_mockReferrerDiscovery
.Setup(x => x.DiscoverReferrersAsync(imageRef, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ReferrerDiscoveryResult
{
IsSuccess = true,
SubjectDigest = "sha256:abc123",
Registry = "registry.example.com",
Repository = "myapp",
SupportsReferrersApi = true,
Referrers =
[
new DiscoveredReferrer
{
Digest = "sha256:fail",
ArtifactType = "application/vnd.cyclonedx+json",
Layers = [new ReferrerLayer { Digest = "sha256:fail-layer", MediaType = "application/json" }]
},
new DiscoveredReferrer
{
Digest = "sha256:succeed",
ArtifactType = "application/vnd.cyclonedx+json",
Layers = [new ReferrerLayer { Digest = "sha256:good-layer", MediaType = "application/json" }]
}
]
});
_mockReferrerDiscovery
.Setup(x => x.GetReferrerContentAsync("registry.example.com", "myapp", "sha256:fail-layer", It.IsAny<CancellationToken>()))
.ReturnsAsync((byte[]?)null);
_mockReferrerDiscovery
.Setup(x => x.GetReferrerContentAsync("registry.example.com", "myapp", "sha256:good-layer", It.IsAny<CancellationToken>()))
.ReturnsAsync(goodContent);
var context = CreateContext(
items:
[
new ResolvedExportItem { ItemId = Guid.NewGuid(), Kind = "sbom", Name = "myapp", SourceRef = imageRef, CreatedAt = FixedTime }
]);
// Act
var result = await _adapter.ProcessAsync(context);
// Assert
Assert.True(result.Success);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DiscoveredReferrer_Category_MapsSbomCorrectly()
{
var referrer = new DiscoveredReferrer
{
Digest = "sha256:test",
ArtifactType = "application/vnd.cyclonedx+json"
};
Assert.Equal(MirrorBundleDataCategory.Sbom, referrer.Category);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DiscoveredReferrer_Category_MapsSpdxCorrectly()
{
var referrer = new DiscoveredReferrer
{
Digest = "sha256:test",
ArtifactType = "application/spdx+json"
};
Assert.Equal(MirrorBundleDataCategory.Sbom, referrer.Category);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DiscoveredReferrer_Category_MapsVexCorrectly()
{
var referrer = new DiscoveredReferrer
{
Digest = "sha256:test",
ArtifactType = "application/vnd.openvex+json"
};
Assert.Equal(MirrorBundleDataCategory.Vex, referrer.Category);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DiscoveredReferrer_Category_MapsCsafVexCorrectly()
{
var referrer = new DiscoveredReferrer
{
Digest = "sha256:test",
ArtifactType = "application/csaf+json"
};
Assert.Equal(MirrorBundleDataCategory.Vex, referrer.Category);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DiscoveredReferrer_Category_MapsDsseCorrectly()
{
var referrer = new DiscoveredReferrer
{
Digest = "sha256:test",
ArtifactType = "application/vnd.dsse.envelope.v1+json"
};
Assert.Equal(MirrorBundleDataCategory.Attestation, referrer.Category);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DiscoveredReferrer_Category_MapsInTotoCorrectly()
{
var referrer = new DiscoveredReferrer
{
Digest = "sha256:test",
ArtifactType = "application/vnd.in-toto+json"
};
Assert.Equal(MirrorBundleDataCategory.Attestation, referrer.Category);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DiscoveredReferrer_Category_MapsSlsaCorrectly()
{
var referrer = new DiscoveredReferrer
{
Digest = "sha256:test",
ArtifactType = "application/vnd.slsa.provenance+json"
};
Assert.Equal(MirrorBundleDataCategory.Attestation, referrer.Category);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DiscoveredReferrer_Category_ReturnsNullForUnknown()
{
var referrer = new DiscoveredReferrer
{
Digest = "sha256:test",
ArtifactType = "application/unknown"
};
Assert.Null(referrer.Category);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ProcessAsync_ProbesRegistryCapabilities_BeforeDiscovery()
{
// Arrange
var imageRef = "registry.example.com/myapp@sha256:abc123def456";
_mockReferrerDiscovery
.Setup(x => x.ProbeRegistryCapabilitiesAsync("registry.example.com", It.IsAny<CancellationToken>()))
.ReturnsAsync(new RegistryCapabilitiesInfo
{
Registry = "registry.example.com",
SupportsReferrersApi = true,
SupportsArtifactType = true,
DistributionVersion = "OCI 1.1",
ProbedAt = FixedTime,
IsSuccess = true
});
_mockReferrerDiscovery
.Setup(x => x.DiscoverReferrersAsync(imageRef, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ReferrerDiscoveryResult
{
IsSuccess = true,
SubjectDigest = "sha256:abc123def456",
Registry = "registry.example.com",
Repository = "myapp",
SupportsReferrersApi = true,
Referrers = []
});
var context = CreateContext(
items:
[
new ResolvedExportItem
{
ItemId = Guid.NewGuid(),
Kind = "sbom",
Name = "myapp-sbom",
SourceRef = imageRef,
CreatedAt = FixedTime
}
]);
// Act
var result = await _adapter.ProcessAsync(context);
// Assert
Assert.True(result.Success);
// Verify ProbeRegistryCapabilitiesAsync was called before DiscoverReferrersAsync
var probeCallOrder = new List<string>();
_mockReferrerDiscovery.Verify(
x => x.ProbeRegistryCapabilitiesAsync("registry.example.com", It.IsAny<CancellationToken>()),
Times.Once);
_mockReferrerDiscovery.Verify(
x => x.DiscoverReferrersAsync(imageRef, It.IsAny<CancellationToken>()),
Times.Once);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ProcessAsync_ProbesMultipleRegistries_OnceEach()
{
// Arrange
var image1 = "registry1.example.com/app1@sha256:111";
var image2 = "registry2.example.com/app2@sha256:222";
var image3 = "registry1.example.com/app3@sha256:333"; // Same registry as image1
_mockReferrerDiscovery
.Setup(x => x.ProbeRegistryCapabilitiesAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((string reg, CancellationToken _) => new RegistryCapabilitiesInfo
{
Registry = reg,
SupportsReferrersApi = reg.Contains("registry1"),
ProbedAt = FixedTime,
IsSuccess = true
});
_mockReferrerDiscovery
.Setup(x => x.DiscoverReferrersAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((string imageRef, CancellationToken _) => new ReferrerDiscoveryResult
{
IsSuccess = true,
SubjectDigest = imageRef.Contains("111") ? "sha256:111" : imageRef.Contains("222") ? "sha256:222" : "sha256:333",
Registry = imageRef.Contains("registry1") ? "registry1.example.com" : "registry2.example.com",
Repository = imageRef.Contains("app1") ? "app1" : imageRef.Contains("app2") ? "app2" : "app3",
SupportsReferrersApi = imageRef.Contains("registry1"),
Referrers = []
});
var context = CreateContext(
items:
[
new ResolvedExportItem { ItemId = Guid.NewGuid(), Kind = "sbom", Name = "app1", SourceRef = image1, CreatedAt = FixedTime },
new ResolvedExportItem { ItemId = Guid.NewGuid(), Kind = "sbom", Name = "app2", SourceRef = image2, CreatedAt = FixedTime },
new ResolvedExportItem { ItemId = Guid.NewGuid(), Kind = "sbom", Name = "app3", SourceRef = image3, CreatedAt = FixedTime }
]);
// Act
var result = await _adapter.ProcessAsync(context);
// Assert
Assert.True(result.Success);
// Each unique registry should be probed exactly once
_mockReferrerDiscovery.Verify(
x => x.ProbeRegistryCapabilitiesAsync("registry1.example.com", It.IsAny<CancellationToken>()),
Times.Once);
_mockReferrerDiscovery.Verify(
x => x.ProbeRegistryCapabilitiesAsync("registry2.example.com", It.IsAny<CancellationToken>()),
Times.Once);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void NullReferrerDiscoveryService_ProbeRegistryCapabilitiesAsync_ReturnsDefaultCapabilities()
{
var result = NullReferrerDiscoveryService.Instance.ProbeRegistryCapabilitiesAsync("test.registry.io", CancellationToken.None).GetAwaiter().GetResult();
Assert.True(result.IsSuccess);
Assert.Equal("test.registry.io", result.Registry);
Assert.False(result.SupportsReferrersApi);
Assert.False(result.SupportsArtifactType);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void NullReferrerDiscoveryService_DiscoverReferrersAsync_ReturnsEmptyResult()
{
var result = NullReferrerDiscoveryService.Instance.DiscoverReferrersAsync("test", CancellationToken.None).GetAwaiter().GetResult();
Assert.True(result.IsSuccess);
Assert.Empty(result.Referrers);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void NullReferrerDiscoveryService_GetReferrerContentAsync_ReturnsNull()
{
var result = NullReferrerDiscoveryService.Instance.GetReferrerContentAsync("reg", "repo", "digest", CancellationToken.None).GetAwaiter().GetResult();
Assert.Null(result);
}
private ExportAdapterContext CreateContext(IReadOnlyList<ResolvedExportItem> items)
{
var outputDir = Path.Combine(_tempDir, Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(outputDir);
var dataFetcher = new InMemoryExportDataFetcher();
foreach (var item in items)
{
dataFetcher.AddContent(item.ItemId, $"{{\"id\":\"{item.ItemId}\"}}");
}
return new ExportAdapterContext
{
Items = items,
Config = new ExportAdapterConfig
{
AdapterId = "mirror:standard",
OutputDirectory = outputDir,
BaseName = "test-export",
FormatOptions = new ExportFormatOptions
{
Format = ExportFormat.Mirror,
SortKeys = false,
NormalizeTimestamps = false
},
IncludeChecksums = false
},
DataFetcher = dataFetcher,
CorrelationId = Guid.NewGuid().ToString(),
TenantId = Guid.NewGuid(),
TimeProvider = new FakeTimeProvider(FixedTime),
GuidProvider = new SequentialGuidProvider()
};
}
private sealed class FakeTimeProvider : TimeProvider
{
private readonly DateTimeOffset _utcNow;
public FakeTimeProvider(DateTimeOffset utcNow) => _utcNow = utcNow;
public override DateTimeOffset GetUtcNow() => _utcNow;
}
private sealed class SequentialGuidProvider : IGuidProvider
{
private int _counter;
public Guid NewGuid() => new Guid(_counter++, 0, 0, [0, 0, 0, 0, 0, 0, 0, 0]);
}
private sealed class FakeCryptoHash : ICryptoHash
{
public byte[] ComputeHash(ReadOnlySpan<byte> data, string? algorithmId = null)
{
using var sha256 = System.Security.Cryptography.SHA256.Create();
return sha256.ComputeHash(data.ToArray());
}
public string ComputeHashHex(ReadOnlySpan<byte> data, string? algorithmId = null)
{
var hash = ComputeHash(data, algorithmId);
return Convert.ToHexString(hash).ToLowerInvariant();
}
public string ComputeHashBase64(ReadOnlySpan<byte> data, string? algorithmId = null)
{
var hash = ComputeHash(data, algorithmId);
return Convert.ToBase64String(hash);
}
public ValueTask<byte[]> ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
{
using var sha256 = System.Security.Cryptography.SHA256.Create();
var hash = sha256.ComputeHash(stream);
return new ValueTask<byte[]>(hash);
}
public async ValueTask<string> ComputeHashHexAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
{
var hash = await ComputeHashAsync(stream, algorithmId, cancellationToken);
return Convert.ToHexString(hash).ToLowerInvariant();
}
public byte[] ComputeHashForPurpose(ReadOnlySpan<byte> data, string purpose)
=> ComputeHash(data, null);
public string ComputeHashHexForPurpose(ReadOnlySpan<byte> data, string purpose)
=> ComputeHashHex(data, null);
public string ComputeHashBase64ForPurpose(ReadOnlySpan<byte> data, string purpose)
=> ComputeHashBase64(data, null);
public ValueTask<byte[]> ComputeHashForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
=> ComputeHashAsync(stream, null, cancellationToken);
public ValueTask<string> ComputeHashHexForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
=> ComputeHashHexAsync(stream, null, cancellationToken);
public string GetAlgorithmForPurpose(string purpose) => "sha256";
public string GetHashPrefix(string purpose) => "sha256:";
public string ComputePrefixedHashForPurpose(ReadOnlySpan<byte> data, string purpose)
=> GetHashPrefix(purpose) + ComputeHashHexForPurpose(data, purpose);
}
}

View File

@@ -0,0 +1,571 @@
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.ExportCenter.Core.MirrorBundle;
using StellaOps.ExportCenter.WebService.Distribution.Oci;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.ExportCenter.Tests.Distribution.Oci;
/// <summary>
/// Tests for OciReferrerDiscoveryService which wraps IOciReferrerDiscovery for use in MirrorAdapter.
/// </summary>
public sealed class OciReferrerDiscoveryServiceTests
{
private readonly Mock<IOciReferrerDiscovery> _mockDiscovery;
private readonly OciReferrerDiscoveryService _service;
public OciReferrerDiscoveryServiceTests()
{
_mockDiscovery = new Mock<IOciReferrerDiscovery>();
_service = new OciReferrerDiscoveryService(
_mockDiscovery.Object,
NullLogger<OciReferrerDiscoveryService>.Instance);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DiscoverReferrersAsync_ValidDigestReference_ReturnsResults()
{
// Arrange
var imageRef = "registry.example.com/myapp@sha256:abc123def456789abc123def456789abc123def456789abc123def456789abcd";
var referrerInfo = new ReferrerInfo
{
Digest = "sha256:referrer111",
ArtifactType = "application/vnd.cyclonedx+json",
MediaType = "application/vnd.oci.image.manifest.v1+json",
Size = 1234
};
_mockDiscovery
.Setup(x => x.ListReferrersAsync(
"registry.example.com",
"myapp",
"sha256:abc123def456789abc123def456789abc123def456789abc123def456789abcd",
null,
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ReferrerListResult
{
IsSuccess = true,
SupportsReferrersApi = true,
Referrers = [referrerInfo]
});
_mockDiscovery
.Setup(x => x.GetReferrerManifestAsync(
"registry.example.com",
"myapp",
"sha256:referrer111",
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ReferrerManifest
{
Digest = "sha256:referrer111",
ArtifactType = "application/vnd.cyclonedx+json",
Layers =
[
new StellaOps.ExportCenter.WebService.Distribution.Oci.ReferrerLayer
{
Digest = "sha256:layer1",
MediaType = "application/vnd.cyclonedx+json",
Size = 1234
}
]
});
// Act
var result = await _service.DiscoverReferrersAsync(imageRef);
// Assert
result.IsSuccess.Should().BeTrue();
result.Registry.Should().Be("registry.example.com");
result.Repository.Should().Be("myapp");
result.SubjectDigest.Should().Be("sha256:abc123def456789abc123def456789abc123def456789abc123def456789abcd");
result.SupportsReferrersApi.Should().BeTrue();
result.Referrers.Should().HaveCount(1);
result.Referrers[0].Digest.Should().Be("sha256:referrer111");
result.Referrers[0].ArtifactType.Should().Be("application/vnd.cyclonedx+json");
result.Referrers[0].Layers.Should().HaveCount(1);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DiscoverReferrersAsync_TagReference_ReturnsFailure()
{
// Arrange - tag references cannot be used directly for referrer discovery
var imageRef = "registry.example.com/myapp:v1.0.0";
// Act
var result = await _service.DiscoverReferrersAsync(imageRef);
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("Invalid image reference");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DiscoverReferrersAsync_InvalidReference_ReturnsFailure()
{
// Arrange
var imageRef = "not-a-valid-reference";
// Act
var result = await _service.DiscoverReferrersAsync(imageRef);
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("Invalid image reference");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DiscoverReferrersAsync_EmptyReference_ReturnsFailure()
{
// Act
var result = await _service.DiscoverReferrersAsync("");
// Assert
result.IsSuccess.Should().BeFalse();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DiscoverReferrersAsync_RegistryError_ReturnsFailure()
{
// Arrange
var imageRef = "registry.example.com/myapp@sha256:abc123def456789abc123def456789abc123def456789abc123def456789abcd";
_mockDiscovery
.Setup(x => x.ListReferrersAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
null,
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ReferrerListResult
{
IsSuccess = false,
Error = "Registry connection failed"
});
// Act
var result = await _service.DiscoverReferrersAsync(imageRef);
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("Registry connection failed");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DiscoverReferrersAsync_NoReferrers_ReturnsEmptyList()
{
// Arrange
var imageRef = "registry.example.com/myapp@sha256:abc123def456789abc123def456789abc123def456789abc123def456789abcd";
_mockDiscovery
.Setup(x => x.ListReferrersAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
null,
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ReferrerListResult
{
IsSuccess = true,
SupportsReferrersApi = true,
Referrers = []
});
// Act
var result = await _service.DiscoverReferrersAsync(imageRef);
// Assert
result.IsSuccess.Should().BeTrue();
result.Referrers.Should().BeEmpty();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DiscoverReferrersAsync_WithPort_ParsesCorrectly()
{
// Arrange
var imageRef = "localhost:5000/myapp@sha256:abc123def456789abc123def456789abc123def456789abc123def456789abcd";
_mockDiscovery
.Setup(x => x.ListReferrersAsync(
"localhost:5000",
"myapp",
"sha256:abc123def456789abc123def456789abc123def456789abc123def456789abcd",
null,
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ReferrerListResult
{
IsSuccess = true,
SupportsReferrersApi = true,
Referrers = []
});
// Act
var result = await _service.DiscoverReferrersAsync(imageRef);
// Assert
result.IsSuccess.Should().BeTrue();
result.Registry.Should().Be("localhost:5000");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DiscoverReferrersAsync_NestedRepository_ParsesCorrectly()
{
// Arrange
var imageRef = "registry.example.com/org/project/app@sha256:abc123def456789abc123def456789abc123def456789abc123def456789abcd";
_mockDiscovery
.Setup(x => x.ListReferrersAsync(
"registry.example.com",
"org/project/app",
"sha256:abc123def456789abc123def456789abc123def456789abc123def456789abcd",
null,
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ReferrerListResult
{
IsSuccess = true,
SupportsReferrersApi = true,
Referrers = []
});
// Act
var result = await _service.DiscoverReferrersAsync(imageRef);
// Assert
result.IsSuccess.Should().BeTrue();
result.Registry.Should().Be("registry.example.com");
result.Repository.Should().Be("org/project/app");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DiscoverReferrersAsync_FallbackToTags_ReportsCorrectly()
{
// Arrange
var imageRef = "registry.example.com/myapp@sha256:abc123def456789abc123def456789abc123def456789abc123def456789abcd";
_mockDiscovery
.Setup(x => x.ListReferrersAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
null,
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ReferrerListResult
{
IsSuccess = true,
SupportsReferrersApi = false, // Using fallback
Referrers = []
});
// Act
var result = await _service.DiscoverReferrersAsync(imageRef);
// Assert
result.IsSuccess.Should().BeTrue();
result.SupportsReferrersApi.Should().BeFalse();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetReferrerContentAsync_ValidDigest_ReturnsContent()
{
// Arrange
var content = "{\"test\":\"content\"}"u8.ToArray();
_mockDiscovery
.Setup(x => x.GetLayerContentAsync(
"registry.example.com",
"myapp",
"sha256:layer123",
It.IsAny<CancellationToken>()))
.ReturnsAsync(content);
// Act
var result = await _service.GetReferrerContentAsync(
"registry.example.com",
"myapp",
"sha256:layer123");
// Assert
result.Should().NotBeNull();
result.Should().BeEquivalentTo(content);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetReferrerContentAsync_NotFound_ReturnsNull()
{
// Arrange
_mockDiscovery
.Setup(x => x.GetLayerContentAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new HttpRequestException("Not found"));
// Act
var result = await _service.GetReferrerContentAsync(
"registry.example.com",
"myapp",
"sha256:nonexistent");
// Assert
result.Should().BeNull();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DiscoverReferrersAsync_ManifestFetchFails_IncludesReferrerWithEmptyLayers()
{
// Arrange
var imageRef = "registry.example.com/myapp@sha256:abc123def456789abc123def456789abc123def456789abc123def456789abcd";
var referrerInfo = new ReferrerInfo
{
Digest = "sha256:referrer111",
ArtifactType = "application/vnd.cyclonedx+json",
MediaType = "application/vnd.oci.image.manifest.v1+json",
Size = 1234
};
_mockDiscovery
.Setup(x => x.ListReferrersAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
null,
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ReferrerListResult
{
IsSuccess = true,
SupportsReferrersApi = true,
Referrers = [referrerInfo]
});
_mockDiscovery
.Setup(x => x.GetReferrerManifestAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((ReferrerManifest?)null);
// Act
var result = await _service.DiscoverReferrersAsync(imageRef);
// Assert
result.IsSuccess.Should().BeTrue();
result.Referrers.Should().HaveCount(1);
result.Referrers[0].Layers.Should().BeEmpty();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AddOciReferrerDiscoveryService_RegistersService()
{
// Arrange
var services = new ServiceCollection();
services.AddScoped<IOciReferrerDiscovery>(_ => _mockDiscovery.Object);
services.AddLogging();
// Act
services.AddOciReferrerDiscoveryService();
var provider = services.BuildServiceProvider();
// Assert
var service = provider.GetService<IReferrerDiscoveryService>();
service.Should().NotBeNull();
service.Should().BeOfType<OciReferrerDiscoveryService>();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Constructor_NullDiscovery_ThrowsArgumentNull()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new OciReferrerDiscoveryService(null!, NullLogger<OciReferrerDiscoveryService>.Instance));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Constructor_NullLogger_ThrowsArgumentNull()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new OciReferrerDiscoveryService(_mockDiscovery.Object, null!));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ProbeRegistryCapabilitiesAsync_WithFallback_ReturnsCapabilities()
{
// Arrange
var mockFallback = new Mock<IOciReferrerFallback>();
var capabilities = new RegistryCapabilities
{
Registry = "registry.example.com",
SupportsReferrersApi = true,
DistributionVersion = "1.1.0",
ProbedAt = DateTimeOffset.UtcNow
};
mockFallback
.Setup(x => x.ProbeCapabilitiesAsync("registry.example.com", It.IsAny<CancellationToken>()))
.ReturnsAsync(capabilities);
var service = new OciReferrerDiscoveryService(
_mockDiscovery.Object,
NullLogger<OciReferrerDiscoveryService>.Instance,
mockFallback.Object);
// Act
var result = await service.ProbeRegistryCapabilitiesAsync("registry.example.com");
// Assert
result.Should().NotBeNull();
result.IsSuccess.Should().BeTrue();
result.SupportsReferrersApi.Should().BeTrue();
result.DistributionVersion.Should().Be("1.1.0");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ProbeRegistryCapabilitiesAsync_WithoutFallback_ReturnsDefaultCapabilities()
{
// Arrange - service without fallback
var service = new OciReferrerDiscoveryService(
_mockDiscovery.Object,
NullLogger<OciReferrerDiscoveryService>.Instance);
// Act
var result = await service.ProbeRegistryCapabilitiesAsync("registry.example.com");
// Assert
result.Should().NotBeNull();
result.IsSuccess.Should().BeTrue();
result.SupportsReferrersApi.Should().BeFalse();
result.Registry.Should().Be("registry.example.com");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ProbeRegistryCapabilitiesAsync_CachesResult()
{
// Arrange
var mockFallback = new Mock<IOciReferrerFallback>();
var capabilities = new RegistryCapabilities
{
Registry = "registry.example.com",
SupportsReferrersApi = true,
ProbedAt = DateTimeOffset.UtcNow
};
mockFallback
.Setup(x => x.ProbeCapabilitiesAsync("registry.example.com", It.IsAny<CancellationToken>()))
.ReturnsAsync(capabilities);
var service = new OciReferrerDiscoveryService(
_mockDiscovery.Object,
NullLogger<OciReferrerDiscoveryService>.Instance,
mockFallback.Object);
// Act - call twice
await service.ProbeRegistryCapabilitiesAsync("registry.example.com");
await service.ProbeRegistryCapabilitiesAsync("registry.example.com");
// Assert - should only call fallback once
mockFallback.Verify(
x => x.ProbeCapabilitiesAsync("registry.example.com", It.IsAny<CancellationToken>()),
Times.Once);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DiscoverReferrersAsync_ProbesCapabilitiesBeforeDiscovery()
{
// Arrange
var mockFallback = new Mock<IOciReferrerFallback>();
var capabilities = new RegistryCapabilities
{
Registry = "registry.example.com",
SupportsReferrersApi = true,
ProbedAt = DateTimeOffset.UtcNow
};
mockFallback
.Setup(x => x.ProbeCapabilitiesAsync("registry.example.com", It.IsAny<CancellationToken>()))
.ReturnsAsync(capabilities);
_mockDiscovery
.Setup(x => x.ListReferrersAsync(
"registry.example.com",
"myapp",
It.IsAny<string>(),
null,
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ReferrerListResult
{
IsSuccess = true,
SupportsReferrersApi = true,
Referrers = []
});
var service = new OciReferrerDiscoveryService(
_mockDiscovery.Object,
NullLogger<OciReferrerDiscoveryService>.Instance,
mockFallback.Object);
var imageRef = "registry.example.com/myapp@sha256:abc123def456789abc123def456789abc123def456789abc123def456789abcd";
// Act
await service.DiscoverReferrersAsync(imageRef);
// Assert - capabilities should be probed
mockFallback.Verify(
x => x.ProbeCapabilitiesAsync("registry.example.com", It.IsAny<CancellationToken>()),
Times.Once);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ClearProbedRegistriesCache_ClearsCachedCapabilities()
{
// Arrange
var mockFallback = new Mock<IOciReferrerFallback>();
var capabilities = new RegistryCapabilities
{
Registry = "registry.example.com",
SupportsReferrersApi = true,
ProbedAt = DateTimeOffset.UtcNow
};
mockFallback
.Setup(x => x.ProbeCapabilitiesAsync("registry.example.com", It.IsAny<CancellationToken>()))
.ReturnsAsync(capabilities);
var service = new OciReferrerDiscoveryService(
_mockDiscovery.Object,
NullLogger<OciReferrerDiscoveryService>.Instance,
mockFallback.Object);
// Act - probe, clear cache, probe again
service.ProbeRegistryCapabilitiesAsync("registry.example.com").Wait();
service.ClearProbedRegistriesCache();
service.ProbeRegistryCapabilitiesAsync("registry.example.com").Wait();
// Assert - should call fallback twice after clearing cache
mockFallback.Verify(
x => x.ProbeCapabilitiesAsync("registry.example.com", It.IsAny<CancellationToken>()),
Times.Exactly(2));
}
}

View File

@@ -0,0 +1,356 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Text.RegularExpressions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.ExportCenter.Core.MirrorBundle;
using StellaOps.ExportCenter.WebService.Telemetry;
namespace StellaOps.ExportCenter.WebService.Distribution.Oci;
/// <summary>
/// Implementation of IReferrerDiscoveryService that wraps OciReferrerDiscovery.
/// Provides OCI referrer discovery for mirror bundle exports with capability probing,
/// logging, and metrics.
/// </summary>
public sealed class OciReferrerDiscoveryService : IReferrerDiscoveryService
{
// Regex to parse image references: registry/repo:tag or registry/repo@sha256:...
private static readonly Regex ImageReferencePattern = new(
@"^(?<registry>[a-zA-Z0-9][-a-zA-Z0-9.]*[a-zA-Z0-9](:[0-9]+)?)/(?<repository>[a-zA-Z0-9][-a-zA-Z0-9._/]*)(?<separator>[:@])(?<reference>.+)$",
RegexOptions.Compiled | RegexOptions.ExplicitCapture);
private readonly IOciReferrerDiscovery _discovery;
private readonly IOciReferrerFallback? _fallback;
private readonly ILogger<OciReferrerDiscoveryService> _logger;
// Track probed registries to log once per export session
private readonly ConcurrentDictionary<string, RegistryCapabilities> _probedRegistries = new();
public OciReferrerDiscoveryService(
IOciReferrerDiscovery discovery,
ILogger<OciReferrerDiscoveryService> logger,
IOciReferrerFallback? fallback = null)
{
_discovery = discovery ?? throw new ArgumentNullException(nameof(discovery));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_fallback = fallback;
}
/// <inheritdoc />
public async Task<RegistryCapabilitiesInfo> ProbeRegistryCapabilitiesAsync(
string registry,
CancellationToken cancellationToken = default)
{
if (_fallback is null)
{
_logger.LogDebug("Registry capability probing not available (no fallback service)");
return new RegistryCapabilitiesInfo
{
Registry = registry,
SupportsReferrersApi = false,
SupportsArtifactType = false,
ProbedAt = DateTimeOffset.UtcNow,
IsSuccess = true
};
}
// Check if already probed in this session
if (_probedRegistries.TryGetValue(registry, out var cached))
{
return new RegistryCapabilitiesInfo
{
Registry = registry,
DistributionVersion = cached.DistributionVersion,
SupportsReferrersApi = cached.SupportsReferrersApi,
SupportsArtifactType = cached.SupportsArtifactType,
ProbedAt = cached.ProbedAt,
IsSuccess = true
};
}
try
{
var stopwatch = Stopwatch.StartNew();
var capabilities = await _fallback.ProbeCapabilitiesAsync(registry, cancellationToken);
stopwatch.Stop();
// Cache for this session
_probedRegistries.TryAdd(registry, capabilities);
// Log capabilities
if (capabilities.SupportsReferrersApi)
{
_logger.LogInformation(
"Registry {Registry}: OCI 1.1 (referrers API supported, version={Version}, probe_ms={ProbeMs})",
registry,
capabilities.DistributionVersion ?? "unknown",
stopwatch.ElapsedMilliseconds);
}
else
{
_logger.LogWarning(
"Registry {Registry}: OCI 1.0 (using fallback tag discovery, version={Version}, probe_ms={ProbeMs})",
registry,
capabilities.DistributionVersion ?? "unknown",
stopwatch.ElapsedMilliseconds);
}
// Record metrics
ExportTelemetry.RegistryCapabilitiesProbedTotal.Add(1,
new KeyValuePair<string, object?>(ExportTelemetryTags.Registry, registry),
new KeyValuePair<string, object?>(ExportTelemetryTags.ApiSupported, capabilities.SupportsReferrersApi.ToString().ToLowerInvariant()));
return new RegistryCapabilitiesInfo
{
Registry = registry,
DistributionVersion = capabilities.DistributionVersion,
SupportsReferrersApi = capabilities.SupportsReferrersApi,
SupportsArtifactType = capabilities.SupportsArtifactType,
ProbedAt = capabilities.ProbedAt,
IsSuccess = true
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to probe capabilities for registry {Registry}", registry);
return RegistryCapabilitiesInfo.Failed(registry, ex.Message);
}
}
/// <inheritdoc />
public async Task<ReferrerDiscoveryResult> DiscoverReferrersAsync(
string imageReference,
CancellationToken cancellationToken = default)
{
var parsed = ParseImageReference(imageReference);
if (parsed is null)
{
return ReferrerDiscoveryResult.Failed(
$"Invalid image reference format: {imageReference}",
string.Empty,
string.Empty,
string.Empty);
}
var (registry, repository, digest) = parsed.Value;
_logger.LogDebug(
"Discovering referrers for {Registry}/{Repository}@{Digest}",
registry, repository, digest);
// Probe capabilities first (if not already done for this registry)
await ProbeRegistryCapabilitiesAsync(registry, cancellationToken);
try
{
// List all referrers (no filter - get everything)
var result = await _discovery.ListReferrersAsync(
registry, repository, digest, filter: null, cancellationToken);
if (!result.IsSuccess)
{
// Record failure metric
ExportTelemetry.ReferrerDiscoveryFailuresTotal.Add(1,
new KeyValuePair<string, object?>(ExportTelemetryTags.Registry, registry),
new KeyValuePair<string, object?>(ExportTelemetryTags.ErrorType, "discovery_failed"));
return ReferrerDiscoveryResult.Failed(
result.Error ?? "Unknown error during referrer discovery",
digest,
registry,
repository);
}
// Record discovery method metric
var discoveryMethod = result.SupportsReferrersApi
? ReferrerDiscoveryMethods.Native
: ReferrerDiscoveryMethods.Fallback;
ExportTelemetry.ReferrerDiscoveryMethodTotal.Add(1,
new KeyValuePair<string, object?>(ExportTelemetryTags.Registry, registry),
new KeyValuePair<string, object?>(ExportTelemetryTags.DiscoveryMethod, discoveryMethod));
// Convert to DiscoveredReferrer records with full manifest info
var referrers = new List<DiscoveredReferrer>();
foreach (var referrerInfo in result.Referrers)
{
// Get full manifest to retrieve layers
var manifest = await _discovery.GetReferrerManifestAsync(
registry, repository, referrerInfo.Digest, cancellationToken);
var layers = manifest?.Layers
.Select(l => new Core.MirrorBundle.ReferrerLayer
{
Digest = l.Digest,
MediaType = l.MediaType,
Size = l.Size,
Annotations = l.Annotations
})
.ToList() ?? [];
referrers.Add(new DiscoveredReferrer
{
Digest = referrerInfo.Digest,
ArtifactType = referrerInfo.ArtifactType,
MediaType = referrerInfo.MediaType,
Size = referrerInfo.Size,
Annotations = referrerInfo.Annotations,
Layers = layers
});
// Record referrer discovered metric
var artifactTypeTag = GetArtifactTypeTag(referrerInfo.ArtifactType);
ExportTelemetry.ReferrersDiscoveredTotal.Add(1,
new KeyValuePair<string, object?>(ExportTelemetryTags.Registry, registry),
new KeyValuePair<string, object?>(ExportTelemetryTags.ArtifactType, artifactTypeTag));
}
_logger.LogInformation(
"Discovered {Count} referrers for {Registry}/{Repository}@{Digest} (method={Method})",
referrers.Count,
registry,
repository,
digest,
discoveryMethod);
return new ReferrerDiscoveryResult
{
IsSuccess = true,
SubjectDigest = digest,
Registry = registry,
Repository = repository,
Referrers = referrers,
SupportsReferrersApi = result.SupportsReferrersApi
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error discovering referrers for {ImageReference}", imageReference);
// Record failure metric
ExportTelemetry.ReferrerDiscoveryFailuresTotal.Add(1,
new KeyValuePair<string, object?>(ExportTelemetryTags.Registry, registry),
new KeyValuePair<string, object?>(ExportTelemetryTags.ErrorType, ex.GetType().Name.ToLowerInvariant()));
return ReferrerDiscoveryResult.Failed(
ex.Message,
digest,
registry,
repository);
}
}
/// <inheritdoc />
public async Task<byte[]?> GetReferrerContentAsync(
string registry,
string repository,
string digest,
CancellationToken cancellationToken = default)
{
_logger.LogDebug(
"Fetching referrer content: {Registry}/{Repository}@{Digest}",
registry, repository, digest);
try
{
return await _discovery.GetLayerContentAsync(registry, repository, digest, cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch referrer content {Digest}", digest);
return null;
}
}
/// <summary>
/// Clears the probed registries cache. Useful for testing or long-running exports.
/// </summary>
public void ClearProbedRegistriesCache()
{
_probedRegistries.Clear();
}
/// <summary>
/// Parses an image reference into registry, repository, and digest.
/// </summary>
private static (string Registry, string Repository, string Digest)? ParseImageReference(string imageReference)
{
if (string.IsNullOrWhiteSpace(imageReference))
return null;
var match = ImageReferencePattern.Match(imageReference);
if (!match.Success)
return null;
var registry = match.Groups["registry"].Value;
var repository = match.Groups["repository"].Value;
var separator = match.Groups["separator"].Value;
var reference = match.Groups["reference"].Value;
// If the reference is a tag, we need to resolve it to a digest
// For now, we only support direct digest references
if (separator == "@" && reference.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
return (registry, repository, reference);
}
// For tag references, the caller should resolve to digest first
// We'll treat tags as potentially having referrers by using a placeholder
if (separator == ":")
{
// This is a tag reference - we cannot discover referrers without resolving to digest
// Return null to indicate the reference needs to be resolved
return null;
}
return null;
}
/// <summary>
/// Gets a normalized artifact type tag for metrics.
/// </summary>
private static string GetArtifactTypeTag(string? artifactType)
{
if (string.IsNullOrEmpty(artifactType))
return "unknown";
if (artifactType.Contains("cyclonedx", StringComparison.OrdinalIgnoreCase) ||
artifactType.Contains("spdx", StringComparison.OrdinalIgnoreCase) ||
artifactType.Contains("sbom", StringComparison.OrdinalIgnoreCase))
return ArtifactTypes.Sbom;
if (artifactType.Contains("vex", StringComparison.OrdinalIgnoreCase))
return ArtifactTypes.Vex;
if (artifactType.Contains("attestation", StringComparison.OrdinalIgnoreCase) ||
artifactType.Contains("in-toto", StringComparison.OrdinalIgnoreCase) ||
artifactType.Contains("dsse", StringComparison.OrdinalIgnoreCase) ||
artifactType.Contains("provenance", StringComparison.OrdinalIgnoreCase))
return ArtifactTypes.Attestation;
return "other";
}
}
/// <summary>
/// Extension methods for registering OCI referrer discovery services.
/// </summary>
public static class OciReferrerDiscoveryServiceExtensions
{
/// <summary>
/// Adds OCI referrer discovery service to the service collection.
/// </summary>
public static IServiceCollection AddOciReferrerDiscoveryService(this IServiceCollection services)
{
services.AddScoped<IReferrerDiscoveryService>(sp =>
{
var discovery = sp.GetRequiredService<IOciReferrerDiscovery>();
var logger = sp.GetRequiredService<ILogger<OciReferrerDiscoveryService>>();
var fallback = sp.GetService<IOciReferrerFallback>(); // Optional
return new OciReferrerDiscoveryService(discovery, logger, fallback);
});
return services;
}
}

View File

@@ -211,6 +211,42 @@ public static class ExportTelemetry
"connections",
"Total number of SSE connections");
/// <summary>
/// Total number of registry capability probes.
/// Tags: registry, api_supported
/// </summary>
public static readonly Counter<long> RegistryCapabilitiesProbedTotal = Meter.CreateCounter<long>(
"export_registry_capabilities_probed_total",
"probes",
"Total number of registry capability probes");
/// <summary>
/// Total number of referrer discovery operations by method.
/// Tags: registry, method (native|fallback)
/// </summary>
public static readonly Counter<long> ReferrerDiscoveryMethodTotal = Meter.CreateCounter<long>(
"export_referrer_discovery_method_total",
"discoveries",
"Total number of referrer discovery operations by method");
/// <summary>
/// Total number of referrers discovered.
/// Tags: registry, artifact_type
/// </summary>
public static readonly Counter<long> ReferrersDiscoveredTotal = Meter.CreateCounter<long>(
"export_referrers_discovered_total",
"referrers",
"Total number of referrers discovered");
/// <summary>
/// Total number of referrer discovery failures.
/// Tags: registry, error_type
/// </summary>
public static readonly Counter<long> ReferrerDiscoveryFailuresTotal = Meter.CreateCounter<long>(
"export_referrer_discovery_failures_total",
"failures",
"Total number of referrer discovery failures");
#endregion
#region Histograms
@@ -291,6 +327,10 @@ public static class ExportTelemetryTags
public const string ErrorCode = "error_code";
public const string RunId = "run_id";
public const string DistributionType = "distribution_type";
public const string Registry = "registry";
public const string ApiSupported = "api_supported";
public const string DiscoveryMethod = "method";
public const string ErrorType = "error_type";
}
/// <summary>
@@ -329,3 +369,12 @@ public static class ExportStatuses
public const string Cancelled = "cancelled";
public const string Timeout = "timeout";
}
/// <summary>
/// Referrer discovery method values.
/// </summary>
public static class ReferrerDiscoveryMethods
{
public const string Native = "native";
public const string Fallback = "fallback";
}