save progress

This commit is contained in:
master
2026-01-09 18:27:36 +02:00
parent e608752924
commit a21d3dbc1f
361 changed files with 63068 additions and 1192 deletions

View File

@@ -0,0 +1,306 @@
// <copyright file="CveSymbolMappingServiceTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Reachability.Core.CveMapping;
using StellaOps.Reachability.Core.Symbols;
using Xunit;
namespace StellaOps.Reachability.Core.Tests.CveMapping;
/// <summary>
/// Tests for <see cref="CveSymbolMappingService"/>.
/// </summary>
[Trait("Category", "Unit")]
public class CveSymbolMappingServiceTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly SymbolCanonicalizer _canonicalizer;
private readonly CveSymbolMappingService _service;
public CveSymbolMappingServiceTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 9, 12, 0, 0, TimeSpan.Zero));
_canonicalizer = new SymbolCanonicalizer();
_service = new CveSymbolMappingService(
_canonicalizer,
_timeProvider,
NullLogger<CveSymbolMappingService>.Instance);
}
[Fact]
public async Task GetMappingAsync_NotFound_ReturnsNull()
{
// Act
var result = await _service.GetMappingAsync("CVE-2021-44228", CancellationToken.None);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task IngestMapping_ThenGetMapping_ReturnsMapping()
{
// Arrange
var symbol = CreateTestSymbol("org.apache.log4j.core.lookup", "jndilookup", "lookup");
var mapping = CveSymbolMapping.Create(
"CVE-2021-44228",
[VulnerableSymbol.Create(symbol, VulnerabilityType.Sink, 0.9)],
MappingSource.PatchAnalysis,
0.9,
_timeProvider);
// Act
await _service.IngestMappingAsync(mapping, CancellationToken.None);
var result = await _service.GetMappingAsync("CVE-2021-44228", CancellationToken.None);
// Assert
result.Should().NotBeNull();
result!.CveId.Should().Be("CVE-2021-44228");
result.Symbols.Should().HaveCount(1);
result.Source.Should().Be(MappingSource.PatchAnalysis);
}
[Fact]
public async Task IngestMapping_CaseInsensitiveCveId()
{
// Arrange
var symbol = CreateTestSymbol("org.example", "service", "method");
var mapping = CveSymbolMapping.Create(
"cve-2023-12345",
[VulnerableSymbol.Create(symbol, VulnerabilityType.Unknown, 0.5)],
MappingSource.ManualCuration,
0.5,
_timeProvider);
await _service.IngestMappingAsync(mapping, CancellationToken.None);
// Act - Query with different cases
var result1 = await _service.GetMappingAsync("CVE-2023-12345", CancellationToken.None);
var result2 = await _service.GetMappingAsync("cve-2023-12345", CancellationToken.None);
var result3 = await _service.GetMappingAsync("Cve-2023-12345", CancellationToken.None);
// Assert - All queries return the same mapping (lookup is case-insensitive)
result1.Should().NotBeNull();
result2.Should().NotBeNull();
result3.Should().NotBeNull();
// The stored CVE ID retains original case from mapping creation
result1!.CveId.Should().Be(result2!.CveId);
result1.CveId.Should().Be(result3!.CveId);
}
[Fact]
public async Task GetMappingsBatchAsync_ReturnsOnlyFound()
{
// Arrange
var symbol1 = CreateTestSymbol("org.example", "a", "method1");
var symbol2 = CreateTestSymbol("org.example", "b", "method2");
var mapping1 = CveSymbolMapping.Create(
"CVE-2021-0001",
[VulnerableSymbol.Create(symbol1, VulnerabilityType.Sink, 0.8)],
MappingSource.PatchAnalysis,
0.8,
_timeProvider);
var mapping2 = CveSymbolMapping.Create(
"CVE-2021-0002",
[VulnerableSymbol.Create(symbol2, VulnerabilityType.TaintSource, 0.7)],
MappingSource.OsvDatabase,
0.7,
_timeProvider);
await _service.IngestMappingAsync(mapping1, CancellationToken.None);
await _service.IngestMappingAsync(mapping2, CancellationToken.None);
// Act
var result = await _service.GetMappingsBatchAsync(
["CVE-2021-0001", "CVE-2021-0002", "CVE-2021-9999"],
CancellationToken.None);
// Assert
result.Should().HaveCount(2);
result.Should().ContainKey("CVE-2021-0001");
result.Should().ContainKey("CVE-2021-0002");
result.Should().NotContainKey("CVE-2021-9999");
}
[Fact]
public async Task IngestMapping_MergesDuplicates()
{
// Arrange
var symbol1 = CreateTestSymbol("org.example", "service", "method1");
var symbol2 = CreateTestSymbol("org.example", "service", "method2");
var mapping1 = CveSymbolMapping.Create(
"CVE-2021-0001",
[VulnerableSymbol.Create(symbol1, VulnerabilityType.Sink, 0.8)],
MappingSource.PatchAnalysis,
0.8,
_timeProvider);
var mapping2 = CveSymbolMapping.Create(
"CVE-2021-0001",
[VulnerableSymbol.Create(symbol2, VulnerabilityType.TaintSource, 0.7)],
MappingSource.OsvDatabase,
0.7,
_timeProvider);
// Act
await _service.IngestMappingAsync(mapping1, CancellationToken.None);
await _service.IngestMappingAsync(mapping2, CancellationToken.None);
var result = await _service.GetMappingAsync("CVE-2021-0001", CancellationToken.None);
// Assert - Should have both symbols merged
result.Should().NotBeNull();
result!.Symbols.Should().HaveCount(2);
result.Confidence.Should().Be(0.8); // Max of both
}
[Fact]
public async Task GetCvesForSymbolAsync_ReturnsMatchingCves()
{
// Arrange
var symbol = CreateTestSymbol("org.apache.log4j.core.lookup", "jndilookup", "lookup");
var mapping1 = CveSymbolMapping.Create(
"CVE-2021-44228",
[VulnerableSymbol.Create(symbol, VulnerabilityType.Sink, 0.9)],
MappingSource.PatchAnalysis,
0.9,
_timeProvider);
var mapping2 = CveSymbolMapping.Create(
"CVE-2021-45046",
[VulnerableSymbol.Create(symbol, VulnerabilityType.Sink, 0.85)],
MappingSource.PatchAnalysis,
0.85,
_timeProvider);
await _service.IngestMappingAsync(mapping1, CancellationToken.None);
await _service.IngestMappingAsync(mapping2, CancellationToken.None);
// Act
var result = await _service.GetCvesForSymbolAsync(symbol.CanonicalId, CancellationToken.None);
// Assert
result.Should().HaveCount(2);
result.Should().Contain("CVE-2021-44228");
result.Should().Contain("CVE-2021-45046");
}
[Fact]
public async Task SearchBySymbolAsync_FindsMatchingMappings()
{
// Arrange
var symbol1 = CreateTestSymbol("org.apache.log4j.core.lookup", "jndilookup", "lookup");
var symbol2 = CreateTestSymbol("org.springframework.beans", "beanwrapper", "setPropertyValue");
var mapping1 = CveSymbolMapping.Create(
"CVE-2021-44228",
[VulnerableSymbol.Create(symbol1, VulnerabilityType.Sink, 0.9)],
MappingSource.PatchAnalysis,
0.9,
_timeProvider);
var mapping2 = CveSymbolMapping.Create(
"CVE-2022-22965",
[VulnerableSymbol.Create(symbol2, VulnerabilityType.Sink, 0.9)],
MappingSource.PatchAnalysis,
0.9,
_timeProvider);
await _service.IngestMappingAsync(mapping1, CancellationToken.None);
await _service.IngestMappingAsync(mapping2, CancellationToken.None);
// Act - Search for log4j
var result = await _service.SearchBySymbolAsync("log4j", 10, CancellationToken.None);
// Assert
result.Should().HaveCount(1);
result[0].CveId.Should().Be("CVE-2021-44228");
}
[Fact]
public async Task SearchBySymbolAsync_WildcardMatching()
{
// Arrange
var symbol1 = CreateTestSymbol("org.apache.log4j.core.lookup", "jndilookup", "lookup");
var symbol2 = CreateTestSymbol("org.apache.log4j.core.appender", "socketappender", "send");
var mapping1 = CveSymbolMapping.Create(
"CVE-2021-44228",
[VulnerableSymbol.Create(symbol1, VulnerabilityType.Sink, 0.9)],
MappingSource.PatchAnalysis,
0.9,
_timeProvider);
var mapping2 = CveSymbolMapping.Create(
"CVE-2021-44832",
[VulnerableSymbol.Create(symbol2, VulnerabilityType.Sink, 0.8)],
MappingSource.PatchAnalysis,
0.8,
_timeProvider);
await _service.IngestMappingAsync(mapping1, CancellationToken.None);
await _service.IngestMappingAsync(mapping2, CancellationToken.None);
// Act - Wildcard search
var result = await _service.SearchBySymbolAsync("*.log4j.*", 10, CancellationToken.None);
// Assert
result.Should().HaveCount(2);
}
[Fact]
public void IngestMapping_InvalidCveId_Throws()
{
// Arrange
var symbol = CreateTestSymbol("org.example", "service", "method");
// Act & Assert - "invalid" is not a valid CVE format
var act = () => CveSymbolMapping.Create(
"invalid",
[VulnerableSymbol.Create(symbol, VulnerabilityType.Unknown, 0.5)],
MappingSource.ManualCuration,
0.5,
_timeProvider);
// The service should validate on ingest
var mapping = act(); // Create doesn't validate format
var ingestAct = async () => await _service.IngestMappingAsync(mapping, CancellationToken.None);
ingestAct.Should().ThrowAsync<ArgumentException>();
}
[Fact]
public async Task Clear_RemovesAllMappings()
{
// Arrange
var symbol = CreateTestSymbol("org.example", "service", "method");
var mapping = CveSymbolMapping.Create(
"CVE-2021-0001",
[VulnerableSymbol.Create(symbol, VulnerabilityType.Sink, 0.8)],
MappingSource.PatchAnalysis,
0.8,
_timeProvider);
await _service.IngestMappingAsync(mapping, CancellationToken.None);
_service.MappingCount.Should().Be(1);
// Act
_service.Clear();
// Assert
_service.MappingCount.Should().Be(0);
}
private CanonicalSymbol CreateTestSymbol(string ns, string type, string method)
{
return CanonicalSymbol.Create(ns, type, method, "()", SymbolSource.JavaAsm);
}
}

