save development progress

This commit is contained in:
StellaOps Bot
2025-12-25 23:09:58 +02:00
parent d71853ad7e
commit aa70af062e
351 changed files with 37683 additions and 150156 deletions

View File

@@ -375,3 +375,185 @@ public sealed class ChunkVerificationResult
/// </summary>
public string? ComputedHash { get; init; }
}
/// <summary>
/// Response model for GET /v1/provcache/{veriKey}/manifest.
/// </summary>
public sealed class InputManifestResponse
{
/// <summary>
/// The VeriKey this manifest describes.
/// </summary>
public required string VeriKey { get; init; }
/// <summary>
/// Information about the source artifact (container image, binary, etc.).
/// </summary>
public required SourceArtifactInfo SourceArtifact { get; init; }
/// <summary>
/// Information about the SBOM used in the decision.
/// </summary>
public required SbomInfoDto Sbom { get; init; }
/// <summary>
/// Information about VEX statements contributing to the decision.
/// </summary>
public required VexInfoDto Vex { get; init; }
/// <summary>
/// Information about the policy used in evaluation.
/// </summary>
public required PolicyInfoDto Policy { get; init; }
/// <summary>
/// Information about signers/attestors.
/// </summary>
public required SignerInfoDto Signers { get; init; }
/// <summary>
/// Time window information for cache validity.
/// </summary>
public required TimeWindowInfoDto TimeWindow { get; init; }
/// <summary>
/// When the manifest was generated.
/// </summary>
public required DateTimeOffset GeneratedAt { get; init; }
}
/// <summary>
/// SBOM information in API response.
/// </summary>
public sealed class SbomInfoDto
{
/// <summary>
/// Canonical hash of the SBOM content.
/// </summary>
public required string Hash { get; init; }
/// <summary>
/// SBOM format (spdx-2.3, cyclonedx-1.6, etc.).
/// </summary>
public string? Format { get; init; }
/// <summary>
/// Number of packages in the SBOM.
/// </summary>
public int? PackageCount { get; init; }
/// <summary>
/// Completeness percentage (0-100).
/// </summary>
public int? CompletenessScore { get; init; }
}
/// <summary>
/// VEX information in API response.
/// </summary>
public sealed class VexInfoDto
{
/// <summary>
/// Hash of the sorted VEX statement set.
/// </summary>
public required string HashSetHash { get; init; }
/// <summary>
/// Number of VEX statements contributing to this decision.
/// </summary>
public int StatementCount { get; init; }
/// <summary>
/// Sources of VEX statements (vendor names, OpenVEX IDs, etc.).
/// </summary>
public IReadOnlyList<string>? Sources { get; init; }
}
/// <summary>
/// Policy information in API response.
/// </summary>
public sealed class PolicyInfoDto
{
/// <summary>
/// Canonical hash of the policy bundle.
/// </summary>
public required string Hash { get; init; }
/// <summary>
/// Policy pack identifier.
/// </summary>
public string? PackId { get; init; }
/// <summary>
/// Policy version number.
/// </summary>
public int? Version { get; init; }
/// <summary>
/// Human-readable policy name.
/// </summary>
public string? Name { get; init; }
}
/// <summary>
/// Signer information in API response.
/// </summary>
public sealed class SignerInfoDto
{
/// <summary>
/// Hash of the sorted signer set.
/// </summary>
public required string SetHash { get; init; }
/// <summary>
/// Number of signers in the set.
/// </summary>
public int SignerCount { get; init; }
/// <summary>
/// Signer certificate information.
/// </summary>
public IReadOnlyList<SignerCertificateDto>? Certificates { get; init; }
}
/// <summary>
/// Signer certificate information in API response.
/// </summary>
public sealed class SignerCertificateDto
{
/// <summary>
/// Subject of the certificate (e.g., CN=...).
/// </summary>
public string? Subject { get; init; }
/// <summary>
/// Certificate issuer.
/// </summary>
public string? Issuer { get; init; }
/// <summary>
/// When the certificate expires.
/// </summary>
public DateTimeOffset? ExpiresAt { get; init; }
}
/// <summary>
/// Time window information in API response.
/// </summary>
public sealed class TimeWindowInfoDto
{
/// <summary>
/// The time window bucket identifier.
/// </summary>
public required string Bucket { get; init; }
/// <summary>
/// Start of the time window (UTC).
/// </summary>
public DateTimeOffset? StartsAt { get; init; }
/// <summary>
/// End of the time window (UTC).
/// </summary>
public DateTimeOffset? EndsAt { get; init; }
}

View File

@@ -70,6 +70,15 @@ public static partial class ProvcacheEndpointExtensions
.Produces<ProvcacheMetricsResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
// GET /v1/provcache/{veriKey}/manifest
group.MapGet("/{veriKey}/manifest", GetInputManifest)
.WithName("GetInputManifest")
.WithSummary("Get input manifest for VeriKey components")
.WithDescription("Returns detailed information about the inputs (SBOM, VEX, policy, signers) that formed a cached decision. Use for transparency and debugging.")
.Produces<InputManifestResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
// Map evidence paging endpoints under /proofs
var proofsGroup = endpoints.MapGroup($"{prefix}/proofs")
.WithTags("Provcache Evidence")
@@ -307,6 +316,119 @@ public static partial class ProvcacheEndpointExtensions
title: "Metrics retrieval failed");
}
}
/// <summary>
/// GET /v1/provcache/{veriKey}/manifest
/// </summary>
private static async Task<IResult> GetInputManifest(
string veriKey,
IProvcacheService provcacheService,
TimeProvider timeProvider,
ILogger<ProvcacheApiEndpoints> logger,
CancellationToken cancellationToken)
{
logger.LogDebug("GET /v1/provcache/{VeriKey}/manifest", veriKey);
try
{
// First get the entry to verify it exists
var result = await provcacheService.GetAsync(veriKey, bypassCache: false, cancellationToken);
if (result.Status == ProvcacheResultStatus.CacheMiss)
{
return Results.NotFound(new ProblemDetails
{
Title = "Entry not found",
Detail = $"No cache entry found for VeriKey: {veriKey}",
Status = StatusCodes.Status404NotFound
});
}
if (result.Status == ProvcacheResultStatus.Expired)
{
return Results.NotFound(new ProblemDetails
{
Title = "Entry expired",
Detail = $"Cache entry for VeriKey '{veriKey}' has expired",
Status = StatusCodes.Status404NotFound
});
}
var entry = result.Entry;
if (entry is null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Entry not found",
Detail = $"No cache entry found for VeriKey: {veriKey}",
Status = StatusCodes.Status404NotFound
});
}
// Build the input manifest from the entry metadata
// In a full implementation, we'd resolve these hashes to more detailed metadata
// from their respective stores (SBOM store, VEX store, policy registry, etc.)
var manifest = BuildInputManifest(entry, timeProvider);
return Results.Ok(manifest);
}
catch (Exception ex)
{
logger.LogError(ex, "Error getting input manifest for VeriKey {VeriKey}", veriKey);
return Results.Problem(
detail: ex.Message,
statusCode: StatusCodes.Status500InternalServerError,
title: "Manifest retrieval failed");
}
}
/// <summary>
/// Builds an InputManifestResponse from a ProvcacheEntry.
/// </summary>
private static InputManifestResponse BuildInputManifest(ProvcacheEntry entry, TimeProvider timeProvider)
{
// Build input manifest from the entry and its embedded DecisionDigest
// The DecisionDigest contains the VeriKey components as hashes
var decision = entry.Decision;
return new InputManifestResponse
{
VeriKey = entry.VeriKey,
SourceArtifact = new SourceArtifactInfo
{
// VeriKey includes source hash as first component
Digest = entry.VeriKey,
},
Sbom = new SbomInfoDto
{
// SBOM hash is embedded in VeriKey computation
// In a full implementation, we'd resolve this from the SBOM store
Hash = $"sha256:{entry.VeriKey[7..39]}...", // Placeholder - actual hash would come from VeriKey decomposition
},
Vex = new VexInfoDto
{
// VEX hash set is embedded in VeriKey computation
HashSetHash = $"sha256:{entry.VeriKey[7..39]}...", // Placeholder
StatementCount = 0, // Would be resolved from VEX store
},
Policy = new PolicyInfoDto
{
Hash = entry.PolicyHash,
},
Signers = new SignerInfoDto
{
SetHash = entry.SignerSetHash,
SignerCount = 0, // Would be resolved from signer registry
},
TimeWindow = new TimeWindowInfoDto
{
Bucket = entry.FeedEpoch,
StartsAt = entry.CreatedAt,
EndsAt = entry.ExpiresAt,
},
GeneratedAt = timeProvider.GetUtcNow(),
};
}
}
/// <summary>

View File

