using System.Collections.Immutable; using System.Security.Cryptography; using System.Text; using FluentAssertions; using Xunit; namespace StellaOps.Scanner.Analyzers.Native.Tests.Reachability; /// /// Tests ensuring native callgraph output conforms to richgraph-v1 schema. /// Per docs/modules/scanner/design/native-reachability-plan.md §8. /// These tests validate the expected identifier formats and behaviors /// defined in the specification. /// public class RichgraphV1AlignmentTests { /// /// §8.2: SymbolID Construction uses sym: prefix with 16 hex chars. /// [Theory] [InlineData("ssl3_read_bytes", 0x401000UL, 256UL, "global")] [InlineData("main", 0x400000UL, 128UL, "global")] [InlineData("helper", 0x402000UL, 64UL, "local")] public void ComputeSymbolId_UsesBinaryPrefix(string name, ulong address, ulong size, string binding) { // Act var symbolId = TestGraphIdentifiers.ComputeSymbolId(name, address, size, binding); // Assert symbolId.Should().StartWith("sym:"); symbolId.Should().HaveLength(20); // sym: (4 chars) + 16 hex chars = 20 } /// /// §8.5: Symbol digest uses SHA-256. /// [Theory] [InlineData("ssl3_read_bytes", 0x401000UL, 256UL, "global")] [InlineData("main", 0x400000UL, 128UL, "global")] public void ComputeSymbolDigest_UsesSha256(string name, ulong address, ulong size, string binding) { // Act var digest = TestGraphIdentifiers.ComputeSymbolDigest(name, address, size, binding); // Assert digest.Should().HaveLength(64); // SHA-256 produces 64 hex chars digest.Should().MatchRegex("^[a-f0-9]{64}$"); } /// /// SymbolID is deterministic for the same inputs. /// [Fact] public void ComputeSymbolId_IsDeterministic() { // Arrange var name = "test_function"; var address = 0x400100UL; var size = 100UL; var binding = "global"; // Act var id1 = TestGraphIdentifiers.ComputeSymbolId(name, address, size, binding); var id2 = TestGraphIdentifiers.ComputeSymbolId(name, address, size, binding); // Assert id1.Should().Be(id2); } /// /// SymbolID differs when inputs differ. /// [Fact] public void ComputeSymbolId_DiffersForDifferentInputs() { // Act var id1 = TestGraphIdentifiers.ComputeSymbolId("func_a", 0x1000, 100, "global"); var id2 = TestGraphIdentifiers.ComputeSymbolId("func_b", 0x1000, 100, "global"); var id3 = TestGraphIdentifiers.ComputeSymbolId("func_a", 0x2000, 100, "global"); // Assert id1.Should().NotBe(id2); id1.Should().NotBe(id3); id2.Should().NotBe(id3); } /// /// EdgeID construction is deterministic. /// [Fact] public void ComputeEdgeId_IsDeterministic() { // Arrange var callerId = "sym:abc123"; var calleeId = "sym:def456"; var offset = 0x100UL; // Act var id1 = TestGraphIdentifiers.ComputeEdgeId(callerId, calleeId, offset); var id2 = TestGraphIdentifiers.ComputeEdgeId(callerId, calleeId, offset); // Assert id1.Should().Be(id2); id1.Should().StartWith("edge:"); } /// /// §8.5: RootID for init array uses correct format. /// [Fact] public void ComputeRootId_UsesCorrectFormat() { // Arrange var targetId = "sym:abc123"; var rootType = TestRootType.InitArray; var order = 0; // Act var rootId = TestGraphIdentifiers.ComputeRootId(targetId, rootType, order); // Assert rootId.Should().StartWith("root:"); rootId.Should().HaveLength(21); // root: (5 chars) + 16 hex chars = 21 } /// /// §8.8: UnknownID for unresolved targets. /// [Fact] public void ComputeUnknownId_UsesCorrectFormat() { // Arrange var sourceId = "edge:abc123"; var unknownType = TestUnknownType.UnresolvedTarget; var name = "dlopen_target"; // Act var unknownId = TestGraphIdentifiers.ComputeUnknownId(sourceId, unknownType, name); // Assert unknownId.Should().StartWith("unk:"); unknownId.Should().HaveLength(20); // unk: (4 chars) + 16 hex chars = 20 } /// /// Graph hash is deterministic for same content. /// [Fact] public void ComputeGraphHash_IsDeterministic() { // Arrange var functions = new[] { new TestFunctionNode("sym:001", "func_a", null, "/bin/app", null, 0x1000, 100, "digest1", "global", "default", true), new TestFunctionNode("sym:002", "func_b", null, "/bin/app", null, 0x2000, 100, "digest2", "global", "default", true), }.ToImmutableArray(); var edges = new[] { new TestCallEdge("edge:001", "sym:001", "sym:002", null, null, TestEdgeType.Direct, 0x50, true, 1.0), }.ToImmutableArray(); var roots = new[] { new TestSyntheticRoot("root:001", "sym:001", TestRootType.Main, "/bin/app", "main", 0), }.ToImmutableArray(); // Act var hash1 = TestGraphIdentifiers.ComputeGraphHash(functions, edges, roots); var hash2 = TestGraphIdentifiers.ComputeGraphHash(functions, edges, roots); // Assert hash1.Should().Be(hash2); hash1.Should().HaveLength(64); // SHA-256 } /// /// Graph hash changes when content changes. /// [Fact] public void ComputeGraphHash_ChangesWhenContentChanges() { // Arrange var functions1 = new[] { new TestFunctionNode("sym:001", "func_a", null, "/bin/app", null, 0x1000, 100, "digest1", "global", "default", true), }.ToImmutableArray(); var functions2 = new[] { new TestFunctionNode("sym:002", "func_b", null, "/bin/app", null, 0x2000, 100, "digest2", "global", "default", true), }.ToImmutableArray(); var emptyEdges = ImmutableArray.Empty; var emptyRoots = ImmutableArray.Empty; // Act var hash1 = TestGraphIdentifiers.ComputeGraphHash(functions1, emptyEdges, emptyRoots); var hash2 = TestGraphIdentifiers.ComputeGraphHash(functions2, emptyEdges, emptyRoots); // Assert hash1.Should().NotBe(hash2); } /// /// §8.4: Edge kind mapping for PLT calls. /// [Fact] public void EdgeType_Plt_MapsToCall() { // Arrange var edge = new TestCallEdge( "edge:001", "sym:caller", "sym:callee", "pkg:deb/ubuntu/openssl@3.0.2", "sha256:abc123", TestEdgeType.Plt, 0x100, true, 0.95); // Assert edge.EdgeType.Should().Be(TestEdgeType.Plt); edge.Confidence.Should().Be(0.95); // PLT resolved confidence } /// /// §8.4: Edge kind mapping for init array. /// [Fact] public void EdgeType_InitArray_MapsToInit() { // Arrange var edge = new TestCallEdge( "edge:002", "sym:init", "sym:constructor", null, null, TestEdgeType.InitArray, 0, true, 1.0); // Assert edge.EdgeType.Should().Be(TestEdgeType.InitArray); edge.Confidence.Should().Be(1.0); // Init array entries have high confidence } /// /// §8.5: Synthetic roots for native entry points. /// [Theory] [InlineData(TestRootType.Start, "load")] [InlineData(TestRootType.Main, "main")] [InlineData(TestRootType.Init, "init")] [InlineData(TestRootType.InitArray, "init")] [InlineData(TestRootType.PreInitArray, "preinit")] [InlineData(TestRootType.Fini, "fini")] [InlineData(TestRootType.FiniArray, "fini")] public void SyntheticRoot_HasCorrectPhase(TestRootType rootType, string expectedPhase) { // This test verifies the expected phase mapping - actual implementation // may use different phase strings, but the mapping should be documented var root = new TestSyntheticRoot( "root:001", "sym:target", rootType, "/bin/app", expectedPhase, 0); root.Phase.Should().Be(expectedPhase); } /// /// §8.7: Stripped binary handling - synthetic name format. /// [Theory] [InlineData(0x401000UL, "sub_401000")] [InlineData(0x402000UL, "sub_402000")] [InlineData(0x500ABCUL, "sub_500abc")] public void StrippedSymbol_UsesSubAddressFormat(ulong address, string expectedName) { // Act var syntheticName = $"sub_{address:x}"; // Assert syntheticName.Should().Be(expectedName); } /// /// §8.6: Build ID handling for ELF. /// [Fact] public void BuildId_FormatForElf() { // Arrange var elfBuildId = "a1b2c3d4e5f6"; // Act var formattedBuildId = $"gnu-build-id:{elfBuildId}"; // Assert formattedBuildId.Should().Be("gnu-build-id:a1b2c3d4e5f6"); formattedBuildId.Should().StartWith("gnu-build-id:"); } /// /// §8.8: Unknown edge targets with candidates. /// [Fact] public void UnknownTarget_HasLowConfidence() { // Arrange var unknownEdge = new TestCallEdge( "edge:003", "sym:caller", "unknown:plt_42", null, null, TestEdgeType.Indirect, 0x200, false, 0.3); // Low confidence for unknown targets // Assert unknownEdge.IsResolved.Should().BeFalse(); unknownEdge.Confidence.Should().BeLessThan(0.5); unknownEdge.EdgeType.Should().Be(TestEdgeType.Indirect); } /// /// TestFunctionNode contains all required fields. /// [Fact] public void TestFunctionNode_HasRequiredFields() { // Arrange & Act var node = new TestFunctionNode( SymbolId: "sym:binary:abc123", Name: "ssl3_read_bytes", Purl: "pkg:deb/ubuntu/openssl@3.0.2?arch=amd64", BinaryPath: "/usr/lib/libssl.so.3", BuildId: "gnu-build-id:a1b2c3d4e5f6", Address: 0x401000, Size: 256, SymbolDigest: "sha256:deadbeef", Binding: "global", Visibility: "default", IsExported: true); // Assert - all fields present per richgraph-v1 §8.1 node.SymbolId.Should().NotBeNullOrEmpty(); node.Name.Should().NotBeNullOrEmpty(); node.Purl.Should().NotBeNullOrEmpty(); node.BinaryPath.Should().NotBeNullOrEmpty(); node.BuildId.Should().NotBeNullOrEmpty(); node.Address.Should().BeGreaterThan(0); node.Size.Should().BeGreaterThan(0); node.SymbolDigest.Should().NotBeNullOrEmpty(); node.Binding.Should().NotBeNullOrEmpty(); node.Visibility.Should().NotBeNullOrEmpty(); } /// /// TestCallEdge contains all required fields. /// [Fact] public void TestCallEdge_HasRequiredFields() { // Arrange & Act var edge = new TestCallEdge( EdgeId: "edge:binary:abc123", CallerId: "sym:binary:caller", CalleeId: "sym:binary:callee", CalleePurl: "pkg:deb/ubuntu/openssl@3.0.2", CalleeSymbolDigest: "sha256:cafebabe", EdgeType: TestEdgeType.Plt, CallSiteOffset: 0x100, IsResolved: true, Confidence: 0.95); // Assert - all fields present per richgraph-v1 §8.3 edge.EdgeId.Should().NotBeNullOrEmpty(); edge.CallerId.Should().NotBeNullOrEmpty(); edge.CalleeId.Should().NotBeNullOrEmpty(); edge.CalleePurl.Should().NotBeNullOrEmpty(); edge.CalleeSymbolDigest.Should().NotBeNullOrEmpty(); edge.CallSiteOffset.Should().BeGreaterThan(0); edge.Confidence.Should().BeInRange(0, 1); } /// /// TestGraphMetadata contains generation info. /// [Fact] public void TestGraphMetadata_HasGeneratorInfo() { // Arrange & Act var metadata = new TestGraphMetadata( GeneratedAt: DateTimeOffset.UtcNow, GeneratorVersion: "1.0.0", LayerDigest: "sha256:layer123", BinaryCount: 5, FunctionCount: 100, EdgeCount: 250, UnknownCount: 10, SyntheticRootCount: 8); // Assert metadata.GeneratedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromMinutes(1)); metadata.GeneratorVersion.Should().Be("1.0.0"); metadata.LayerDigest.Should().StartWith("sha256:"); metadata.BinaryCount.Should().BeGreaterThan(0); metadata.FunctionCount.Should().BeGreaterThan(0); metadata.EdgeCount.Should().BeGreaterThan(0); } /// /// Generator version is retrievable. /// [Fact] public void GetGeneratorVersion_ReturnsSemanticVersion() { // Act var version = TestGraphIdentifiers.GetGeneratorVersion(); // Assert version.Should().MatchRegex(@"^\d+\.\d+\.\d+$"); } #region Test Model Definitions (mirror richgraph-v1 schema) /// Test model mirroring NativeFunctionNode. internal sealed record TestFunctionNode( string SymbolId, string Name, string? Purl, string BinaryPath, string? BuildId, ulong Address, ulong Size, string SymbolDigest, string Binding, string Visibility, bool IsExported); /// Test model mirroring NativeCallEdge. internal sealed record TestCallEdge( string EdgeId, string CallerId, string CalleeId, string? CalleePurl, string? CalleeSymbolDigest, TestEdgeType EdgeType, ulong CallSiteOffset, bool IsResolved, double Confidence); /// Test model mirroring NativeSyntheticRoot. internal sealed record TestSyntheticRoot( string RootId, string TargetId, TestRootType RootType, string BinaryPath, string Phase, int Order); /// Test model mirroring NativeGraphMetadata. internal sealed record TestGraphMetadata( DateTimeOffset GeneratedAt, string GeneratorVersion, string LayerDigest, int BinaryCount, int FunctionCount, int EdgeCount, int UnknownCount, int SyntheticRootCount); /// Test enum mirroring NativeEdgeType. public enum TestEdgeType { Direct, Plt, Got, Relocation, Indirect, InitArray, FiniArray, } /// Test enum mirroring NativeRootType. public enum TestRootType { Start, Init, PreInitArray, InitArray, FiniArray, Fini, Main, Constructor, Destructor, } /// Test enum mirroring NativeUnknownType. public enum TestUnknownType { UnresolvedPurl, UnresolvedTarget, UnresolvedHash, UnresolvedBinary, AmbiguousTarget, } /// /// Test implementation of identifier computation methods. /// These mirror the expected behavior defined in richgraph-v1 schema. /// internal static class TestGraphIdentifiers { private const string GeneratorVersion = "1.0.0"; public static string ComputeSymbolId(string name, ulong address, ulong size, string binding) { var input = $"{name}:{address:x}:{size}:{binding}"; var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); return $"sym:{Convert.ToHexString(hash[..8]).ToLowerInvariant()}"; } public static string ComputeSymbolDigest(string name, ulong address, ulong size, string binding) { var input = $"{name}:{address:x}:{size}:{binding}"; var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); return Convert.ToHexString(hash).ToLowerInvariant(); } public static string ComputeEdgeId(string callerId, string calleeId, ulong callSiteOffset) { var input = $"{callerId}:{calleeId}:{callSiteOffset:x}"; var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); return $"edge:{Convert.ToHexString(hash[..8]).ToLowerInvariant()}"; } public static string ComputeRootId(string targetId, TestRootType rootType, int order) { var input = $"{targetId}:{rootType}:{order}"; var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); return $"root:{Convert.ToHexString(hash[..8]).ToLowerInvariant()}"; } public static string ComputeUnknownId(string sourceId, TestUnknownType unknownType, string? name) { var input = $"{sourceId}:{unknownType}:{name ?? ""}"; var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); return $"unk:{Convert.ToHexString(hash[..8]).ToLowerInvariant()}"; } public static string ComputeGraphHash( ImmutableArray functions, ImmutableArray edges, ImmutableArray roots) { using var sha = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); foreach (var f in functions.OrderBy(f => f.SymbolId)) { sha.AppendData(Encoding.UTF8.GetBytes(f.SymbolId)); sha.AppendData(Encoding.UTF8.GetBytes(f.SymbolDigest)); } foreach (var e in edges.OrderBy(e => e.EdgeId)) { sha.AppendData(Encoding.UTF8.GetBytes(e.EdgeId)); } foreach (var r in roots.OrderBy(r => r.RootId)) { sha.AppendData(Encoding.UTF8.GetBytes(r.RootId)); } return Convert.ToHexString(sha.GetCurrentHash()).ToLowerInvariant(); } public static string GetGeneratorVersion() => GeneratorVersion; } #endregion }