sprints work
This commit is contained in:
325
src/__Libraries/StellaOps.Provcache/DecisionDigestBuilder.cs
Normal file
325
src/__Libraries/StellaOps.Provcache/DecisionDigestBuilder.cs
Normal file
@@ -0,0 +1,325 @@
|
||||
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 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>
|
||||
/// Computes trust score from component scores using weighted formula.
|
||||
/// </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)
|
||||
{
|
||||
// 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);
|
||||
|
||||
// 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
|
||||
};
|
||||
}
|
||||
|
||||
/// <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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user