@@ -17,6 +17,7 @@ public sealed class DecisionDigestBuilder
private DateTimeOffset? _createdAt;
private DateTimeOffset? _expiresAt;
private int? _trustScore;
private TrustScoreBreakdown? _trustScoreBreakdown;
private readonly ProvcacheOptions _options;
private readonly TimeProvider _timeProvider;
@@ -217,7 +218,20 @@ public sealed class DecisionDigestBuilder
}
/// <summary>
/// Computes trust score from component scores using weighted formula.
/// Sets the trust score from a breakdown, computing the total automatically.
/// </summary>
/// <param name="breakdown">The trust score breakdown with component scores.</param>
public DecisionDigestBuilder WithTrustScoreBreakdown(TrustScoreBreakdown breakdown)
{
ArgumentNullException.ThrowIfNull(breakdown);
_trustScoreBreakdown = breakdown;
_trustScore = breakdown.ComputeTotal();
return this;
}
/// <summary>
/// Computes trust score from component scores using weighted formula,
/// and stores the breakdown for API responses.
/// </summary>
/// <param name="reachabilityScore">Reachability analysis coverage (0-100).</param>
/// <param name="sbomCompletenessScore">SBOM completeness (0-100).</param>
@@ -231,14 +245,16 @@ public sealed class DecisionDigestBuilder
int policyFreshnessScore,
int signerTrustScore)
{
// Weights from documentation:
// Reachability: 25%, SBOM: 20%, VEX: 20%, Policy: 15%, Signer: 20%
_trustScore = (int)Math.Round(
reachabilityScore * 0.25 +
sbomCompletenessScore * 0.20 +
vexCoverageScore * 0.20 +
policyFreshnessScore * 0.15 +
signerTrustScore * 0.20);
// Create breakdown with standard weights
_trustScoreBreakdown = TrustScoreBreakdown.CreateDefault(
reachabilityScore,
sbomCompletenessScore,
vexCoverageScore,
policyFreshnessScore,
signerTrustScore);
// Compute total from breakdown
_trustScore = _trustScoreBreakdown.ComputeTotal();
// Clamp to valid range
_trustScore = Math.Clamp(_trustScore.Value, 0, 100);
@@ -263,7 +279,8 @@ public sealed class DecisionDigestBuilder
ReplaySeed = _replaySeed!,
CreatedAt = _createdAt!.Value,
ExpiresAt = _expiresAt!.Value,
TrustScore = _trustScore!.Value
TrustScore = _trustScore!.Value,
TrustScoreBreakdown = _trustScoreBreakdown
};
}
@@ -279,6 +296,7 @@ public sealed class DecisionDigestBuilder
_createdAt = null;
_expiresAt = null;
_trustScore = null;
_trustScoreBreakdown = null;
return this;
}

View File

@@ -52,6 +52,99 @@ public sealed record DecisionDigest
/// Based on reachability, SBOM completeness, VEX coverage, policy freshness, and signer trust.
/// </summary>
public required int TrustScore { get; init; }
/// <summary>
/// Breakdown of trust score by component.
/// Each component has its own score (0-100) and weight that contributed to <see cref="TrustScore"/>.
/// </summary>
public TrustScoreBreakdown? TrustScoreBreakdown { get; init; }
}
/// <summary>
/// Breakdown of trust score by component, showing contribution from each evidence type.
/// </summary>
public sealed record TrustScoreBreakdown
{
/// <summary>
/// Reachability evidence contribution (weight: 25%).
/// Based on call graph / static analysis evidence.
/// </summary>
public required TrustScoreComponent Reachability { get; init; }
/// <summary>
/// SBOM completeness contribution (weight: 20%).
/// Based on package coverage and license data.
/// </summary>
public required TrustScoreComponent SbomCompleteness { get; init; }
/// <summary>
/// VEX statement coverage contribution (weight: 20%).
/// Based on vendor statements and OpenVEX coverage.
/// </summary>
public required TrustScoreComponent VexCoverage { get; init; }
/// <summary>
/// Policy freshness contribution (weight: 15%).
/// Based on last policy update timestamp.
/// </summary>
public required TrustScoreComponent PolicyFreshness { get; init; }
/// <summary>
/// Signer trust contribution (weight: 20%).
/// Based on signer reputation and key age.
/// </summary>
public required TrustScoreComponent SignerTrust { get; init; }
/// <summary>
/// Computes weighted total score from all components.
/// </summary>
public int ComputeTotal()
{
return (int)Math.Round(
Reachability.Score * Reachability.Weight +
SbomCompleteness.Score * SbomCompleteness.Weight +
VexCoverage.Score * VexCoverage.Weight +
PolicyFreshness.Score * PolicyFreshness.Weight +
SignerTrust.Score * SignerTrust.Weight);
}
/// <summary>
/// Creates a default breakdown with standard weights.
/// </summary>
public static TrustScoreBreakdown CreateDefault(
int reachabilityScore = 0,
int sbomScore = 0,
int vexScore = 0,
int policyScore = 0,
int signerScore = 0) => new()
{
Reachability = new TrustScoreComponent { Score = reachabilityScore, Weight = 0.25m },
SbomCompleteness = new TrustScoreComponent { Score = sbomScore, Weight = 0.20m },
VexCoverage = new TrustScoreComponent { Score = vexScore, Weight = 0.20m },
PolicyFreshness = new TrustScoreComponent { Score = policyScore, Weight = 0.15m },
SignerTrust = new TrustScoreComponent { Score = signerScore, Weight = 0.20m }
};
}
/// <summary>
/// Individual component of trust score with its score and weight.
/// </summary>
public sealed record TrustScoreComponent
{
/// <summary>
/// Component score (0-100).
/// </summary>
public required int Score { get; init; }
/// <summary>
/// Weight of this component in the total score (0.0-1.0).
/// </summary>
public required decimal Weight { get; init; }
/// <summary>
/// Weighted contribution to total score.
/// </summary>
public decimal Contribution => Score * Weight;
}
/// <summary>

View File

@@ -0,0 +1,245 @@
namespace StellaOps.Provcache;
/// <summary>
/// Manifest showing the exact inputs that form a VeriKey and cached decision.
/// Used for transparency and debugging to show what evidence contributed to a decision.
/// </summary>
public sealed record InputManifest
{
/// <summary>
/// The VeriKey this manifest describes.
/// </summary>
public required string VeriKey { get; init; }
/// <summary>
/// Information about the source artifact (container image, binary, etc.).
/// </summary>
public required SourceArtifactInfo SourceArtifact { get; init; }
/// <summary>
/// Information about the SBOM used in the decision.
/// </summary>
public required SbomInfo Sbom { get; init; }
/// <summary>
/// Information about VEX statements contributing to the decision.
/// </summary>
public required VexInfo Vex { get; init; }
/// <summary>
/// Information about the policy used in evaluation.
/// </summary>
public required PolicyInfo Policy { get; init; }
/// <summary>
/// Information about signers/attestors.
/// </summary>
public required SignerInfo Signers { get; init; }
/// <summary>
/// Time window information for cache validity.
/// </summary>
public required TimeWindowInfo TimeWindow { get; init; }
}
/// <summary>
/// Information about the source artifact.
/// </summary>
public sealed record SourceArtifactInfo
{
/// <summary>
/// Content-addressed hash of the artifact (e.g., sha256:abc123...).
/// </summary>
public required string Digest { get; init; }
/// <summary>
/// Type of artifact (container-image, binary, archive, etc.).
/// </summary>
public string? ArtifactType { get; init; }
/// <summary>
/// OCI reference if applicable (e.g., ghcr.io/org/repo:tag).
/// </summary>
public string? OciReference { get; init; }
/// <summary>
/// Size of the artifact in bytes.
/// </summary>
public long? SizeBytes { get; init; }
}
/// <summary>
/// Information about the SBOM.
/// </summary>
public sealed record SbomInfo
{
/// <summary>
/// Canonical hash of the SBOM content.
/// </summary>
public required string Hash { get; init; }
/// <summary>
/// SBOM format (spdx-2.3, cyclonedx-1.6, etc.).
/// </summary>
public string? Format { get; init; }
/// <summary>
/// Number of packages in the SBOM.
/// </summary>
public int? PackageCount { get; init; }
/// <summary>
/// Number of packages with license information.
/// </summary>
public int? PackagesWithLicense { get; init; }
/// <summary>
/// Completeness percentage (0-100).
/// </summary>
public int? CompletenessScore { get; init; }
/// <summary>
/// When the SBOM was created or last updated.
/// </summary>
public DateTimeOffset? CreatedAt { get; init; }
}
/// <summary>
/// Information about VEX statements.
/// </summary>
public sealed record VexInfo
{
/// <summary>
/// Hash of the sorted VEX statement set.
/// </summary>
public required string HashSetHash { get; init; }
/// <summary>
/// Number of VEX statements contributing to this decision.
/// </summary>
public int StatementCount { get; init; }
/// <summary>
/// Sources of VEX statements (vendor names, OpenVEX IDs, etc.).
/// </summary>
public IReadOnlyList<string> Sources { get; init; } = [];
/// <summary>
/// Most recent VEX statement timestamp.
/// </summary>
public DateTimeOffset? LatestStatementAt { get; init; }
/// <summary>
/// Individual statement hashes (for verification).
/// </summary>
public IReadOnlyList<string> StatementHashes { get; init; } = [];
}
/// <summary>
/// Information about the policy.
/// </summary>
public sealed record PolicyInfo
{
/// <summary>
/// Canonical hash of the policy bundle.
/// </summary>
public required string Hash { get; init; }
/// <summary>
/// Policy pack identifier.
/// </summary>
public string? PackId { get; init; }
/// <summary>
/// Policy version number.
/// </summary>
public int? Version { get; init; }
/// <summary>
/// When the policy was last updated.
/// </summary>
public DateTimeOffset? LastUpdatedAt { get; init; }
/// <summary>
/// Human-readable policy name.
/// </summary>
public string? Name { get; init; }
}
/// <summary>
/// Information about signers and attestors.
/// </summary>
public sealed record SignerInfo
{
/// <summary>
/// Hash of the sorted signer set.
/// </summary>
public required string SetHash { get; init; }
/// <summary>
/// Number of signers in the set.
/// </summary>
public int SignerCount { get; init; }
/// <summary>
/// Signer certificate information.
/// </summary>
public IReadOnlyList<SignerCertificate> Certificates { get; init; } = [];
}
/// <summary>
/// Information about a signer certificate.
/// </summary>
public sealed record SignerCertificate
{
/// <summary>
/// Subject of the certificate (e.g., CN=...).
/// </summary>
public string? Subject { get; init; }
/// <summary>
/// Certificate issuer.
/// </summary>
public string? Issuer { get; init; }
/// <summary>
/// Certificate serial number or fingerprint.
/// </summary>
public string? Fingerprint { get; init; }
/// <summary>
/// When the certificate expires.
/// </summary>
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// Trust level (fulcio, self-signed, enterprise-ca, etc.).
/// </summary>
public string? TrustLevel { get; init; }
}
/// <summary>
/// Information about the time window used in VeriKey.
/// </summary>
public sealed record TimeWindowInfo
{
/// <summary>
/// The time window bucket identifier.
/// </summary>
public required string Bucket { get; init; }
/// <summary>
/// Start of the time window (UTC).
/// </summary>
public DateTimeOffset? StartsAt { get; init; }
/// <summary>
/// End of the time window (UTC).
/// </summary>
public DateTimeOffset? EndsAt { get; init; }
/// <summary>
/// Duration of the time window.
/// </summary>
public TimeSpan? Duration { get; init; }
}