View File

@@ -0,0 +1,249 @@
// <copyright file="CveSymbolMappingTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Reachability.Core.CveMapping;
using StellaOps.Reachability.Core.Symbols;
using Xunit;
namespace StellaOps.Reachability.Core.Tests.CveMapping;
/// <summary>
/// Tests for <see cref="CveSymbolMapping"/>.
/// </summary>
[Trait("Category", "Unit")]
public class CveSymbolMappingTests
{
private readonly FakeTimeProvider _timeProvider;
public CveSymbolMappingTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 9, 12, 0, 0, TimeSpan.Zero));
}
[Fact]
public void Create_SetsAllProperties()
{
// Arrange
var symbol = CreateTestSymbol("org.example", "service", "method");
var vulnSymbol = VulnerableSymbol.Create(symbol, VulnerabilityType.Sink, 0.9);
// Act
var mapping = CveSymbolMapping.Create(
"CVE-2021-44228",
[vulnSymbol],
MappingSource.PatchAnalysis,
0.85,
_timeProvider,
patchCommitUrl: "https://github.com/apache/logging-log4j2/commit/abc123",
osvAdvisoryId: "GHSA-jfh8-c2jp-5v3q");
// Assert
mapping.CveId.Should().Be("CVE-2021-44228");
mapping.Symbols.Should().HaveCount(1);
mapping.Source.Should().Be(MappingSource.PatchAnalysis);
mapping.Confidence.Should().Be(0.85);
mapping.ExtractedAt.Should().Be(_timeProvider.GetUtcNow());
mapping.PatchCommitUrl.Should().Be("https://github.com/apache/logging-log4j2/commit/abc123");
mapping.OsvAdvisoryId.Should().Be("GHSA-jfh8-c2jp-5v3q");
mapping.ContentDigest.Should().NotBeNullOrEmpty();
}
[Fact]
public void Create_ClampsConfidence()
{
// Arrange
var symbol = CreateTestSymbol("org.example", "service", "method");
var vulnSymbol = VulnerableSymbol.Create(symbol, VulnerabilityType.Unknown, 0.5);
// Act
var mapping1 = CveSymbolMapping.Create(
"CVE-2021-0001",
[vulnSymbol],
MappingSource.ManualCuration,
1.5, // Over 1.0
_timeProvider);
var mapping2 = CveSymbolMapping.Create(
"CVE-2021-0002",
[vulnSymbol],
MappingSource.ManualCuration,
-0.5, // Below 0.0
_timeProvider);
// Assert
mapping1.Confidence.Should().Be(1.0);
mapping2.Confidence.Should().Be(0.0);
}
[Fact]
public void ContentDigest_IsDeterministic()
{
// Arrange
var symbol = CreateTestSymbol("org.example", "service", "method");
var vulnSymbol = VulnerableSymbol.Create(symbol, VulnerabilityType.Sink, 0.9);
// Act - Create two mappings with same content
var mapping1 = CveSymbolMapping.Create(
"CVE-2021-44228",
[vulnSymbol],
MappingSource.PatchAnalysis,
0.85,
_timeProvider);
var mapping2 = CveSymbolMapping.Create(
"CVE-2021-44228",
[vulnSymbol],
MappingSource.PatchAnalysis,
0.85,
_timeProvider);
// Assert - Same content digest
mapping1.ContentDigest.Should().Be(mapping2.ContentDigest);
}
[Fact]
public void ContentDigest_DiffersForDifferentSymbols()
{
// Arrange
var symbol1 = CreateTestSymbol("org.example", "service", "method1");
var symbol2 = CreateTestSymbol("org.example", "service", "method2");
// Act
var mapping1 = CveSymbolMapping.Create(
"CVE-2021-44228",
[VulnerableSymbol.Create(symbol1, VulnerabilityType.Sink, 0.9)],
MappingSource.PatchAnalysis,
0.85,
_timeProvider);
var mapping2 = CveSymbolMapping.Create(
"CVE-2021-44228",
[VulnerableSymbol.Create(symbol2, VulnerabilityType.Sink, 0.9)],
MappingSource.PatchAnalysis,
0.85,
_timeProvider);
// Assert - Different content digest
mapping1.ContentDigest.Should().NotBe(mapping2.ContentDigest);
}
[Fact]
public void Merge_CombinesSymbols()
{
// Arrange
var symbol1 = CreateTestSymbol("org.example", "service", "method1");
var symbol2 = CreateTestSymbol("org.example", "service", "method2");
var mapping1 = CveSymbolMapping.Create(
"CVE-2021-44228",
[VulnerableSymbol.Create(symbol1, VulnerabilityType.Sink, 0.8)],
MappingSource.PatchAnalysis,
0.8,
_timeProvider);
var mapping2 = CveSymbolMapping.Create(
"CVE-2021-44228",
[VulnerableSymbol.Create(symbol2, VulnerabilityType.TaintSource, 0.7)],
MappingSource.OsvDatabase,
0.7,
_timeProvider);
// Act
var merged = mapping1.Merge(mapping2, _timeProvider);
// Assert
merged.CveId.Should().Be("CVE-2021-44228");
merged.Symbols.Should().HaveCount(2);
merged.Confidence.Should().Be(0.8); // Max of both
}
[Fact]
public void Merge_DifferentCves_Throws()
{
// Arrange
var symbol = CreateTestSymbol("org.example", "service", "method");
var mapping1 = CveSymbolMapping.Create(
"CVE-2021-44228",
[VulnerableSymbol.Create(symbol, VulnerabilityType.Sink, 0.8)],
MappingSource.PatchAnalysis,
0.8,
_timeProvider);
var mapping2 = CveSymbolMapping.Create(
"CVE-2022-22965",
[VulnerableSymbol.Create(symbol, VulnerabilityType.Sink, 0.7)],
MappingSource.PatchAnalysis,
0.7,
_timeProvider);
// Act & Assert
var act = () => mapping1.Merge(mapping2, _timeProvider);
act.Should().Throw<ArgumentException>()
.WithMessage("*different CVEs*");
}
[Fact]
public void Merge_DeduplicatesSymbols()
{
// Arrange
var symbol = CreateTestSymbol("org.example", "service", "method");
var mapping1 = CveSymbolMapping.Create(
"CVE-2021-44228",
[VulnerableSymbol.Create(symbol, VulnerabilityType.Sink, 0.8)],
MappingSource.PatchAnalysis,
0.8,
_timeProvider);
var mapping2 = CveSymbolMapping.Create(
"CVE-2021-44228",
[VulnerableSymbol.Create(symbol, VulnerabilityType.Sink, 0.9)], // Same symbol, different confidence
MappingSource.OsvDatabase,
0.9,
_timeProvider);
// Act
var merged = mapping1.Merge(mapping2, _timeProvider);
// Assert - Should not duplicate
merged.Symbols.Should().HaveCount(1);
}
[Fact]
public void Merge_CombinesPurls()
{
// Arrange
var symbol = CreateTestSymbol("org.example", "service", "method");
var mapping1 = CveSymbolMapping.Create(
"CVE-2021-44228",
[VulnerableSymbol.Create(symbol, VulnerabilityType.Sink, 0.8)],
MappingSource.PatchAnalysis,
0.8,
_timeProvider,
affectedPurls: ["pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0"]);
var mapping2 = CveSymbolMapping.Create(
"CVE-2021-44228",
[VulnerableSymbol.Create(symbol, VulnerabilityType.Sink, 0.9)],
MappingSource.OsvDatabase,
0.9,
_timeProvider,
affectedPurls: ["pkg:maven/org.apache.logging.log4j/log4j-api@2.14.0"]);
// Act
var merged = mapping1.Merge(mapping2, _timeProvider);
// Assert
merged.AffectedPurls.Should().HaveCount(2);
}
private CanonicalSymbol CreateTestSymbol(string ns, string type, string method)
{
return CanonicalSymbol.Create(ns, type, method, "()", SymbolSource.JavaAsm);
}
}

