up
Some checks failed
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Some checks failed
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
This commit is contained in:
117
src/__Libraries/StellaOps.Replay.Core/CasValidator.cs
Normal file
117
src/__Libraries/StellaOps.Replay.Core/CasValidator.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Replay.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Validates CAS references before manifest signing.
|
||||
/// </summary>
|
||||
public interface ICasValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates that a CAS URI exists and matches the expected hash.
|
||||
/// </summary>
|
||||
Task<CasValidationResult> ValidateAsync(string casUri, string expectedHash);
|
||||
|
||||
/// <summary>
|
||||
/// Validates multiple CAS references in batch.
|
||||
/// </summary>
|
||||
Task<CasValidationResult> ValidateBatchAsync(IEnumerable<CasReference> references);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A reference to a CAS object for validation.
|
||||
/// </summary>
|
||||
public sealed record CasReference(
|
||||
string CasUri,
|
||||
string ExpectedHash,
|
||||
string? HashAlgorithm = null
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Result of a CAS validation operation.
|
||||
/// </summary>
|
||||
public sealed record CasValidationResult(
|
||||
bool IsValid,
|
||||
string? ActualHash = null,
|
||||
string? Error = null,
|
||||
IReadOnlyList<CasValidationError>? Errors = null
|
||||
)
|
||||
{
|
||||
public static CasValidationResult Success(string actualHash) =>
|
||||
new(true, actualHash);
|
||||
|
||||
public static CasValidationResult Failure(string error) =>
|
||||
new(false, Error: error);
|
||||
|
||||
public static CasValidationResult NotFound(string casUri) =>
|
||||
new(false, Error: $"CAS object not found: {casUri}");
|
||||
|
||||
public static CasValidationResult HashMismatch(string casUri, string expected, string actual) =>
|
||||
new(false, ActualHash: actual, Error: $"Hash mismatch for {casUri}: expected {expected}, got {actual}");
|
||||
|
||||
public static CasValidationResult BatchResult(bool isValid, IReadOnlyList<CasValidationError> errors) =>
|
||||
new(isValid, Errors: errors);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error details for a single CAS validation failure in a batch.
|
||||
/// </summary>
|
||||
public sealed record CasValidationError(
|
||||
string CasUri,
|
||||
string ErrorCode,
|
||||
string Message
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// In-memory CAS validator for testing and offline scenarios.
|
||||
/// </summary>
|
||||
public sealed class InMemoryCasValidator : ICasValidator
|
||||
{
|
||||
private readonly Dictionary<string, string> _objects = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Registers a CAS object for validation.
|
||||
/// </summary>
|
||||
public void Register(string casUri, string hash)
|
||||
{
|
||||
_objects[casUri] = hash;
|
||||
}
|
||||
|
||||
public Task<CasValidationResult> ValidateAsync(string casUri, string expectedHash)
|
||||
{
|
||||
if (!_objects.TryGetValue(casUri, out var actualHash))
|
||||
{
|
||||
return Task.FromResult(CasValidationResult.NotFound(casUri));
|
||||
}
|
||||
|
||||
if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(CasValidationResult.HashMismatch(casUri, expectedHash, actualHash));
|
||||
}
|
||||
|
||||
return Task.FromResult(CasValidationResult.Success(actualHash));
|
||||
}
|
||||
|
||||
public async Task<CasValidationResult> ValidateBatchAsync(IEnumerable<CasReference> references)
|
||||
{
|
||||
var errors = new List<CasValidationError>();
|
||||
foreach (var reference in references)
|
||||
{
|
||||
var result = await ValidateAsync(reference.CasUri, reference.ExpectedHash).ConfigureAwait(false);
|
||||
if (!result.IsValid)
|
||||
{
|
||||
errors.Add(new CasValidationError(
|
||||
reference.CasUri,
|
||||
result.Error?.Contains("not found") == true
|
||||
? ReplayManifestErrorCodes.CasNotFound
|
||||
: ReplayManifestErrorCodes.HashMismatch,
|
||||
result.Error ?? "Unknown error"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return CasValidationResult.BatchResult(errors.Count == 0, errors);
|
||||
}
|
||||
}
|
||||
@@ -58,17 +58,43 @@ public static class ReachabilityReplayWriter
|
||||
throw new InvalidOperationException("Graph casUri is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(graph.Sha256))
|
||||
// v2: Prefer Hash field with algorithm prefix
|
||||
if (string.IsNullOrWhiteSpace(graph.Hash))
|
||||
{
|
||||
throw new InvalidOperationException("Graph sha256 is required.");
|
||||
// Backward compat: migrate from legacy Sha256 field
|
||||
if (!string.IsNullOrWhiteSpace(graph.Sha256))
|
||||
{
|
||||
graph.Hash = $"sha256:{graph.Sha256}";
|
||||
graph.HashAlgorithm = "sha256";
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException("Graph hash is required.");
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize hash algorithm from hash prefix if not explicitly set
|
||||
if (string.IsNullOrWhiteSpace(graph.HashAlgorithm))
|
||||
{
|
||||
graph.HashAlgorithm = InferHashAlgorithm(graph.Hash);
|
||||
}
|
||||
|
||||
graph.HashAlgorithm = string.IsNullOrWhiteSpace(graph.HashAlgorithm) ? "blake3-256" : graph.HashAlgorithm;
|
||||
graph.Kind = string.IsNullOrWhiteSpace(graph.Kind) ? "static" : graph.Kind;
|
||||
graph.Namespace = string.IsNullOrWhiteSpace(graph.Namespace) ? "reachability_graphs" : graph.Namespace;
|
||||
return graph;
|
||||
}
|
||||
|
||||
private static string InferHashAlgorithm(string hash)
|
||||
{
|
||||
if (hash.StartsWith("blake3:", StringComparison.OrdinalIgnoreCase))
|
||||
return "blake3-256";
|
||||
if (hash.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
return "sha256";
|
||||
if (hash.StartsWith("sha512:", StringComparison.OrdinalIgnoreCase))
|
||||
return "sha512";
|
||||
return "blake3-256"; // Default for v2
|
||||
}
|
||||
|
||||
private static ReplayReachabilityTraceReference NormalizeTrace(ReplayReachabilityTraceReference trace)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(trace.CasUri))
|
||||
@@ -76,12 +102,27 @@ public static class ReachabilityReplayWriter
|
||||
throw new InvalidOperationException("Trace casUri is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(trace.Sha256))
|
||||
// v2: Prefer Hash field with algorithm prefix
|
||||
if (string.IsNullOrWhiteSpace(trace.Hash))
|
||||
{
|
||||
throw new InvalidOperationException("Trace sha256 is required.");
|
||||
// Backward compat: migrate from legacy Sha256 field
|
||||
if (!string.IsNullOrWhiteSpace(trace.Sha256))
|
||||
{
|
||||
trace.Hash = $"sha256:{trace.Sha256}";
|
||||
trace.HashAlgorithm = "sha256";
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException("Trace hash is required.");
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize hash algorithm from hash prefix if not explicitly set
|
||||
if (string.IsNullOrWhiteSpace(trace.HashAlgorithm))
|
||||
{
|
||||
trace.HashAlgorithm = InferHashAlgorithm(trace.Hash);
|
||||
}
|
||||
|
||||
trace.HashAlgorithm = string.IsNullOrWhiteSpace(trace.HashAlgorithm) ? "sha256" : trace.HashAlgorithm;
|
||||
trace.Namespace = string.IsNullOrWhiteSpace(trace.Namespace) ? "runtime_traces" : trace.Namespace;
|
||||
trace.Source = string.IsNullOrWhiteSpace(trace.Source) ? "runtime" : trace.Source;
|
||||
return trace;
|
||||
|
||||
@@ -47,6 +47,24 @@ public sealed class ReplayReachabilitySection
|
||||
|
||||
[JsonPropertyName("runtimeTraces")]
|
||||
public List<ReplayReachabilityTraceReference> RuntimeTraces { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("code_id_coverage")]
|
||||
public CodeIdCoverage? CodeIdCoverage { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CodeIdCoverage
|
||||
{
|
||||
[JsonPropertyName("total_nodes")]
|
||||
public int TotalNodes { get; set; }
|
||||
|
||||
[JsonPropertyName("nodes_with_symbol_id")]
|
||||
public int NodesWithSymbolId { get; set; }
|
||||
|
||||
[JsonPropertyName("nodes_with_code_id")]
|
||||
public int NodesWithCodeId { get; set; }
|
||||
|
||||
[JsonPropertyName("coverage_percent")]
|
||||
public double CoveragePercent { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ReplayReachabilityGraphReference
|
||||
@@ -57,11 +75,22 @@ public sealed class ReplayReachabilityGraphReference
|
||||
[JsonPropertyName("casUri")]
|
||||
public string CasUri { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sha256")]
|
||||
public string Sha256 { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Hash with algorithm prefix, e.g., "blake3:a1b2c3d4..." or "sha256:feedface..."
|
||||
/// </summary>
|
||||
[JsonPropertyName("hash")]
|
||||
public string Hash { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("hashAlg")]
|
||||
public string HashAlgorithm { get; set; } = "sha256";
|
||||
public string HashAlgorithm { get; set; } = "blake3-256";
|
||||
|
||||
/// <summary>
|
||||
/// Legacy SHA-256 field for backward compatibility with v1 manifests.
|
||||
/// In v2, use the Hash field with algorithm prefix instead.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sha256")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Sha256 { get; set; }
|
||||
|
||||
[JsonPropertyName("namespace")]
|
||||
public string Namespace { get; set; } = "reachability_graphs";
|
||||
@@ -84,12 +113,23 @@ public sealed class ReplayReachabilityTraceReference
|
||||
[JsonPropertyName("casUri")]
|
||||
public string CasUri { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sha256")]
|
||||
public string Sha256 { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Hash with algorithm prefix, e.g., "sha256:feedface..."
|
||||
/// </summary>
|
||||
[JsonPropertyName("hash")]
|
||||
public string Hash { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("hashAlg")]
|
||||
public string HashAlgorithm { get; set; } = "sha256";
|
||||
|
||||
/// <summary>
|
||||
/// Legacy SHA-256 field for backward compatibility with v1 manifests.
|
||||
/// In v2, use the Hash field with algorithm prefix instead.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sha256")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Sha256 { get; set; }
|
||||
|
||||
[JsonPropertyName("namespace")]
|
||||
public string Namespace { get; set; } = "runtime_traces";
|
||||
|
||||
|
||||
397
src/__Libraries/StellaOps.Replay.Core/ReplayManifestValidator.cs
Normal file
397
src/__Libraries/StellaOps.Replay.Core/ReplayManifestValidator.cs
Normal file
@@ -0,0 +1,397 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Replay.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Error codes for replay manifest validation per acceptance contract.
|
||||
/// </summary>
|
||||
public static class ReplayManifestErrorCodes
|
||||
{
|
||||
public const string MissingVersion = "REPLAY_MANIFEST_MISSING_VERSION";
|
||||
public const string VersionMismatch = "REPLAY_MANIFEST_VERSION_MISMATCH";
|
||||
public const string MissingHashAlg = "REPLAY_MANIFEST_MISSING_HASH_ALG";
|
||||
public const string UnsortedEntries = "REPLAY_MANIFEST_UNSORTED_ENTRIES";
|
||||
public const string CasNotFound = "REPLAY_MANIFEST_CAS_NOT_FOUND";
|
||||
public const string HashMismatch = "REPLAY_MANIFEST_HASH_MISMATCH";
|
||||
public const string MissingHash = "REPLAY_MANIFEST_MISSING_HASH";
|
||||
public const string MissingCasUri = "REPLAY_MANIFEST_MISSING_CAS_URI";
|
||||
public const string InvalidHashFormat = "REPLAY_MANIFEST_INVALID_HASH_FORMAT";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of manifest validation.
|
||||
/// </summary>
|
||||
public sealed record ManifestValidationResult(
|
||||
bool IsValid,
|
||||
IReadOnlyList<ManifestValidationError> Errors
|
||||
)
|
||||
{
|
||||
public static ManifestValidationResult Success() =>
|
||||
new(true, Array.Empty<ManifestValidationError>());
|
||||
|
||||
public static ManifestValidationResult Failure(IEnumerable<ManifestValidationError> errors) =>
|
||||
new(false, errors.ToList());
|
||||
|
||||
public static ManifestValidationResult Failure(ManifestValidationError error) =>
|
||||
new(false, new[] { error });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single validation error.
|
||||
/// </summary>
|
||||
public sealed record ManifestValidationError(
|
||||
string ErrorCode,
|
||||
string Message,
|
||||
string? Path = null
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Validates replay manifests against v2 schema rules and CAS registration requirements.
|
||||
/// </summary>
|
||||
public sealed class ReplayManifestValidator
|
||||
{
|
||||
private readonly ICasValidator? _casValidator;
|
||||
private readonly bool _requireV2;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a validator with optional CAS validation.
|
||||
/// </summary>
|
||||
/// <param name="casValidator">Optional CAS validator for reference verification.</param>
|
||||
/// <param name="requireV2">If true, only v2 manifests are accepted.</param>
|
||||
public ReplayManifestValidator(ICasValidator? casValidator = null, bool requireV2 = false)
|
||||
{
|
||||
_casValidator = casValidator;
|
||||
_requireV2 = requireV2;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a manifest against v2 schema rules.
|
||||
/// </summary>
|
||||
public async Task<ManifestValidationResult> ValidateAsync(ReplayManifest manifest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
var errors = new List<ManifestValidationError>();
|
||||
|
||||
// 1. Validate schema version
|
||||
if (string.IsNullOrWhiteSpace(manifest.SchemaVersion))
|
||||
{
|
||||
errors.Add(new ManifestValidationError(
|
||||
ReplayManifestErrorCodes.MissingVersion,
|
||||
"schemaVersion is required",
|
||||
"schemaVersion"));
|
||||
}
|
||||
else if (_requireV2 && manifest.SchemaVersion != ReplayManifestVersions.V2)
|
||||
{
|
||||
errors.Add(new ManifestValidationError(
|
||||
ReplayManifestErrorCodes.VersionMismatch,
|
||||
$"schemaVersion must be {ReplayManifestVersions.V2} when v2 is required",
|
||||
"schemaVersion"));
|
||||
}
|
||||
|
||||
// 2. Validate graph references
|
||||
var isV2 = manifest.SchemaVersion == ReplayManifestVersions.V2;
|
||||
var graphErrors = ValidateGraphs(manifest.Reachability?.Graphs, isV2);
|
||||
errors.AddRange(graphErrors);
|
||||
|
||||
// 3. Validate trace references
|
||||
var traceErrors = ValidateTraces(manifest.Reachability?.RuntimeTraces, isV2);
|
||||
errors.AddRange(traceErrors);
|
||||
|
||||
// 4. Validate sorting (v2 only)
|
||||
if (isV2)
|
||||
{
|
||||
var sortingErrors = ValidateSorting(manifest);
|
||||
errors.AddRange(sortingErrors);
|
||||
}
|
||||
|
||||
// 5. Validate CAS registration if validator provided
|
||||
if (_casValidator is not null && errors.Count == 0)
|
||||
{
|
||||
var casErrors = await ValidateCasReferencesAsync(manifest).ConfigureAwait(false);
|
||||
errors.AddRange(casErrors);
|
||||
}
|
||||
|
||||
return errors.Count == 0
|
||||
? ManifestValidationResult.Success()
|
||||
: ManifestValidationResult.Failure(errors);
|
||||
}
|
||||
|
||||
private static IEnumerable<ManifestValidationError> ValidateGraphs(
|
||||
IReadOnlyList<ReplayReachabilityGraphReference>? graphs, bool isV2)
|
||||
{
|
||||
if (graphs is null || graphs.Count == 0)
|
||||
yield break;
|
||||
|
||||
for (var i = 0; i < graphs.Count; i++)
|
||||
{
|
||||
var graph = graphs[i];
|
||||
var path = $"reachability.graphs[{i}]";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(graph.CasUri))
|
||||
{
|
||||
yield return new ManifestValidationError(
|
||||
ReplayManifestErrorCodes.MissingCasUri,
|
||||
"casUri is required",
|
||||
$"{path}.casUri");
|
||||
}
|
||||
|
||||
if (isV2)
|
||||
{
|
||||
// v2 requires hash field with algorithm prefix
|
||||
if (string.IsNullOrWhiteSpace(graph.Hash))
|
||||
{
|
||||
yield return new ManifestValidationError(
|
||||
ReplayManifestErrorCodes.MissingHash,
|
||||
"hash is required in v2",
|
||||
$"{path}.hash");
|
||||
}
|
||||
else if (!graph.Hash.Contains(':'))
|
||||
{
|
||||
yield return new ManifestValidationError(
|
||||
ReplayManifestErrorCodes.InvalidHashFormat,
|
||||
"hash must include algorithm prefix (e.g., blake3:...)",
|
||||
$"{path}.hash");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(graph.HashAlgorithm))
|
||||
{
|
||||
yield return new ManifestValidationError(
|
||||
ReplayManifestErrorCodes.MissingHashAlg,
|
||||
"hashAlg is required in v2",
|
||||
$"{path}.hashAlg");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<ManifestValidationError> ValidateTraces(
|
||||
IReadOnlyList<ReplayReachabilityTraceReference>? traces, bool isV2)
|
||||
{
|
||||
if (traces is null || traces.Count == 0)
|
||||
yield break;
|
||||
|
||||
for (var i = 0; i < traces.Count; i++)
|
||||
{
|
||||
var trace = traces[i];
|
||||
var path = $"reachability.runtimeTraces[{i}]";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(trace.CasUri))
|
||||
{
|
||||
yield return new ManifestValidationError(
|
||||
ReplayManifestErrorCodes.MissingCasUri,
|
||||
"casUri is required",
|
||||
$"{path}.casUri");
|
||||
}
|
||||
|
||||
if (isV2)
|
||||
{
|
||||
// v2 requires hash field with algorithm prefix
|
||||
if (string.IsNullOrWhiteSpace(trace.Hash))
|
||||
{
|
||||
yield return new ManifestValidationError(
|
||||
ReplayManifestErrorCodes.MissingHash,
|
||||
"hash is required in v2",
|
||||
$"{path}.hash");
|
||||
}
|
||||
else if (!trace.Hash.Contains(':'))
|
||||
{
|
||||
yield return new ManifestValidationError(
|
||||
ReplayManifestErrorCodes.InvalidHashFormat,
|
||||
"hash must include algorithm prefix (e.g., sha256:...)",
|
||||
$"{path}.hash");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(trace.HashAlgorithm))
|
||||
{
|
||||
yield return new ManifestValidationError(
|
||||
ReplayManifestErrorCodes.MissingHashAlg,
|
||||
"hashAlg is required in v2",
|
||||
$"{path}.hashAlg");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<ManifestValidationError> ValidateSorting(ReplayManifest manifest)
|
||||
{
|
||||
var graphs = manifest.Reachability?.Graphs;
|
||||
if (graphs is not null && graphs.Count > 1)
|
||||
{
|
||||
var sorted = graphs.OrderBy(g => g.CasUri, StringComparer.Ordinal).ToList();
|
||||
for (var i = 0; i < graphs.Count; i++)
|
||||
{
|
||||
if (!string.Equals(graphs[i].CasUri, sorted[i].CasUri, StringComparison.Ordinal))
|
||||
{
|
||||
yield return new ManifestValidationError(
|
||||
ReplayManifestErrorCodes.UnsortedEntries,
|
||||
"reachability.graphs must be sorted by casUri (lexicographic)",
|
||||
"reachability.graphs");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var traces = manifest.Reachability?.RuntimeTraces;
|
||||
if (traces is not null && traces.Count > 1)
|
||||
{
|
||||
var sorted = traces.OrderBy(t => t.CasUri, StringComparer.Ordinal).ToList();
|
||||
for (var i = 0; i < traces.Count; i++)
|
||||
{
|
||||
if (!string.Equals(traces[i].CasUri, sorted[i].CasUri, StringComparison.Ordinal))
|
||||
{
|
||||
yield return new ManifestValidationError(
|
||||
ReplayManifestErrorCodes.UnsortedEntries,
|
||||
"reachability.runtimeTraces must be sorted by casUri (lexicographic)",
|
||||
"reachability.runtimeTraces");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<ManifestValidationError>> ValidateCasReferencesAsync(ReplayManifest manifest)
|
||||
{
|
||||
var references = new List<CasReference>();
|
||||
|
||||
// Collect graph references
|
||||
if (manifest.Reachability?.Graphs is not null)
|
||||
{
|
||||
foreach (var graph in manifest.Reachability.Graphs)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(graph.CasUri) && !string.IsNullOrWhiteSpace(graph.Hash))
|
||||
{
|
||||
references.Add(new CasReference(graph.CasUri, graph.Hash, graph.HashAlgorithm));
|
||||
|
||||
// Also check for DSSE envelope
|
||||
var dsseUri = $"{graph.CasUri}.dsse";
|
||||
references.Add(new CasReference(dsseUri, $"{graph.Hash}.dsse", graph.HashAlgorithm));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect trace references
|
||||
if (manifest.Reachability?.RuntimeTraces is not null)
|
||||
{
|
||||
foreach (var trace in manifest.Reachability.RuntimeTraces)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(trace.CasUri) && !string.IsNullOrWhiteSpace(trace.Hash))
|
||||
{
|
||||
references.Add(new CasReference(trace.CasUri, trace.Hash, trace.HashAlgorithm));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (references.Count == 0)
|
||||
return Array.Empty<ManifestValidationError>();
|
||||
|
||||
var result = await _casValidator!.ValidateBatchAsync(references).ConfigureAwait(false);
|
||||
if (result.IsValid)
|
||||
return Array.Empty<ManifestValidationError>();
|
||||
|
||||
return result.Errors?.Select(e => new ManifestValidationError(e.ErrorCode, e.Message, e.CasUri))
|
||||
?? Array.Empty<ManifestValidationError>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upgrades a v1 manifest to v2 format.
|
||||
/// </summary>
|
||||
public static ReplayManifest UpgradeToV2(ReplayManifest v1)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(v1);
|
||||
|
||||
var v2 = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V2,
|
||||
Scan = v1.Scan,
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
AnalysisId = v1.Reachability?.AnalysisId,
|
||||
CodeIdCoverage = v1.Reachability?.CodeIdCoverage,
|
||||
Graphs = v1.Reachability?.Graphs?
|
||||
.Select(g => UpgradeGraph(g))
|
||||
.OrderBy(g => g.CasUri, StringComparer.Ordinal)
|
||||
.ToList() ?? new List<ReplayReachabilityGraphReference>(),
|
||||
RuntimeTraces = v1.Reachability?.RuntimeTraces?
|
||||
.Select(t => UpgradeTrace(t))
|
||||
.OrderBy(t => t.CasUri, StringComparer.Ordinal)
|
||||
.ToList() ?? new List<ReplayReachabilityTraceReference>()
|
||||
}
|
||||
};
|
||||
|
||||
return v2;
|
||||
}
|
||||
|
||||
private static ReplayReachabilityGraphReference UpgradeGraph(ReplayReachabilityGraphReference g)
|
||||
{
|
||||
var hash = g.Hash;
|
||||
var hashAlg = g.HashAlgorithm;
|
||||
|
||||
// If Hash is empty, derive from legacy Sha256
|
||||
if (string.IsNullOrWhiteSpace(hash) && !string.IsNullOrWhiteSpace(g.Sha256))
|
||||
{
|
||||
hash = $"sha256:{g.Sha256}";
|
||||
hashAlg = "sha256";
|
||||
}
|
||||
|
||||
// Infer hash algorithm from prefix if not set
|
||||
if (string.IsNullOrWhiteSpace(hashAlg) && !string.IsNullOrWhiteSpace(hash))
|
||||
{
|
||||
hashAlg = InferHashAlgorithmFromPrefix(hash);
|
||||
}
|
||||
|
||||
return new ReplayReachabilityGraphReference
|
||||
{
|
||||
Kind = g.Kind,
|
||||
CasUri = g.CasUri,
|
||||
Hash = hash ?? string.Empty,
|
||||
HashAlgorithm = hashAlg ?? "blake3-256",
|
||||
Namespace = g.Namespace,
|
||||
CallgraphId = g.CallgraphId,
|
||||
Analyzer = g.Analyzer,
|
||||
Version = g.Version
|
||||
};
|
||||
}
|
||||
|
||||
private static ReplayReachabilityTraceReference UpgradeTrace(ReplayReachabilityTraceReference t)
|
||||
{
|
||||
var hash = t.Hash;
|
||||
var hashAlg = t.HashAlgorithm;
|
||||
|
||||
// If Hash is empty, derive from legacy Sha256
|
||||
if (string.IsNullOrWhiteSpace(hash) && !string.IsNullOrWhiteSpace(t.Sha256))
|
||||
{
|
||||
hash = $"sha256:{t.Sha256}";
|
||||
hashAlg = "sha256";
|
||||
}
|
||||
|
||||
// Infer hash algorithm from prefix if not set
|
||||
if (string.IsNullOrWhiteSpace(hashAlg) && !string.IsNullOrWhiteSpace(hash))
|
||||
{
|
||||
hashAlg = InferHashAlgorithmFromPrefix(hash);
|
||||
}
|
||||
|
||||
return new ReplayReachabilityTraceReference
|
||||
{
|
||||
Source = t.Source,
|
||||
CasUri = t.CasUri,
|
||||
Hash = hash ?? string.Empty,
|
||||
HashAlgorithm = hashAlg ?? "sha256",
|
||||
Namespace = t.Namespace,
|
||||
RecordedAt = t.RecordedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static string InferHashAlgorithmFromPrefix(string hash)
|
||||
{
|
||||
if (hash.StartsWith("blake3:", StringComparison.OrdinalIgnoreCase))
|
||||
return "blake3-256";
|
||||
if (hash.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
return "sha256";
|
||||
if (hash.StartsWith("sha512:", StringComparison.OrdinalIgnoreCase))
|
||||
return "sha512";
|
||||
return "blake3-256";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user