View File

@@ -0,0 +1,372 @@
// ----------------------------------------------------------------------------
// Copyright (c) 2025 StellaOps contributors. All rights reserved.
// SPDX-License-Identifier: AGPL-3.0-or-later
// ----------------------------------------------------------------------------
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Provcache.Oci;
/// <summary>
/// Builds OCI attestations for Provcache DecisionDigest objects.
/// The attestation follows the in-toto Statement format with a custom predicate type.
/// </summary>
public sealed class ProvcacheOciAttestationBuilder : IProvcacheOciAttestationBuilder
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false // Deterministic output
};
private readonly TimeProvider _timeProvider;
/// <summary>
/// Initializes a new instance of the <see cref="ProvcacheOciAttestationBuilder"/> class.
/// </summary>
public ProvcacheOciAttestationBuilder(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc/>
public ProvcacheOciAttestationResult Build(ProvcacheOciAttestationRequest request)
{
ArgumentNullException.ThrowIfNull(request);
ValidateRequest(request);
// Build subject from artifact reference
var subject = BuildSubject(request.ArtifactReference, request.ArtifactDigest);
// Build predicate from DecisionDigest
var predicate = BuildPredicate(request);
// Build the in-toto statement
var statement = new ProvcacheStatement
{
Subject = [subject],
Predicate = predicate
};
// Serialize to canonical JSON (deterministic)
var statementJson = JsonSerializer.Serialize(statement, SerializerOptions);
var statementBytes = Encoding.UTF8.GetBytes(statementJson);
// Build OCI annotations
var annotations = BuildAnnotations(request, predicate);
return new ProvcacheOciAttestationResult(
Statement: statement,
StatementJson: statementJson,
StatementBytes: statementBytes,
MediaType: ProvcachePredicateTypes.MediaType,
Annotations: annotations);
}
/// <inheritdoc/>
public ProvcacheOciAttachment CreateAttachment(ProvcacheOciAttestationRequest request)
{
var result = Build(request);
return new ProvcacheOciAttachment(
ArtifactReference: request.ArtifactReference,
MediaType: result.MediaType,
Payload: result.StatementJson,
PayloadBytes: result.StatementBytes,
Annotations: result.Annotations);
}
private static void ValidateRequest(ProvcacheOciAttestationRequest request)
{
if (string.IsNullOrWhiteSpace(request.ArtifactReference))
{
throw new ArgumentException("Artifact reference is required.", nameof(request));
}
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
{
throw new ArgumentException("Artifact digest is required.", nameof(request));
}
if (request.DecisionDigest is null)
{
throw new ArgumentException("DecisionDigest is required.", nameof(request));
}
if (string.IsNullOrWhiteSpace(request.DecisionDigest.VeriKey))
{
throw new ArgumentException("DecisionDigest.VeriKey is required.", nameof(request));
}
}
private static ProvcacheSubject BuildSubject(string artifactReference, string artifactDigest)
{
// Parse digest format: "sha256:abc123..." or just the hash
var (algorithm, hash) = ParseDigest(artifactDigest);
// Extract name from reference (remove tag/digest suffix)
var name = ExtractArtifactName(artifactReference);
return new ProvcacheSubject
{
Name = name,
Digest = new Dictionary<string, string>(StringComparer.Ordinal)
{
[algorithm] = hash
}
};
}
private static (string algorithm, string hash) ParseDigest(string digest)
{
if (string.IsNullOrWhiteSpace(digest))
{
throw new ArgumentException("Digest cannot be empty.", nameof(digest));
}
// Handle "sha256:abc123..." format
var colonIndex = digest.IndexOf(':');
if (colonIndex > 0)
{
return (digest[..colonIndex], digest[(colonIndex + 1)..]);
}
// Assume SHA256 if no algorithm prefix
return ("sha256", digest);
}
private static string ExtractArtifactName(string reference)
{
// Remove @sha256:... digest suffix
var atIndex = reference.LastIndexOf('@');
if (atIndex > 0)
{
return reference[..atIndex];
}
// Remove :tag suffix, but be careful with port numbers
// e.g., ghcr.io:443/org/repo:tag -> ghcr.io:443/org/repo
var colonIndex = reference.LastIndexOf(':');
if (colonIndex > 0)
{
// Check if this is a port number (no slash after colon)
var slashIndex = reference.LastIndexOf('/');
if (slashIndex > colonIndex)
{
// The colon is before the last slash, so it's a port number
return reference;
}
return reference[..colonIndex];
}
return reference;
}
private ProvcachePredicate BuildPredicate(ProvcacheOciAttestationRequest request)
{
var digest = request.DecisionDigest;
var manifest = request.InputManifest;
return new ProvcachePredicate
{
VeriKey = digest.VeriKey,
VerdictHash = digest.VerdictHash,
ProofRoot = digest.ProofRoot,
TrustScore = digest.TrustScore,
TrustScoreBreakdown = BuildTrustBreakdown(digest.TrustScoreBreakdown),
InputManifest = BuildInputSummary(manifest, digest),
ReplaySeed = new ProvcacheReplaySeed
{
FeedIds = digest.ReplaySeed.FeedIds,
RuleIds = digest.ReplaySeed.RuleIds,
FrozenEpoch = digest.ReplaySeed.FrozenEpoch?.ToString("O")
},
CreatedAt = digest.CreatedAt.ToString("O"),
ExpiresAt = digest.ExpiresAt.ToString("O"),
VerdictSummary = request.VerdictSummary
};
}
private static ProvcacheTrustBreakdown? BuildTrustBreakdown(TrustScoreBreakdown? breakdown)
{
if (breakdown is null)
{
return null;
}
return new ProvcacheTrustBreakdown
{
Reachability = new ProvcacheTrustComponent
{
Score = breakdown.Reachability.Score,
Weight = breakdown.Reachability.Weight
},
SbomCompleteness = new ProvcacheTrustComponent
{
Score = breakdown.SbomCompleteness.Score,
Weight = breakdown.SbomCompleteness.Weight
},
VexCoverage = new ProvcacheTrustComponent
{
Score = breakdown.VexCoverage.Score,
Weight = breakdown.VexCoverage.Weight
},
PolicyFreshness = new ProvcacheTrustComponent
{
Score = breakdown.PolicyFreshness.Score,
Weight = breakdown.PolicyFreshness.Weight
},
SignerTrust = new ProvcacheTrustComponent
{
Score = breakdown.SignerTrust.Score,
Weight = breakdown.SignerTrust.Weight
}
};
}
private static ProvcacheInputSummary BuildInputSummary(InputManifest? manifest, DecisionDigest digest)
{
if (manifest is null)
{
// Fallback: extract from VeriKey components if available
return new ProvcacheInputSummary
{
SourceHash = ExtractComponentHash(digest.VeriKey, 0),
SbomHash = null,
VexSetHash = null,
PolicyHash = null,
SignerSetHash = null
};
}
return new ProvcacheInputSummary
{
SourceHash = manifest.SourceArtifact.Digest,
SbomHash = manifest.Sbom.Hash,
VexSetHash = manifest.Vex.SetHash,
PolicyHash = manifest.Policy.Hash,
SignerSetHash = manifest.Signers.SetHash
};
}
private static string ExtractComponentHash(string veriKey, int index)
{
// VeriKey format is typically sha256:<hash>
// This is a fallback; prefer using InputManifest
return veriKey;
}
private static IReadOnlyDictionary<string, string> BuildAnnotations(
ProvcacheOciAttestationRequest request,
ProvcachePredicate predicate)
{
var annotations = new Dictionary<string, string>(StringComparer.Ordinal)
{
// Standard OCI annotations
["org.opencontainers.image.title"] = "stellaops.provcache.decision",
["org.opencontainers.image.description"] = "Provcache decision attestation for provenance-cached vulnerability decisions",
["org.opencontainers.image.created"] = predicate.CreatedAt,
// StellaOps-specific annotations
["stellaops.provcache.verikey"] = predicate.VeriKey,
["stellaops.provcache.verdict-hash"] = predicate.VerdictHash,
["stellaops.provcache.proof-root"] = predicate.ProofRoot,
["stellaops.provcache.trust-score"] = predicate.TrustScore.ToString(),
["stellaops.provcache.expires-at"] = predicate.ExpiresAt
};
// Add optional tenant annotation
if (!string.IsNullOrWhiteSpace(request.TenantId))
{
annotations["stellaops.tenant"] = request.TenantId;
}
// Add optional scope annotation
if (!string.IsNullOrWhiteSpace(request.Scope))
{
annotations["stellaops.scope"] = request.Scope;
}
return annotations;
}
}
/// <summary>
/// Interface for building OCI attestations for Provcache decisions.
/// </summary>
public interface IProvcacheOciAttestationBuilder
{
/// <summary>
/// Builds an OCI attestation from a DecisionDigest.
/// </summary>
ProvcacheOciAttestationResult Build(ProvcacheOciAttestationRequest request);
/// <summary>
/// Creates an OCI attachment ready for pushing to a registry.
/// </summary>
ProvcacheOciAttachment CreateAttachment(ProvcacheOciAttestationRequest request);
}
/// <summary>
/// Request for building a Provcache OCI attestation.
/// </summary>
public sealed record ProvcacheOciAttestationRequest
{
/// <summary>
/// OCI artifact reference (e.g., ghcr.io/org/repo:tag or ghcr.io/org/repo@sha256:...).
/// </summary>
public required string ArtifactReference { get; init; }
/// <summary>
/// Digest of the artifact (e.g., sha256:abc123...).
/// </summary>
public required string ArtifactDigest { get; init; }
/// <summary>
/// The DecisionDigest to create an attestation for.
/// </summary>
public required DecisionDigest DecisionDigest { get; init; }
/// <summary>
/// Optional: Full InputManifest for detailed provenance.
/// </summary>
public InputManifest? InputManifest { get; init; }
/// <summary>
/// Optional: Summary of verdicts.
/// </summary>
public ProvcacheVerdictSummary? VerdictSummary { get; init; }
/// <summary>
/// Optional: Tenant identifier for multi-tenant scenarios.
/// </summary>
public string? TenantId { get; init; }
/// <summary>
/// Optional: Scope identifier (e.g., environment, pipeline).
/// </summary>
public string? Scope { get; init; }
}
/// <summary>
/// Result of building an OCI attestation.
/// </summary>
public sealed record ProvcacheOciAttestationResult(
ProvcacheStatement Statement,
string StatementJson,
byte[] StatementBytes,
string MediaType,
IReadOnlyDictionary<string, string> Annotations);
/// <summary>
/// OCI attachment ready for pushing to a registry.
/// </summary>
public sealed record ProvcacheOciAttachment(
string ArtifactReference,
string MediaType,
string Payload,
byte[] PayloadBytes,
IReadOnlyDictionary<string, string> Annotations);

