Files
git.stella-ops.org/src/__Libraries/StellaOps.Provcache/DecisionDigestBuilder.cs
2025-12-25 23:10:09 +02:00

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;
}
}