more audit work
This commit is contained in:
@@ -0,0 +1,465 @@
|
||||
// <copyright file="DeterminismVerifier.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Replay.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies determinism by comparing original and replay verdicts.
|
||||
/// Sprint: SPRINT_20260107_006_005 Task RB-004
|
||||
/// </summary>
|
||||
public sealed class DeterminismVerifier
|
||||
{
|
||||
private readonly ILogger<DeterminismVerifier> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeterminismVerifier"/> class.
|
||||
/// </summary>
|
||||
public DeterminismVerifier(ILogger<DeterminismVerifier> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the replay result matches the original verdict.
|
||||
/// </summary>
|
||||
/// <param name="original">The original verdict.</param>
|
||||
/// <param name="replay">The replayed verdict.</param>
|
||||
/// <returns>The verification result.</returns>
|
||||
public VerificationResult Verify(VerdictRecord original, VerdictRecord replay)
|
||||
{
|
||||
var differences = new List<VerdictDifference>();
|
||||
|
||||
// Compare verdict digests
|
||||
var originalDigest = ComputeVerdictDigest(original);
|
||||
var replayDigest = ComputeVerdictDigest(replay);
|
||||
|
||||
var digestsMatch = string.Equals(originalDigest, replayDigest, StringComparison.Ordinal);
|
||||
|
||||
if (!digestsMatch)
|
||||
{
|
||||
// Identify specific differences
|
||||
differences.AddRange(FindDifferences(original, replay));
|
||||
}
|
||||
|
||||
var result = new VerificationResult
|
||||
{
|
||||
OriginalDigest = originalDigest,
|
||||
ReplayDigest = replayDigest,
|
||||
IsDeterministic = digestsMatch,
|
||||
Differences = differences.ToImmutableArray(),
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
DeterminismScore = CalculateDeterminismScore(original, replay, differences)
|
||||
};
|
||||
|
||||
if (digestsMatch)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Determinism verified: original and replay match (digest: {Digest})",
|
||||
originalDigest);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Determinism violation: {Count} differences found between original ({Original}) and replay ({Replay})",
|
||||
differences.Count, originalDigest, replayDigest);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a canonical digest of a verdict for comparison.
|
||||
/// </summary>
|
||||
public string ComputeVerdictDigest(VerdictRecord verdict)
|
||||
{
|
||||
// Create canonical representation for hashing
|
||||
var canonical = new
|
||||
{
|
||||
verdict.Outcome,
|
||||
verdict.Severity,
|
||||
verdict.PolicyId,
|
||||
verdict.RuleIds,
|
||||
FindingCount = verdict.Findings.Length,
|
||||
FindingDigests = verdict.Findings
|
||||
.OrderBy(f => f.FindingId, StringComparer.Ordinal)
|
||||
.Select(f => ComputeFindingDigest(f))
|
||||
.ToArray()
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(canonical, CanonicalJsonOptions);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a diff report for non-matching verdicts.
|
||||
/// </summary>
|
||||
public string GenerateDiffReport(VerificationResult result)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine("# Determinism Verification Report");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(string.Format(CultureInfo.InvariantCulture,
|
||||
"**Verified At:** {0:O}", result.VerifiedAt));
|
||||
sb.AppendLine(string.Format(CultureInfo.InvariantCulture,
|
||||
"**Determinism Score:** {0:P1}", result.DeterminismScore));
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("## Digests");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"| Version | Digest |");
|
||||
sb.AppendLine($"|---------|--------|");
|
||||
sb.AppendLine($"| Original | `{result.OriginalDigest}` |");
|
||||
sb.AppendLine($"| Replay | `{result.ReplayDigest}` |");
|
||||
sb.AppendLine();
|
||||
|
||||
if (result.IsDeterministic)
|
||||
{
|
||||
sb.AppendLine("## Result: MATCH");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("The replay produced an identical verdict to the original.");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine("## Result: MISMATCH");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Found **{result.Differences.Length}** difference(s):");
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (var diff in result.Differences)
|
||||
{
|
||||
sb.AppendLine($"### {diff.Field}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"- **Original:** `{diff.OriginalValue}`");
|
||||
sb.AppendLine($"- **Replay:** `{diff.ReplayValue}`");
|
||||
sb.AppendLine($"- **Severity:** {diff.Severity}");
|
||||
if (!string.IsNullOrEmpty(diff.Explanation))
|
||||
{
|
||||
sb.AppendLine($"- **Explanation:** {diff.Explanation}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
sb.AppendLine("## Possible Causes");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("- Non-deterministic timestamp usage");
|
||||
sb.AppendLine("- Random number generation without fixed seed");
|
||||
sb.AppendLine("- Unstable ordering in collections");
|
||||
sb.AppendLine("- External service dependencies");
|
||||
sb.AppendLine("- Feed data drift between original and replay");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string ComputeFindingDigest(FindingRecord finding)
|
||||
{
|
||||
var canonical = $"{finding.FindingId}:{finding.VulnerabilityId}:{finding.Component}:{finding.Severity}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant()[..16];
|
||||
}
|
||||
|
||||
private static List<VerdictDifference> FindDifferences(VerdictRecord original, VerdictRecord replay)
|
||||
{
|
||||
var differences = new List<VerdictDifference>();
|
||||
|
||||
// Check outcome
|
||||
if (original.Outcome != replay.Outcome)
|
||||
{
|
||||
differences.Add(new VerdictDifference
|
||||
{
|
||||
Field = "Outcome",
|
||||
OriginalValue = original.Outcome.ToString(),
|
||||
ReplayValue = replay.Outcome.ToString(),
|
||||
Severity = DifferenceSeverity.Critical,
|
||||
Explanation = "The final pass/fail decision differs"
|
||||
});
|
||||
}
|
||||
|
||||
// Check severity
|
||||
if (original.Severity != replay.Severity)
|
||||
{
|
||||
differences.Add(new VerdictDifference
|
||||
{
|
||||
Field = "Severity",
|
||||
OriginalValue = original.Severity,
|
||||
ReplayValue = replay.Severity,
|
||||
Severity = DifferenceSeverity.High,
|
||||
Explanation = "The severity assessment differs"
|
||||
});
|
||||
}
|
||||
|
||||
// Check finding count
|
||||
if (original.Findings.Length != replay.Findings.Length)
|
||||
{
|
||||
differences.Add(new VerdictDifference
|
||||
{
|
||||
Field = "FindingCount",
|
||||
OriginalValue = original.Findings.Length.ToString(CultureInfo.InvariantCulture),
|
||||
ReplayValue = replay.Findings.Length.ToString(CultureInfo.InvariantCulture),
|
||||
Severity = DifferenceSeverity.High,
|
||||
Explanation = "Different number of findings detected"
|
||||
});
|
||||
}
|
||||
|
||||
// Check individual findings
|
||||
var originalFindings = original.Findings.ToDictionary(f => f.FindingId);
|
||||
var replayFindings = replay.Findings.ToDictionary(f => f.FindingId);
|
||||
|
||||
var missingInReplay = originalFindings.Keys.Except(replayFindings.Keys).ToList();
|
||||
var newInReplay = replayFindings.Keys.Except(originalFindings.Keys).ToList();
|
||||
|
||||
foreach (var id in missingInReplay)
|
||||
{
|
||||
differences.Add(new VerdictDifference
|
||||
{
|
||||
Field = $"Finding:{id}",
|
||||
OriginalValue = "Present",
|
||||
ReplayValue = "Missing",
|
||||
Severity = DifferenceSeverity.Medium,
|
||||
Explanation = $"Finding {id} was not reproduced"
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var id in newInReplay)
|
||||
{
|
||||
differences.Add(new VerdictDifference
|
||||
{
|
||||
Field = $"Finding:{id}",
|
||||
OriginalValue = "Missing",
|
||||
ReplayValue = "Present",
|
||||
Severity = DifferenceSeverity.Medium,
|
||||
Explanation = $"Finding {id} is new in replay"
|
||||
});
|
||||
}
|
||||
|
||||
// Check common findings for property differences
|
||||
foreach (var id in originalFindings.Keys.Intersect(replayFindings.Keys))
|
||||
{
|
||||
var origFinding = originalFindings[id];
|
||||
var replayFinding = replayFindings[id];
|
||||
|
||||
if (origFinding.Severity != replayFinding.Severity)
|
||||
{
|
||||
differences.Add(new VerdictDifference
|
||||
{
|
||||
Field = $"Finding:{id}:Severity",
|
||||
OriginalValue = origFinding.Severity,
|
||||
ReplayValue = replayFinding.Severity,
|
||||
Severity = DifferenceSeverity.Medium,
|
||||
Explanation = "Finding severity differs"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check rule evaluation order
|
||||
if (!original.RuleIds.SequenceEqual(replay.RuleIds))
|
||||
{
|
||||
differences.Add(new VerdictDifference
|
||||
{
|
||||
Field = "RuleOrder",
|
||||
OriginalValue = string.Join(",", original.RuleIds),
|
||||
ReplayValue = string.Join(",", replay.RuleIds),
|
||||
Severity = DifferenceSeverity.Low,
|
||||
Explanation = "Rule evaluation order differs"
|
||||
});
|
||||
}
|
||||
|
||||
return differences;
|
||||
}
|
||||
|
||||
private static double CalculateDeterminismScore(
|
||||
VerdictRecord original,
|
||||
VerdictRecord replay,
|
||||
List<VerdictDifference> differences)
|
||||
{
|
||||
if (differences.Count == 0)
|
||||
{
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// Weight differences by severity
|
||||
double penalty = 0;
|
||||
foreach (var diff in differences)
|
||||
{
|
||||
penalty += diff.Severity switch
|
||||
{
|
||||
DifferenceSeverity.Critical => 0.5,
|
||||
DifferenceSeverity.High => 0.2,
|
||||
DifferenceSeverity.Medium => 0.1,
|
||||
DifferenceSeverity.Low => 0.05,
|
||||
_ => 0.05
|
||||
};
|
||||
}
|
||||
|
||||
return Math.Max(0, 1.0 - penalty);
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of determinism verification.
|
||||
/// </summary>
|
||||
public sealed record VerificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the original verdict digest.
|
||||
/// </summary>
|
||||
public required string OriginalDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the replay verdict digest.
|
||||
/// </summary>
|
||||
public required string ReplayDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the verdicts are deterministic (match).
|
||||
/// </summary>
|
||||
public bool IsDeterministic { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the differences found.
|
||||
/// </summary>
|
||||
public ImmutableArray<VerdictDifference> Differences { get; init; } =
|
||||
ImmutableArray<VerdictDifference>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets when verification was performed.
|
||||
/// </summary>
|
||||
public DateTimeOffset VerifiedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the determinism score (0-1, 1 = perfect match).
|
||||
/// </summary>
|
||||
public double DeterminismScore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A difference between original and replay verdicts.
|
||||
/// </summary>
|
||||
public sealed record VerdictDifference
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the field that differs.
|
||||
/// </summary>
|
||||
public required string Field { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the original value.
|
||||
/// </summary>
|
||||
public required string OriginalValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the replay value.
|
||||
/// </summary>
|
||||
public required string ReplayValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the severity of the difference.
|
||||
/// </summary>
|
||||
public required DifferenceSeverity Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets an explanation of the difference.
|
||||
/// </summary>
|
||||
public string? Explanation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity of a verdict difference.
|
||||
/// </summary>
|
||||
public enum DifferenceSeverity
|
||||
{
|
||||
/// <summary>Low severity (cosmetic).</summary>
|
||||
Low,
|
||||
|
||||
/// <summary>Medium severity (detail differences).</summary>
|
||||
Medium,
|
||||
|
||||
/// <summary>High severity (significant differences).</summary>
|
||||
High,
|
||||
|
||||
/// <summary>Critical severity (outcome differs).</summary>
|
||||
Critical
|
||||
}
|
||||
|
||||
// Verdict models (would reference actual modules)
|
||||
|
||||
/// <summary>
|
||||
/// A verdict record from policy evaluation.
|
||||
/// </summary>
|
||||
public sealed record VerdictRecord
|
||||
{
|
||||
/// <summary>Gets the verdict ID.</summary>
|
||||
public required string VerdictId { get; init; }
|
||||
|
||||
/// <summary>Gets the outcome.</summary>
|
||||
public required VerdictOutcome Outcome { get; init; }
|
||||
|
||||
/// <summary>Gets the severity.</summary>
|
||||
public required string Severity { get; init; }
|
||||
|
||||
/// <summary>Gets the policy ID used.</summary>
|
||||
public required string PolicyId { get; init; }
|
||||
|
||||
/// <summary>Gets the rule IDs evaluated.</summary>
|
||||
public ImmutableArray<string> RuleIds { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>Gets the findings.</summary>
|
||||
public ImmutableArray<FindingRecord> Findings { get; init; } = ImmutableArray<FindingRecord>.Empty;
|
||||
|
||||
/// <summary>Gets when the verdict was rendered.</summary>
|
||||
public DateTimeOffset RenderedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verdict outcome.
|
||||
/// </summary>
|
||||
public enum VerdictOutcome
|
||||
{
|
||||
/// <summary>Policy passed.</summary>
|
||||
Pass,
|
||||
|
||||
/// <summary>Policy failed.</summary>
|
||||
Fail,
|
||||
|
||||
/// <summary>Warning (pass with issues).</summary>
|
||||
Warn,
|
||||
|
||||
/// <summary>Error during evaluation.</summary>
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A finding record.
|
||||
/// </summary>
|
||||
public sealed record FindingRecord
|
||||
{
|
||||
/// <summary>Gets the finding ID.</summary>
|
||||
public required string FindingId { get; init; }
|
||||
|
||||
/// <summary>Gets the vulnerability ID.</summary>
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>Gets the component.</summary>
|
||||
public required string Component { get; init; }
|
||||
|
||||
/// <summary>Gets the severity.</summary>
|
||||
public required string Severity { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
// <copyright file="InputManifestResolver.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Replay.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves input manifests for deterministic replay.
|
||||
/// Sprint: SPRINT_20260107_006_005 Task RB-002
|
||||
/// </summary>
|
||||
public sealed class InputManifestResolver
|
||||
{
|
||||
private readonly IFeedSnapshotStore _feedStore;
|
||||
private readonly IPolicyManifestStore _policyStore;
|
||||
private readonly IVexDocumentStore _vexStore;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<InputManifestResolver> _logger;
|
||||
private readonly InputManifestResolverOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InputManifestResolver"/> class.
|
||||
/// </summary>
|
||||
public InputManifestResolver(
|
||||
IFeedSnapshotStore feedStore,
|
||||
IPolicyManifestStore policyStore,
|
||||
IVexDocumentStore vexStore,
|
||||
IMemoryCache cache,
|
||||
ILogger<InputManifestResolver> logger,
|
||||
InputManifestResolverOptions? options = null)
|
||||
{
|
||||
_feedStore = feedStore;
|
||||
_policyStore = policyStore;
|
||||
_vexStore = vexStore;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
_options = options ?? new InputManifestResolverOptions();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves all inputs required for replay.
|
||||
/// </summary>
|
||||
/// <param name="manifest">The input manifest specifying what to resolve.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The resolved inputs or errors.</returns>
|
||||
public async Task<ResolvedInputs> ResolveAsync(
|
||||
InputManifest manifest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var errors = new List<InputResolutionError>();
|
||||
|
||||
// Resolve feed snapshot
|
||||
var feedData = await ResolveFeedSnapshotAsync(
|
||||
manifest.FeedSnapshotHash, errors, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Resolve policy manifest
|
||||
var policyBundle = await ResolvePolicyManifestAsync(
|
||||
manifest.PolicyManifestHash, errors, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Resolve VEX documents
|
||||
var vexDocuments = await ResolveVexDocumentsAsync(
|
||||
manifest.VexDocumentHashes, errors, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Build resolved inputs
|
||||
return new ResolvedInputs
|
||||
{
|
||||
FeedData = feedData,
|
||||
PolicyBundle = policyBundle,
|
||||
VexDocuments = vexDocuments,
|
||||
SourceCodeHash = manifest.SourceCodeHash,
|
||||
BaseImageDigest = manifest.BaseImageDigest,
|
||||
ToolchainVersion = manifest.ToolchainVersion,
|
||||
RandomSeed = manifest.RandomSeed,
|
||||
TimestampOverride = manifest.TimestampOverride,
|
||||
Errors = errors.ToImmutableArray(),
|
||||
IsComplete = errors.Count == 0
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a manifest can be resolved without actually fetching data.
|
||||
/// </summary>
|
||||
public async Task<ManifestValidationResult> ValidateAsync(
|
||||
InputManifest manifest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var missing = new List<string>();
|
||||
|
||||
// Check feed snapshot exists
|
||||
if (!string.IsNullOrEmpty(manifest.FeedSnapshotHash))
|
||||
{
|
||||
var feedExists = await _feedStore.ExistsAsync(manifest.FeedSnapshotHash, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!feedExists)
|
||||
{
|
||||
missing.Add($"Feed snapshot: {manifest.FeedSnapshotHash}");
|
||||
}
|
||||
}
|
||||
|
||||
// Check policy manifest exists
|
||||
if (!string.IsNullOrEmpty(manifest.PolicyManifestHash))
|
||||
{
|
||||
var policyExists = await _policyStore.ExistsAsync(manifest.PolicyManifestHash, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!policyExists)
|
||||
{
|
||||
missing.Add($"Policy manifest: {manifest.PolicyManifestHash}");
|
||||
}
|
||||
}
|
||||
|
||||
// Check VEX documents exist
|
||||
foreach (var vexHash in manifest.VexDocumentHashes)
|
||||
{
|
||||
var vexExists = await _vexStore.ExistsAsync(vexHash, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!vexExists)
|
||||
{
|
||||
missing.Add($"VEX document: {vexHash}");
|
||||
}
|
||||
}
|
||||
|
||||
return new ManifestValidationResult
|
||||
{
|
||||
IsValid = missing.Count == 0,
|
||||
MissingInputs = missing.ToImmutableArray()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<FeedData?> ResolveFeedSnapshotAsync(
|
||||
string? hash,
|
||||
List<InputResolutionError> errors,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(hash))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var cacheKey = $"feed:{hash}";
|
||||
if (_cache.TryGetValue(cacheKey, out FeedData? cached) && cached is not null)
|
||||
{
|
||||
_logger.LogDebug("Feed snapshot {Hash} resolved from cache", hash);
|
||||
return cached;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var data = await _feedStore.GetAsync(hash, cancellationToken).ConfigureAwait(false);
|
||||
if (data is null)
|
||||
{
|
||||
errors.Add(new InputResolutionError(InputType.FeedSnapshot, hash, "Not found"));
|
||||
return null;
|
||||
}
|
||||
|
||||
_cache.Set(cacheKey, data, _options.CacheDuration);
|
||||
_logger.LogDebug("Feed snapshot {Hash} resolved and cached", hash);
|
||||
return data;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to resolve feed snapshot {Hash}", hash);
|
||||
errors.Add(new InputResolutionError(InputType.FeedSnapshot, hash, ex.Message));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<PolicyBundle?> ResolvePolicyManifestAsync(
|
||||
string? hash,
|
||||
List<InputResolutionError> errors,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(hash))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var cacheKey = $"policy:{hash}";
|
||||
if (_cache.TryGetValue(cacheKey, out PolicyBundle? cached) && cached is not null)
|
||||
{
|
||||
_logger.LogDebug("Policy manifest {Hash} resolved from cache", hash);
|
||||
return cached;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var bundle = await _policyStore.GetAsync(hash, cancellationToken).ConfigureAwait(false);
|
||||
if (bundle is null)
|
||||
{
|
||||
errors.Add(new InputResolutionError(InputType.PolicyManifest, hash, "Not found"));
|
||||
return null;
|
||||
}
|
||||
|
||||
_cache.Set(cacheKey, bundle, _options.CacheDuration);
|
||||
_logger.LogDebug("Policy manifest {Hash} resolved and cached", hash);
|
||||
return bundle;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to resolve policy manifest {Hash}", hash);
|
||||
errors.Add(new InputResolutionError(InputType.PolicyManifest, hash, ex.Message));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ImmutableArray<VexDocument>> ResolveVexDocumentsAsync(
|
||||
ImmutableArray<string> hashes,
|
||||
List<InputResolutionError> errors,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (hashes.IsEmpty)
|
||||
{
|
||||
return ImmutableArray<VexDocument>.Empty;
|
||||
}
|
||||
|
||||
var documents = new List<VexDocument>();
|
||||
|
||||
foreach (var hash in hashes)
|
||||
{
|
||||
var cacheKey = $"vex:{hash}";
|
||||
if (_cache.TryGetValue(cacheKey, out VexDocument? cached) && cached is not null)
|
||||
{
|
||||
documents.Add(cached);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var doc = await _vexStore.GetAsync(hash, cancellationToken).ConfigureAwait(false);
|
||||
if (doc is null)
|
||||
{
|
||||
errors.Add(new InputResolutionError(InputType.VexDocument, hash, "Not found"));
|
||||
continue;
|
||||
}
|
||||
|
||||
_cache.Set(cacheKey, doc, _options.CacheDuration);
|
||||
documents.Add(doc);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to resolve VEX document {Hash}", hash);
|
||||
errors.Add(new InputResolutionError(InputType.VexDocument, hash, ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
return documents.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input manifest specifying what to resolve for replay.
|
||||
/// </summary>
|
||||
public sealed record InputManifest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the feed snapshot hash.
|
||||
/// </summary>
|
||||
public string? FeedSnapshotHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the policy manifest hash.
|
||||
/// </summary>
|
||||
public string? PolicyManifestHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the source code hash.
|
||||
/// </summary>
|
||||
public string? SourceCodeHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the base image digest.
|
||||
/// </summary>
|
||||
public string? BaseImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the VEX document hashes.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> VexDocumentHashes { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the toolchain version used.
|
||||
/// </summary>
|
||||
public string? ToolchainVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the random seed for determinism.
|
||||
/// </summary>
|
||||
public int? RandomSeed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp override for determinism.
|
||||
/// </summary>
|
||||
public DateTimeOffset? TimestampOverride { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolved inputs ready for replay.
|
||||
/// </summary>
|
||||
public sealed record ResolvedInputs
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the resolved feed data.
|
||||
/// </summary>
|
||||
public FeedData? FeedData { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the resolved policy bundle.
|
||||
/// </summary>
|
||||
public PolicyBundle? PolicyBundle { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the resolved VEX documents.
|
||||
/// </summary>
|
||||
public ImmutableArray<VexDocument> VexDocuments { get; init; } = ImmutableArray<VexDocument>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the source code hash.
|
||||
/// </summary>
|
||||
public string? SourceCodeHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the base image digest.
|
||||
/// </summary>
|
||||
public string? BaseImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the toolchain version.
|
||||
/// </summary>
|
||||
public string? ToolchainVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the random seed.
|
||||
/// </summary>
|
||||
public int? RandomSeed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp override.
|
||||
/// </summary>
|
||||
public DateTimeOffset? TimestampOverride { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets any resolution errors.
|
||||
/// </summary>
|
||||
public ImmutableArray<InputResolutionError> Errors { get; init; } =
|
||||
ImmutableArray<InputResolutionError>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether all inputs were resolved successfully.
|
||||
/// </summary>
|
||||
public bool IsComplete { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error resolving an input.
|
||||
/// </summary>
|
||||
public sealed record InputResolutionError(InputType Type, string Hash, string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Types of replay inputs.
|
||||
/// </summary>
|
||||
public enum InputType
|
||||
{
|
||||
/// <summary>Feed snapshot.</summary>
|
||||
FeedSnapshot,
|
||||
|
||||
/// <summary>Policy manifest.</summary>
|
||||
PolicyManifest,
|
||||
|
||||
/// <summary>VEX document.</summary>
|
||||
VexDocument,
|
||||
|
||||
/// <summary>Source code.</summary>
|
||||
SourceCode,
|
||||
|
||||
/// <summary>Base image.</summary>
|
||||
BaseImage
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of manifest validation.
|
||||
/// </summary>
|
||||
public sealed record ManifestValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether all inputs are available.
|
||||
/// </summary>
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the missing inputs.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> MissingInputs { get; init; } = ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for input manifest resolution.
|
||||
/// </summary>
|
||||
public sealed class InputManifestResolverOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the cache duration for resolved inputs.
|
||||
/// Default: 1 hour.
|
||||
/// </summary>
|
||||
public TimeSpan CacheDuration { get; set; } = TimeSpan.FromHours(1);
|
||||
}
|
||||
|
||||
// Store interfaces (to be implemented elsewhere)
|
||||
|
||||
/// <summary>
|
||||
/// Interface for feed snapshot storage.
|
||||
/// </summary>
|
||||
public interface IFeedSnapshotStore
|
||||
{
|
||||
/// <summary>Gets feed data by hash.</summary>
|
||||
Task<FeedData?> GetAsync(string hash, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Checks if feed data exists.</summary>
|
||||
Task<bool> ExistsAsync(string hash, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for policy manifest storage.
|
||||
/// </summary>
|
||||
public interface IPolicyManifestStore
|
||||
{
|
||||
/// <summary>Gets policy bundle by hash.</summary>
|
||||
Task<PolicyBundle?> GetAsync(string hash, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Checks if policy bundle exists.</summary>
|
||||
Task<bool> ExistsAsync(string hash, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for VEX document storage.
|
||||
/// </summary>
|
||||
public interface IVexDocumentStore
|
||||
{
|
||||
/// <summary>Gets VEX document by hash.</summary>
|
||||
Task<VexDocument?> GetAsync(string hash, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Checks if VEX document exists.</summary>
|
||||
Task<bool> ExistsAsync(string hash, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
// Data models (placeholders - would reference actual modules)
|
||||
|
||||
/// <summary>
|
||||
/// Feed data snapshot.
|
||||
/// </summary>
|
||||
public sealed record FeedData
|
||||
{
|
||||
/// <summary>Gets the hash.</summary>
|
||||
public required string Hash { get; init; }
|
||||
|
||||
/// <summary>Gets the content.</summary>
|
||||
public required byte[] Content { get; init; }
|
||||
|
||||
/// <summary>Gets the snapshot timestamp.</summary>
|
||||
public required DateTimeOffset SnapshotAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy bundle.
|
||||
/// </summary>
|
||||
public sealed record PolicyBundle
|
||||
{
|
||||
/// <summary>Gets the hash.</summary>
|
||||
public required string Hash { get; init; }
|
||||
|
||||
/// <summary>Gets the policy rules.</summary>
|
||||
public required ImmutableArray<byte> Content { get; init; }
|
||||
|
||||
/// <summary>Gets the bundle version.</summary>
|
||||
public required string Version { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX document.
|
||||
/// </summary>
|
||||
public sealed record VexDocument
|
||||
{
|
||||
/// <summary>Gets the hash.</summary>
|
||||
public required string Hash { get; init; }
|
||||
|
||||
/// <summary>Gets the content.</summary>
|
||||
public required string Content { get; init; }
|
||||
|
||||
/// <summary>Gets the format (OpenVEX, CSAF, etc.).</summary>
|
||||
public required string Format { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Description>Replay.Core - Deterministic replay and verification for StellaOps</Description>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user