View File

@@ -0,0 +1,312 @@
// ----------------------------------------------------------------------------
// Copyright (c) 2025 StellaOps contributors. All rights reserved.
// SPDX-License-Identifier: AGPL-3.0-or-later
// ----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Provcache.Oci;
/// <summary>
/// Constants for the Provcache OCI attestation predicate type.
/// </summary>
public static class ProvcachePredicateTypes
{
/// <summary>
/// The predicate type URI for Provcache decision attestations.
/// Format: stella.ops/provcache@v1
/// </summary>
public const string ProvcacheV1 = "stella.ops/provcache@v1";
/// <summary>
/// OCI media type for the attestation payload.
/// </summary>
public const string MediaType = "application/vnd.stellaops.provcache.decision+json";
/// <summary>
/// Statement type for in-toto attestations.
/// </summary>
public const string InTotoStatementType = "https://in-toto.io/Statement/v1";
}
/// <summary>
/// In-toto statement wrapper for Provcache attestations following SLSA v1.0 format.
/// </summary>
public sealed record ProvcacheStatement
{
/// <summary>
/// Statement type, always "https://in-toto.io/Statement/v1".
/// </summary>
[JsonPropertyName("_type")]
public string Type { get; init; } = ProvcachePredicateTypes.InTotoStatementType;
/// <summary>
/// List of subjects (artifacts) this attestation applies to.
/// </summary>
[JsonPropertyName("subject")]
public required IReadOnlyList<ProvcacheSubject> Subject { get; init; }
/// <summary>
/// Predicate type URI: stella.ops/provcache@v1.
/// </summary>
[JsonPropertyName("predicateType")]
public string PredicateType { get; init; } = ProvcachePredicateTypes.ProvcacheV1;
/// <summary>
/// The predicate payload containing the decision digest details.
/// </summary>
[JsonPropertyName("predicate")]
public required ProvcachePredicate Predicate { get; init; }
}
/// <summary>
/// Subject representing the artifact (container image) this attestation applies to.
/// </summary>
public sealed record ProvcacheSubject
{
/// <summary>
/// Name of the artifact (e.g., container image reference without tag).
/// Example: ghcr.io/stellaops/scanner
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// Cryptographic digests of the artifact.
/// </summary>
[JsonPropertyName("digest")]
public required IReadOnlyDictionary<string, string> Digest { get; init; }
}
/// <summary>
/// The Provcache predicate containing decision digest and provenance metadata.
/// </summary>
public sealed record ProvcachePredicate
{
/// <summary>
/// Schema version of this predicate format.
/// </summary>
[JsonPropertyName("version")]
public string Version { get; init; } = "v1";
/// <summary>
/// Composite cache key (VeriKey) that uniquely identifies the decision context.
/// Format: sha256:&lt;hex&gt;
/// </summary>
[JsonPropertyName("veriKey")]
public required string VeriKey { get; init; }
/// <summary>
/// Hash of sorted dispositions from the evaluation result.
/// Format: sha256:&lt;hex&gt;
/// </summary>
[JsonPropertyName("verdictHash")]
public required string VerdictHash { get; init; }
/// <summary>
/// Merkle root of all evidence chunks used in this decision.
/// Format: sha256:&lt;hex&gt;
/// </summary>
[JsonPropertyName("proofRoot")]
public required string ProofRoot { get; init; }
/// <summary>
/// Composite trust score (0-100) indicating decision confidence.
/// </summary>
[JsonPropertyName("trustScore")]
public required int TrustScore { get; init; }
/// <summary>
/// Breakdown of trust score by component.
/// </summary>
[JsonPropertyName("trustScoreBreakdown")]
public ProvcacheTrustBreakdown? TrustScoreBreakdown { get; init; }
/// <summary>
/// Input manifest describing components used for the decision.
/// </summary>
[JsonPropertyName("inputManifest")]
public required ProvcacheInputSummary InputManifest { get; init; }
/// <summary>
/// Replay identifiers for deterministic re-evaluation.
/// </summary>
[JsonPropertyName("replaySeed")]
public required ProvcacheReplaySeed ReplaySeed { get; init; }
/// <summary>
/// UTC timestamp when this decision was made.
/// ISO 8601 format.
/// </summary>
[JsonPropertyName("createdAt")]
public required string CreatedAt { get; init; }
/// <summary>
/// UTC timestamp when this decision expires.
/// ISO 8601 format.
/// </summary>
[JsonPropertyName("expiresAt")]
public required string ExpiresAt { get; init; }
/// <summary>
/// Summary of verdicts included in this decision.
/// </summary>
[JsonPropertyName("verdictSummary")]
public ProvcacheVerdictSummary? VerdictSummary { get; init; }
}
/// <summary>
/// Trust score breakdown by evidence component.
/// </summary>
public sealed record ProvcacheTrustBreakdown
{
/// <summary>
/// Reachability evidence contribution (weight: 25%).
/// </summary>
[JsonPropertyName("reachability")]
public required ProvcacheTrustComponent Reachability { get; init; }
/// <summary>
/// SBOM completeness contribution (weight: 20%).
/// </summary>
[JsonPropertyName("sbomCompleteness")]
public required ProvcacheTrustComponent SbomCompleteness { get; init; }
/// <summary>
/// VEX coverage contribution (weight: 20%).
/// </summary>
[JsonPropertyName("vexCoverage")]
public required ProvcacheTrustComponent VexCoverage { get; init; }
/// <summary>
/// Policy freshness contribution (weight: 15%).
/// </summary>
[JsonPropertyName("policyFreshness")]
public required ProvcacheTrustComponent PolicyFreshness { get; init; }
/// <summary>
/// Signer trust contribution (weight: 20%).
/// </summary>
[JsonPropertyName("signerTrust")]
public required ProvcacheTrustComponent SignerTrust { get; init; }
}
/// <summary>
/// Individual trust score component.
/// </summary>
public sealed record ProvcacheTrustComponent
{
/// <summary>
/// Component score (0-100).
/// </summary>
[JsonPropertyName("score")]
public required int Score { get; init; }
/// <summary>
/// Weight of this component (0.0-1.0).
/// </summary>
[JsonPropertyName("weight")]
public required decimal Weight { get; init; }
}
/// <summary>
/// Summary of input components used for the decision.
/// </summary>
public sealed record ProvcacheInputSummary
{
/// <summary>
/// Hash of the source artifact.
/// </summary>
[JsonPropertyName("sourceHash")]
public required string SourceHash { get; init; }
/// <summary>
/// Hash of the SBOM used.
/// </summary>
[JsonPropertyName("sbomHash")]
public string? SbomHash { get; init; }
/// <summary>
/// Hash of the VEX set used.
/// </summary>
[JsonPropertyName("vexSetHash")]
public string? VexSetHash { get; init; }
/// <summary>
/// Hash of the policy used.
/// </summary>
[JsonPropertyName("policyHash")]
public string? PolicyHash { get; init; }
/// <summary>
/// Hash of the signer set.
/// </summary>
[JsonPropertyName("signerSetHash")]
public string? SignerSetHash { get; init; }
}
/// <summary>
/// Replay seed for deterministic re-evaluation.
/// </summary>
public sealed record ProvcacheReplaySeed
{
/// <summary>
/// Advisory feed identifiers used.
/// </summary>
[JsonPropertyName("feedIds")]
public required IReadOnlyList<string> FeedIds { get; init; }
/// <summary>
/// Policy rule identifiers used.
/// </summary>
[JsonPropertyName("ruleIds")]
public required IReadOnlyList<string> RuleIds { get; init; }
/// <summary>
/// Optional frozen epoch timestamp.
/// </summary>
[JsonPropertyName("frozenEpoch")]
public string? FrozenEpoch { get; init; }
}
/// <summary>
/// Summary of verdicts in the decision.
/// </summary>
public sealed record ProvcacheVerdictSummary
{
/// <summary>
/// Total number of findings evaluated.
/// </summary>
[JsonPropertyName("totalFindings")]
public required int TotalFindings { get; init; }
/// <summary>
/// Number of findings marked as affected.
/// </summary>
[JsonPropertyName("affected")]
public required int Affected { get; init; }
/// <summary>
/// Number of findings marked as not_affected.
/// </summary>
[JsonPropertyName("notAffected")]
public required int NotAffected { get; init; }
/// <summary>
/// Number of findings with mitigations applied.
/// </summary>
[JsonPropertyName("mitigated")]
public required int Mitigated { get; init; }
/// <summary>
/// Number of findings under investigation.
/// </summary>
[JsonPropertyName("underInvestigation")]
public required int UnderInvestigation { get; init; }
/// <summary>
/// Number of findings with known fixes.
/// </summary>
[JsonPropertyName("fixed")]
public required int Fixed { get; init; }
}

