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

This commit is contained in:
StellaOps Bot
2025-12-13 09:37:15 +02:00
parent e00f6365da
commit 6e45066e37
349 changed files with 17160 additions and 1867 deletions

View File

@@ -0,0 +1,43 @@
using StellaOps.Messaging.Abstractions;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// DPoP replay cache backed by <see cref="IIdempotencyStore"/>.
/// Supports any transport (InMemory, Valkey, PostgreSQL) via factory injection.
/// </summary>
public sealed class MessagingDpopReplayCache : IDpopReplayCache
{
private readonly IIdempotencyStore _store;
private readonly TimeProvider _timeProvider;
public MessagingDpopReplayCache(
IIdempotencyStoreFactory storeFactory,
TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(storeFactory);
_store = storeFactory.Create("dpop:replay");
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async ValueTask<bool> TryStoreAsync(
string jwtId,
DateTimeOffset expiresAt,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(jwtId);
var now = _timeProvider.GetUtcNow();
var ttl = expiresAt - now;
if (ttl <= TimeSpan.Zero)
{
// Already expired, treat as valid to store (won't conflict)
return true;
}
var result = await _store.TryClaimAsync(jwtId, jwtId, ttl, cancellationToken).ConfigureAwait(false);
return result.IsFirstClaim;
}
}

View File

@@ -5,7 +5,7 @@ using Xunit;
public class ReplayManifestTests
{
[Fact]
public void SerializesWithNamespacesAndAnalysis()
public void SerializesWithNamespacesAndAnalysis_V1()
{
var manifest = new ReplayManifest
{
@@ -20,7 +20,9 @@ public class ReplayManifestTests
{
Kind = "static",
CasUri = "cas://reachability_graphs/aa/aagraph.tar.zst",
Sha256 = "aa",
Hash = "sha256:aa",
HashAlgorithm = "sha256",
Sha256 = "aa", // Legacy field for v1 compat
Namespace = "reachability_graphs",
CallgraphId = "cg-1",
Analyzer = "scanner",
@@ -31,7 +33,9 @@ public class ReplayManifestTests
{
Source = "runtime",
CasUri = "cas://runtime_traces/bb/bbtrace.tar.zst",
Sha256 = "bb",
Hash = "sha256:bb",
HashAlgorithm = "sha256",
Sha256 = "bb", // Legacy field for v1 compat
Namespace = "runtime_traces",
RecordedAt = System.DateTimeOffset.Parse("2025-11-26T00:00:00Z")
});
@@ -43,4 +47,36 @@ public class ReplayManifestTests
Assert.Contains("\"callgraphId\":\"cg-1\"", json);
Assert.Contains("\"namespace\":\"runtime_traces\"", json);
}
[Fact]
public void SerializesWithV2HashFields()
{
var manifest = new ReplayManifest
{
SchemaVersion = ReplayManifestVersions.V2,
Reachability = new ReplayReachabilitySection
{
AnalysisId = "analysis-v2"
}
};
manifest.AddReachabilityGraph(new ReplayReachabilityGraphReference
{
Kind = "static",
CasUri = "cas://reachability/graphs/blake3:abc123",
Hash = "blake3:abc123def456789012345678901234567890123456789012345678901234",
HashAlgorithm = "blake3-256",
Namespace = "reachability_graphs",
Analyzer = "scanner.java@10.0.0",
Version = "10.0.0"
});
var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions(JsonSerializerDefaults.Web));
Assert.Contains("\"schemaVersion\":\"2.0\"", json);
Assert.Contains("\"hash\":\"blake3:", json);
Assert.Contains("\"hashAlg\":\"blake3-256\"", json);
// v2 manifests should not emit legacy sha256 field (JsonIgnore when null)
Assert.DoesNotContain("\"sha256\":", json);
}
}

View File

@@ -0,0 +1,483 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
using StellaOps.Replay.Core;
using Xunit;
namespace StellaOps.Replay.Core.Tests;
/// <summary>
/// Test vectors from replay-manifest-v2-acceptance.md
/// </summary>
public class ReplayManifestV2Tests
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false
};
#region Section 4.1: Minimal Valid Manifest v2
[Fact]
public void MinimalValidManifestV2_SerializesCorrectly()
{
var manifest = new ReplayManifest
{
SchemaVersion = ReplayManifestVersions.V2,
Scan = new ReplayScanMetadata
{
Id = "scan-test-001",
Time = DateTimeOffset.Parse("2025-12-13T10:00:00Z")
},
Reachability = new ReplayReachabilitySection
{
Graphs = new List<ReplayReachabilityGraphReference>
{
new()
{
Kind = "static",
Analyzer = "scanner.java@10.2.0",
Hash = "blake3:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
HashAlgorithm = "blake3-256",
CasUri = "cas://reachability/graphs/blake3:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"
}
},
RuntimeTraces = new List<ReplayReachabilityTraceReference>(),
CodeIdCoverage = new CodeIdCoverage
{
TotalNodes = 100,
NodesWithSymbolId = 100,
NodesWithCodeId = 0,
CoveragePercent = 100.0
}
}
};
var json = JsonSerializer.Serialize(manifest, JsonOptions);
Assert.Contains("\"schemaVersion\":\"2.0\"", json);
Assert.Contains("\"hash\":\"blake3:", json);
Assert.Contains("\"hashAlg\":\"blake3-256\"", json);
Assert.Contains("\"code_id_coverage\"", json);
Assert.Contains("\"total_nodes\":100", json);
}
#endregion
#region Section 4.2: Manifest with Runtime Traces
[Fact]
public void ManifestWithRuntimeTraces_SerializesCorrectly()
{
var manifest = new ReplayManifest
{
SchemaVersion = ReplayManifestVersions.V2,
Scan = new ReplayScanMetadata
{
Id = "scan-test-002",
Time = DateTimeOffset.Parse("2025-12-13T11:00:00Z")
},
Reachability = new ReplayReachabilitySection
{
Graphs = new List<ReplayReachabilityGraphReference>
{
new()
{
Kind = "static",
Analyzer = "scanner.java@10.2.0",
Hash = "blake3:1111111111111111111111111111111111111111111111111111111111111111",
HashAlgorithm = "blake3-256",
CasUri = "cas://reachability/graphs/blake3:1111111111111111111111111111111111111111111111111111111111111111"
}
},
RuntimeTraces = new List<ReplayReachabilityTraceReference>
{
new()
{
Source = "eventpipe",
Hash = "sha256:2222222222222222222222222222222222222222222222222222222222222222",
HashAlgorithm = "sha256",
CasUri = "cas://reachability/runtime/sha256:2222222222222222222222222222222222222222222222222222222222222222",
RecordedAt = DateTimeOffset.Parse("2025-12-13T10:30:00Z")
}
}
}
};
var json = JsonSerializer.Serialize(manifest, JsonOptions);
Assert.Contains("\"source\":\"eventpipe\"", json);
Assert.Contains("\"hash\":\"sha256:", json);
Assert.Contains("\"hashAlg\":\"sha256\"", json);
}
#endregion
#region Section 4.3: Sorting Validation
[Fact]
public void SortingValidation_UnsortedGraphs_FailsValidation()
{
var manifest = new ReplayManifest
{
SchemaVersion = ReplayManifestVersions.V2,
Reachability = new ReplayReachabilitySection
{
Graphs = new List<ReplayReachabilityGraphReference>
{
new()
{
Kind = "framework",
Hash = "blake3:zzzz1111111111111111111111111111111111111111111111111111111111",
HashAlgorithm = "blake3-256",
CasUri = "cas://reachability/graphs/blake3:zzzz..."
},
new()
{
Kind = "static",
Hash = "blake3:aaaa1111111111111111111111111111111111111111111111111111111111",
HashAlgorithm = "blake3-256",
CasUri = "cas://reachability/graphs/blake3:aaaa..."
}
}
}
};
var validator = new ReplayManifestValidator();
var result = validator.ValidateAsync(manifest).GetAwaiter().GetResult();
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.ErrorCode == ReplayManifestErrorCodes.UnsortedEntries);
}
[Fact]
public void SortingValidation_SortedGraphs_PassesValidation()
{
var manifest = new ReplayManifest
{
SchemaVersion = ReplayManifestVersions.V2,
Reachability = new ReplayReachabilitySection
{
Graphs = new List<ReplayReachabilityGraphReference>
{
new()
{
Kind = "static",
Hash = "blake3:aaaa1111111111111111111111111111111111111111111111111111111111",
HashAlgorithm = "blake3-256",
CasUri = "cas://reachability/graphs/blake3:aaaa..."
},
new()
{
Kind = "framework",
Hash = "blake3:zzzz1111111111111111111111111111111111111111111111111111111111",
HashAlgorithm = "blake3-256",
CasUri = "cas://reachability/graphs/blake3:zzzz..."
}
}
}
};
var validator = new ReplayManifestValidator();
var result = validator.ValidateAsync(manifest).GetAwaiter().GetResult();
Assert.True(result.IsValid);
}
#endregion
#region Section 4.4: Invalid Manifest Vectors
[Fact]
public void InvalidManifest_MissingSchemaVersion_FailsValidation()
{
var manifest = new ReplayManifest
{
SchemaVersion = null!
};
var validator = new ReplayManifestValidator();
var result = validator.ValidateAsync(manifest).GetAwaiter().GetResult();
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.ErrorCode == ReplayManifestErrorCodes.MissingVersion);
}
[Fact]
public void InvalidManifest_VersionMismatch_WhenV2Required_FailsValidation()
{
var manifest = new ReplayManifest
{
SchemaVersion = ReplayManifestVersions.V1
};
var validator = new ReplayManifestValidator(requireV2: true);
var result = validator.ValidateAsync(manifest).GetAwaiter().GetResult();
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.ErrorCode == ReplayManifestErrorCodes.VersionMismatch);
}
[Fact]
public void InvalidManifest_MissingHashAlg_InV2_FailsValidation()
{
var manifest = new ReplayManifest
{
SchemaVersion = ReplayManifestVersions.V2,
Reachability = new ReplayReachabilitySection
{
Graphs = new List<ReplayReachabilityGraphReference>
{
new()
{
Hash = "blake3:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
HashAlgorithm = null!, // Missing
CasUri = "cas://reachability/graphs/blake3:..."
}
}
}
};
var validator = new ReplayManifestValidator();
var result = validator.ValidateAsync(manifest).GetAwaiter().GetResult();
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.ErrorCode == ReplayManifestErrorCodes.MissingHashAlg);
}
[Fact]
public async Task InvalidManifest_MissingCasReference_FailsValidation()
{
var casValidator = new InMemoryCasValidator();
// Don't register any objects
var manifest = new ReplayManifest
{
SchemaVersion = ReplayManifestVersions.V2,
Reachability = new ReplayReachabilitySection
{
Graphs = new List<ReplayReachabilityGraphReference>
{
new()
{
Hash = "blake3:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
HashAlgorithm = "blake3-256",
CasUri = "cas://reachability/graphs/blake3:missing"
}
}
}
};
var validator = new ReplayManifestValidator(casValidator);
var result = await validator.ValidateAsync(manifest);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.ErrorCode == ReplayManifestErrorCodes.CasNotFound);
}
[Fact]
public async Task InvalidManifest_HashMismatch_FailsValidation()
{
var casValidator = new InMemoryCasValidator();
casValidator.Register(
"cas://reachability/graphs/blake3:actual",
"blake3:differenthash");
casValidator.Register(
"cas://reachability/graphs/blake3:actual.dsse",
"blake3:differenthash.dsse");
var manifest = new ReplayManifest
{
SchemaVersion = ReplayManifestVersions.V2,
Reachability = new ReplayReachabilitySection
{
Graphs = new List<ReplayReachabilityGraphReference>
{
new()
{
Hash = "blake3:expected",
HashAlgorithm = "blake3-256",
CasUri = "cas://reachability/graphs/blake3:actual"
}
}
}
};
var validator = new ReplayManifestValidator(casValidator);
var result = await validator.ValidateAsync(manifest);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.ErrorCode == ReplayManifestErrorCodes.HashMismatch);
}
#endregion
#region Section 5: Migration Path
[Fact]
public void UpgradeToV2_ConvertsV1ManifestCorrectly()
{
var v1 = new ReplayManifest
{
SchemaVersion = ReplayManifestVersions.V1,
Scan = new ReplayScanMetadata
{
Id = "scan-legacy"
},
Reachability = new ReplayReachabilitySection
{
Graphs = new List<ReplayReachabilityGraphReference>
{
new()
{
Kind = "static",
Sha256 = "abc123",
CasUri = "cas://reachability/graphs/abc123"
}
}
}
};
var v2 = ReplayManifestValidator.UpgradeToV2(v1);
Assert.Equal(ReplayManifestVersions.V2, v2.SchemaVersion);
Assert.Single(v2.Reachability.Graphs);
Assert.Equal("sha256:abc123", v2.Reachability.Graphs[0].Hash);
Assert.Equal("sha256", v2.Reachability.Graphs[0].HashAlgorithm);
}
[Fact]
public void UpgradeToV2_SortsGraphsByUri()
{
var v1 = new ReplayManifest
{
SchemaVersion = ReplayManifestVersions.V1,
Reachability = new ReplayReachabilitySection
{
Graphs = new List<ReplayReachabilityGraphReference>
{
new() { Sha256 = "zzz", CasUri = "cas://graphs/zzz" },
new() { Sha256 = "aaa", CasUri = "cas://graphs/aaa" }
}
}
};
var v2 = ReplayManifestValidator.UpgradeToV2(v1);
Assert.Equal("cas://graphs/aaa", v2.Reachability.Graphs[0].CasUri);
Assert.Equal("cas://graphs/zzz", v2.Reachability.Graphs[1].CasUri);
}
#endregion
#region ReachabilityReplayWriter Tests
[Fact]
public void BuildManifestV2_WithValidGraphs_CreatesSortedManifest()
{
var scan = new ReplayScanMetadata { Id = "test-scan" };
var graphs = new[]
{
new ReplayReachabilityGraphReference
{
Hash = "blake3:zzzz",
CasUri = "cas://graphs/zzzz"
},
new ReplayReachabilityGraphReference
{
Hash = "blake3:aaaa",
CasUri = "cas://graphs/aaaa"
}
};
var manifest = ReachabilityReplayWriter.BuildManifestV2(
scan,
graphs,
Array.Empty<ReplayReachabilityTraceReference>());
Assert.Equal(ReplayManifestVersions.V2, manifest.SchemaVersion);
Assert.Equal("cas://graphs/aaaa", manifest.Reachability.Graphs[0].CasUri);
Assert.Equal("cas://graphs/zzzz", manifest.Reachability.Graphs[1].CasUri);
}
[Fact]
public void BuildManifestV2_WithLegacySha256_MigratesHashField()
{
var scan = new ReplayScanMetadata { Id = "test-scan" };
var graphs = new[]
{
new ReplayReachabilityGraphReference
{
Sha256 = "abc123",
CasUri = "cas://graphs/abc123"
}
};
var manifest = ReachabilityReplayWriter.BuildManifestV2(
scan,
graphs,
Array.Empty<ReplayReachabilityTraceReference>());
Assert.Equal("sha256:abc123", manifest.Reachability.Graphs[0].Hash);
Assert.Equal("sha256", manifest.Reachability.Graphs[0].HashAlgorithm);
}
[Fact]
public void BuildManifestV2_InfersHashAlgorithmFromPrefix()
{
var scan = new ReplayScanMetadata { Id = "test-scan" };
var graphs = new[]
{
new ReplayReachabilityGraphReference
{
Hash = "blake3:a1b2c3d4",
CasUri = "cas://graphs/a1b2c3d4"
}
};
var manifest = ReachabilityReplayWriter.BuildManifestV2(
scan,
graphs,
Array.Empty<ReplayReachabilityTraceReference>());
Assert.Equal("blake3-256", manifest.Reachability.Graphs[0].HashAlgorithm);
}
[Fact]
public void BuildManifestV2_RequiresAtLeastOneGraph()
{
var scan = new ReplayScanMetadata { Id = "test-scan" };
Assert.Throws<InvalidOperationException>(() =>
ReachabilityReplayWriter.BuildManifestV2(
scan,
Array.Empty<ReplayReachabilityGraphReference>(),
Array.Empty<ReplayReachabilityTraceReference>()));
}
#endregion
#region CodeIdCoverage Tests
[Fact]
public void CodeIdCoverage_SerializesWithSnakeCaseKeys()
{
var coverage = new CodeIdCoverage
{
TotalNodes = 1247,
NodesWithSymbolId = 1189,
NodesWithCodeId = 58,
CoveragePercent = 100.0
};
var json = JsonSerializer.Serialize(coverage, JsonOptions);
Assert.Contains("\"total_nodes\":1247", json);
Assert.Contains("\"nodes_with_symbol_id\":1189", json);
Assert.Contains("\"nodes_with_code_id\":58", json);
Assert.Contains("\"coverage_percent\":100", json);
}
#endregion
}

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

