// -----------------------------------------------------------------------------
// 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
}