View File

@@ -331,6 +331,9 @@ public sealed class ProvcacheService : IProvcacheService
{
var stats = await _repository.GetStatisticsAsync(cancellationToken).ConfigureAwait(false);
// Update the items count gauge for Prometheus
ProvcacheTelemetry.SetItemsCount(stats.TotalEntries);
double avgLatency, p99Latency;
lock (_metricsLock)
{

View File

@@ -40,6 +40,7 @@ public static class ProvcacheTelemetry
private const string WriteBehindQueueSizeMetric = "provcache_writebehind_queue_size";
private const string LatencySecondsMetric = "provcache_latency_seconds";
private const string EntriesSizeMetric = "provcache_entry_bytes";
private const string ItemsCountMetric = "provcache_items_count";
#endregion
@@ -92,6 +93,7 @@ public static class ProvcacheTelemetry
#region Gauges
private static int _writeBehindQueueSize;
private static long _itemsCount;
/// <summary>
/// Observable gauge for write-behind queue size.
@@ -102,6 +104,15 @@ public static class ProvcacheTelemetry
unit: "items",
description: "Current write-behind queue size.");
/// <summary>
/// Observable gauge for total cache items count.
/// </summary>
public static readonly ObservableGauge<long> ItemsCountGauge = Meter.CreateObservableGauge(
ItemsCountMetric,
() => _itemsCount,
unit: "items",
description: "Current number of cached entries.");
/// <summary>
/// Update the write-behind queue size gauge.
/// </summary>
@@ -111,6 +122,15 @@ public static class ProvcacheTelemetry
_writeBehindQueueSize = size;
}
/// <summary>
/// Update the items count gauge.
/// </summary>
/// <param name="count">Current count of cache entries.</param>
public static void SetItemsCount(long count)
{
_itemsCount = count;
}
#endregion
#region Activity Tracing

View File

@@ -0,0 +1,361 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2025 StellaOps Contributors
using FluentAssertions;
using StellaOps.Provcache.Api;
using System.Text.Json;
using Xunit;
namespace StellaOps.Provcache.Tests;
/// <summary>
/// Contract tests verifying the structure and serialization of new API response fields.
/// These tests ensure the API contracts remain stable across versions.
/// </summary>
public sealed class ApiContractTests
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
};
#region CacheSource Contract Tests
[Theory]
[InlineData("none")]
[InlineData("inMemory")]
[InlineData("redis")]
public void CacheSource_AllValues_SerializeCorrectly(string expectedValue)
{
// CacheSource enum values should serialize to their lowercase/camelCase string equivalents
// This ensures compatibility with the OpenAPI spec and frontend models
var response = new
{
cacheSource = expectedValue,
cacheHit = expectedValue != "none"
};
var json = JsonSerializer.Serialize(response, JsonOptions);
json.Should().Contain($"\"cacheSource\":\"{expectedValue}\"");
}
#endregion
#region TrustScoreBreakdown Contract Tests
[Fact]
public void TrustScoreBreakdown_DefaultWeights_SumToOne()
{
// Verify the standard weights sum to 1.0 (100%)
var breakdown = TrustScoreBreakdown.CreateDefault();
var totalWeight = breakdown.Reachability.Weight +
breakdown.SbomCompleteness.Weight +
breakdown.VexCoverage.Weight +
breakdown.PolicyFreshness.Weight +
breakdown.SignerTrust.Weight;
totalWeight.Should().Be(1.00m, "standard weights must sum to 100%");
}
[Fact]
public void TrustScoreBreakdown_StandardWeights_MatchDocumentation()
{
// Verify weights match the documented percentages
// Reachability: 25%, SBOM: 20%, VEX: 20%, Policy: 15%, Signer: 20%
var breakdown = TrustScoreBreakdown.CreateDefault();
breakdown.Reachability.Weight.Should().Be(0.25m, "Reachability weight should be 25%");
breakdown.SbomCompleteness.Weight.Should().Be(0.20m, "SBOM completeness weight should be 20%");
breakdown.VexCoverage.Weight.Should().Be(0.20m, "VEX coverage weight should be 20%");
breakdown.PolicyFreshness.Weight.Should().Be(0.15m, "Policy freshness weight should be 15%");
breakdown.SignerTrust.Weight.Should().Be(0.20m, "Signer trust weight should be 20%");
}
[Fact]
public void TrustScoreBreakdown_ComputeTotal_ReturnsCorrectWeightedSum()
{
// Given all scores at 100, total should be 100
var breakdown = TrustScoreBreakdown.CreateDefault(
reachabilityScore: 100,
sbomScore: 100,
vexScore: 100,
policyScore: 100,
signerScore: 100);
breakdown.ComputeTotal().Should().Be(100);
}
[Fact]
public void TrustScoreBreakdown_ComputeTotal_WithZeroScores_ReturnsZero()
{
var breakdown = TrustScoreBreakdown.CreateDefault();
breakdown.ComputeTotal().Should().Be(0);
}
[Fact]
public void TrustScoreBreakdown_ComputeTotal_WithMixedScores_ComputesCorrectly()
{
// Specific test case:
// Reachability: 80 * 0.25 = 20
// SBOM: 90 * 0.20 = 18
// VEX: 70 * 0.20 = 14
// Policy: 100 * 0.15 = 15
// Signer: 60 * 0.20 = 12
// Total: 79
var breakdown = TrustScoreBreakdown.CreateDefault(
reachabilityScore: 80,
sbomScore: 90,
vexScore: 70,
policyScore: 100,
signerScore: 60);
breakdown.ComputeTotal().Should().Be(79);
}
[Fact]
public void TrustScoreBreakdown_Serialization_IncludesAllComponents()
{
var breakdown = TrustScoreBreakdown.CreateDefault(50, 60, 70, 80, 90);
var json = JsonSerializer.Serialize(breakdown, JsonOptions);
json.Should().Contain("\"reachability\":");
json.Should().Contain("\"sbomCompleteness\":");
json.Should().Contain("\"vexCoverage\":");
json.Should().Contain("\"policyFreshness\":");
json.Should().Contain("\"signerTrust\":");
}
[Fact]
public void TrustScoreComponent_Contribution_CalculatesCorrectly()
{
var component = new TrustScoreComponent { Score = 80, Weight = 0.25m };
component.Contribution.Should().Be(20m, "80 * 0.25 = 20");
}
#endregion
#region DecisionDigest Contract Tests
[Fact]
public void DecisionDigest_TrustScoreBreakdown_IsOptional()
{
// DecisionDigest should serialize correctly without TrustScoreBreakdown
// The field is nullable and can be omitted when not applicable
var digest = new DecisionDigest
{
DigestVersion = "v1",
VeriKey = "sha256:abc",
VerdictHash = "sha256:def",
ProofRoot = "sha256:ghi",
ReplaySeed = new ReplaySeed { FeedIds = [], RuleIds = [] },
CreatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
TrustScore = 85,
TrustScoreBreakdown = null
};
var json = JsonSerializer.Serialize(digest, JsonOptions);
// The JSON should serialize successfully, proving the field is optional
json.Should().Contain("\"trustScore\":85");
// trustScoreBreakdown being null is a valid state
digest.TrustScoreBreakdown.Should().BeNull("TrustScoreBreakdown should be optional");
}
[Fact]
public void DecisionDigest_WithBreakdown_SerializesCorrectly()
{
var digest = new DecisionDigest
{
DigestVersion = "v1",
VeriKey = "sha256:abc",
VerdictHash = "sha256:def",
ProofRoot = "sha256:ghi",
ReplaySeed = new ReplaySeed { FeedIds = [], RuleIds = [] },
CreatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
TrustScore = 79,
TrustScoreBreakdown = TrustScoreBreakdown.CreateDefault(80, 90, 70, 100, 60)
};
var json = JsonSerializer.Serialize(digest, JsonOptions);
json.Should().Contain("\"trustScoreBreakdown\":");
json.Should().Contain("\"reachability\":");
}
#endregion
#region InputManifest Contract Tests
[Fact]
public void InputManifestResponse_RequiredFields_NotNull()
{
var manifest = new InputManifestResponse
{
VeriKey = "sha256:test",
SourceArtifact = new SourceArtifactInfo { Digest = "sha256:image" },
Sbom = new SbomInfoDto { Hash = "sha256:sbom" },
Vex = new VexInfoDto { HashSetHash = "sha256:vex" },
Policy = new PolicyInfoDto { Hash = "sha256:policy" },
Signers = new SignerInfoDto { SetHash = "sha256:signers" },
TimeWindow = new TimeWindowInfoDto { Bucket = "2024-12-24" },
GeneratedAt = DateTimeOffset.UtcNow,
};
manifest.VeriKey.Should().NotBeNull();
manifest.SourceArtifact.Should().NotBeNull();
manifest.Sbom.Should().NotBeNull();
manifest.Vex.Should().NotBeNull();
manifest.Policy.Should().NotBeNull();
manifest.Signers.Should().NotBeNull();
manifest.TimeWindow.Should().NotBeNull();
}
[Fact]
public void InputManifestResponse_Serialization_IncludesAllComponents()
{
var manifest = new InputManifestResponse
{
VeriKey = "sha256:test",
SourceArtifact = new SourceArtifactInfo { Digest = "sha256:image" },
Sbom = new SbomInfoDto { Hash = "sha256:sbom" },
Vex = new VexInfoDto { HashSetHash = "sha256:vex" },
Policy = new PolicyInfoDto { Hash = "sha256:policy" },
Signers = new SignerInfoDto { SetHash = "sha256:signers" },
TimeWindow = new TimeWindowInfoDto { Bucket = "2024-12-24" },
GeneratedAt = DateTimeOffset.UtcNow,
};
var json = JsonSerializer.Serialize(manifest, JsonOptions);
json.Should().Contain("\"veriKey\":");
json.Should().Contain("\"sourceArtifact\":");
json.Should().Contain("\"sbom\":");
json.Should().Contain("\"vex\":");
json.Should().Contain("\"policy\":");
json.Should().Contain("\"signers\":");
json.Should().Contain("\"timeWindow\":");
json.Should().Contain("\"generatedAt\":");
}
[Fact]
public void SbomInfoDto_OptionalFields_CanBeNull()
{
var sbom = new SbomInfoDto
{
Hash = "sha256:required",
Format = null,
PackageCount = null,
CompletenessScore = null
};
var json = JsonSerializer.Serialize(sbom, JsonOptions);
json.Should().Contain("\"hash\":");
// Optional fields should not be serialized as null (default JsonSerializer behavior with ignore defaults)
}
[Fact]
public void VexInfoDto_Sources_CanBeEmpty()
{
var vex = new VexInfoDto
{
HashSetHash = "sha256:test",
StatementCount = 0,
Sources = []
};
vex.Sources.Should().BeEmpty();
vex.StatementCount.Should().Be(0);
}
[Fact]
public void PolicyInfoDto_OptionalFields_PreserveValues()
{
var policy = new PolicyInfoDto
{
Hash = "sha256:policy",
PackId = "org-policy-v2",
Version = 5,
Name = "Organization Security Policy"
};
policy.Hash.Should().Be("sha256:policy");
policy.PackId.Should().Be("org-policy-v2");
policy.Version.Should().Be(5);
policy.Name.Should().Be("Organization Security Policy");
}
[Fact]
public void SignerInfoDto_Certificates_CanBeNull()
{
var signers = new SignerInfoDto
{
SetHash = "sha256:signers",
SignerCount = 0,
Certificates = null
};
signers.Certificates.Should().BeNull();
}
[Fact]
public void SignerCertificateDto_AllFields_AreOptional()
{
var cert = new SignerCertificateDto
{
Subject = null,
Issuer = null,
ExpiresAt = null
};
// Should not throw - all fields are optional
var json = JsonSerializer.Serialize(cert, JsonOptions);
json.Should().NotBeNullOrEmpty();
}
[Fact]
public void TimeWindowInfoDto_Bucket_IsRequired()
{
var timeWindow = new TimeWindowInfoDto
{
Bucket = "2024-W52",
StartsAt = DateTimeOffset.Parse("2024-12-23T00:00:00Z"),
EndsAt = DateTimeOffset.Parse("2024-12-30T00:00:00Z")
};
timeWindow.Bucket.Should().NotBeNullOrEmpty();
}
#endregion
#region API Response Backwards Compatibility
[Fact]
public void ProvcacheGetResponse_Status_ValidValues()
{
// Verify status field uses expected values
var statuses = new[] { "hit", "miss", "bypassed", "expired" };
foreach (var status in statuses)
{
var response = new ProvcacheGetResponse
{
VeriKey = "sha256:test",
Status = status,
ElapsedMs = 1.0
};
response.Status.Should().Be(status);
}
}
#endregion
}