View File

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

View File

@@ -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";

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

View File

@@ -10,7 +10,7 @@
<NoWarn>$(NoWarn);CA2255</NoWarn>
<IsPackable>false</IsPackable>
<RootNamespace>StellaOps.Microservice.Tests</RootNamespace>
<!-- Disable Concelier test infrastructure (Mongo2Go, etc.) since not needed for Microservice SDK tests -->
<!-- Disable Concelier test infrastructure since not needed for Microservice SDK tests -->
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>

View File

@@ -10,7 +10,7 @@
<NoWarn>$(NoWarn);CA2255</NoWarn>
<IsPackable>false</IsPackable>
<RootNamespace>StellaOps.Router.Common.Tests</RootNamespace>
<!-- Disable Concelier test infrastructure (Mongo2Go, etc.) since not needed for Router tests -->
<!-- Disable Concelier test infrastructure since not needed for Router tests -->
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>

View File

@@ -10,7 +10,7 @@
<NoWarn>$(NoWarn);CA2255</NoWarn>
<IsPackable>false</IsPackable>
<RootNamespace>StellaOps.Router.Config.Tests</RootNamespace>
<!-- Disable Concelier test infrastructure (Mongo2Go, etc.) since not needed for Router tests -->
<!-- Disable Concelier test infrastructure since not needed for Router tests -->
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>

View File

@@ -10,7 +10,7 @@
<NoWarn>$(NoWarn);CA2255</NoWarn>
<IsPackable>false</IsPackable>
<RootNamespace>StellaOps.Router.Transport.InMemory.Tests</RootNamespace>
<!-- Disable Concelier test infrastructure (Mongo2Go, etc.) since not needed for InMemory tests -->
<!-- Disable Concelier test infrastructure since not needed for InMemory tests -->
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>

View File

@@ -10,7 +10,7 @@
<NoWarn>$(NoWarn);CA2255</NoWarn>
<IsPackable>false</IsPackable>
<RootNamespace>StellaOps.Router.Transport.RabbitMq.Tests</RootNamespace>
<!-- Disable Concelier test infrastructure (Mongo2Go, etc.) since not needed for Router tests -->
<!-- Disable Concelier test infrastructure since not needed for Router tests -->
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>