// ----------------------------------------------------------------------------- // ReachabilityEvidenceDeterminismTests.cs // Sprint: SPRINT_5100_0009_0001 - Scanner Module Test Implementation // Task: SCANNER-5100-008 - Expand determinism tests: reachability evidence hash stable // Description: Tests to validate reachability evidence generation determinism // ----------------------------------------------------------------------------- using System.Text; using FluentAssertions; using StellaOps.Canonical.Json; using StellaOps.Scanner.Evidence.Models; using StellaOps.Scanner.Reachability; using StellaOps.Scanner.Reachability.Attestation; using StellaOps.Scanner.Reachability.Ordering; using StellaOps.Scanner.Reachability.Witnesses; using StellaOps.Testing.Determinism; using Xunit; namespace StellaOps.Integration.Determinism; /// /// Determinism validation tests for reachability evidence generation. /// Ensures identical inputs produce identical reachability evidence across: /// - Multiple runs with frozen time /// - Parallel execution /// - Path ordering /// - Confidence calculation /// public class ReachabilityEvidenceDeterminismTests { #region Basic Determinism Tests [Fact] public void ReachabilityEvidence_WithIdenticalInput_ProducesDeterministicOutput() { // Arrange var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); var input = CreateSampleReachabilityInput(); // Act - Generate evidence multiple times var evidence1 = GenerateReachabilityEvidence(input, frozenTime); var evidence2 = GenerateReachabilityEvidence(input, frozenTime); var evidence3 = GenerateReachabilityEvidence(input, frozenTime); // Serialize to canonical JSON var json1 = CanonJson.Serialize(evidence1); var json2 = CanonJson.Serialize(evidence2); var json3 = CanonJson.Serialize(evidence3); // Assert - All outputs should be identical json1.Should().Be(json2); json2.Should().Be(json3); } [Fact] public void ReachabilityEvidence_CanonicalHash_IsStable() { // Arrange var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); var input = CreateSampleReachabilityInput(); // Act - Generate evidence and compute canonical hash twice var evidence1 = GenerateReachabilityEvidence(input, frozenTime); var hash1 = ComputeCanonicalHash(evidence1); var evidence2 = GenerateReachabilityEvidence(input, frozenTime); var hash2 = ComputeCanonicalHash(evidence2); // Assert hash1.Should().Be(hash2, "Same input should produce same canonical hash"); hash1.Should().MatchRegex("^[0-9a-f]{64}$"); } [Fact] public void ReachabilityEvidence_DeterminismManifest_CanBeCreated() { // Arrange var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); var input = CreateSampleReachabilityInput(); var evidence = GenerateReachabilityEvidence(input, frozenTime); var evidenceBytes = Encoding.UTF8.GetBytes(CanonJson.Serialize(evidence)); var artifactInfo = new ArtifactInfo { Type = "reachability-evidence", Name = "test-finding-reachability", Version = "1.0.0", Format = "reachability-evidence@1.0" }; var toolchain = new ToolchainInfo { Platform = ".NET 10.0", Components = new[] { new ComponentInfo { Name = "StellaOps.Scanner.Reachability", Version = "1.0.0" } } }; // Act - Create determinism manifest var manifest = DeterminismManifestWriter.CreateManifest( evidenceBytes, artifactInfo, toolchain); // Assert manifest.SchemaVersion.Should().Be("1.0"); manifest.Artifact.Format.Should().Be("reachability-evidence@1.0"); manifest.CanonicalHash.Algorithm.Should().Be("SHA-256"); manifest.CanonicalHash.Value.Should().MatchRegex("^[0-9a-f]{64}$"); } [Fact] public async Task ReachabilityEvidence_ParallelGeneration_ProducesDeterministicOutput() { // Arrange var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); var input = CreateSampleReachabilityInput(); // Act - Generate in parallel 20 times var tasks = Enumerable.Range(0, 20) .Select(_ => Task.Run(() => CanonJson.Serialize(GenerateReachabilityEvidence(input, frozenTime)))) .ToArray(); var evidences = await Task.WhenAll(tasks); // Assert - All outputs should be identical evidences.Should().AllBe(evidences[0]); } #endregion #region Path Ordering Tests [Fact] public void ReachabilityEvidence_PathsAreDeterministicallyOrdered() { // Arrange - Create input with multiple paths in random order var paths = new[] { CreatePath("path-c", "moduleC.dll"), CreatePath("path-a", "moduleA.dll"), CreatePath("path-b", "moduleB.dll") }; var input = new ReachabilityInput { Result = "reachable", Confidence = 0.95, Paths = paths, GraphDigest = "sha256:testgraph123" }; var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); // Act var evidence1 = GenerateReachabilityEvidence(input, frozenTime); var evidence2 = GenerateReachabilityEvidence(input, frozenTime); // Assert - Paths should be in deterministic order var json1 = CanonJson.Serialize(evidence1); var json2 = CanonJson.Serialize(evidence2); json1.Should().Be(json2); // Verify paths are sorted by PathId for (int i = 1; i < evidence1.Paths.Count; i++) { string.CompareOrdinal(evidence1.Paths[i - 1].PathId, evidence1.Paths[i].PathId) .Should().BeLessOrEqualTo(0, "Paths should be sorted by PathId"); } } [Fact] public void ReachabilityEvidence_StepsWithinPathsAreDeterministicallyOrdered() { // Arrange - Create path with steps var steps = new[] { CreateStep("step-3", "file3.cs", 30), CreateStep("step-1", "file1.cs", 10), CreateStep("step-2", "file2.cs", 20) }; var input = new ReachabilityInput { Result = "reachable", Confidence = 0.85, Paths = new[] { new PathInput { PathId = "path-1", Steps = steps } }, GraphDigest = "sha256:testgraph456" }; var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); // Act var evidence1 = GenerateReachabilityEvidence(input, frozenTime); var evidence2 = GenerateReachabilityEvidence(input, frozenTime); // Assert var json1 = CanonJson.Serialize(evidence1); var json2 = CanonJson.Serialize(evidence2); json1.Should().Be(json2); } #endregion #region Confidence Calculation Tests [Fact] public void ReachabilityEvidence_ConfidenceIsStable() { // Arrange var input = CreateSampleReachabilityInput(); var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); // Act - Generate multiple times var confidences = Enumerable.Range(0, 10) .Select(_ => GenerateReachabilityEvidence(input, frozenTime).Confidence) .ToList(); // Assert - All confidence values should be identical confidences.Should().AllSatisfy(c => c.Should().Be(confidences[0])); } [Theory] [InlineData(0.0)] [InlineData(0.5)] [InlineData(1.0)] [InlineData(0.123456789)] public void ReachabilityEvidence_ConfidencePreservesFullPrecision(double confidence) { // Arrange var input = new ReachabilityInput { Result = "reachable", Confidence = confidence, Paths = Array.Empty(), GraphDigest = "sha256:test" }; var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); // Act var evidence = GenerateReachabilityEvidence(input, frozenTime); // Assert evidence.Confidence.Should().Be(confidence); } #endregion #region Graph Digest Tests [Fact] public void ReachabilityEvidence_GraphDigestIsPreserved() { // Arrange var expectedDigest = "sha256:abc123def456789"; var input = new ReachabilityInput { Result = "reachable", Confidence = 0.9, Paths = Array.Empty(), GraphDigest = expectedDigest }; var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); // Act var evidence = GenerateReachabilityEvidence(input, frozenTime); // Assert evidence.GraphDigest.Should().Be(expectedDigest); } [Fact] public void ReachabilityEvidence_DifferentGraphDigest_ProducesDifferentHash() { // Arrange var input1 = CreateSampleReachabilityInput() with { GraphDigest = "sha256:graph1" }; var input2 = CreateSampleReachabilityInput() with { GraphDigest = "sha256:graph2" }; var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); // Act var hash1 = ComputeCanonicalHash(GenerateReachabilityEvidence(input1, frozenTime)); var hash2 = ComputeCanonicalHash(GenerateReachabilityEvidence(input2, frozenTime)); // Assert hash1.Should().NotBe(hash2); } #endregion #region Result Status Tests [Theory] [InlineData("reachable")] [InlineData("unreachable")] [InlineData("unknown")] [InlineData("partial")] public void ReachabilityEvidence_ResultStatusIsPreserved(string result) { // Arrange var input = new ReachabilityInput { Result = result, Confidence = 0.8, Paths = Array.Empty(), GraphDigest = "sha256:test" }; var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); // Act var evidence = GenerateReachabilityEvidence(input, frozenTime); // Assert evidence.Result.Should().Be(result); } #endregion #region Empty/Edge Case Tests [Fact] public void ReachabilityEvidence_EmptyPaths_ProducesDeterministicOutput() { // Arrange var input = new ReachabilityInput { Result = "unreachable", Confidence = 1.0, Paths = Array.Empty(), GraphDigest = "sha256:emptygraph" }; var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); // Act var hash1 = ComputeCanonicalHash(GenerateReachabilityEvidence(input, frozenTime)); var hash2 = ComputeCanonicalHash(GenerateReachabilityEvidence(input, frozenTime)); // Assert hash1.Should().Be(hash2); } [Fact] public void ReachabilityEvidence_ManyPaths_ProducesDeterministicOutput() { // Arrange - Create 100 paths var paths = Enumerable.Range(0, 100) .Select(i => CreatePath($"path-{i:D3}", $"module{i}.dll")) .ToArray(); var input = new ReachabilityInput { Result = "reachable", Confidence = 0.75, Paths = paths, GraphDigest = "sha256:largegraph" }; var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); // Act var hash1 = ComputeCanonicalHash(GenerateReachabilityEvidence(input, frozenTime)); var hash2 = ComputeCanonicalHash(GenerateReachabilityEvidence(input, frozenTime)); // Assert hash1.Should().Be(hash2); } #endregion #region Helper Methods private static ReachabilityInput CreateSampleReachabilityInput() { return new ReachabilityInput { Result = "reachable", Confidence = 0.92, Paths = new[] { CreatePath("path-main", "MainModule.dll"), CreatePath("path-helper", "HelperModule.dll") }, GraphDigest = "sha256:sampledigest123456789" }; } private static PathInput CreatePath(string pathId, string moduleName) { return new PathInput { PathId = pathId, Steps = new[] { CreateStep($"{pathId}-entry", $"{moduleName}/entry.cs", 10), CreateStep($"{pathId}-middle", $"{moduleName}/middle.cs", 50), CreateStep($"{pathId}-sink", $"{moduleName}/sink.cs", 100) } }; } private static StepInput CreateStep(string node, string file, int line) { return new StepInput { Node = node, FileHash = $"sha256:{node}hash", Lines = new[] { line, line + 5 } }; } private static ReachabilityEvidence GenerateReachabilityEvidence(ReachabilityInput input, DateTimeOffset timestamp) { // Sort paths deterministically by PathId var sortedPaths = input.Paths .OrderBy(p => p.PathId, StringComparer.Ordinal) .Select(p => new ReachabilityPath { PathId = p.PathId, Steps = p.Steps .OrderBy(s => s.Node, StringComparer.Ordinal) .Select(s => new ReachabilityStep { Node = s.Node, FileHash = s.FileHash, Lines = s.Lines }) .ToList() }) .ToList(); return new ReachabilityEvidence { Result = input.Result, Confidence = input.Confidence, Paths = sortedPaths, GraphDigest = input.GraphDigest }; } private static string ComputeCanonicalHash(ReachabilityEvidence evidence) { var json = CanonJson.Serialize(evidence); return CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(json)); } #endregion #region CanonicalGraph Determinism Tests (Real Types) [Fact] public void CanonicalGraph_WithIdenticalInput_ProducesDeterministicOutput() { // Arrange var graph = CreateSampleRichGraph(); var orderer = new DeterministicGraphOrderer(); // Act - Canonicalize multiple times var canonical1 = orderer.Canonicalize(graph, GraphOrderingStrategy.TopologicalLexicographic); var canonical2 = orderer.Canonicalize(graph, GraphOrderingStrategy.TopologicalLexicographic); var canonical3 = orderer.Canonicalize(graph, GraphOrderingStrategy.TopologicalLexicographic); // Assert - All content hashes should be identical canonical1.ContentHash.Should().Be(canonical2.ContentHash); canonical2.ContentHash.Should().Be(canonical3.ContentHash); } [Fact] public void CanonicalGraph_ContentHash_IsStable() { // Arrange var graph = CreateSampleRichGraph(); var orderer = new DeterministicGraphOrderer(); // Act var canonical1 = orderer.Canonicalize(graph); var canonical2 = orderer.Canonicalize(graph); // Assert canonical1.ContentHash.Should().Be(canonical2.ContentHash); canonical1.ContentHash.Should().StartWith("sha256:"); canonical1.ContentHash.Should().MatchRegex(@"^sha256:[0-9a-f]{64}$"); } [Fact] public void CanonicalGraph_NodeOrdering_IsDeterministic() { // Arrange - Graph with nodes added in different order var nodes1 = new[] { CreateRichGraphNode("node-c", "method", "C#"), CreateRichGraphNode("node-a", "method", "C#"), CreateRichGraphNode("node-b", "method", "C#"), }; var nodes2 = new[] { CreateRichGraphNode("node-b", "method", "C#"), CreateRichGraphNode("node-c", "method", "C#"), CreateRichGraphNode("node-a", "method", "C#"), }; var edges = Array.Empty(); var roots = Array.Empty(); var analyzer = new RichGraphAnalyzer("test", "1.0.0", null); var graph1 = new RichGraph(nodes1, edges, roots, analyzer); var graph2 = new RichGraph(nodes2, edges, roots, analyzer); var orderer = new DeterministicGraphOrderer(); // Act var canonical1 = orderer.Canonicalize(graph1, GraphOrderingStrategy.Lexicographic); var canonical2 = orderer.Canonicalize(graph2, GraphOrderingStrategy.Lexicographic); // Assert - Node order should be deterministic regardless of input order canonical1.Nodes.Select(n => n.Id).Should().Equal(canonical2.Nodes.Select(n => n.Id)); canonical1.ContentHash.Should().Be(canonical2.ContentHash); } [Fact] public void CanonicalGraph_EdgeOrdering_IsDeterministic() { // Arrange - Graph with edges added in different order var nodes = new[] { CreateRichGraphNode("node-a", "method", "C#"), CreateRichGraphNode("node-b", "method", "C#"), CreateRichGraphNode("node-c", "method", "C#"), }; var edges1 = new[] { CreateRichGraphEdge("node-c", "node-b"), CreateRichGraphEdge("node-a", "node-b"), CreateRichGraphEdge("node-b", "node-c"), }; var edges2 = new[] { CreateRichGraphEdge("node-a", "node-b"), CreateRichGraphEdge("node-b", "node-c"), CreateRichGraphEdge("node-c", "node-b"), }; var roots = Array.Empty(); var analyzer = new RichGraphAnalyzer("test", "1.0.0", null); var graph1 = new RichGraph(nodes, edges1, roots, analyzer); var graph2 = new RichGraph(nodes, edges2, roots, analyzer); var orderer = new DeterministicGraphOrderer(); // Act var canonical1 = orderer.Canonicalize(graph1); var canonical2 = orderer.Canonicalize(graph2); // Assert - Edge order should be deterministic regardless of input order var edgeIds1 = canonical1.Edges.Select(e => $"{e.SourceIndex}->{e.TargetIndex}").ToList(); var edgeIds2 = canonical2.Edges.Select(e => $"{e.SourceIndex}->{e.TargetIndex}").ToList(); edgeIds1.Should().Equal(edgeIds2); canonical1.ContentHash.Should().Be(canonical2.ContentHash); } [Fact] public void CanonicalGraph_AllStrategies_ProduceConsistentHashesForSameStrategy() { // Arrange var graph = CreateSampleRichGraph(); var orderer = new DeterministicGraphOrderer(); // Act - Test each strategy produces consistent results var strategies = new[] { GraphOrderingStrategy.TopologicalLexicographic, GraphOrderingStrategy.BreadthFirstLexicographic, GraphOrderingStrategy.DepthFirstLexicographic, GraphOrderingStrategy.Lexicographic }; foreach (var strategy in strategies) { var canonical1 = orderer.Canonicalize(graph, strategy); var canonical2 = orderer.Canonicalize(graph, strategy); // Assert - Same strategy should always produce same hash canonical1.ContentHash.Should().Be(canonical2.ContentHash, $"Strategy {strategy} should produce consistent hashes"); } } [Fact] public async Task CanonicalGraph_ParallelCanonicalization_ProducesDeterministicOutput() { // Arrange var graph = CreateSampleRichGraph(); var orderer = new DeterministicGraphOrderer(); // Act - Canonicalize in parallel 20 times var tasks = Enumerable.Range(0, 20) .Select(_ => Task.Run(() => orderer.Canonicalize(graph))) .ToArray(); var results = await Task.WhenAll(tasks); // Assert - All content hashes should be identical var hashes = results.Select(r => r.ContentHash).ToList(); hashes.Should().AllBe(hashes[0]); } #endregion #region ReachabilityWitnessStatement Determinism Tests [Fact] public void ReachabilityWitnessStatement_WithIdenticalInput_ProducesDeterministicOutput() { // Arrange var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); // Act - Create statements multiple times var statement1 = CreateWitnessStatement(frozenTime); var statement2 = CreateWitnessStatement(frozenTime); var statement3 = CreateWitnessStatement(frozenTime); // Assert var json1 = SerializeWitnessStatement(statement1); var json2 = SerializeWitnessStatement(statement2); var json3 = SerializeWitnessStatement(statement3); json1.Should().Be(json2); json2.Should().Be(json3); } [Fact] public void ReachabilityWitnessStatement_CanonicalHash_IsStable() { // Arrange var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); // Act var statement1 = CreateWitnessStatement(frozenTime); var json1 = SerializeWitnessStatement(statement1); var hash1 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(json1)); var statement2 = CreateWitnessStatement(frozenTime); var json2 = SerializeWitnessStatement(statement2); var hash2 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(json2)); // Assert hash1.Should().Be(hash2, "Same input should produce same canonical hash"); hash1.Should().MatchRegex("^[0-9a-f]{64}$"); } [Fact] public async Task ReachabilityWitnessStatement_ParallelGeneration_ProducesDeterministicOutput() { // Arrange var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); // Act - Generate in parallel 20 times var tasks = Enumerable.Range(0, 20) .Select(_ => Task.Run(() => { var statement = CreateWitnessStatement(frozenTime); return SerializeWitnessStatement(statement); })) .ToArray(); var results = await Task.WhenAll(tasks); // Assert - All outputs should be identical results.Should().AllBe(results[0]); } #endregion #region PathWitness Determinism Tests [Fact] public void PathWitness_WithIdenticalInput_ProducesDeterministicOutput() { // Arrange var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); // Act - Create witnesses multiple times var witness1 = CreatePathWitness(frozenTime); var witness2 = CreatePathWitness(frozenTime); var witness3 = CreatePathWitness(frozenTime); // Assert var json1 = SerializePathWitness(witness1); var json2 = SerializePathWitness(witness2); var json3 = SerializePathWitness(witness3); json1.Should().Be(json2); json2.Should().Be(json3); } [Fact] public void PathWitness_CanonicalHash_IsStable() { // Arrange var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); // Act var witness1 = CreatePathWitness(frozenTime); var json1 = SerializePathWitness(witness1); var hash1 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(json1)); var witness2 = CreatePathWitness(frozenTime); var json2 = SerializePathWitness(witness2); var hash2 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(json2)); // Assert hash1.Should().Be(hash2, "Same input should produce same canonical hash"); hash1.Should().MatchRegex("^[0-9a-f]{64}$"); } [Fact] public void PathWitness_PathStepOrdering_IsDeterministic() { // Arrange var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); // Act - Create witness with same path steps var witness1 = CreatePathWitness(frozenTime); var witness2 = CreatePathWitness(frozenTime); // Assert - Path steps should be in identical order witness1.Path.Select(s => s.SymbolId).Should().Equal(witness2.Path.Select(s => s.SymbolId)); } [Fact] public void PathWitness_GateOrdering_IsDeterministic() { // Arrange var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); // Act var witness1 = CreatePathWitnessWithGates(frozenTime); var witness2 = CreatePathWitnessWithGates(frozenTime); // Assert - Gates should be in identical order var gates1 = witness1.Gates?.Select(g => $"{g.Type}:{g.GuardSymbol}").ToList() ?? new List(); var gates2 = witness2.Gates?.Select(g => $"{g.Type}:{g.GuardSymbol}").ToList() ?? new List(); gates1.Should().Equal(gates2); } [Fact] public async Task PathWitness_ParallelGeneration_ProducesDeterministicOutput() { // Arrange var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); // Act - Generate in parallel 20 times var tasks = Enumerable.Range(0, 20) .Select(_ => Task.Run(() => { var witness = CreatePathWitness(frozenTime); return SerializePathWitness(witness); })) .ToArray(); var results = await Task.WhenAll(tasks); // Assert - All outputs should be identical results.Should().AllBe(results[0]); } #endregion #region RichGraph.Trimmed Determinism Tests [Fact] public void RichGraph_Trimmed_ProducesDeterministicOutput() { // Arrange var graph = CreateSampleRichGraph(); // Act - Trim multiple times var trimmed1 = graph.Trimmed(); var trimmed2 = graph.Trimmed(); var trimmed3 = graph.Trimmed(); // Assert - Trimming is idempotent and deterministic var orderer = new DeterministicGraphOrderer(); var canonical1 = orderer.Canonicalize(trimmed1); var canonical2 = orderer.Canonicalize(trimmed2); var canonical3 = orderer.Canonicalize(trimmed3); canonical1.ContentHash.Should().Be(canonical2.ContentHash); canonical2.ContentHash.Should().Be(canonical3.ContentHash); } [Fact] public void RichGraph_Trimmed_IsIdempotent() { // Arrange var graph = CreateSampleRichGraph(); // Act - Trim once, then trim again var trimmed1 = graph.Trimmed(); var trimmed2 = trimmed1.Trimmed(); var trimmed3 = trimmed2.Trimmed(); // Assert - Multiple trims produce same result var orderer = new DeterministicGraphOrderer(); var canonical1 = orderer.Canonicalize(trimmed1); var canonical2 = orderer.Canonicalize(trimmed2); var canonical3 = orderer.Canonicalize(trimmed3); canonical1.ContentHash.Should().Be(canonical2.ContentHash); canonical2.ContentHash.Should().Be(canonical3.ContentHash); } #endregion #region End-to-End Reachability Evidence Determinism [Fact] public void ReachabilityEvidence_EndToEnd_ProducesDeterministicHash() { // Arrange var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); var graph = CreateSampleRichGraph(); var orderer = new DeterministicGraphOrderer(); // Act - Generate complete evidence bundle twice var canonical1 = orderer.Canonicalize(graph); var statement1 = CreateWitnessStatement(frozenTime, canonical1.ContentHash); var witness1 = CreatePathWitness(frozenTime); var canonical2 = orderer.Canonicalize(graph); var statement2 = CreateWitnessStatement(frozenTime, canonical2.ContentHash); var witness2 = CreatePathWitness(frozenTime); // Assert - All components should produce identical outputs canonical1.ContentHash.Should().Be(canonical2.ContentHash); var statementJson1 = SerializeWitnessStatement(statement1); var statementJson2 = SerializeWitnessStatement(statement2); statementJson1.Should().Be(statementJson2); var witnessJson1 = SerializePathWitness(witness1); var witnessJson2 = SerializePathWitness(witness2); witnessJson1.Should().Be(witnessJson2); } [Theory] [InlineData(1)] [InlineData(10)] [InlineData(50)] [InlineData(100)] public void ReachabilityEvidence_MultipleIterations_ProducesStableHash(int iterations) { // Arrange var frozenTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z"); var graph = CreateSampleRichGraph(); var orderer = new DeterministicGraphOrderer(); // Act - Generate hash multiple times var hashes = Enumerable.Range(0, iterations) .Select(_ => { var canonical = orderer.Canonicalize(graph); return canonical.ContentHash; }) .ToList(); // Assert - All hashes should be identical hashes.Should().AllBe(hashes[0]); } #endregion #region Real Types Helper Methods private static RichGraph CreateSampleRichGraph() { var nodes = new[] { CreateRichGraphNode("entrypoint-main", "entrypoint", "C#", new Dictionary { ["is_entrypoint"] = "true" }), CreateRichGraphNode("method-process", "method", "C#"), CreateRichGraphNode("method-validate", "method", "C#"), CreateRichGraphNode("sink-deserialize", "sink", "C#"), }; var edges = new[] { CreateRichGraphEdge("entrypoint-main", "method-process"), CreateRichGraphEdge("method-process", "method-validate"), CreateRichGraphEdge("method-validate", "sink-deserialize"), }; var roots = new[] { new RichGraphRoot("entrypoint-main", "runtime", "http") }; var analyzer = new RichGraphAnalyzer("stellaops.scanner.reachability", "1.0.0", null); return new RichGraph(nodes, edges, roots, analyzer); } private static RichGraphNode CreateRichGraphNode( string id, string kind, string lang, IReadOnlyDictionary? attributes = null) { return new RichGraphNode( Id: id, SymbolId: id, CodeId: $"code:{id}", Purl: $"pkg:test/{id}@1.0.0", Lang: lang, Kind: kind, Display: id, BuildId: null, Evidence: null, Attributes: attributes, SymbolDigest: null, Symbol: null, CodeBlockHash: null); } private static RichGraphEdge CreateRichGraphEdge(string from, string to) { return new RichGraphEdge( From: from, To: to, Kind: "call", Purl: null, SymbolDigest: null, Evidence: null, Confidence: 0.9, Candidates: null); } private static ReachabilityWitnessStatement CreateWitnessStatement( DateTimeOffset timestamp, string? graphHash = null) { return new ReachabilityWitnessStatement { GraphHash = graphHash ?? "sha256:abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234", GraphCasUri = "cas://richgraph/sha256:abcd1234", GeneratedAt = timestamp, Language = "C#", NodeCount = 4, EdgeCount = 3, EntrypointCount = 1, SinkCount = 1, ReachableSinkCount = 1, PolicyHash = "sha256:policy123", AnalyzerVersion = "1.0.0", SourceCommit = "abc123def", SubjectDigest = "sha256:image-digest-here" }; } private static PathWitness CreatePathWitness(DateTimeOffset timestamp) { var witnessId = ComputeWitnessId("CVE-2024-1234", "pkg:test/component@1.0.0"); return new PathWitness { WitnessId = witnessId, Artifact = new WitnessArtifact { SbomDigest = "sha256:sbom-digest-here", ComponentPurl = "pkg:test/component@1.0.0" }, Vuln = new WitnessVuln { Id = "CVE-2024-1234", Source = "NVD", AffectedRange = ">=1.0.0 <2.0.0" }, Entrypoint = new WitnessEntrypoint { Kind = "http", Name = "GET /api/process", SymbolId = "entrypoint-main" }, Path = new[] { new PathStep { Symbol = "Main", SymbolId = "entrypoint-main", File = "Program.cs", Line = 10, Column = 5 }, new PathStep { Symbol = "Process", SymbolId = "method-process", File = "Services/Processor.cs", Line = 25, Column = 9 }, new PathStep { Symbol = "Validate", SymbolId = "method-validate", File = "Services/Validator.cs", Line = 42, Column = 13 } }, Sink = new WitnessSink { Symbol = "Deserialize", SymbolId = "sink-deserialize", SinkType = "deserialization" }, Gates = null, Evidence = new WitnessEvidence { CallgraphDigest = "blake3:callgraph-digest-here", SurfaceDigest = "sha256:surface-digest-here", AnalysisConfigDigest = "sha256:config-digest-here", BuildId = "build-123" }, ObservedAt = timestamp }; } private static PathWitness CreatePathWitnessWithGates(DateTimeOffset timestamp) { var witnessId = ComputeWitnessId("CVE-2024-5678", "pkg:test/gated-component@1.0.0"); return new PathWitness { WitnessId = witnessId, Artifact = new WitnessArtifact { SbomDigest = "sha256:sbom-digest-gated", ComponentPurl = "pkg:test/gated-component@1.0.0" }, Vuln = new WitnessVuln { Id = "CVE-2024-5678", Source = "GHSA", AffectedRange = ">=1.0.0 <1.5.0" }, Entrypoint = new WitnessEntrypoint { Kind = "http", Name = "POST /api/admin", SymbolId = "admin-entrypoint" }, Path = new[] { new PathStep { Symbol = "AdminHandler", SymbolId = "admin-entrypoint", File = "Controllers/AdminController.cs", Line = 15, Column = 5 }, new PathStep { Symbol = "ExecuteCommand", SymbolId = "method-execute", File = "Services/CommandService.cs", Line = 30, Column = 9 } }, Sink = new WitnessSink { Symbol = "RunShell", SymbolId = "sink-shell", SinkType = "command_injection" }, Gates = new[] { new DetectedGate { Type = "authRequired", GuardSymbol = "AuthorizeAttribute", Confidence = 0.95, Detail = "[Authorize(Roles = \"Admin\")]" }, new DetectedGate { Type = "inputValidation", GuardSymbol = "ValidateInput", Confidence = 0.80, Detail = "Command whitelist check" } }, Evidence = new WitnessEvidence { CallgraphDigest = "blake3:callgraph-gated", SurfaceDigest = "sha256:surface-gated", AnalysisConfigDigest = "sha256:config-gated", BuildId = "build-456" }, ObservedAt = timestamp }; } private static string ComputeWitnessId(string vulnId, string componentPurl) { var seed = $"{vulnId}:{componentPurl}"; var hash = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(seed)); return $"wit:sha256:{hash}"; } private static string SerializeWitnessStatement(ReachabilityWitnessStatement statement) { return System.Text.Json.JsonSerializer.Serialize(statement, new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, WriteIndented = false, DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull }); } private static string SerializePathWitness(PathWitness witness) { return System.Text.Json.JsonSerializer.Serialize(witness, new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, WriteIndented = false, DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull }); } #endregion #region DTOs private sealed record ReachabilityInput { public required string Result { get; init; } public required double Confidence { get; init; } public required PathInput[] Paths { get; init; } public required string GraphDigest { get; init; } } private sealed record PathInput { public required string PathId { get; init; } public required StepInput[] Steps { get; init; } } private sealed record StepInput { public required string Node { get; init; } public required string FileHash { get; init; } public required int[] Lines { get; init; } } #endregion }