View File

@@ -40,6 +40,8 @@ public sealed class EvidenceApiTests : IAsyncLifetime
services.AddSingleton(_mockChunker.Object);
// Add mock IProvcacheService to satisfy the main endpoints
services.AddSingleton(Mock.Of<IProvcacheService>());
// Add TimeProvider for InputManifest endpoint
services.AddSingleton(TimeProvider.System);
})
.Configure(app =>
{

View File

@@ -0,0 +1,574 @@
// ----------------------------------------------------------------------------
// Copyright (c) 2025 StellaOps contributors. All rights reserved.
// SPDX-License-Identifier: AGPL-3.0-or-later
// ----------------------------------------------------------------------------
using System.Text.Json;
using FluentAssertions;
using StellaOps.Provcache.Oci;
using Xunit;
namespace StellaOps.Provcache.Tests.Oci;
/// <summary>
/// Tests for <see cref="ProvcacheOciAttestationBuilder"/>.
/// Includes cosign verify-attestation compatibility tests.
/// </summary>
public sealed class ProvcacheOciAttestationBuilderTests
{
private readonly ProvcacheOciAttestationBuilder _sut = new();
private static DecisionDigest CreateTestDigest() => new()
{
DigestVersion = "v1",
VeriKey = "sha256:abc123def456789",
VerdictHash = "sha256:verdict123",
ProofRoot = "sha256:proof456",
TrustScore = 85,
ReplaySeed = new ReplaySeed
{
FeedIds = ["cve-2024", "ghsa-2024"],
RuleIds = ["default-policy-v2"],
FrozenEpoch = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero)
},
CreatedAt = new DateTimeOffset(2025, 1, 1, 12, 0, 0, TimeSpan.Zero),
ExpiresAt = new DateTimeOffset(2025, 1, 2, 12, 0, 0, TimeSpan.Zero),
TrustScoreBreakdown = TrustScoreBreakdown.CreateDefault(80, 90, 85, 75, 95)
};
private static InputManifest CreateTestManifest() => new()
{
VeriKey = "sha256:abc123def456789",
SourceArtifact = new SourceArtifactInfo
{
Digest = "sha256:source123",
ArtifactType = "container-image",
OciReference = "ghcr.io/stellaops/test:latest",
SizeBytes = 1024 * 1024
},
Sbom = new SbomInfo
{
Hash = "sha256:sbom123",
Format = "cyclonedx",
Version = "1.6",
PackageCount = 42,
CompletenessScore = 95
},
Vex = new VexInfo
{
SetHash = "sha256:vex123",
StatementCount = 5,
Sources = ["vendor", "osv"]
},
Policy = new PolicyInfo
{
Hash = "sha256:policy123",
Name = "default-policy",
PackId = "stellaops-base",
Version = "v2.0"
},
Signers = new SignerInfo
{
SetHash = "sha256:signers123",
SignerCount = 2,
Certificates = []
},
TimeWindow = new TimeWindowInfo
{
Bucket = "2025-01-01",
StartTime = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
EndTime = new DateTimeOffset(2025, 1, 2, 0, 0, 0, TimeSpan.Zero)
}
};
[Fact]
public void Build_ValidRequest_ReturnsValidResult()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest(),
InputManifest = CreateTestManifest()
};
// Act
var result = _sut.Build(request);
// Assert
result.Should().NotBeNull();
result.Statement.Should().NotBeNull();
result.StatementJson.Should().NotBeNullOrWhiteSpace();
result.StatementBytes.Should().NotBeEmpty();
result.MediaType.Should().Be(ProvcachePredicateTypes.MediaType);
result.Annotations.Should().NotBeEmpty();
}
[Fact]
public void Build_ValidRequest_ProducesValidInTotoStatement()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest()
};
// Act
var result = _sut.Build(request);
// Assert - In-toto statement format
result.Statement.Type.Should().Be("https://in-toto.io/Statement/v1");
result.Statement.PredicateType.Should().Be(ProvcachePredicateTypes.ProvcacheV1);
result.Statement.Subject.Should().ContainSingle();
}
[Fact]
public void Build_ValidRequest_ProducesValidSubject()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest()
};
// Act
var result = _sut.Build(request);
// Assert
var subject = result.Statement.Subject.Single();
subject.Name.Should().Be("ghcr.io/stellaops/scanner");
subject.Digest.Should().ContainKey("sha256");
subject.Digest["sha256"].Should().Be("abc123def456");
}
[Fact]
public void Build_ArtifactWithDigestSuffix_ExtractsNameCorrectly()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner@sha256:abc123def456",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest()
};
// Act
var result = _sut.Build(request);
// Assert
var subject = result.Statement.Subject.Single();
subject.Name.Should().Be("ghcr.io/stellaops/scanner");
}
[Fact]
public void Build_ValidRequest_IncludesVeriKeyInPredicate()
{
// Arrange
var digest = CreateTestDigest();
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = digest
};
// Act
var result = _sut.Build(request);
// Assert
result.Statement.Predicate.VeriKey.Should().Be(digest.VeriKey);
result.Statement.Predicate.VerdictHash.Should().Be(digest.VerdictHash);
result.Statement.Predicate.ProofRoot.Should().Be(digest.ProofRoot);
result.Statement.Predicate.TrustScore.Should().Be(digest.TrustScore);
}
[Fact]
public void Build_WithTrustScoreBreakdown_IncludesBreakdown()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest()
};
// Act
var result = _sut.Build(request);
// Assert
result.Statement.Predicate.TrustScoreBreakdown.Should().NotBeNull();
result.Statement.Predicate.TrustScoreBreakdown!.Reachability.Score.Should().Be(80);
result.Statement.Predicate.TrustScoreBreakdown.SbomCompleteness.Score.Should().Be(90);
}
[Fact]
public void Build_WithInputManifest_IncludesManifestSummary()
{
// Arrange
var manifest = CreateTestManifest();
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest(),
InputManifest = manifest
};
// Act
var result = _sut.Build(request);
// Assert
var inputSummary = result.Statement.Predicate.InputManifest;
inputSummary.SourceHash.Should().Be(manifest.SourceArtifact.Digest);
inputSummary.SbomHash.Should().Be(manifest.Sbom.Hash);
inputSummary.VexSetHash.Should().Be(manifest.Vex.SetHash);
inputSummary.PolicyHash.Should().Be(manifest.Policy.Hash);
inputSummary.SignerSetHash.Should().Be(manifest.Signers.SetHash);
}
[Fact]
public void Build_WithVerdictSummary_IncludesSummary()
{
// Arrange
var summary = new ProvcacheVerdictSummary
{
TotalFindings = 100,
Affected = 10,
NotAffected = 70,
Mitigated = 15,
UnderInvestigation = 3,
Fixed = 2
};
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest(),
VerdictSummary = summary
};
// Act
var result = _sut.Build(request);
// Assert
result.Statement.Predicate.VerdictSummary.Should().NotBeNull();
result.Statement.Predicate.VerdictSummary!.TotalFindings.Should().Be(100);
result.Statement.Predicate.VerdictSummary.Affected.Should().Be(10);
}
[Fact]
public void Build_WithTenantAndScope_IncludesInAnnotations()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest(),
TenantId = "acme-corp",
Scope = "production"
};
// Act
var result = _sut.Build(request);
// Assert
result.Annotations.Should().ContainKey("stellaops.tenant");
result.Annotations["stellaops.tenant"].Should().Be("acme-corp");
result.Annotations.Should().ContainKey("stellaops.scope");
result.Annotations["stellaops.scope"].Should().Be("production");
}
[Fact]
public void Build_AlwaysIncludesRequiredOciAnnotations()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest()
};
// Act
var result = _sut.Build(request);
// Assert
result.Annotations.Should().ContainKey("org.opencontainers.image.title");
result.Annotations.Should().ContainKey("org.opencontainers.image.description");
result.Annotations.Should().ContainKey("org.opencontainers.image.created");
result.Annotations.Should().ContainKey("stellaops.provcache.verikey");
result.Annotations.Should().ContainKey("stellaops.provcache.trust-score");
}
[Fact]
public void Build_ProducesDeterministicJson()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest(),
InputManifest = CreateTestManifest()
};
// Act
var result1 = _sut.Build(request);
var result2 = _sut.Build(request);
// Assert - Same input should produce same output (deterministic)
result1.StatementJson.Should().Be(result2.StatementJson);
result1.StatementBytes.Should().BeEquivalentTo(result2.StatementBytes);
}
[Fact]
public void Build_ProducesValidJson()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest()
};
// Act
var result = _sut.Build(request);
// Assert - Should be valid JSON
var parseAction = () => JsonDocument.Parse(result.StatementJson);
parseAction.Should().NotThrow();
}
// ==================== Cosign Compatibility Tests ====================
/// <summary>
/// Verifies the attestation format is compatible with cosign verify-attestation.
/// cosign expects the "_type" field to be "https://in-toto.io/Statement/v1".
/// </summary>
[Fact]
public void Build_CosignCompatible_HasCorrectType()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest()
};
// Act
var result = _sut.Build(request);
using var doc = JsonDocument.Parse(result.StatementJson);
// Assert - cosign requires _type field
doc.RootElement.TryGetProperty("_type", out var typeElement).Should().BeTrue();
typeElement.GetString().Should().Be("https://in-toto.io/Statement/v1");
}
/// <summary>
/// Verifies the subject array is valid for cosign.
/// cosign expects at least one subject with name and digest.
/// </summary>
[Fact]
public void Build_CosignCompatible_HasValidSubject()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest()
};
// Act
var result = _sut.Build(request);
using var doc = JsonDocument.Parse(result.StatementJson);
// Assert - cosign requires subject array with name and digest
doc.RootElement.TryGetProperty("subject", out var subjectElement).Should().BeTrue();
subjectElement.GetArrayLength().Should().BeGreaterThan(0);
var firstSubject = subjectElement.EnumerateArray().First();
firstSubject.TryGetProperty("name", out var nameElement).Should().BeTrue();
firstSubject.TryGetProperty("digest", out var digestElement).Should().BeTrue();
nameElement.GetString().Should().NotBeNullOrEmpty();
digestElement.TryGetProperty("sha256", out var sha256Element).Should().BeTrue();
sha256Element.GetString().Should().NotBeNullOrEmpty();
}
/// <summary>
/// Verifies predicateType is present for cosign filtering.
/// cosign verify-attestation --type allows filtering by predicateType.
/// </summary>
[Fact]
public void Build_CosignCompatible_HasPredicateType()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest()
};
// Act
var result = _sut.Build(request);
using var doc = JsonDocument.Parse(result.StatementJson);
// Assert - cosign uses predicateType for filtering
doc.RootElement.TryGetProperty("predicateType", out var predicateTypeElement).Should().BeTrue();
predicateTypeElement.GetString().Should().Be(ProvcachePredicateTypes.ProvcacheV1);
}
/// <summary>
/// Verifies predicate object is present.
/// cosign verify-attestation expects a predicate object.
/// </summary>
[Fact]
public void Build_CosignCompatible_HasPredicate()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest()
};
// Act
var result = _sut.Build(request);
using var doc = JsonDocument.Parse(result.StatementJson);
// Assert - cosign expects predicate object
doc.RootElement.TryGetProperty("predicate", out var predicateElement).Should().BeTrue();
predicateElement.ValueKind.Should().Be(JsonValueKind.Object);
// Verify key fields are present in predicate
predicateElement.TryGetProperty("veriKey", out _).Should().BeTrue();
predicateElement.TryGetProperty("verdictHash", out _).Should().BeTrue();
predicateElement.TryGetProperty("proofRoot", out _).Should().BeTrue();
predicateElement.TryGetProperty("trustScore", out _).Should().BeTrue();
}
/// <summary>
/// Verifies the media type is appropriate for OCI referrers.
/// </summary>
[Fact]
public void Build_CosignCompatible_HasCorrectMediaType()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest()
};
// Act
var result = _sut.Build(request);
// Assert
result.MediaType.Should().Be("application/vnd.stellaops.provcache.decision+json");
}
// ==================== Validation Tests ====================
[Fact]
public void Build_NullRequest_ThrowsArgumentNullException()
{
// Act & Assert
var action = () => _sut.Build(null!);
action.Should().Throw<ArgumentNullException>();
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void Build_EmptyArtifactReference_ThrowsArgumentException(string? artifactRef)
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = artifactRef!,
ArtifactDigest = "sha256:abc123",
DecisionDigest = CreateTestDigest()
};
// Act & Assert
var action = () => _sut.Build(request);
action.Should().Throw<ArgumentException>()
.WithParameterName("request");
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void Build_EmptyArtifactDigest_ThrowsArgumentException(string? digest)
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/test/image:v1",
ArtifactDigest = digest!,
DecisionDigest = CreateTestDigest()
};
// Act & Assert
var action = () => _sut.Build(request);
action.Should().Throw<ArgumentException>()
.WithParameterName("request");
}
[Fact]
public void Build_NullDecisionDigest_ThrowsArgumentException()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/test/image:v1",
ArtifactDigest = "sha256:abc123",
DecisionDigest = null!
};
// Act & Assert
var action = () => _sut.Build(request);
action.Should().Throw<ArgumentException>()
.WithParameterName("request");
}
// ==================== CreateAttachment Tests ====================
[Fact]
public void CreateAttachment_ValidRequest_ReturnsValidAttachment()
{
// Arrange
var request = new ProvcacheOciAttestationRequest
{
ArtifactReference = "ghcr.io/stellaops/scanner:v1.0.0",
ArtifactDigest = "sha256:abc123def456",
DecisionDigest = CreateTestDigest()
};
// Act
var attachment = _sut.CreateAttachment(request);
// Assert
attachment.Should().NotBeNull();
attachment.ArtifactReference.Should().Be(request.ArtifactReference);
attachment.MediaType.Should().Be(ProvcachePredicateTypes.MediaType);
attachment.Payload.Should().NotBeNullOrEmpty();
attachment.PayloadBytes.Should().NotBeEmpty();
attachment.Annotations.Should().NotBeEmpty();
}
}

