344 lines
11 KiB
C#
344 lines
11 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace StellaOps.Provcache;
|
|
|
|
/// <summary>
|
|
/// Builder for constructing <see cref="DecisionDigest"/> from evaluation results.
|
|
/// Ensures deterministic digest computation for cache consistency.
|
|
/// </summary>
|
|
public sealed class DecisionDigestBuilder
|
|
{
|
|
private string? _veriKey;
|
|
private string? _verdictHash;
|
|
private string? _proofRoot;
|
|
private ReplaySeed? _replaySeed;
|
|
private DateTimeOffset? _createdAt;
|
|
private DateTimeOffset? _expiresAt;
|
|
private int? _trustScore;
|
|
private TrustScoreBreakdown? _trustScoreBreakdown;
|
|
private readonly ProvcacheOptions _options;
|
|
private readonly TimeProvider _timeProvider;
|
|
|
|
/// <summary>
|
|
/// Creates a new DecisionDigestBuilder with default options.
|
|
/// </summary>
|
|
public DecisionDigestBuilder() : this(new ProvcacheOptions(), TimeProvider.System)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new DecisionDigestBuilder with the specified options.
|
|
/// </summary>
|
|
/// <param name="options">Provcache configuration options.</param>
|
|
/// <param name="timeProvider">Time provider for timestamps.</param>
|
|
public DecisionDigestBuilder(ProvcacheOptions options, TimeProvider? timeProvider = null)
|
|
{
|
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets the VeriKey for this digest.
|
|
/// </summary>
|
|
public DecisionDigestBuilder WithVeriKey(string veriKey)
|
|
{
|
|
_veriKey = veriKey ?? throw new ArgumentNullException(nameof(veriKey));
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets the VeriKey from a builder.
|
|
/// </summary>
|
|
public DecisionDigestBuilder WithVeriKey(VeriKeyBuilder builder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
_veriKey = builder.Build();
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets the verdict hash directly.
|
|
/// </summary>
|
|
public DecisionDigestBuilder WithVerdictHash(string verdictHash)
|
|
{
|
|
_verdictHash = verdictHash ?? throw new ArgumentNullException(nameof(verdictHash));
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Computes verdict hash from sorted dispositions.
|
|
/// Dispositions are sorted by key for deterministic hashing.
|
|
/// </summary>
|
|
/// <param name="dispositions">Dictionary of finding ID to disposition.</param>
|
|
public DecisionDigestBuilder WithDispositions(IReadOnlyDictionary<string, string> dispositions)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(dispositions);
|
|
|
|
// Sort by key for deterministic hash
|
|
var sorted = dispositions
|
|
.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)
|
|
.ToList();
|
|
|
|
if (sorted.Count == 0)
|
|
{
|
|
_verdictHash = ComputeHash(Encoding.UTF8.GetBytes("empty-verdict"));
|
|
return this;
|
|
}
|
|
|
|
// Build deterministic string: key1=value1|key2=value2|...
|
|
var sb = new StringBuilder();
|
|
foreach (var (key, value) in sorted)
|
|
{
|
|
if (sb.Length > 0) sb.Append('|');
|
|
sb.Append(key);
|
|
sb.Append('=');
|
|
sb.Append(value);
|
|
}
|
|
|
|
_verdictHash = ComputeHash(Encoding.UTF8.GetBytes(sb.ToString()));
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets the proof root (Merkle root of evidence) directly.
|
|
/// </summary>
|
|
public DecisionDigestBuilder WithProofRoot(string proofRoot)
|
|
{
|
|
_proofRoot = proofRoot ?? throw new ArgumentNullException(nameof(proofRoot));
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Computes proof root from a list of evidence chunk hashes.
|
|
/// Builds a simple binary Merkle tree for verification.
|
|
/// </summary>
|
|
/// <param name="evidenceChunkHashes">Ordered list of evidence chunk hashes.</param>
|
|
public DecisionDigestBuilder WithEvidenceChunks(IReadOnlyList<string> evidenceChunkHashes)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(evidenceChunkHashes);
|
|
|
|
if (evidenceChunkHashes.Count == 0)
|
|
{
|
|
_proofRoot = ComputeHash(Encoding.UTF8.GetBytes("empty-proof"));
|
|
return this;
|
|
}
|
|
|
|
// Simple Merkle tree: recursively pair and hash until single root
|
|
var currentLevel = evidenceChunkHashes
|
|
.Select(h => Convert.FromHexString(StripPrefix(h)))
|
|
.ToList();
|
|
|
|
while (currentLevel.Count > 1)
|
|
{
|
|
var nextLevel = new List<byte[]>();
|
|
|
|
for (int i = 0; i < currentLevel.Count; i += 2)
|
|
{
|
|
if (i + 1 < currentLevel.Count)
|
|
{
|
|
// Hash pair
|
|
var combined = new byte[currentLevel[i].Length + currentLevel[i + 1].Length];
|
|
currentLevel[i].CopyTo(combined, 0);
|
|
currentLevel[i + 1].CopyTo(combined, currentLevel[i].Length);
|
|
nextLevel.Add(SHA256.HashData(combined));
|
|
}
|
|
else
|
|
{
|
|
// Odd element: promote to next level
|
|
nextLevel.Add(currentLevel[i]);
|
|
}
|
|
}
|
|
|
|
currentLevel = nextLevel;
|
|
}
|
|
|
|
_proofRoot = $"sha256:{Convert.ToHexStringLower(currentLevel[0])}";
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets the replay seed directly.
|
|
/// </summary>
|
|
public DecisionDigestBuilder WithReplaySeed(ReplaySeed replaySeed)
|
|
{
|
|
_replaySeed = replaySeed ?? throw new ArgumentNullException(nameof(replaySeed));
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds replay seed from feed and rule identifiers.
|
|
/// </summary>
|
|
public DecisionDigestBuilder WithReplaySeed(
|
|
IEnumerable<string> feedIds,
|
|
IEnumerable<string> ruleIds,
|
|
DateTimeOffset? frozenEpoch = null)
|
|
{
|
|
_replaySeed = new ReplaySeed
|
|
{
|
|
FeedIds = feedIds?.ToList() ?? [],
|
|
RuleIds = ruleIds?.ToList() ?? [],
|
|
FrozenEpoch = frozenEpoch
|
|
};
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets explicit timestamps for created and expires.
|
|
/// </summary>
|
|
public DecisionDigestBuilder WithTimestamps(DateTimeOffset createdAt, DateTimeOffset expiresAt)
|
|
{
|
|
_createdAt = createdAt;
|
|
_expiresAt = expiresAt;
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets timestamps using the default TTL from options.
|
|
/// </summary>
|
|
public DecisionDigestBuilder WithDefaultTimestamps()
|
|
{
|
|
_createdAt = _timeProvider.GetUtcNow();
|
|
_expiresAt = _createdAt.Value.Add(_options.DefaultTtl);
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets the trust score directly.
|
|
/// </summary>
|
|
/// <param name="trustScore">Trust score (0-100).</param>
|
|
public DecisionDigestBuilder WithTrustScore(int trustScore)
|
|
{
|
|
if (trustScore < 0 || trustScore > 100)
|
|
throw new ArgumentOutOfRangeException(nameof(trustScore), "Trust score must be between 0 and 100.");
|
|
|
|
_trustScore = trustScore;
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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>
|
|
/// <param name="vexCoverageScore">VEX statement coverage (0-100).</param>
|
|
/// <param name="policyFreshnessScore">Policy freshness (0-100).</param>
|
|
/// <param name="signerTrustScore">Signer trust level (0-100).</param>
|
|
public DecisionDigestBuilder WithTrustScore(
|
|
int reachabilityScore,
|
|
int sbomCompletenessScore,
|
|
int vexCoverageScore,
|
|
int policyFreshnessScore,
|
|
int signerTrustScore)
|
|
{
|
|
// 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);
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the final DecisionDigest.
|
|
/// </summary>
|
|
/// <returns>The constructed DecisionDigest.</returns>
|
|
/// <exception cref="InvalidOperationException">If required components are missing.</exception>
|
|
public DecisionDigest Build()
|
|
{
|
|
ValidateRequiredComponents();
|
|
|
|
return new DecisionDigest
|
|
{
|
|
DigestVersion = _options.DigestVersion,
|
|
VeriKey = _veriKey!,
|
|
VerdictHash = _verdictHash!,
|
|
ProofRoot = _proofRoot!,
|
|
ReplaySeed = _replaySeed!,
|
|
CreatedAt = _createdAt!.Value,
|
|
ExpiresAt = _expiresAt!.Value,
|
|
TrustScore = _trustScore!.Value,
|
|
TrustScoreBreakdown = _trustScoreBreakdown
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resets the builder to its initial state.
|
|
/// </summary>
|
|
public DecisionDigestBuilder Reset()
|
|
{
|
|
_veriKey = null;
|
|
_verdictHash = null;
|
|
_proofRoot = null;
|
|
_replaySeed = null;
|
|
_createdAt = null;
|
|
_expiresAt = null;
|
|
_trustScore = null;
|
|
_trustScoreBreakdown = null;
|
|
return this;
|
|
}
|
|
|
|
private void ValidateRequiredComponents()
|
|
{
|
|
var missing = new List<string>();
|
|
|
|
if (string.IsNullOrWhiteSpace(_veriKey))
|
|
missing.Add("VeriKey");
|
|
if (string.IsNullOrWhiteSpace(_verdictHash))
|
|
missing.Add("VerdictHash");
|
|
if (string.IsNullOrWhiteSpace(_proofRoot))
|
|
missing.Add("ProofRoot");
|
|
if (_replaySeed is null)
|
|
missing.Add("ReplaySeed");
|
|
if (!_createdAt.HasValue)
|
|
missing.Add("CreatedAt");
|
|
if (!_expiresAt.HasValue)
|
|
missing.Add("ExpiresAt");
|
|
if (!_trustScore.HasValue)
|
|
missing.Add("TrustScore");
|
|
|
|
if (missing.Count > 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Cannot build DecisionDigest: missing required components: {string.Join(", ", missing)}. " +
|
|
"Use the With* methods to set all required components before calling Build().");
|
|
}
|
|
}
|
|
|
|
private static string ComputeHash(ReadOnlySpan<byte> data)
|
|
{
|
|
Span<byte> hash = stackalloc byte[32];
|
|
SHA256.HashData(data, hash);
|
|
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
|
}
|
|
|
|
private static string StripPrefix(string hash)
|
|
{
|
|
if (hash.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
|
return hash[7..];
|
|
return hash;
|
|
}
|
|
}
|