// Licensed to StellaOps under the BUSL-1.1 license. using StellaOps.ReachGraph.Hashing; using StellaOps.ReachGraph.Schema; using StellaOps.ReachGraph.Serialization; using Xunit; namespace StellaOps.ReachGraph.Tests; public class DigestComputerTests { private readonly CanonicalReachGraphSerializer _serializer = new(); private readonly ReachGraphDigestComputer _digestComputer; public DigestComputerTests() { _digestComputer = new ReachGraphDigestComputer(_serializer); } [Fact] public void ComputeDigest_WithSameInput_ProducesSameDigest() { // Arrange var graph = CreateSampleGraph(); // Act var digest1 = _digestComputer.ComputeDigest(graph); var digest2 = _digestComputer.ComputeDigest(graph); // Assert Assert.Equal(digest1, digest2); } [Fact] public void ComputeDigest_ReturnsBlake3Format() { // Arrange var graph = CreateSampleGraph(); // Act var digest = _digestComputer.ComputeDigest(graph); // Assert Assert.StartsWith("blake3:", digest); Assert.Equal(71, digest.Length); // "blake3:" (7) + 64 hex chars } [Fact] public void ComputeDigest_ExcludesSignatures() { // Arrange var unsigned = CreateSampleGraph(); var signed = unsigned with { Signatures = [new ReachGraphSignature("key-1", "sig-base64")] }; // Act var digestUnsigned = _digestComputer.ComputeDigest(unsigned); var digestSigned = _digestComputer.ComputeDigest(signed); // Assert - signatures should not affect digest Assert.Equal(digestUnsigned, digestSigned); } [Fact] public void ComputeDigest_DifferentInputs_ProduceDifferentDigests() { // Arrange var graph1 = CreateSampleGraph(); var graph2 = graph1 with { Artifact = new ReachGraphArtifact("different-app", "sha256:different", ["linux/amd64"]) }; // Act var digest1 = _digestComputer.ComputeDigest(graph1); var digest2 = _digestComputer.ComputeDigest(graph2); // Assert Assert.NotEqual(digest1, digest2); } [Fact] public void VerifyDigest_ValidDigest_ReturnsTrue() { // Arrange var graph = CreateSampleGraph(); var digest = _digestComputer.ComputeDigest(graph); // Act var result = _digestComputer.VerifyDigest(graph, digest); // Assert Assert.True(result); } [Fact] public void VerifyDigest_InvalidDigest_ReturnsFalse() { // Arrange var graph = CreateSampleGraph(); var wrongDigest = "blake3:0000000000000000000000000000000000000000000000000000000000000000"; // Act var result = _digestComputer.VerifyDigest(graph, wrongDigest); // Assert Assert.False(result); } [Fact] public void IsValidBlake3Digest_ValidFormat_ReturnsTrue() { // Arrange var validDigest = "blake3:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; // Act var result = ReachGraphDigestComputer.IsValidBlake3Digest(validDigest); // Assert Assert.True(result); } [Theory] [InlineData("sha256:abcdef")] // Wrong algorithm [InlineData("blake3:short")] // Too short [InlineData("blake3:ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ")] // Invalid hex [InlineData("")] // Empty [InlineData("blake3")] // No colon public void IsValidBlake3Digest_InvalidFormat_ReturnsFalse(string digest) { // Act var result = ReachGraphDigestComputer.IsValidBlake3Digest(digest); // Assert Assert.False(result); } [Fact] public void ParseDigest_ValidFormat_ReturnsComponents() { // Arrange var digest = "blake3:abc123def456"; // Act var result = ReachGraphDigestComputer.ParseDigest(digest); // Assert Assert.NotNull(result); Assert.Equal("blake3", result.Value.Algorithm); Assert.Equal("abc123def456", result.Value.Hash); } [Theory] [InlineData("")] [InlineData("nocolon")] [InlineData(":noleft")] [InlineData("noright:")] public void ParseDigest_InvalidFormat_ReturnsNull(string digest) { // Act var result = ReachGraphDigestComputer.ParseDigest(digest); // Assert Assert.Null(result); } [Fact] public void ComputeDigest_IsDeterministic_AcrossNodeOrdering() { // Arrange - nodes in different order var graph1 = CreateSampleGraph() with { Nodes = [ new ReachGraphNode { Id = "sha256:aaa", Kind = ReachGraphNodeKind.Function, Ref = "a()" }, new ReachGraphNode { Id = "sha256:bbb", Kind = ReachGraphNodeKind.Function, Ref = "b()" } ] }; var graph2 = CreateSampleGraph() with { Nodes = [ new ReachGraphNode { Id = "sha256:bbb", Kind = ReachGraphNodeKind.Function, Ref = "b()" }, new ReachGraphNode { Id = "sha256:aaa", Kind = ReachGraphNodeKind.Function, Ref = "a()" } ] }; // Act var digest1 = _digestComputer.ComputeDigest(graph1); var digest2 = _digestComputer.ComputeDigest(graph2); // Assert - canonical serialization should produce same digest regardless of input order Assert.Equal(digest1, digest2); } private static ReachGraphMinimal CreateSampleGraph() => new() { Artifact = new ReachGraphArtifact("test-app", "sha256:abc123", ["linux/amd64"]), Scope = new ReachGraphScope(["/app/main"], ["prod"]), Nodes = [ new ReachGraphNode { Id = "sha256:001", Kind = ReachGraphNodeKind.Function, Ref = "main()" } ], Edges = [], Provenance = new ReachGraphProvenance { Inputs = new ReachGraphInputs { Sbom = "sha256:sbom123" }, ComputedAt = new DateTimeOffset(2025, 12, 27, 10, 0, 0, TimeSpan.Zero), Analyzer = new ReachGraphAnalyzer("test", "1.0.0", "sha256:toolchain") } }; }