View File

@@ -37,6 +37,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
.ConfigureServices(services =>
{
services.AddSingleton(_mockService.Object);
services.AddSingleton(TimeProvider.System);
services.AddRouting();
services.AddLogging(b => b.SetMinimumLevel(LogLevel.Warning));
})

View File

@@ -0,0 +1,307 @@
using System.Diagnostics;
using System.Diagnostics.Metrics;
using FluentAssertions;
using StellaOps.Provcache;
using Xunit;
namespace StellaOps.Provcache.Tests.Telemetry;
/// <summary>
/// Tests for <see cref="ProvcacheTelemetry"/> metrics and activity tracing.
/// </summary>
public sealed class ProvcacheTelemetryTests
{
[Fact]
public void StartGetActivity_CreatesActivityWithVeriKeyTag()
{
// Arrange
using var listener = CreateActivityListener();
const string veriKey = "sha256:abc123def456...";
// Act
using var activity = ProvcacheTelemetry.StartGetActivity(veriKey);
// Assert
activity.Should().NotBeNull();
activity!.DisplayName.Should().Be("provcache.get");
activity.GetTagItem("provcache.verikey").Should().NotBeNull();
}
[Fact]
public void StartSetActivity_CreatesActivityWithVeriKeyAndTrustScore()
{
// Arrange
using var listener = CreateActivityListener();
const string veriKey = "sha256:abc123def456...";
const int trustScore = 85;
// Act
using var activity = ProvcacheTelemetry.StartSetActivity(veriKey, trustScore);
// Assert
activity.Should().NotBeNull();
activity!.DisplayName.Should().Be("provcache.set");
activity.GetTagItem("provcache.verikey").Should().NotBeNull();
activity.GetTagItem("provcache.trust_score").Should().Be(trustScore);
}
[Fact]
public void StartInvalidateActivity_CreatesActivityWithTypeAndTarget()
{
// Arrange
using var listener = CreateActivityListener();
const string invalidationType = "policy_hash";
const string targetValue = "sha256:policy:abc123";
// Act
using var activity = ProvcacheTelemetry.StartInvalidateActivity(invalidationType, targetValue);
// Assert
activity.Should().NotBeNull();
activity!.DisplayName.Should().Be("provcache.invalidate");
activity.GetTagItem("provcache.invalidation_type").Should().Be(invalidationType);
activity.GetTagItem("provcache.target").Should().NotBeNull();
}
[Fact]
public void MarkCacheHit_SetsResultAndSource()
{
// Arrange
using var listener = CreateActivityListener();
using var activity = ProvcacheTelemetry.StartGetActivity("sha256:test");
// Act
ProvcacheTelemetry.MarkCacheHit(activity, "valkey");
// Assert
activity.Should().NotBeNull();
activity!.GetTagItem("provcache.result").Should().Be("hit");
activity.GetTagItem("provcache.source").Should().Be("valkey");
}
[Fact]
public void MarkCacheMiss_SetsResult()
{
// Arrange
using var listener = CreateActivityListener();
using var activity = ProvcacheTelemetry.StartGetActivity("sha256:test");
// Act
ProvcacheTelemetry.MarkCacheMiss(activity);
// Assert
activity.Should().NotBeNull();
activity!.GetTagItem("provcache.result").Should().Be("miss");
}
[Fact]
public void MarkError_SetsErrorStatusAndResult()
{
// Arrange
using var listener = CreateActivityListener();
using var activity = ProvcacheTelemetry.StartGetActivity("sha256:test");
const string errorMessage = "Test error";
// Act
ProvcacheTelemetry.MarkError(activity, errorMessage);
// Assert
activity.Should().NotBeNull();
activity!.Status.Should().Be(ActivityStatusCode.Error);
activity.GetTagItem("provcache.result").Should().Be("error");
}
[Fact]
public void RecordRequest_IncrementsRequestCounter()
{
// Arrange
var measurements = new List<KeyValuePair<string, object?>>();
using var meterListener = CreateMeterListener(measurements);
// Act
ProvcacheTelemetry.RecordRequest("get", "hit");
// Assert - counter should have been incremented
measurements.Should().ContainSingle(m => m.Key == "provcache_requests_total");
}
[Fact]
public void RecordHit_IncrementsHitCounter()
{
// Arrange
var measurements = new List<KeyValuePair<string, object?>>();
using var meterListener = CreateMeterListener(measurements);
// Act
ProvcacheTelemetry.RecordHit("valkey");
// Assert
measurements.Should().ContainSingle(m => m.Key == "provcache_hits_total");
}
[Fact]
public void RecordMiss_IncrementsMissCounter()
{
// Arrange
var measurements = new List<KeyValuePair<string, object?>>();
using var meterListener = CreateMeterListener(measurements);
// Act
ProvcacheTelemetry.RecordMiss();
// Assert
measurements.Should().ContainSingle(m => m.Key == "provcache_misses_total");
}
[Fact]
public void RecordInvalidation_IncrementsInvalidationCounter()
{
// Arrange
var measurements = new List<KeyValuePair<string, object?>>();
using var meterListener = CreateMeterListener(measurements);
// Act
ProvcacheTelemetry.RecordInvalidation("policy", 5);
// Assert
measurements.Should().ContainSingle(m => m.Key == "provcache_invalidations_total");
}
[Fact]
public void RecordLatency_RecordsToHistogram()
{
// Arrange
var measurements = new List<KeyValuePair<string, object?>>();
using var meterListener = CreateMeterListener(measurements);
// Act
ProvcacheTelemetry.RecordLatency("get", TimeSpan.FromMilliseconds(15.5));
// Assert
measurements.Should().ContainSingle(m => m.Key == "provcache_latency_seconds");
}
[Fact]
public void SetWriteBehindQueueSize_UpdatesGauge()
{
// Arrange & Act
ProvcacheTelemetry.SetWriteBehindQueueSize(42);
// Assert - verify through the observable gauge
// The gauge will report 42 when observed
// We can't directly verify this without full OTel integration,
// but we ensure the method doesn't throw
var size = ProvcacheTelemetry.WriteBehindQueueGauge;
size.Should().NotBeNull();
}
[Fact]
public void SetItemsCount_UpdatesGauge()
{
// Arrange & Act
ProvcacheTelemetry.SetItemsCount(1000);
// Assert - verify through the observable gauge
var gauge = ProvcacheTelemetry.ItemsCountGauge;
gauge.Should().NotBeNull();
}
[Fact]
public void ActivitySource_HasCorrectName()
{
// The activity source should be named correctly for OTel integration
using var listener = CreateActivityListener();
using var activity = ProvcacheTelemetry.StartGetActivity("test");
activity.Should().NotBeNull();
activity!.Source.Name.Should().Be("StellaOps.Provcache");
}
[Fact]
public void StartWriteBehindFlushActivity_CreatesBatchActivity()
{
// Arrange
using var listener = CreateActivityListener();
const int batchSize = 10;
// Act
using var activity = ProvcacheTelemetry.StartWriteBehindFlushActivity(batchSize);
// Assert
activity.Should().NotBeNull();
activity!.DisplayName.Should().Be("provcache.writebehind.flush");
activity.GetTagItem("provcache.batch_size").Should().Be(batchSize);
}
[Fact]
public void StartVeriKeyBuildActivity_CreatesActivity()
{
// Arrange
using var listener = CreateActivityListener();
// Act
using var activity = ProvcacheTelemetry.StartVeriKeyBuildActivity();
// Assert
activity.Should().NotBeNull();
activity!.DisplayName.Should().Be("provcache.verikey.build");
}
[Fact]
public void StartDecisionDigestBuildActivity_CreatesActivity()
{
// Arrange
using var listener = CreateActivityListener();
// Act
using var activity = ProvcacheTelemetry.StartDecisionDigestBuildActivity();
// Assert
activity.Should().NotBeNull();
activity!.DisplayName.Should().Be("provcache.digest.build");
}
#region Helpers
private static ActivityListener CreateActivityListener()
{
var listener = new ActivityListener
{
ShouldListenTo = source => source.Name == "StellaOps.Provcache",
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded,
SampleUsingParentId = (ref ActivityCreationOptions<string> _) => ActivitySamplingResult.AllDataAndRecorded
};
ActivitySource.AddActivityListener(listener);
return listener;
}
private static MeterListener CreateMeterListener(List<KeyValuePair<string, object?>> measurements)
{
var listener = new MeterListener
{
InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == "StellaOps.Provcache")
{
listener.EnableMeasurementEvents(instrument);
}
}
};
listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
{
measurements.Add(new KeyValuePair<string, object?>(instrument.Name, measurement));
});
listener.SetMeasurementEventCallback<double>((instrument, measurement, tags, state) =>
{
measurements.Add(new KeyValuePair<string, object?>(instrument.Name, measurement));
});
listener.Start();
return listener;
}
#endregion
}