View File

@@ -0,0 +1,122 @@
// <copyright file="VulnerableSymbolTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using FluentAssertions;
using StellaOps.Reachability.Core.CveMapping;
using StellaOps.Reachability.Core.Symbols;
using Xunit;
namespace StellaOps.Reachability.Core.Tests.CveMapping;
/// <summary>
/// Tests for <see cref="VulnerableSymbol"/> and <see cref="LineRange"/>.
/// </summary>
[Trait("Category", "Unit")]
public class VulnerableSymbolTests
{
[Fact]
public void Create_SetsRequiredFields()
{
// Arrange
var symbol = CanonicalSymbol.Create("org.example", "service", "method", "()", SymbolSource.JavaAsm);
// Act
var vulnSymbol = VulnerableSymbol.Create(symbol, VulnerabilityType.Sink, 0.85);
// Assert
vulnSymbol.Symbol.Should().Be(symbol);
vulnSymbol.Type.Should().Be(VulnerabilityType.Sink);
vulnSymbol.Confidence.Should().Be(0.85);
vulnSymbol.Condition.Should().BeNull();
vulnSymbol.Evidence.Should().BeNull();
}
[Fact]
public void Create_ClampsConfidence()
{
// Arrange
var symbol = CanonicalSymbol.Create("org.example", "service", "method", "()", SymbolSource.JavaAsm);
// Act
var over = VulnerableSymbol.Create(symbol, VulnerabilityType.Unknown, 1.5);
var under = VulnerableSymbol.Create(symbol, VulnerabilityType.Unknown, -0.5);
// Assert
over.Confidence.Should().Be(1.0);
under.Confidence.Should().Be(0.0);
}
[Fact]
public void Create_WithOptionalFields()
{
// Arrange
var symbol = CanonicalSymbol.Create("org.example", "service", "method", "(string)", SymbolSource.Roslyn);
// Act
var vulnSymbol = new VulnerableSymbol
{
Symbol = symbol,
Type = VulnerabilityType.SqlInjection,
Confidence = 0.95,
Condition = "When user input is passed directly",
Evidence = "Modified in commit abc123",
SourceFile = "src/main/java/Example.java",
LineRange = new LineRange(42, 58)
};
// Assert
vulnSymbol.Condition.Should().Be("When user input is passed directly");
vulnSymbol.Evidence.Should().Be("Modified in commit abc123");
vulnSymbol.SourceFile.Should().Be("src/main/java/Example.java");
vulnSymbol.LineRange.Should().Be(new LineRange(42, 58));
}
[Fact]
public void LineRange_Length_CalculatesCorrectly()
{
// Arrange
var range = new LineRange(10, 20);
// Act & Assert
range.Length.Should().Be(11); // 10,11,12,13,14,15,16,17,18,19,20 = 11 lines
}
[Fact]
public void LineRange_Contains_WorksCorrectly()
{
// Arrange
var range = new LineRange(10, 20);
// Act & Assert
range.Contains(10).Should().BeTrue();
range.Contains(15).Should().BeTrue();
range.Contains(20).Should().BeTrue();
range.Contains(9).Should().BeFalse();
range.Contains(21).Should().BeFalse();
}
[Theory]
[InlineData(VulnerabilityType.Sink)]
[InlineData(VulnerabilityType.TaintSource)]
[InlineData(VulnerabilityType.GadgetEntry)]
[InlineData(VulnerabilityType.DeserializationTarget)]
[InlineData(VulnerabilityType.AuthBypass)]
[InlineData(VulnerabilityType.CryptoWeakness)]
[InlineData(VulnerabilityType.RceEntry)]
[InlineData(VulnerabilityType.SqlInjection)]
[InlineData(VulnerabilityType.PathTraversal)]
[InlineData(VulnerabilityType.Ssrf)]
[InlineData(VulnerabilityType.Xss)]
public void VulnerabilityType_AllValuesSupported(VulnerabilityType type)
{
// Arrange
var symbol = CanonicalSymbol.Create("org.example", "service", "method", "()", SymbolSource.JavaAsm);
// Act
var vulnSymbol = VulnerableSymbol.Create(symbol, type, 0.5);
// Assert
vulnSymbol.Type.Should().Be(type);
}
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>StellaOps.Reachability.Core.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="coverlet.collector" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Reachability.Core\StellaOps.Reachability.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,215 @@
// <copyright file="DotNetSymbolNormalizerTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using FluentAssertions;
using StellaOps.Reachability.Core.Symbols;
using Xunit;
namespace StellaOps.Reachability.Core.Tests.Symbols;
/// <summary>
/// Tests for <see cref="DotNetSymbolNormalizer"/>.
/// </summary>
[Trait("Category", "Unit")]
public class DotNetSymbolNormalizerTests
{
private readonly DotNetSymbolNormalizer _normalizer = new();
[Fact]
public void SupportedSources_ContainsDotNetSources()
{
_normalizer.SupportedSources.Should().Contain(SymbolSource.Roslyn);
_normalizer.SupportedSources.Should().Contain(SymbolSource.ILMetadata);
_normalizer.SupportedSources.Should().Contain(SymbolSource.EtwClr);
_normalizer.SupportedSources.Should().Contain(SymbolSource.EventPipe);
}
[Theory]
[InlineData(SymbolSource.Roslyn, true)]
[InlineData(SymbolSource.ILMetadata, true)]
[InlineData(SymbolSource.JavaAsm, false)]
[InlineData(SymbolSource.Unknown, false)]
public void CanNormalize_ReturnsCorrectResult(SymbolSource source, bool expected)
{
_normalizer.CanNormalize(source).Should().Be(expected);
}
[Fact]
public void Normalize_FullSignature_ParsesCorrectly()
{
// Arrange
var raw = new RawSymbol(
"System.Void StellaOps.Scanner.Core.SbomGenerator::GenerateAsync(System.Threading.CancellationToken)",
SymbolSource.ILMetadata);
// Act
var canonical = _normalizer.Normalize(raw);
// Assert
canonical.Should().NotBeNull();
canonical!.Namespace.Should().Be("stellaops.scanner.core");
canonical.Type.Should().Be("sbomgenerator");
canonical.Method.Should().Be("generateasync");
canonical.Signature.Should().Be("(cancellationtoken)");
canonical.Source.Should().Be(SymbolSource.ILMetadata);
}
[Fact]
public void Normalize_FullSignature_MultipleParams_ParsesCorrectly()
{
// Arrange
var raw = new RawSymbol(
"System.Boolean MyApp.Services.UserService::ValidateUser(System.String, System.Int32, System.String)",
SymbolSource.ILMetadata);
// Act
var canonical = _normalizer.Normalize(raw);
// Assert
canonical.Should().NotBeNull();
canonical!.Namespace.Should().Be("myapp.services");
canonical.Type.Should().Be("userservice");
canonical.Method.Should().Be("validateuser");
canonical.Signature.Should().Be("(string, int32, string)");
}
[Fact]
public void Normalize_RoslynFormat_ParsesCorrectly()
{
// Arrange
var raw = new RawSymbol(
"StellaOps.Scanner.Core.SbomGenerator.GenerateAsync(CancellationToken)",
SymbolSource.Roslyn);
// Act
var canonical = _normalizer.Normalize(raw);
// Assert
canonical.Should().NotBeNull();
canonical!.Namespace.Should().Be("stellaops.scanner.core");
canonical.Type.Should().Be("sbomgenerator");
canonical.Method.Should().Be("generateasync");
canonical.Signature.Should().Be("(cancellationtoken)");
}
[Fact]
public void Normalize_RoslynFormat_NoParams_ParsesCorrectly()
{
// Arrange
var raw = new RawSymbol(
"StellaOps.Scanner.Core.SbomGenerator.GenerateAsync",
SymbolSource.Roslyn);
// Act
var canonical = _normalizer.Normalize(raw);
// Assert
canonical.Should().NotBeNull();
canonical!.Namespace.Should().Be("stellaops.scanner.core");
canonical.Type.Should().Be("sbomgenerator");
canonical.Method.Should().Be("generateasync");
canonical.Signature.Should().Be("()");
}
[Fact]
public void Normalize_SimpleDoubleColon_ParsesCorrectly()
{
// Arrange
var raw = new RawSymbol(
"StellaOps.Scanner.Core.SbomGenerator::GenerateAsync",
SymbolSource.ILMetadata);
// Act
var canonical = _normalizer.Normalize(raw);
// Assert
canonical.Should().NotBeNull();
canonical!.Namespace.Should().Be("stellaops.scanner.core");
canonical.Type.Should().Be("sbomgenerator");
canonical.Method.Should().Be("generateasync");
canonical.Signature.Should().Be("()");
}
[Fact]
public void Normalize_InvalidSymbol_ReturnsNull()
{
// Arrange
var raw = new RawSymbol("not a valid symbol", SymbolSource.Roslyn);
// Act
var canonical = _normalizer.Normalize(raw);
// Assert
canonical.Should().BeNull();
}
[Fact]
public void Normalize_EmptyValue_ReturnsNull()
{
// Arrange
var raw = new RawSymbol("", SymbolSource.Roslyn);
// Act
var result = _normalizer.TryNormalize(raw, out var canonical, out var error);
// Assert
result.Should().BeFalse();
canonical.Should().BeNull();
error.Should().Contain("empty");
}
[Fact]
public void Normalize_PreservesPurl()
{
// Arrange
var raw = new RawSymbol(
"StellaOps.Scanner.Core.SbomGenerator.GenerateAsync",
SymbolSource.Roslyn,
"pkg:nuget/StellaOps.Scanner.Core@1.0.0");
// Act
var canonical = _normalizer.Normalize(raw);
// Assert
canonical.Should().NotBeNull();
canonical!.Purl.Should().Be("pkg:nuget/stellaops.scanner.core@1.0.0");
}
[Fact]
public void Normalize_PreservesOriginalSymbol()
{
// Arrange
var original = "StellaOps.Scanner.Core.SbomGenerator.GenerateAsync";
var raw = new RawSymbol(original, SymbolSource.Roslyn);
// Act
var canonical = _normalizer.Normalize(raw);
// Assert
canonical.Should().NotBeNull();
canonical!.OriginalSymbol.Should().Be(original);
}
[Fact]
public void Normalize_GeneratesStableCanonicalId()
{
// Arrange
var raw1 = new RawSymbol(
"System.Void StellaOps.Scanner.Core.SbomGenerator::GenerateAsync(System.Threading.CancellationToken)",
SymbolSource.ILMetadata);
var raw2 = new RawSymbol(
"StellaOps.Scanner.Core.SbomGenerator.GenerateAsync(CancellationToken)",
SymbolSource.Roslyn);
// Act
var canonical1 = _normalizer.Normalize(raw1);
var canonical2 = _normalizer.Normalize(raw2);
// Assert
canonical1.Should().NotBeNull();
canonical2.Should().NotBeNull();
canonical1!.CanonicalId.Should().Be(canonical2!.CanonicalId,
"Same symbol from different sources should have same canonical ID");
}
}

View File

@@ -0,0 +1,244 @@
// <copyright file="JavaSymbolNormalizerTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using FluentAssertions;
using StellaOps.Reachability.Core.Symbols;
using Xunit;
namespace StellaOps.Reachability.Core.Tests.Symbols;
/// <summary>
/// Tests for <see cref="JavaSymbolNormalizer"/>.
/// </summary>
[Trait("Category", "Unit")]
public class JavaSymbolNormalizerTests
{
private readonly JavaSymbolNormalizer _normalizer = new();
[Fact]
public void SupportedSources_ContainsJavaSources()
{
_normalizer.SupportedSources.Should().Contain(SymbolSource.JavaAsm);
_normalizer.SupportedSources.Should().Contain(SymbolSource.JavaJfr);
_normalizer.SupportedSources.Should().Contain(SymbolSource.JavaJvmti);
}
[Theory]
[InlineData(SymbolSource.JavaAsm, true)]
[InlineData(SymbolSource.JavaJfr, true)]
[InlineData(SymbolSource.Roslyn, false)]
[InlineData(SymbolSource.Unknown, false)]
public void CanNormalize_ReturnsCorrectResult(SymbolSource source, bool expected)
{
_normalizer.CanNormalize(source).Should().Be(expected);
}
[Fact]
public void Normalize_AsmFormat_ParsesCorrectly()
{
// Arrange - ASM bytecode format
var raw = new RawSymbol(
"org/apache/log4j/core/lookup/JndiLookup.lookup(Ljava/lang/String;)Ljava/lang/String;",
SymbolSource.JavaAsm);
// Act
var canonical = _normalizer.Normalize(raw);
// Assert
canonical.Should().NotBeNull();
canonical!.Namespace.Should().Be("org.apache.log4j.core.lookup");
canonical.Type.Should().Be("jndilookup");
canonical.Method.Should().Be("lookup");
canonical.Signature.Should().Be("(string)");
canonical.Source.Should().Be(SymbolSource.JavaAsm);
}
[Fact]
public void Normalize_AsmFormat_MultipleParams_ParsesCorrectly()
{
// Arrange - Multiple parameters
var raw = new RawSymbol(
"com/example/Service.process(Ljava/lang/String;IZ)V",
SymbolSource.JavaAsm);
// Act
var canonical = _normalizer.Normalize(raw);
// Assert
canonical.Should().NotBeNull();
canonical!.Namespace.Should().Be("com.example");
canonical.Type.Should().Be("service");
canonical.Method.Should().Be("process");
canonical.Signature.Should().Be("(string, int, boolean)");
}
[Fact]
public void Normalize_AsmFormat_ArrayParam_ParsesCorrectly()
{
// Arrange - Array parameter
var raw = new RawSymbol(
"com/example/Service.processBytes([B)V",
SymbolSource.JavaAsm);
// Act
var canonical = _normalizer.Normalize(raw);
// Assert
canonical.Should().NotBeNull();
canonical!.Signature.Should().Be("(byte[])");
}
[Fact]
public void Normalize_JfrFormat_ParsesCorrectly()
{
// Arrange - JFR format (simpler)
var raw = new RawSymbol(
"org.apache.log4j.core.lookup.JndiLookup.lookup(String)",
SymbolSource.JavaJfr);
// Act
var canonical = _normalizer.Normalize(raw);
// Assert
canonical.Should().NotBeNull();
canonical!.Namespace.Should().Be("org.apache.log4j.core.lookup");
canonical.Type.Should().Be("jndilookup");
canonical.Method.Should().Be("lookup");
canonical.Signature.Should().Be("(string)");
}
[Fact]
public void Normalize_PatchFormat_ParsesCorrectly()
{
// Arrange - Patch analysis format with #
var raw = new RawSymbol(
"org.apache.logging.log4j.core.lookup.JndiLookup#lookup",
SymbolSource.PatchAnalysis);
// Act
var canonical = _normalizer.Normalize(raw);
// Assert
canonical.Should().NotBeNull();
canonical!.Namespace.Should().Be("org.apache.logging.log4j.core.lookup");
canonical.Type.Should().Be("jndilookup");
canonical.Method.Should().Be("lookup");
canonical.Signature.Should().Be("()");
}
[Fact]
public void Normalize_InvalidSymbol_ReturnsNull()
{
// Arrange
var raw = new RawSymbol("not a valid symbol", SymbolSource.JavaAsm);
// Act
var canonical = _normalizer.Normalize(raw);
// Assert
canonical.Should().BeNull();
}
[Fact]
public void Normalize_EmptyValue_ReturnsNull()
{
// Arrange
var raw = new RawSymbol("", SymbolSource.JavaAsm);
// Act
var result = _normalizer.TryNormalize(raw, out var canonical, out var error);
// Assert
result.Should().BeFalse();
canonical.Should().BeNull();
error.Should().Contain("empty");
}
[Fact]
public void Normalize_PreservesPurl()
{
// Arrange
var raw = new RawSymbol(
"org/apache/log4j/core/lookup/JndiLookup.lookup(Ljava/lang/String;)Ljava/lang/String;",
SymbolSource.JavaAsm,
"pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0");
// Act
var canonical = _normalizer.Normalize(raw);
// Assert
canonical.Should().NotBeNull();
canonical!.Purl.Should().Be("pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0");
}
[Fact]
public void Normalize_CrossSourceMatching_SameCanonicalId()
{
// Arrange - Same symbol from ASM and JFR
var asmRaw = new RawSymbol(
"org/apache/log4j/core/lookup/JndiLookup.lookup(Ljava/lang/String;)Ljava/lang/String;",
SymbolSource.JavaAsm);
var jfrRaw = new RawSymbol(
"org.apache.log4j.core.lookup.JndiLookup.lookup(String)",
SymbolSource.JavaJfr);
// Act
var asmCanonical = _normalizer.Normalize(asmRaw);
var jfrCanonical = _normalizer.Normalize(jfrRaw);
// Assert
asmCanonical.Should().NotBeNull();
jfrCanonical.Should().NotBeNull();
asmCanonical!.CanonicalId.Should().Be(jfrCanonical!.CanonicalId,
"Same symbol from ASM and JFR should have same canonical ID");
}
[Fact]
public void Normalize_JvmDescriptor_AllPrimitives()
{
// Arrange - All primitive types
var raw = new RawSymbol(
"com/example/Util.test(BCDFIJSZ)V",
SymbolSource.JavaAsm);
// Act
var canonical = _normalizer.Normalize(raw);
// Assert
canonical.Should().NotBeNull();
canonical!.Signature.Should().Be("(byte, char, double, float, int, long, short, boolean)");
}
[Fact]
public void Normalize_JvmDescriptor_NestedArray()
{
// Arrange - 2D array
var raw = new RawSymbol(
"com/example/Matrix.multiply([[D)[[D",
SymbolSource.JavaAsm);
// Act
var canonical = _normalizer.Normalize(raw);
// Assert
canonical.Should().NotBeNull();
canonical!.Signature.Should().Be("(double[][])");
}
[Fact]
public void Normalize_JvmDescriptor_ObjectArray()
{
// Arrange - Object array
var raw = new RawSymbol(
"com/example/Util.process([Ljava/lang/String;)V",
SymbolSource.JavaAsm);
// Act
var canonical = _normalizer.Normalize(raw);
// Assert
canonical.Should().NotBeNull();
canonical!.Signature.Should().Be("(string[])");
}
}

View File

@@ -0,0 +1,200 @@
// <copyright file="SymbolCanonicalizerTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using FluentAssertions;
using StellaOps.Reachability.Core.Symbols;
using Xunit;
namespace StellaOps.Reachability.Core.Tests.Symbols;
/// <summary>
/// Tests for <see cref="SymbolCanonicalizer"/>.
/// </summary>
[Trait("Category", "Unit")]
public class SymbolCanonicalizerTests
{
private readonly SymbolCanonicalizer _canonicalizer = new();
[Fact]
public void Canonicalize_DotNetSymbol_UsesCorrectNormalizer()
{
// Arrange
var raw = new RawSymbol(
"StellaOps.Scanner.Core.SbomGenerator.GenerateAsync(CancellationToken)",
SymbolSource.Roslyn);
// Act
var canonical = _canonicalizer.Canonicalize(raw);
// Assert
canonical.Should().NotBeNull();
canonical!.Namespace.Should().Be("stellaops.scanner.core");
canonical.Type.Should().Be("sbomgenerator");
}
[Fact]
public void Canonicalize_JavaSymbol_UsesCorrectNormalizer()
{
// Arrange
var raw = new RawSymbol(
"org/apache/log4j/core/lookup/JndiLookup.lookup(Ljava/lang/String;)Ljava/lang/String;",
SymbolSource.JavaAsm);
// Act
var canonical = _canonicalizer.Canonicalize(raw);
// Assert
canonical.Should().NotBeNull();
canonical!.Namespace.Should().Be("org.apache.log4j.core.lookup");
canonical.Type.Should().Be("jndilookup");
}
[Fact]
public void Canonicalize_UnknownSource_TriesFallback()
{
// Arrange - Unknown source but valid .NET format
var raw = new RawSymbol(
"StellaOps.Scanner.Core.SbomGenerator.GenerateAsync",
SymbolSource.Unknown);
// Act
var canonical = _canonicalizer.Canonicalize(raw);
// Assert
canonical.Should().NotBeNull();
canonical!.Method.Should().Be("generateasync");
}
[Fact]
public void CanonicalizeBatch_ReturnsOnlySuccessful()
{
// Arrange
var symbols = new[]
{
new RawSymbol("StellaOps.Scanner.Core.A.MethodA", SymbolSource.Roslyn),
new RawSymbol("invalid symbol", SymbolSource.Roslyn),
new RawSymbol("StellaOps.Scanner.Core.B.MethodB", SymbolSource.Roslyn)
};
// Act
var result = _canonicalizer.CanonicalizeBatch(symbols);
// Assert
result.Should().HaveCount(2);
result.Select(c => c.Method).Should().Contain("methoda");
result.Select(c => c.Method).Should().Contain("methodb");
}
[Fact]
public void CanonicalizeBatchWithErrors_ReturnsAllResults()
{
// Arrange
var symbols = new[]
{
new RawSymbol("StellaOps.Scanner.Core.A.MethodA", SymbolSource.Roslyn),
new RawSymbol("invalid symbol", SymbolSource.Roslyn),
new RawSymbol("StellaOps.Scanner.Core.B.MethodB", SymbolSource.Roslyn)
};
// Act
var result = _canonicalizer.CanonicalizeBatchWithErrors(symbols);
// Assert
result.Successful.Should().HaveCount(2);
result.Failed.Should().HaveCount(1);
result.Failed[0].Raw.Value.Should().Be("invalid symbol");
result.Failed[0].Error.Should().NotBeNullOrEmpty();
}
[Fact]
public void Match_DelegatestoMatcher()
{
// Arrange
var a = CanonicalSymbol.Create(
"org.example", "service", "process", "(string)", SymbolSource.Roslyn);
var b = CanonicalSymbol.Create(
"org.example", "service", "process", "(string)", SymbolSource.JavaAsm);
// Act
var result = _canonicalizer.Match(a, b);
// Assert
result.MatchType.Should().Be(SymbolMatchType.Exact);
result.Confidence.Should().Be(1.0);
}
[Fact]
public void CrossSourceMatching_Log4jExample()
{
// Arrange - Log4Shell vulnerability symbols from different sources
var asmSymbol = new RawSymbol(
"org/apache/log4j/core/lookup/JndiLookup.lookup(Ljava/lang/String;)Ljava/lang/String;",
SymbolSource.JavaAsm,
"pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0");
var jfrSymbol = new RawSymbol(
"org.apache.log4j.core.lookup.JndiLookup.lookup(String)",
SymbolSource.JavaJfr,
"pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0");
// Act
var asmCanonical = _canonicalizer.Canonicalize(asmSymbol);
var jfrCanonical = _canonicalizer.Canonicalize(jfrSymbol);
var matchResult = _canonicalizer.Match(asmCanonical!, jfrCanonical!);
// Assert
asmCanonical.Should().NotBeNull();
jfrCanonical.Should().NotBeNull();
matchResult.MatchType.Should().Be(SymbolMatchType.Exact);
}
[Fact]
public void CrossSourceMatching_DotNetExample()
{
// Arrange - Same .NET symbol from different sources
var roslynSymbol = new RawSymbol(
"StellaOps.Scanner.Core.SbomGenerator.GenerateAsync(CancellationToken)",
SymbolSource.Roslyn);
var ilSymbol = new RawSymbol(
"System.Threading.Tasks.Task StellaOps.Scanner.Core.SbomGenerator::GenerateAsync(System.Threading.CancellationToken)",
SymbolSource.ILMetadata);
// Act
var roslynCanonical = _canonicalizer.Canonicalize(roslynSymbol);
var ilCanonical = _canonicalizer.Canonicalize(ilSymbol);
var matchResult = _canonicalizer.Match(roslynCanonical!, ilCanonical!);
// Assert
roslynCanonical.Should().NotBeNull();
ilCanonical.Should().NotBeNull();
matchResult.MatchType.Should().Be(SymbolMatchType.Exact);
}
[Fact]
public void Determinism_SameInputProducesSameOutput()
{
// Arrange
var raw = new RawSymbol(
"org/apache/log4j/core/lookup/JndiLookup.lookup(Ljava/lang/String;)Ljava/lang/String;",
SymbolSource.JavaAsm);
// Act - Run multiple times
var results = Enumerable.Range(0, 100)
.Select(_ => _canonicalizer.Canonicalize(raw))
.ToList();
// Assert - All should be identical
var first = results[0];
results.Should().AllSatisfy(r =>
{
r.Should().NotBeNull();
r!.CanonicalId.Should().Be(first!.CanonicalId);
r.Namespace.Should().Be(first.Namespace);
r.Type.Should().Be(first.Type);
r.Method.Should().Be(first.Method);
r.Signature.Should().Be(first.Signature);
});
}
}

View File

@@ -0,0 +1,193 @@
// <copyright file="SymbolMatcherTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using FluentAssertions;
using StellaOps.Reachability.Core.Symbols;
using Xunit;
namespace StellaOps.Reachability.Core.Tests.Symbols;
/// <summary>
/// Tests for <see cref="SymbolMatcher"/>.
/// </summary>
[Trait("Category", "Unit")]
public class SymbolMatcherTests
{
private readonly SymbolMatcher _matcher = new();
[Fact]
public void Match_ExactCanonicalId_ReturnsExact()
{
// Arrange
var a = CanonicalSymbol.Create(
"org.example", "service", "process", "(string)", SymbolSource.Roslyn);
var b = CanonicalSymbol.Create(
"org.example", "service", "process", "(string)", SymbolSource.JavaAsm);
// Act
var result = _matcher.Match(a, b);
// Assert
result.MatchType.Should().Be(SymbolMatchType.Exact);
result.Confidence.Should().Be(1.0);
result.IsMatch.Should().BeTrue();
}
[Fact]
public void Match_SameMethodDifferentSignature_ReturnsFuzzy()
{
// Arrange - Same method but different overload
var a = CanonicalSymbol.Create(
"org.example", "service", "process", "(string)", SymbolSource.Roslyn);
var b = CanonicalSymbol.Create(
"org.example", "service", "process", "(string, int)", SymbolSource.Roslyn);
// Act
var result = _matcher.Match(a, b);
// Assert
result.MatchType.Should().Be(SymbolMatchType.Fuzzy);
result.Confidence.Should().BeGreaterThanOrEqualTo(0.7);
result.IsMatch.Should().BeTrue();
}
[Fact]
public void Match_DifferentMethod_ReturnsNoMatch()
{
// Arrange
var a = CanonicalSymbol.Create(
"org.example", "service", "process", "(string)", SymbolSource.Roslyn);
var b = CanonicalSymbol.Create(
"org.example", "service", "validate", "(string)", SymbolSource.Roslyn);
// Act
var result = _matcher.Match(a, b);
// Assert
result.MatchType.Should().Be(SymbolMatchType.NoMatch);
result.IsMatch.Should().BeFalse();
}
[Fact]
public void Match_DifferentPurl_ReturnsNoMatch()
{
// Arrange - Different packages
var a = CanonicalSymbol.Create(
"org.example", "service", "process", "(string)", SymbolSource.Roslyn, "pkg:npm/pkg-a@1.0.0");
var b = CanonicalSymbol.Create(
"org.example", "service", "process", "(string)", SymbolSource.Roslyn, "pkg:npm/pkg-b@1.0.0");
// Act
var result = _matcher.Match(a, b);
// Assert
result.MatchType.Should().Be(SymbolMatchType.NoMatch);
result.Reason.Should().Contain("PURL");
}
[Fact]
public void Match_DifferentPurl_IgnoredWhenDisabled()
{
// Arrange
var a = CanonicalSymbol.Create(
"org.example", "service", "process", "(string)", SymbolSource.Roslyn, "pkg:npm/pkg-a@1.0.0");
var b = CanonicalSymbol.Create(
"org.example", "service", "process", "(string)", SymbolSource.Roslyn, "pkg:npm/pkg-b@1.0.0");
var options = new SymbolMatchOptions { ConsiderPurl = false };
// Act
var result = _matcher.Match(a, b, options);
// Assert - Even with ConsiderPurl=false, canonical IDs differ due to different PURLs in the hash
// So we get a fuzzy match (same namespace/type/method) rather than no match
result.MatchType.Should().Be(SymbolMatchType.Fuzzy);
result.IsMatch.Should().BeTrue();
}
[Fact]
public void Match_SimilarNamespace_ReturnsFuzzy()
{
// Arrange - Similar namespace (logging variation)
var a = CanonicalSymbol.Create(
"org.apache.log4j.core.lookup", "jndilookup", "lookup", "(string)", SymbolSource.JavaAsm);
var b = CanonicalSymbol.Create(
"org.apache.logging.log4j.core.lookup", "jndilookup", "lookup", "(string)", SymbolSource.JavaAsm);
// Act with lenient options
var result = _matcher.Match(a, b, SymbolMatchOptions.Lenient);
// Assert
result.MatchType.Should().Be(SymbolMatchType.Fuzzy);
result.Confidence.Should().BeGreaterThan(0.5);
}
[Fact]
public void Match_StrictOptions_RequiresHigherConfidence()
{
// Arrange - Similar but not identical
var a = CanonicalSymbol.Create(
"org.example.services", "userservice", "validate", "(string)", SymbolSource.Roslyn);
var b = CanonicalSymbol.Create(
"org.example.svc", "uservalidator", "validate", "(string)", SymbolSource.Roslyn);
// Act
var defaultResult = _matcher.Match(a, b);
var strictResult = _matcher.Match(a, b, SymbolMatchOptions.Strict);
// Assert - Strict should reject matches that default accepts
// (or at least have different confidence thresholds)
strictResult.Confidence.Should().BeLessThanOrEqualTo(defaultResult.Confidence);
}
[Fact]
public void Match_CompatibleTypes_MatchesSuccessfully()
{
// Arrange - System.String vs string
var a = CanonicalSymbol.Create(
"org.example", "service", "process", "(string)", SymbolSource.Roslyn);
var b = CanonicalSymbol.Create(
"org.example", "service", "process", "(system.string)", SymbolSource.ILMetadata);
// Act - These should be fuzzy match since signatures differ but types are compatible
var result = _matcher.Match(a, b);
// Assert
result.MatchType.Should().Be(SymbolMatchType.Fuzzy);
result.Confidence.Should().BeGreaterThanOrEqualTo(0.7);
}
[Fact]
public void Match_Symmetric_SameResultBothDirections()
{
// Arrange
var a = CanonicalSymbol.Create(
"org.example", "service", "process", "(string, int)", SymbolSource.Roslyn);
var b = CanonicalSymbol.Create(
"org.example", "service", "process", "(string)", SymbolSource.Roslyn);
// Act
var resultAB = _matcher.Match(a, b);
var resultBA = _matcher.Match(b, a);
// Assert
resultAB.MatchType.Should().Be(resultBA.MatchType);
resultAB.Confidence.Should().Be(resultBA.Confidence);
}
[Fact]
public void Match_EmptySignatures_Matches()
{
// Arrange - Both have empty signatures
var a = CanonicalSymbol.Create(
"org.example", "service", "run", "()", SymbolSource.Roslyn);
var b = CanonicalSymbol.Create(
"org.example", "service", "run", "()", SymbolSource.JavaAsm);
// Act
var result = _matcher.Match(a, b);
// Assert
result.MatchType.Should().Be(SymbolMatchType.Exact);
}
}