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

@@ -16,6 +16,14 @@ public interface IEventSigner
/// <returns>The DSSE signature string.</returns>
string Sign(TimelineEvent timelineEvent);
/// <summary>
/// Signs binary content asynchronously and returns the signature.
/// </summary>
/// <param name="content">The content to sign.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The signature string.</returns>
Task<string> SignAsync(byte[] content, CancellationToken cancellationToken = default);
/// <summary>
/// Verifies a timeline event signature.
/// </summary>

View File

@@ -38,7 +38,7 @@ public class InMemoryEvidenceStoreTests
var result = await _store.StoreAsync(evidence);
Assert.Equal(evidence.EvidenceId, result);
Assert.Single(_store);
Assert.Equal(1, _store.Count);
}
[Trait("Category", TestCategories.Unit)]
@@ -50,7 +50,7 @@ public class InMemoryEvidenceStoreTests
await _store.StoreAsync(evidence);
await _store.StoreAsync(evidence);
Assert.Single(_store);
Assert.Equal(1, _store.Count);
}
[Trait("Category", TestCategories.Unit)]
@@ -99,7 +99,7 @@ public class InMemoryEvidenceStoreTests
var count = await _store.StoreBatchAsync([]);
Assert.Equal(0, count);
Assert.Empty(_store);
Assert.Equal(0, _store.Count);
}
#endregion
@@ -283,7 +283,7 @@ public class InMemoryEvidenceStoreTests
var deleted = await _store.DeleteAsync(evidence.EvidenceId);
Assert.True(deleted);
Assert.Empty(_store);
Assert.Equal(0, _store.Count);
}
[Trait("Category", TestCategories.Unit)]
@@ -348,7 +348,7 @@ public class InMemoryEvidenceStoreTests
_store.Clear();
Assert.Empty(_store);
Assert.Equal(0, _store.Count);
}
#endregion

View File

@@ -0,0 +1,10 @@
# ReachGraph Core Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0105-M | DONE | Revalidated 2026-01-08; maintainability audit for ReachGraph core. |
| AUDIT-0105-T | DONE | Revalidated 2026-01-08; test coverage audit for ReachGraph core. |
| AUDIT-0105-A | TODO | Pending approval (revalidated 2026-01-08). |

View File

@@ -0,0 +1,227 @@
// <copyright file="ConfidenceCalculatorTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using Xunit;
namespace StellaOps.Reachability.Core.Tests;
/// <summary>
/// Unit tests for <see cref="ConfidenceCalculator"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ConfidenceCalculatorTests
{
private readonly ConfidenceCalculator _calculator = new();
[Theory]
[InlineData(LatticeState.Unknown, 0.00, 0.29)]
[InlineData(LatticeState.StaticReachable, 0.30, 0.49)]
[InlineData(LatticeState.StaticUnreachable, 0.50, 0.69)]
[InlineData(LatticeState.RuntimeObserved, 0.70, 0.89)]
[InlineData(LatticeState.RuntimeUnobserved, 0.70, 0.89)]
[InlineData(LatticeState.ConfirmedReachable, 0.90, 1.00)]
[InlineData(LatticeState.ConfirmedUnreachable, 0.90, 1.00)]
[InlineData(LatticeState.Contested, 0.00, 0.00)]
public void GetConfidenceRange_Returns_Correct_Range(LatticeState state, double expectedMin, double expectedMax)
{
// Act
var (min, max) = ConfidenceCalculator.GetConfidenceRange(state);
// Assert
min.Should().Be(expectedMin);
max.Should().Be(expectedMax);
}
[Fact]
public void Calculate_Unknown_Returns_Value_In_Range()
{
// Act
var confidence = _calculator.Calculate(LatticeState.Unknown, 0.5);
// Assert
confidence.Should().BeInRange(0.00, 0.29);
}
[Fact]
public void Calculate_ConfirmedReachable_Returns_High_Confidence()
{
// Act
var confidence = _calculator.Calculate(LatticeState.ConfirmedReachable, 0.5);
// Assert
confidence.Should().BeInRange(0.90, 1.00);
}
[Fact]
public void Calculate_WithStaticResult_PathCount_Increases_Confidence()
{
// Arrange
var baseResult = CreateStaticResult(isReachable: true, pathCount: 1);
var highPathResult = CreateStaticResult(isReachable: true, pathCount: 10);
// Act
var baseConfidence = _calculator.Calculate(
LatticeState.StaticReachable, baseResult, null);
var highPathConfidence = _calculator.Calculate(
LatticeState.StaticReachable, highPathResult, null);
// Assert
highPathConfidence.Should().BeGreaterThan(baseConfidence);
}
[Fact]
public void Calculate_WithGuards_Decreases_Confidence()
{
// Arrange
var noGuards = CreateStaticResult(isReachable: true, pathCount: 5, guardCount: 0);
var withGuards = CreateStaticResult(isReachable: true, pathCount: 5, guardCount: 2);
// Act
var noGuardConfidence = _calculator.Calculate(
LatticeState.StaticReachable, noGuards, null);
var withGuardConfidence = _calculator.Calculate(
LatticeState.StaticReachable, withGuards, null);
// Assert
withGuardConfidence.Should().BeLessThan(noGuardConfidence);
}
[Fact]
public void Calculate_WithRuntimeResult_HitCount_Increases_Confidence()
{
// Arrange
var lowHits = CreateRuntimeResult(wasObserved: true, hitCount: 1);
var highHits = CreateRuntimeResult(wasObserved: true, hitCount: 10000);
// Act
var lowHitConfidence = _calculator.Calculate(
LatticeState.RuntimeObserved, null, lowHits);
var highHitConfidence = _calculator.Calculate(
LatticeState.RuntimeObserved, null, highHits);
// Assert
highHitConfidence.Should().BeGreaterThan(lowHitConfidence);
}
[Fact]
public void Calculate_MultipleEnvironments_Increases_Confidence()
{
// Arrange
var singleEnv = CreateRuntimeResult(wasObserved: true, hitCount: 100, environmentCount: 1);
var multiEnv = CreateRuntimeResult(wasObserved: true, hitCount: 100, environmentCount: 3);
// Act
var singleConfidence = _calculator.Calculate(
LatticeState.RuntimeObserved, null, singleEnv);
var multiConfidence = _calculator.Calculate(
LatticeState.RuntimeObserved, null, multiEnv);
// Assert
multiConfidence.Should().BeGreaterThan(singleConfidence);
}
[Fact]
public void Calculate_Confidence_Always_Within_State_Range()
{
// Arrange
var staticResult = CreateStaticResult(isReachable: true, pathCount: 100);
var runtimeResult = CreateRuntimeResult(wasObserved: true, hitCount: 1000000, environmentCount: 10);
// Act
var confidence = _calculator.Calculate(
LatticeState.RuntimeObserved, staticResult, runtimeResult);
// Assert
var (min, max) = ConfidenceCalculator.GetConfidenceRange(LatticeState.RuntimeObserved);
confidence.Should().BeInRange(min, max);
}
[Fact]
public void Calculate_Contested_Returns_Zero()
{
// Act
var confidence = _calculator.Calculate(LatticeState.Contested, 0.5);
// Assert
confidence.Should().Be(0.0);
}
[Fact]
public void CustomWeights_Affect_Calculation()
{
// Arrange - default pathCountBonus is 0.02, custom is 0.05
// With pathCount=5, default adds 5*0.02=0.1 bonus, custom adds 5*0.05=0.25 (capped)
var customWeights = new ConfidenceWeights
{
PathCountBonus = 0.05,
MaxPathBonus = 0.20
};
var customCalculator = new ConfidenceCalculator(customWeights);
var defaultCalculator = new ConfidenceCalculator();
var staticResult = CreateStaticResult(isReachable: true, pathCount: 5);
// Act
var customConfidence = customCalculator.Calculate(
LatticeState.StaticReachable, staticResult, null);
var defaultConfidence = defaultCalculator.Calculate(
LatticeState.StaticReachable, staticResult, null);
// Assert - both should be in StaticReachable range (0.30-0.49) but custom should be higher
// due to larger path count bonus
customConfidence.Should().BeGreaterThanOrEqualTo(defaultConfidence);
}
private static StaticReachabilityResult CreateStaticResult(
bool isReachable,
int pathCount,
int guardCount = 0)
{
var guards = Enumerable.Range(0, guardCount)
.Select(i => new GuardCondition { Type = "FeatureFlag", Key = $"flag_{i}" })
.ToImmutableArray();
return new StaticReachabilityResult
{
Symbol = CreateTestSymbol(),
ArtifactDigest = "sha256:abc123",
IsReachable = isReachable,
PathCount = pathCount,
Guards = guards,
AnalyzedAt = DateTimeOffset.UtcNow
};
}
private static RuntimeReachabilityResult CreateRuntimeResult(
bool wasObserved,
long hitCount,
int environmentCount = 1)
{
var contexts = Enumerable.Range(0, environmentCount)
.Select(i => new ExecutionContext { Environment = $"env_{i}" })
.ToImmutableArray();
return new RuntimeReachabilityResult
{
Symbol = CreateTestSymbol(),
ArtifactDigest = "sha256:abc123",
WasObserved = wasObserved,
HitCount = hitCount,
Contexts = contexts,
ObservationWindow = TimeSpan.FromDays(7),
WindowStart = DateTimeOffset.UtcNow.AddDays(-7),
WindowEnd = DateTimeOffset.UtcNow
};
}
private static SymbolRef CreateTestSymbol() => new()
{
Purl = "pkg:npm/lodash@4.17.21",
Namespace = "lodash",
Type = "_",
Method = "get"
};
}

View File

@@ -0,0 +1,217 @@
// <copyright file="EvidenceUriBuilderTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using FluentAssertions;
using Xunit;
namespace StellaOps.Reachability.Core.Tests;
/// <summary>
/// Unit tests for <see cref="EvidenceUriBuilder"/> and <see cref="EvidenceUri"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class EvidenceUriBuilderTests
{
private readonly EvidenceUriBuilder _builder = new();
[Fact]
public void BuildReachGraphUri_Returns_Valid_Uri()
{
// Act
var uri = _builder.BuildReachGraphUri("sha256:abc123");
// Assert
uri.Should().Be("stella://reachgraph/sha256:abc123");
}
[Fact]
public void BuildReachGraphUri_Adds_Sha256_Prefix_If_Missing()
{
// Act
var uri = _builder.BuildReachGraphUri("abc123");
// Assert
uri.Should().Be("stella://reachgraph/sha256:abc123");
}
[Fact]
public void BuildReachGraphSliceUri_Returns_Uri_With_Symbol()
{
// Act
var uri = _builder.BuildReachGraphSliceUri("sha256:abc123", "symbol_id_123");
// Assert
uri.Should().StartWith("stella://reachgraph/sha256:abc123/slice?symbol=");
uri.Should().Contain("symbol_id_123");
}
[Fact]
public void BuildReachGraphSliceUri_Escapes_SymbolId()
{
// Arrange
var symbolWithSpecialChars = "System.IO/File.Read()";
// Act
var uri = _builder.BuildReachGraphSliceUri("sha256:abc123", symbolWithSpecialChars);
// Assert
uri.Should().NotContain("/File"); // Should be escaped
uri.Should().Contain(Uri.EscapeDataString(symbolWithSpecialChars));
}
[Fact]
public void BuildRuntimeFactsUri_Returns_Uri_With_Tenant()
{
// Act
var uri = _builder.BuildRuntimeFactsUri("tenant1", "sha256:abc123");
// Assert
uri.Should().Be("stella://signals/runtime/tenant1/sha256:abc123");
}
[Fact]
public void BuildRuntimeFactsUri_WithSymbol_Returns_Full_Uri()
{
// Act
var uri = _builder.BuildRuntimeFactsUri("tenant1", "sha256:abc123", "symbol_id");
// Assert
uri.Should().Be("stella://signals/runtime/tenant1/sha256:abc123?symbol=symbol_id");
}
[Fact]
public void BuildRuntimeFactsUri_Escapes_TenantId()
{
// Arrange
var tenantWithSpace = "tenant with space";
// Act
var uri = _builder.BuildRuntimeFactsUri(tenantWithSpace, "sha256:abc123");
// Assert
uri.Should().Contain(Uri.EscapeDataString(tenantWithSpace));
uri.Should().NotContain(" ");
}
[Fact]
public void BuildCveMappingUri_Returns_Valid_Uri()
{
// Act
var uri = _builder.BuildCveMappingUri("CVE-2024-1234");
// Assert
uri.Should().Be("stella://cvemap/CVE-2024-1234");
}
[Fact]
public void BuildAttestationUri_Returns_Valid_Uri()
{
// Act
var uri = _builder.BuildAttestationUri("sha256:attestation_digest");
// Assert
uri.Should().Be("stella://attestation/sha256:attestation_digest");
}
[Fact]
public void BuildHybridResultUri_Returns_Valid_Uri()
{
// Act
var uri = _builder.BuildHybridResultUri("sha256:result_digest");
// Assert
uri.Should().Be("stella://hybrid/sha256:result_digest");
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void BuildReachGraphUri_Throws_On_Invalid_Digest(string? digest)
{
// Act & Assert
var act = () => _builder.BuildReachGraphUri(digest!);
act.Should().Throw<ArgumentException>();
}
// EvidenceUri parsing tests
[Fact]
public void Parse_Valid_ReachGraph_Uri()
{
// Act
var parsed = EvidenceUri.Parse("stella://reachgraph/sha256:abc123");
// Assert
parsed.Should().NotBeNull();
parsed!.Scheme.Should().Be("stella");
parsed.Type.Should().Be("reachgraph");
parsed.Segments.Should().HaveCount(1);
parsed.Segments[0].Should().Be("sha256:abc123");
}
[Fact]
public void Parse_Uri_With_Query_Parameters()
{
// Act
var parsed = EvidenceUri.Parse("stella://reachgraph/sha256:abc123/slice?symbol=test_symbol");
// Assert
parsed.Should().NotBeNull();
parsed!.Type.Should().Be("reachgraph");
parsed.Segments.Should().Contain("slice");
parsed.Query.Should().ContainKey("symbol");
parsed.Query["symbol"].Should().Be("test_symbol");
}
[Fact]
public void Parse_Uri_With_Multiple_Query_Parameters()
{
// Act
var parsed = EvidenceUri.Parse("stella://signals/runtime/tenant?symbol=sym&version=1");
// Assert
parsed.Should().NotBeNull();
parsed!.Query.Should().HaveCount(2);
parsed.Query["symbol"].Should().Be("sym");
parsed.Query["version"].Should().Be("1");
}
[Fact]
public void Parse_Decodes_Escaped_Values()
{
// Act
var parsed = EvidenceUri.Parse("stella://cvemap/CVE%2D2024%2D1234");
// Assert
parsed.Should().NotBeNull();
// Note: path segments aren't automatically decoded in this implementation
// but query params are
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("http://example.com")]
[InlineData("stella://")]
public void Parse_Returns_Null_For_Invalid_Uris(string? uri)
{
// Act
var parsed = EvidenceUri.Parse(uri!);
// Assert
parsed.Should().BeNull();
}
[Fact]
public void Parse_Is_Case_Insensitive_For_Scheme()
{
// Act
var parsed = EvidenceUri.Parse("STELLA://reachgraph/sha256:abc");
// Assert
parsed.Should().NotBeNull();
parsed!.Type.Should().Be("reachgraph");
}
}

View File

@@ -0,0 +1,184 @@
// <copyright file="HybridReachabilityResultTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using Xunit;
namespace StellaOps.Reachability.Core.Tests;
/// <summary>
/// Unit tests for <see cref="HybridReachabilityResult"/> and related types.
/// </summary>
[Trait("Category", "Unit")]
public sealed class HybridReachabilityResultTests
{
[Fact]
public void ContentDigest_Is_Deterministic()
{
// Arrange
var now = DateTimeOffset.Parse("2026-01-09T12:00:00Z");
var result1 = CreateResult(now);
var result2 = CreateResult(now);
// Act & Assert
result1.ContentDigest.Should().Be(result2.ContentDigest);
}
[Fact]
public void ContentDigest_Has_Sha256_Prefix()
{
// Arrange
var result = CreateResult(DateTimeOffset.UtcNow);
// Act & Assert
result.ContentDigest.Should().StartWith("sha256:");
result.ContentDigest.Should().HaveLength(7 + 64); // sha256: + 64 hex chars
}
[Fact]
public void ContentDigest_Changes_With_LatticeState()
{
// Arrange
var now = DateTimeOffset.UtcNow;
var result1 = CreateResult(now, LatticeState.ConfirmedReachable);
var result2 = CreateResult(now, LatticeState.ConfirmedUnreachable);
// Act & Assert
result1.ContentDigest.Should().NotBe(result2.ContentDigest);
}
[Fact]
public void ContentDigest_Changes_With_Confidence()
{
// Arrange
var now = DateTimeOffset.UtcNow;
var result1 = CreateResult(now, confidence: 0.5);
var result2 = CreateResult(now, confidence: 0.9);
// Act & Assert
result1.ContentDigest.Should().NotBe(result2.ContentDigest);
}
[Fact]
public void VerdictRecommendation_Affected_Has_Correct_Status()
{
// Act
var verdict = VerdictRecommendation.Affected("Test notes");
// Assert
verdict.VexStatus.Should().Be("affected");
verdict.StatusNotes.Should().Be("Test notes");
verdict.Justification.Should().BeNull();
verdict.RequiresManualReview.Should().BeFalse();
}
[Fact]
public void VerdictRecommendation_NotAffectedUnreachable_Has_Correct_Justification()
{
// Act
var verdict = VerdictRecommendation.NotAffectedUnreachable("Test notes");
// Assert
verdict.VexStatus.Should().Be("not_affected");
verdict.Justification.Should().Be("vulnerable_code_not_in_execute_path");
verdict.StatusNotes.Should().Be("Test notes");
}
[Fact]
public void VerdictRecommendation_UnderInvestigation_Has_Correct_Status()
{
// Act
var verdict = VerdictRecommendation.UnderInvestigation("Analyzing...");
// Assert
verdict.VexStatus.Should().Be("under_investigation");
verdict.StatusNotes.Should().Be("Analyzing...");
verdict.RequiresManualReview.Should().BeFalse();
}
[Fact]
public void VerdictRecommendation_NeedsReview_Sets_ManualReviewFlag()
{
// Act
var verdict = VerdictRecommendation.NeedsReview("Conflict detected");
// Assert
verdict.VexStatus.Should().Be("under_investigation");
verdict.RequiresManualReview.Should().BeTrue();
}
[Fact]
public void EvidenceBundle_Empty_Has_No_Uris()
{
// Act
var bundle = EvidenceBundle.Empty(DateTimeOffset.UtcNow);
// Assert
bundle.Uris.Should().BeEmpty();
bundle.Static.Should().BeNull();
bundle.Runtime.Should().BeNull();
}
[Fact]
public void StaticEvidence_Contains_Required_Fields()
{
// Arrange
var evidence = new StaticEvidence
{
ReachGraphDigest = "sha256:abc123",
SliceUri = "stella://reachgraph/sha256:abc123/slice?symbol=test"
};
// Assert
evidence.ReachGraphDigest.Should().Be("sha256:abc123");
evidence.SliceUri.Should().Contain("stella://");
evidence.CallPath.Should().BeEmpty();
}
[Fact]
public void RuntimeEvidence_Contains_Required_Fields()
{
// Arrange
var evidence = new RuntimeEvidence
{
FactsUri = "stella://signals/runtime/tenant/sha256:abc123",
ObservationWindow = TimeSpan.FromDays(7)
};
// Assert
evidence.FactsUri.Should().Contain("stella://");
evidence.ObservationWindow.Should().Be(TimeSpan.FromDays(7));
evidence.SampleTraceIds.Should().BeEmpty();
}
private static HybridReachabilityResult CreateResult(
DateTimeOffset computedAt,
LatticeState state = LatticeState.ConfirmedReachable,
double confidence = 0.95)
{
var symbol = new SymbolRef
{
Purl = "pkg:npm/lodash@4.17.21",
Namespace = "lodash",
Type = "_",
Method = "get"
};
return new HybridReachabilityResult
{
Symbol = symbol,
ArtifactDigest = "sha256:abc123",
LatticeState = state,
Confidence = confidence,
Verdict = VerdictRecommendation.Affected(),
Evidence = new EvidenceBundle
{
Uris = ImmutableArray.Create("stella://reachgraph/sha256:abc123"),
CollectedAt = computedAt
},
ComputedAt = computedAt
};
}
}

View File

@@ -0,0 +1,303 @@
// <copyright file="ReachabilityLatticeTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using FluentAssertions;
using Xunit;
namespace StellaOps.Reachability.Core.Tests;
/// <summary>
/// Unit tests for <see cref="ReachabilityLattice"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ReachabilityLatticeTests
{
[Fact]
public void Initial_State_Is_Unknown()
{
// Arrange & Act
var lattice = new ReachabilityLattice();
// Assert
lattice.CurrentState.Should().Be(LatticeState.Unknown);
lattice.Confidence.Should().Be(0.0);
}
[Fact]
public void ApplyEvidence_StaticReachable_TransitionsToStaticReachable()
{
// Arrange
var lattice = new ReachabilityLattice();
// Act
var transition = lattice.ApplyEvidence(EvidenceType.StaticReachable);
// Assert
transition.Should().NotBeNull();
transition!.ToState.Should().Be(LatticeState.StaticReachable);
lattice.CurrentState.Should().Be(LatticeState.StaticReachable);
lattice.Confidence.Should().BeGreaterThan(0);
}
[Fact]
public void ApplyEvidence_StaticUnreachable_TransitionsToStaticUnreachable()
{
// Arrange
var lattice = new ReachabilityLattice();
// Act
var transition = lattice.ApplyEvidence(EvidenceType.StaticUnreachable);
// Assert
transition.Should().NotBeNull();
transition!.ToState.Should().Be(LatticeState.StaticUnreachable);
lattice.CurrentState.Should().Be(LatticeState.StaticUnreachable);
}
[Fact]
public void ApplyEvidence_RuntimeObserved_TransitionsToRuntimeObserved()
{
// Arrange
var lattice = new ReachabilityLattice();
// Act
var transition = lattice.ApplyEvidence(EvidenceType.RuntimeObserved);
// Assert
transition.Should().NotBeNull();
transition!.ToState.Should().Be(LatticeState.RuntimeObserved);
lattice.CurrentState.Should().Be(LatticeState.RuntimeObserved);
}
[Fact]
public void StaticReachable_Plus_RuntimeObserved_ConfirmsReachable()
{
// Arrange
var lattice = new ReachabilityLattice();
// Act
lattice.ApplyEvidence(EvidenceType.StaticReachable);
var transition = lattice.ApplyEvidence(EvidenceType.RuntimeObserved);
// Assert
transition.Should().NotBeNull();
transition!.ToState.Should().Be(LatticeState.ConfirmedReachable);
lattice.CurrentState.Should().Be(LatticeState.ConfirmedReachable);
}
[Fact]
public void StaticUnreachable_Plus_RuntimeUnobserved_ConfirmsUnreachable()
{
// Arrange
var lattice = new ReachabilityLattice();
// Act
lattice.ApplyEvidence(EvidenceType.StaticUnreachable);
var transition = lattice.ApplyEvidence(EvidenceType.RuntimeUnobserved);
// Assert
transition.Should().NotBeNull();
transition!.ToState.Should().Be(LatticeState.ConfirmedUnreachable);
lattice.CurrentState.Should().Be(LatticeState.ConfirmedUnreachable);
}
[Fact]
public void StaticUnreachable_Plus_RuntimeObserved_CreatesContest()
{
// Arrange
var lattice = new ReachabilityLattice();
// Act
lattice.ApplyEvidence(EvidenceType.StaticUnreachable);
var transition = lattice.ApplyEvidence(EvidenceType.RuntimeObserved);
// Assert - conflict detected
transition.Should().NotBeNull();
transition!.ToState.Should().Be(LatticeState.Contested);
lattice.CurrentState.Should().Be(LatticeState.Contested);
}
[Fact]
public void RuntimeObserved_Plus_StaticUnreachable_CreatesContest()
{
// Arrange
var lattice = new ReachabilityLattice();
// Act
lattice.ApplyEvidence(EvidenceType.RuntimeObserved);
var transition = lattice.ApplyEvidence(EvidenceType.StaticUnreachable);
// Assert - conflict detected
transition.Should().NotBeNull();
transition!.ToState.Should().Be(LatticeState.Contested);
lattice.CurrentState.Should().Be(LatticeState.Contested);
}
[Fact]
public void Contested_State_Stays_Contested()
{
// Arrange
var lattice = new ReachabilityLattice();
lattice.ApplyEvidence(EvidenceType.StaticUnreachable);
lattice.ApplyEvidence(EvidenceType.RuntimeObserved);
// Act - try more evidence
var transition1 = lattice.ApplyEvidence(EvidenceType.StaticReachable);
var transition2 = lattice.ApplyEvidence(EvidenceType.RuntimeUnobserved);
// Assert - stays contested
transition1!.ToState.Should().Be(LatticeState.Contested);
transition2!.ToState.Should().Be(LatticeState.Contested);
lattice.CurrentState.Should().Be(LatticeState.Contested);
}
[Fact]
public void Confidence_Is_Clamped_To_Valid_Range()
{
// Arrange
var lattice = new ReachabilityLattice();
// Act - apply conflicting evidence that might cause negative confidence
lattice.ApplyEvidence(EvidenceType.StaticUnreachable);
lattice.ApplyEvidence(EvidenceType.RuntimeObserved);
// Assert
lattice.Confidence.Should().BeGreaterThanOrEqualTo(0.0);
lattice.Confidence.Should().BeLessThanOrEqualTo(1.0);
}
[Fact]
public void Reset_Returns_To_Unknown()
{
// Arrange
var lattice = new ReachabilityLattice();
lattice.ApplyEvidence(EvidenceType.StaticReachable);
lattice.ApplyEvidence(EvidenceType.RuntimeObserved);
// Act
lattice.Reset();
// Assert
lattice.CurrentState.Should().Be(LatticeState.Unknown);
lattice.Confidence.Should().Be(0.0);
}
[Fact]
public void Combine_StaticReachable_RuntimeObserved_Returns_ConfirmedReachable()
{
// Arrange
var staticResult = new StaticReachabilityResult
{
Symbol = CreateTestSymbol(),
ArtifactDigest = "sha256:abc123",
IsReachable = true,
AnalyzedAt = DateTimeOffset.UtcNow
};
var runtimeResult = new RuntimeReachabilityResult
{
Symbol = CreateTestSymbol(),
ArtifactDigest = "sha256:abc123",
WasObserved = true,
ObservationWindow = TimeSpan.FromDays(7),
WindowStart = DateTimeOffset.UtcNow.AddDays(-7),
WindowEnd = DateTimeOffset.UtcNow,
HitCount = 100
};
// Act
var (state, confidence) = ReachabilityLattice.Combine(staticResult, runtimeResult);
// Assert
state.Should().Be(LatticeState.ConfirmedReachable);
confidence.Should().BeGreaterThan(0);
}
[Fact]
public void Combine_StaticUnreachable_RuntimeUnobserved_Returns_ConfirmedUnreachable()
{
// Arrange
var staticResult = new StaticReachabilityResult
{
Symbol = CreateTestSymbol(),
ArtifactDigest = "sha256:abc123",
IsReachable = false,
AnalyzedAt = DateTimeOffset.UtcNow
};
var runtimeResult = new RuntimeReachabilityResult
{
Symbol = CreateTestSymbol(),
ArtifactDigest = "sha256:abc123",
WasObserved = false,
ObservationWindow = TimeSpan.FromDays(7),
WindowStart = DateTimeOffset.UtcNow.AddDays(-7),
WindowEnd = DateTimeOffset.UtcNow
};
// Act
var (state, confidence) = ReachabilityLattice.Combine(staticResult, runtimeResult);
// Assert
state.Should().Be(LatticeState.ConfirmedUnreachable);
confidence.Should().BeGreaterThan(0);
}
[Fact]
public void Combine_NullStatic_RuntimeObserved_Returns_RuntimeObserved()
{
// Arrange
var runtimeResult = new RuntimeReachabilityResult
{
Symbol = CreateTestSymbol(),
ArtifactDigest = "sha256:abc123",
WasObserved = true,
ObservationWindow = TimeSpan.FromDays(7),
WindowStart = DateTimeOffset.UtcNow.AddDays(-7),
WindowEnd = DateTimeOffset.UtcNow
};
// Act
var (state, _) = ReachabilityLattice.Combine(null, runtimeResult);
// Assert
state.Should().Be(LatticeState.RuntimeObserved);
}
[Fact]
public void Combine_StaticReachable_NullRuntime_Returns_StaticReachable()
{
// Arrange
var staticResult = new StaticReachabilityResult
{
Symbol = CreateTestSymbol(),
ArtifactDigest = "sha256:abc123",
IsReachable = true,
AnalyzedAt = DateTimeOffset.UtcNow
};
// Act
var (state, _) = ReachabilityLattice.Combine(staticResult, null);
// Assert
state.Should().Be(LatticeState.StaticReachable);
}
[Fact]
public void Combine_BothNull_Returns_Unknown()
{
// Act
var (state, confidence) = ReachabilityLattice.Combine(null, null);
// Assert
state.Should().Be(LatticeState.Unknown);
confidence.Should().Be(0);
}
private static SymbolRef CreateTestSymbol() => new()
{
Purl = "pkg:npm/lodash@4.17.21",
Namespace = "lodash",
Type = "_",
Method = "get"
};
}

View File

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Reachability.Core\StellaOps.Reachability.Core.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="FluentAssertions" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,212 @@
// <copyright file="SymbolRefTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using FluentAssertions;
using Xunit;
namespace StellaOps.Reachability.Core.Tests;
/// <summary>
/// Unit tests for <see cref="SymbolRef"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class SymbolRefTests
{
[Fact]
public void CanonicalId_Is_Deterministic()
{
// Arrange
var symbol1 = new SymbolRef
{
Purl = "pkg:npm/lodash@4.17.21",
Namespace = "lodash",
Type = "_",
Method = "get"
};
var symbol2 = new SymbolRef
{
Purl = "pkg:npm/lodash@4.17.21",
Namespace = "lodash",
Type = "_",
Method = "get"
};
// Act & Assert
symbol1.CanonicalId.Should().Be(symbol2.CanonicalId);
}
[Fact]
public void CanonicalId_Is_64_Char_Hex()
{
// Arrange
var symbol = new SymbolRef
{
Purl = "pkg:npm/lodash@4.17.21",
Namespace = "lodash",
Type = "_",
Method = "get"
};
// Act & Assert
symbol.CanonicalId.Should().HaveLength(64);
symbol.CanonicalId.Should().MatchRegex("^[a-f0-9]+$");
}
[Fact]
public void Different_Symbols_Have_Different_CanonicalIds()
{
// Arrange
var symbol1 = new SymbolRef
{
Purl = "pkg:npm/lodash@4.17.21",
Namespace = "lodash",
Type = "_",
Method = "get"
};
var symbol2 = new SymbolRef
{
Purl = "pkg:npm/lodash@4.17.21",
Namespace = "lodash",
Type = "_",
Method = "set"
};
// Act & Assert
symbol1.CanonicalId.Should().NotBe(symbol2.CanonicalId);
}
[Fact]
public void DisplayName_Without_Type_Excludes_Underscore()
{
// Arrange
var symbol = new SymbolRef
{
Purl = "pkg:npm/lodash@4.17.21",
Namespace = "lodash",
Type = "_",
Method = "get"
};
// Act & Assert
symbol.DisplayName.Should().Be("lodash.get");
}
[Fact]
public void DisplayName_With_Type_Includes_Type()
{
// Arrange
var symbol = new SymbolRef
{
Purl = "pkg:nuget/System.Text.Json@8.0.0",
Namespace = "System.Text.Json",
Type = "JsonSerializer",
Method = "Serialize",
Signature = "(object)"
};
// Act & Assert
symbol.DisplayName.Should().Be("System.Text.Json.JsonSerializer.Serialize(object)");
}
[Fact]
public void FromFullyQualified_Parses_Simple_Name()
{
// Act
var symbol = SymbolRef.FromFullyQualified("pkg:npm/lodash@4.17.21", "get");
// Assert
symbol.Namespace.Should().Be("_");
symbol.Type.Should().Be("_");
symbol.Method.Should().Be("get");
}
[Fact]
public void FromFullyQualified_Parses_With_Namespace()
{
// Act
var symbol = SymbolRef.FromFullyQualified("pkg:npm/lodash@4.17.21", "lodash.get");
// Assert
symbol.Namespace.Should().Be("lodash");
symbol.Type.Should().Be("_");
symbol.Method.Should().Be("get");
}
[Fact]
public void FromFullyQualified_Parses_With_Type()
{
// Act
var symbol = SymbolRef.FromFullyQualified(
"pkg:nuget/System.Text.Json@8.0.0",
"System.Text.Json.JsonSerializer.Serialize");
// Assert
symbol.Namespace.Should().Be("System.Text.Json");
symbol.Type.Should().Be("JsonSerializer");
symbol.Method.Should().Be("Serialize");
}
[Fact]
public void FromFullyQualified_Extracts_Signature()
{
// Act
var symbol = SymbolRef.FromFullyQualified(
"pkg:nuget/System.Text.Json@8.0.0",
"System.Text.Json.JsonSerializer.Serialize(object, Type)");
// Assert
symbol.Method.Should().Be("Serialize");
symbol.Signature.Should().Be("(object, Type)");
}
[Fact]
public void FromFullyQualified_Handles_Empty_Signature()
{
// Act
var symbol = SymbolRef.FromFullyQualified(
"pkg:npm/lodash@4.17.21",
"lodash.get");
// Assert
symbol.Signature.Should().BeEmpty();
}
[Theory]
[InlineData(null, "test")]
[InlineData("", "test")]
[InlineData(" ", "test")]
[InlineData("pkg:npm/test@1.0.0", null)]
[InlineData("pkg:npm/test@1.0.0", "")]
[InlineData("pkg:npm/test@1.0.0", " ")]
public void FromFullyQualified_Throws_On_Invalid_Input(string? purl, string? name)
{
// Act & Assert
var act = () => SymbolRef.FromFullyQualified(purl!, name!);
act.Should().Throw<ArgumentException>();
}
[Fact]
public void Equality_Works_Correctly()
{
// Arrange
var symbol1 = new SymbolRef
{
Purl = "pkg:npm/lodash@4.17.21",
Namespace = "lodash",
Type = "_",
Method = "get"
};
var symbol2 = new SymbolRef
{
Purl = "pkg:npm/lodash@4.17.21",
Namespace = "lodash",
Type = "_",
Method = "get"
};
// Act & Assert
symbol1.Should().Be(symbol2);
(symbol1 == symbol2).Should().BeTrue();
}
}

View File

@@ -0,0 +1,152 @@
// <copyright file="ConfidenceCalculator.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Globalization;
namespace StellaOps.Reachability.Core;
/// <summary>
/// Calculates evidence-weighted confidence scores for reachability results.
/// </summary>
public sealed class ConfidenceCalculator
{
private readonly ConfidenceWeights _weights;
/// <summary>
/// Initializes a new instance of the <see cref="ConfidenceCalculator"/> class.
/// </summary>
/// <param name="weights">Confidence weight configuration.</param>
public ConfidenceCalculator(ConfidenceWeights? weights = null)
{
_weights = weights ?? ConfidenceWeights.Default;
}
/// <summary>
/// Calculates confidence from lattice state and accumulated evidence.
/// </summary>
/// <param name="state">The lattice state.</param>
/// <param name="accumulatedConfidence">Raw confidence from transitions.</param>
/// <returns>Normalized confidence score (0.0-1.0).</returns>
public double Calculate(LatticeState state, double accumulatedConfidence)
{
var (rangeMin, rangeMax) = GetConfidenceRange(state);
var rangeSize = rangeMax - rangeMin;
// Map accumulated confidence to the state's range
var normalizedAccumulated = Math.Clamp(accumulatedConfidence, 0.0, 1.0);
var confidence = rangeMin + (normalizedAccumulated * rangeSize);
return Math.Clamp(confidence, 0.0, 1.0);
}
/// <summary>
/// Calculates confidence from static and runtime results.
/// </summary>
public double Calculate(
LatticeState state,
StaticReachabilityResult? staticResult,
RuntimeReachabilityResult? runtimeResult)
{
var baseConfidence = GetBaseConfidence(state);
// Add bonus for supporting evidence
var bonus = 0.0;
if (staticResult is not null)
{
// More paths = higher confidence in reachability
bonus += Math.Min(staticResult.PathCount * _weights.PathCountBonus, _weights.MaxPathBonus);
// Guards reduce confidence (conditional reachability)
if (staticResult.Guards.Length > 0)
{
bonus -= staticResult.Guards.Length * _weights.GuardPenalty;
}
}
if (runtimeResult is not null && runtimeResult.WasObserved)
{
// More hits = higher confidence
var hitBonus = Math.Log10(Math.Max(1, runtimeResult.HitCount)) * _weights.HitCountBonus;
bonus += Math.Min(hitBonus, _weights.MaxHitBonus);
// Multiple environments increase confidence
if (runtimeResult.Contexts.Length > 1)
{
bonus += (runtimeResult.Contexts.Length - 1) * _weights.EnvironmentBonus;
}
}
var (rangeMin, rangeMax) = GetConfidenceRange(state);
var finalConfidence = Math.Clamp(baseConfidence + bonus, rangeMin, rangeMax);
return finalConfidence;
}
/// <summary>
/// Gets the confidence range for a lattice state.
/// </summary>
public static (double Min, double Max) GetConfidenceRange(LatticeState state)
{
return state switch
{
LatticeState.Unknown => (0.00, 0.29),
LatticeState.StaticReachable => (0.30, 0.49),
LatticeState.StaticUnreachable => (0.50, 0.69),
LatticeState.RuntimeObserved => (0.70, 0.89),
LatticeState.RuntimeUnobserved => (0.70, 0.89),
LatticeState.ConfirmedReachable => (0.90, 1.00),
LatticeState.ConfirmedUnreachable => (0.90, 1.00),
LatticeState.Contested => (0.00, 0.00), // N/A - requires manual review
_ => (0.00, 0.00)
};
}
private static double GetBaseConfidence(LatticeState state)
{
var (min, max) = GetConfidenceRange(state);
return (min + max) / 2.0; // Start at midpoint
}
}
/// <summary>
/// Configuration weights for confidence calculation.
/// </summary>
public sealed record ConfidenceWeights
{
/// <summary>
/// Gets the bonus per additional path found.
/// </summary>
public double PathCountBonus { get; init; } = 0.02;
/// <summary>
/// Gets the maximum bonus from path count.
/// </summary>
public double MaxPathBonus { get; init; } = 0.10;
/// <summary>
/// Gets the penalty per guard condition.
/// </summary>
public double GuardPenalty { get; init; } = 0.03;
/// <summary>
/// Gets the bonus per log10(hit count).
/// </summary>
public double HitCountBonus { get; init; } = 0.02;
/// <summary>
/// Gets the maximum bonus from hit count.
/// </summary>
public double MaxHitBonus { get; init; } = 0.10;
/// <summary>
/// Gets the bonus per additional environment observed.
/// </summary>
public double EnvironmentBonus { get; init; } = 0.03;
/// <summary>
/// Gets default weights.
/// </summary>
public static ConfidenceWeights Default { get; } = new();
}

View File

@@ -0,0 +1,162 @@
// <copyright file="CveSymbolMapping.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.Reachability.Core.CveMapping;
/// <summary>
/// Mapping from CVE identifier to vulnerable symbols.
/// Sprint: SPRINT_20260109_009_003 Task: Create CveSymbolMapping model
/// </summary>
public sealed record CveSymbolMapping
{
/// <summary>
/// CVE identifier (e.g., "CVE-2021-44228").
/// </summary>
public required string CveId { get; init; }
/// <summary>
/// Vulnerable symbols associated with this CVE.
/// </summary>
public required ImmutableArray<VulnerableSymbol> Symbols { get; init; }
/// <summary>
/// Primary source of this mapping.
/// </summary>
public required MappingSource Source { get; init; }
/// <summary>
/// Overall confidence score for this mapping (0.0 to 1.0).
/// </summary>
public required double Confidence { get; init; }
/// <summary>
/// When the mapping was extracted or last updated.
/// </summary>
public required DateTimeOffset ExtractedAt { get; init; }
/// <summary>
/// URL to the patch commit if available.
/// </summary>
public string? PatchCommitUrl { get; init; }
/// <summary>
/// Delta signature digest for binary matching.
/// </summary>
public string? DeltaSigDigest { get; init; }
/// <summary>
/// OSV advisory ID if enriched from OSV.
/// </summary>
public string? OsvAdvisoryId { get; init; }
/// <summary>
/// Package URLs (PURLs) of affected packages.
/// </summary>
public ImmutableArray<string> AffectedPurls { get; init; } = [];
/// <summary>
/// Content digest for deduplication and caching.
/// </summary>
public required string ContentDigest { get; init; }
/// <summary>
/// Creates a new mapping with computed content digest.
/// </summary>
public static CveSymbolMapping Create(
string cveId,
IEnumerable<VulnerableSymbol> symbols,
MappingSource source,
double confidence,
TimeProvider timeProvider,
string? patchCommitUrl = null,
string? osvAdvisoryId = null,
IEnumerable<string>? affectedPurls = null)
{
var symbolsArray = symbols.ToImmutableArray();
var purlsArray = affectedPurls?.ToImmutableArray() ?? [];
var extractedAt = timeProvider.GetUtcNow();
var contentDigest = ComputeContentDigest(cveId, symbolsArray, source);
return new CveSymbolMapping
{
CveId = cveId,
Symbols = symbolsArray,
Source = source,
Confidence = Math.Clamp(confidence, 0.0, 1.0),
ExtractedAt = extractedAt,
PatchCommitUrl = patchCommitUrl,
OsvAdvisoryId = osvAdvisoryId,
AffectedPurls = purlsArray,
ContentDigest = contentDigest
};
}
/// <summary>
/// Computes a deterministic content digest for the mapping.
/// </summary>
public static string ComputeContentDigest(
string cveId,
ImmutableArray<VulnerableSymbol> symbols,
MappingSource source)
{
// Sort symbol canonical IDs for determinism
var sortedIds = symbols
.Select(s => s.Symbol.CanonicalId)
.Order()
.ToList();
var content = new
{
cve = cveId.ToUpperInvariant(),
source = source.ToString(),
symbols = sortedIds
};
var json = JsonSerializer.Serialize(content);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return Convert.ToHexStringLower(hash);
}
/// <summary>
/// Merges another mapping into this one, combining symbols.
/// </summary>
public CveSymbolMapping Merge(CveSymbolMapping other, TimeProvider timeProvider)
{
if (!string.Equals(CveId, other.CveId, StringComparison.OrdinalIgnoreCase))
throw new ArgumentException($"Cannot merge mappings for different CVEs: {CveId} vs {other.CveId}");
// Combine symbols, deduplicating by canonical ID
var existingIds = Symbols.Select(s => s.Symbol.CanonicalId).ToHashSet();
var newSymbols = other.Symbols.Where(s => !existingIds.Contains(s.Symbol.CanonicalId));
var mergedSymbols = Symbols.AddRange(newSymbols);
// Combine PURLs
var mergedPurls = AffectedPurls.Union(other.AffectedPurls).ToImmutableArray();
// Take highest confidence
var mergedConfidence = Math.Max(Confidence, other.Confidence);
// Prefer patch commit URL if we don't have one
var patchUrl = PatchCommitUrl ?? other.PatchCommitUrl;
// Prefer OSV ID if we don't have one
var osvId = OsvAdvisoryId ?? other.OsvAdvisoryId;
return Create(
CveId,
mergedSymbols,
Source,
mergedConfidence,
timeProvider,
patchUrl,
osvId,
mergedPurls);
}
}

View File

@@ -0,0 +1,287 @@
// <copyright file="CveSymbolMappingService.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using StellaOps.Reachability.Core.Symbols;
namespace StellaOps.Reachability.Core.CveMapping;
/// <summary>
/// In-memory implementation of CVE symbol mapping service.
/// Sprint: SPRINT_20260109_009_003 Task: Implement CveSymbolMappingService
/// </summary>
public sealed class CveSymbolMappingService : ICveSymbolMappingService
{
private readonly ConcurrentDictionary<string, CveSymbolMapping> _mappings = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, HashSet<string>> _symbolToCves = new(StringComparer.Ordinal);
private readonly IPatchSymbolExtractor? _patchExtractor;
private readonly IOsvEnricher? _osvEnricher;
private readonly ISymbolCanonicalizer _canonicalizer;
private readonly TimeProvider _timeProvider;
private readonly ILogger<CveSymbolMappingService> _logger;
/// <summary>
/// Creates a new CVE symbol mapping service.
/// </summary>
public CveSymbolMappingService(
ISymbolCanonicalizer canonicalizer,
TimeProvider timeProvider,
ILogger<CveSymbolMappingService> logger,
IPatchSymbolExtractor? patchExtractor = null,
IOsvEnricher? osvEnricher = null)
{
_canonicalizer = canonicalizer;
_timeProvider = timeProvider;
_logger = logger;
_patchExtractor = patchExtractor;
_osvEnricher = osvEnricher;
}
/// <inheritdoc />
public Task<CveSymbolMapping?> GetMappingAsync(string cveId, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var normalizedId = NormalizeCveId(cveId);
_mappings.TryGetValue(normalizedId, out var mapping);
return Task.FromResult(mapping);
}
/// <inheritdoc />
public Task<IReadOnlyDictionary<string, CveSymbolMapping>> GetMappingsBatchAsync(
IEnumerable<string> cveIds,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var result = new Dictionary<string, CveSymbolMapping>(StringComparer.OrdinalIgnoreCase);
foreach (var cveId in cveIds)
{
var normalizedId = NormalizeCveId(cveId);
if (_mappings.TryGetValue(normalizedId, out var mapping))
{
result[normalizedId] = mapping;
}
}
return Task.FromResult<IReadOnlyDictionary<string, CveSymbolMapping>>(result);
}
/// <inheritdoc />
public Task IngestMappingAsync(CveSymbolMapping mapping, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var normalizedId = NormalizeCveId(mapping.CveId);
// Merge with existing mapping if present
_mappings.AddOrUpdate(
normalizedId,
mapping,
(_, existing) => existing.Merge(mapping, _timeProvider));
// Update symbol-to-CVE index
foreach (var symbol in mapping.Symbols)
{
var cves = _symbolToCves.GetOrAdd(symbol.Symbol.CanonicalId, _ => new HashSet<string>(StringComparer.OrdinalIgnoreCase));
lock (cves)
{
cves.Add(normalizedId);
}
}
_logger.LogInformation(
"Ingested mapping for {CveId} with {SymbolCount} symbols from {Source}",
normalizedId,
mapping.Symbols.Length,
mapping.Source);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<CveSymbolMapping> ExtractFromPatchAsync(
string cveId,
string commitUrl,
CancellationToken ct)
{
if (_patchExtractor is null)
throw new InvalidOperationException("Patch extractor is not configured");
var normalizedId = NormalizeCveId(cveId);
_logger.LogInformation("Extracting symbols from patch for {CveId}: {CommitUrl}", normalizedId, commitUrl);
var result = await _patchExtractor.ExtractFromCommitUrlAsync(commitUrl, ct);
if (!result.Success)
{
throw new InvalidOperationException($"Failed to extract symbols from patch: {result.Error}");
}
var mapping = CveSymbolMapping.Create(
normalizedId,
result.Symbols,
MappingSource.PatchAnalysis,
confidence: 0.8,
_timeProvider,
patchCommitUrl: commitUrl);
return mapping;
}
/// <inheritdoc />
public async Task<CveSymbolMapping> EnrichWithOsvAsync(
CveSymbolMapping mapping,
CancellationToken ct)
{
if (_osvEnricher is null)
{
_logger.LogWarning("OSV enricher is not configured, returning original mapping");
return mapping;
}
var enrichment = await _osvEnricher.EnrichAsync(mapping.CveId, ct);
if (!enrichment.Found)
{
_logger.LogDebug("CVE {CveId} not found in OSV", mapping.CveId);
return mapping;
}
// Merge OSV symbols with existing
var mergedSymbols = mapping.Symbols.ToList();
var existingIds = mapping.Symbols.Select(s => s.Symbol.CanonicalId).ToHashSet();
foreach (var symbol in enrichment.Symbols)
{
if (!existingIds.Contains(symbol.Symbol.CanonicalId))
{
mergedSymbols.Add(symbol);
}
}
// Merge PURLs
var mergedPurls = mapping.AffectedPurls.Union(enrichment.AffectedPurls).ToList();
return CveSymbolMapping.Create(
mapping.CveId,
mergedSymbols,
mapping.Source,
Math.Max(mapping.Confidence, 0.7),
_timeProvider,
mapping.PatchCommitUrl,
enrichment.OsvId,
mergedPurls);
}
/// <inheritdoc />
public Task<IReadOnlyList<CveSymbolMapping>> SearchBySymbolAsync(
string symbolPattern,
int limit,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var pattern = symbolPattern.ToLowerInvariant();
var isWildcard = pattern.Contains('*');
var results = new List<CveSymbolMapping>();
foreach (var mapping in _mappings.Values)
{
if (results.Count >= limit)
break;
foreach (var symbol in mapping.Symbols)
{
var displayName = symbol.Symbol.DisplayName.ToLowerInvariant();
var matches = isWildcard
? MatchesWildcard(displayName, pattern)
: displayName.Contains(pattern);
if (matches)
{
results.Add(mapping);
break;
}
}
}
return Task.FromResult<IReadOnlyList<CveSymbolMapping>>(results);
}
/// <inheritdoc />
public Task<IReadOnlyList<string>> GetCvesForSymbolAsync(
string canonicalId,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
if (_symbolToCves.TryGetValue(canonicalId, out var cves))
{
lock (cves)
{
return Task.FromResult<IReadOnlyList<string>>(cves.ToList());
}
}
return Task.FromResult<IReadOnlyList<string>>([]);
}
/// <summary>
/// Gets the total number of mappings.
/// </summary>
public int MappingCount => _mappings.Count;
/// <summary>
/// Gets all CVE IDs that have mappings.
/// </summary>
public IEnumerable<string> GetAllCveIds() => _mappings.Keys;
/// <summary>
/// Clears all mappings.
/// </summary>
public void Clear()
{
_mappings.Clear();
_symbolToCves.Clear();
}
private static string NormalizeCveId(string cveId)
{
// Normalize to uppercase CVE-YYYY-NNNNN format
var normalized = cveId.Trim().ToUpperInvariant();
if (!normalized.StartsWith("CVE-", StringComparison.Ordinal))
{
throw new ArgumentException($"Invalid CVE ID format: {cveId}. Expected format: CVE-YYYY-NNNNN");
}
return normalized;
}
private static bool MatchesWildcard(string text, string pattern)
{
// Simple wildcard matching (* matches any characters)
var patternParts = pattern.Split('*');
var currentIndex = 0;
foreach (var part in patternParts)
{
if (string.IsNullOrEmpty(part))
continue;
var foundIndex = text.IndexOf(part, currentIndex, StringComparison.Ordinal);
if (foundIndex < 0)
return false;
currentIndex = foundIndex + part.Length;
}
return true;
}
}

View File

@@ -0,0 +1,81 @@
// <copyright file="ICveSymbolMappingService.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.Reachability.Core.CveMapping;
/// <summary>
/// Service for mapping CVE identifiers to vulnerable symbols.
/// Sprint: SPRINT_20260109_009_003 Task: Create ICveSymbolMappingService interface
/// </summary>
public interface ICveSymbolMappingService
{
/// <summary>
/// Gets the mapping for a specific CVE.
/// </summary>
/// <param name="cveId">CVE identifier (e.g., "CVE-2021-44228").</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Mapping if exists, null otherwise.</returns>
Task<CveSymbolMapping?> GetMappingAsync(string cveId, CancellationToken ct);
/// <summary>
/// Gets mappings for multiple CVEs in a single call.
/// </summary>
/// <param name="cveIds">CVE identifiers to look up.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Dictionary mapping CVE ID to its mapping (only includes found mappings).</returns>
Task<IReadOnlyDictionary<string, CveSymbolMapping>> GetMappingsBatchAsync(
IEnumerable<string> cveIds,
CancellationToken ct);
/// <summary>
/// Ingests a mapping from any source.
/// </summary>
/// <param name="mapping">The mapping to ingest.</param>
/// <param name="ct">Cancellation token.</param>
Task IngestMappingAsync(CveSymbolMapping mapping, CancellationToken ct);
/// <summary>
/// Extracts a mapping by analyzing a patch commit.
/// </summary>
/// <param name="cveId">CVE identifier.</param>
/// <param name="commitUrl">URL to the patch commit.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Extracted mapping.</returns>
Task<CveSymbolMapping> ExtractFromPatchAsync(
string cveId,
string commitUrl,
CancellationToken ct);
/// <summary>
/// Enriches an existing mapping with data from OSV.
/// </summary>
/// <param name="mapping">Mapping to enrich.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Enriched mapping.</returns>
Task<CveSymbolMapping> EnrichWithOsvAsync(
CveSymbolMapping mapping,
CancellationToken ct);
/// <summary>
/// Searches for mappings by symbol pattern.
/// </summary>
/// <param name="symbolPattern">Pattern to match (supports wildcards).</param>
/// <param name="limit">Maximum results to return.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Matching mappings.</returns>
Task<IReadOnlyList<CveSymbolMapping>> SearchBySymbolAsync(
string symbolPattern,
int limit,
CancellationToken ct);
/// <summary>
/// Gets all CVEs that have mappings for a specific canonical symbol.
/// </summary>
/// <param name="canonicalId">The canonical symbol ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of CVE IDs that affect the symbol.</returns>
Task<IReadOnlyList<string>> GetCvesForSymbolAsync(
string canonicalId,
CancellationToken ct);
}

View File

@@ -0,0 +1,272 @@
// <copyright file="IOsvEnricher.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
namespace StellaOps.Reachability.Core.CveMapping;
/// <summary>
/// Enriches CVE mappings with data from the OSV database.
/// Sprint: SPRINT_20260109_009_003 Task: Create IOsvEnricher interface
/// </summary>
public interface IOsvEnricher
{
/// <summary>
/// Enriches a mapping with OSV data.
/// </summary>
/// <param name="cveId">CVE identifier to look up.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Enrichment result.</returns>
Task<OsvEnrichmentResult> EnrichAsync(string cveId, CancellationToken ct);
/// <summary>
/// Queries OSV for a vulnerability by ID.
/// </summary>
/// <param name="vulnId">Vulnerability ID (CVE, GHSA, etc.).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>OSV vulnerability data if found.</returns>
Task<OsvVulnerability?> GetVulnerabilityAsync(string vulnId, CancellationToken ct);
/// <summary>
/// Queries OSV for vulnerabilities affecting a package.
/// </summary>
/// <param name="ecosystem">Package ecosystem (npm, pypi, maven, etc.).</param>
/// <param name="packageName">Package name.</param>
/// <param name="version">Package version.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of affecting vulnerabilities.</returns>
Task<IReadOnlyList<OsvVulnerability>> QueryByPackageAsync(
string ecosystem,
string packageName,
string? version,
CancellationToken ct);
}
/// <summary>
/// Result of OSV enrichment.
/// </summary>
public sealed record OsvEnrichmentResult
{
/// <summary>
/// CVE ID that was looked up.
/// </summary>
public required string CveId { get; init; }
/// <summary>
/// Whether the CVE was found in OSV.
/// </summary>
public required bool Found { get; init; }
/// <summary>
/// OSV advisory ID if found.
/// </summary>
public string? OsvId { get; init; }
/// <summary>
/// Package URLs of affected packages.
/// </summary>
public ImmutableArray<string> AffectedPurls { get; init; } = [];
/// <summary>
/// Vulnerable symbols extracted from OSV.
/// </summary>
public ImmutableArray<VulnerableSymbol> Symbols { get; init; } = [];
/// <summary>
/// Affected version ranges per package.
/// </summary>
public ImmutableArray<AffectedVersionRange> AffectedVersions { get; init; } = [];
/// <summary>
/// Creates a "not found" result.
/// </summary>
public static OsvEnrichmentResult NotFound(string cveId) => new()
{
CveId = cveId,
Found = false
};
}
/// <summary>
/// Affected version range from OSV.
/// </summary>
public sealed record AffectedVersionRange
{
/// <summary>
/// Package URL.
/// </summary>
public required string Purl { get; init; }
/// <summary>
/// Introduced version (inclusive).
/// </summary>
public string? IntroducedVersion { get; init; }
/// <summary>
/// Fixed version (exclusive).
/// </summary>
public string? FixedVersion { get; init; }
/// <summary>
/// Last affected version if fixed version is unknown.
/// </summary>
public string? LastAffectedVersion { get; init; }
}
/// <summary>
/// OSV vulnerability record (simplified).
/// </summary>
public sealed record OsvVulnerability
{
/// <summary>
/// OSV vulnerability ID.
/// </summary>
public required string Id { get; init; }
/// <summary>
/// Summary description.
/// </summary>
public string? Summary { get; init; }
/// <summary>
/// Detailed description.
/// </summary>
public string? Details { get; init; }
/// <summary>
/// Aliases (CVE IDs, GHSA IDs, etc.).
/// </summary>
public ImmutableArray<string> Aliases { get; init; } = [];
/// <summary>
/// Affected packages and versions.
/// </summary>
public ImmutableArray<OsvAffected> Affected { get; init; } = [];
/// <summary>
/// Severity information.
/// </summary>
public ImmutableArray<OsvSeverity> Severity { get; init; } = [];
/// <summary>
/// References (URLs).
/// </summary>
public ImmutableArray<OsvReference> References { get; init; } = [];
}
/// <summary>
/// OSV affected package record.
/// </summary>
public sealed record OsvAffected
{
/// <summary>
/// Package information.
/// </summary>
public OsvPackage? Package { get; init; }
/// <summary>
/// Version ranges.
/// </summary>
public ImmutableArray<OsvRange> Ranges { get; init; } = [];
/// <summary>
/// Specific affected versions.
/// </summary>
public ImmutableArray<string> Versions { get; init; } = [];
/// <summary>
/// Ecosystem-specific data (may contain function names).
/// </summary>
public ImmutableDictionary<string, object>? EcosystemSpecific { get; init; }
}
/// <summary>
/// OSV package record.
/// </summary>
public sealed record OsvPackage
{
/// <summary>
/// Package ecosystem.
/// </summary>
public required string Ecosystem { get; init; }
/// <summary>
/// Package name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Package URL.
/// </summary>
public string? Purl { get; init; }
}
/// <summary>
/// OSV version range.
/// </summary>
public sealed record OsvRange
{
/// <summary>
/// Range type (SEMVER, ECOSYSTEM, GIT).
/// </summary>
public required string Type { get; init; }
/// <summary>
/// Events defining the range.
/// </summary>
public ImmutableArray<OsvEvent> Events { get; init; } = [];
}
/// <summary>
/// OSV range event.
/// </summary>
public sealed record OsvEvent
{
/// <summary>
/// Introduced version.
/// </summary>
public string? Introduced { get; init; }
/// <summary>
/// Fixed version.
/// </summary>
public string? Fixed { get; init; }
/// <summary>
/// Last affected version.
/// </summary>
public string? LastAffected { get; init; }
}
/// <summary>
/// OSV severity record.
/// </summary>
public sealed record OsvSeverity
{
/// <summary>
/// Severity type (CVSS_V3, etc.).
/// </summary>
public required string Type { get; init; }
/// <summary>
/// Score or vector string.
/// </summary>
public required string Score { get; init; }
}
/// <summary>
/// OSV reference record.
/// </summary>
public sealed record OsvReference
{
/// <summary>
/// Reference type (ADVISORY, FIX, WEB, etc.).
/// </summary>
public required string Type { get; init; }
/// <summary>
/// Reference URL.
/// </summary>
public required string Url { get; init; }
}

View File

@@ -0,0 +1,44 @@
// <copyright file="IPatchSymbolExtractor.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.Reachability.Core.CveMapping;
/// <summary>
/// Extracts vulnerable symbols from patch/diff data.
/// Sprint: SPRINT_20260109_009_003 Task: Create IPatchSymbolExtractor interface
/// </summary>
public interface IPatchSymbolExtractor
{
/// <summary>
/// Extracts symbols from a commit URL.
/// </summary>
/// <param name="commitUrl">URL to the commit (GitHub, GitLab, etc.).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Extraction result with symbols.</returns>
Task<PatchAnalysisResult> ExtractFromCommitUrlAsync(
string commitUrl,
CancellationToken ct);
/// <summary>
/// Extracts symbols from raw unified diff content.
/// </summary>
/// <param name="diffContent">Raw unified diff text.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Extraction result with symbols.</returns>
Task<PatchAnalysisResult> ExtractFromDiffAsync(
string diffContent,
CancellationToken ct);
/// <summary>
/// Extracts symbols from a local git repository commit.
/// </summary>
/// <param name="repositoryPath">Path to the local repository.</param>
/// <param name="commitSha">Commit SHA to analyze.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Extraction result with symbols.</returns>
Task<PatchAnalysisResult> ExtractFromLocalCommitAsync(
string repositoryPath,
string commitSha,
CancellationToken ct);
}

View File

@@ -0,0 +1,33 @@
// <copyright file="MappingSource.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.Reachability.Core.CveMapping;
/// <summary>
/// Source of CVE-symbol mapping.
/// Sprint: SPRINT_20260109_009_003 Task: Create MappingSource enum
/// </summary>
public enum MappingSource
{
/// <summary>Unknown source.</summary>
Unknown = 0,
/// <summary>Automated extraction from git diff/patch.</summary>
PatchAnalysis = 1,
/// <summary>OSV database with function-level data.</summary>
OsvDatabase = 2,
/// <summary>Manual security researcher curation.</summary>
ManualCuration = 3,
/// <summary>Binary delta signature matching.</summary>
DeltaSignature = 4,
/// <summary>AI-assisted extraction from CVE description.</summary>
AiExtraction = 5,
/// <summary>Vendor security advisory.</summary>
VendorAdvisory = 6
}

View File

@@ -0,0 +1,83 @@
// <copyright file="PatchAnalysisResult.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
namespace StellaOps.Reachability.Core.CveMapping;
/// <summary>
/// Result of analyzing a patch for vulnerable symbols.
/// Sprint: SPRINT_20260109_009_003 Task: Create PatchAnalysisResult model
/// </summary>
public sealed record PatchAnalysisResult
{
/// <summary>
/// Whether the analysis was successful.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Error message if analysis failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Extracted vulnerable symbols.
/// </summary>
public ImmutableArray<VulnerableSymbol> Symbols { get; init; } = [];
/// <summary>
/// Files that were modified in the patch.
/// </summary>
public ImmutableArray<string> ModifiedFiles { get; init; } = [];
/// <summary>
/// Total lines added across all files.
/// </summary>
public int LinesAdded { get; init; }
/// <summary>
/// Total lines removed across all files.
/// </summary>
public int LinesRemoved { get; init; }
/// <summary>
/// Commit SHA if available.
/// </summary>
public string? CommitSha { get; init; }
/// <summary>
/// Repository URL.
/// </summary>
public string? RepositoryUrl { get; init; }
/// <summary>
/// Creates a successful result.
/// </summary>
public static PatchAnalysisResult Successful(
IEnumerable<VulnerableSymbol> symbols,
IEnumerable<string> modifiedFiles,
int linesAdded = 0,
int linesRemoved = 0,
string? commitSha = null,
string? repositoryUrl = null) => new()
{
Success = true,
Symbols = symbols.ToImmutableArray(),
ModifiedFiles = modifiedFiles.ToImmutableArray(),
LinesAdded = linesAdded,
LinesRemoved = linesRemoved,
CommitSha = commitSha,
RepositoryUrl = repositoryUrl
};
/// <summary>
/// Creates a failed result.
/// </summary>
public static PatchAnalysisResult Failed(string error) => new()
{
Success = false,
Error = error
};
}

View File

@@ -0,0 +1,48 @@
// <copyright file="VulnerabilityType.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.Reachability.Core.CveMapping;
/// <summary>
/// Type of vulnerability relationship.
/// Sprint: SPRINT_20260109_009_003 Task: Create VulnerabilityType enum
/// </summary>
public enum VulnerabilityType
{
/// <summary>Unknown type.</summary>
Unknown = 0,
/// <summary>Sink where untrusted data causes harm.</summary>
Sink = 1,
/// <summary>Source of untrusted data.</summary>
TaintSource = 2,
/// <summary>Entry point for gadget chain.</summary>
GadgetEntry = 3,
/// <summary>Deserialization target.</summary>
DeserializationTarget = 4,
/// <summary>Authentication bypass.</summary>
AuthBypass = 5,
/// <summary>Cryptographic weakness.</summary>
CryptoWeakness = 6,
/// <summary>Remote code execution entry point.</summary>
RceEntry = 7,
/// <summary>SQL injection sink.</summary>
SqlInjection = 8,
/// <summary>Path traversal sink.</summary>
PathTraversal = 9,
/// <summary>Server-side request forgery sink.</summary>
Ssrf = 10,
/// <summary>Cross-site scripting sink.</summary>
Xss = 11
}

View File

@@ -0,0 +1,82 @@
// <copyright file="VulnerableSymbol.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using StellaOps.Reachability.Core.Symbols;
namespace StellaOps.Reachability.Core.CveMapping;
/// <summary>
/// A symbol identified as vulnerable.
/// Sprint: SPRINT_20260109_009_003 Task: Create VulnerableSymbol model
/// </summary>
public sealed record VulnerableSymbol
{
/// <summary>
/// Canonical symbol representation.
/// </summary>
public required CanonicalSymbol Symbol { get; init; }
/// <summary>
/// Type of vulnerability this symbol represents.
/// </summary>
public required VulnerabilityType Type { get; init; }
/// <summary>
/// Condition under which the vulnerability is triggered (optional).
/// Example: "When input is not sanitized"
/// </summary>
public string? Condition { get; init; }
/// <summary>
/// Confidence score for this symbol mapping (0.0 to 1.0).
/// </summary>
public required double Confidence { get; init; }
/// <summary>
/// Evidence supporting this mapping.
/// Example: "Modified in fix commit abc123"
/// </summary>
public string? Evidence { get; init; }
/// <summary>
/// Source file where the symbol was found (in patch).
/// </summary>
public string? SourceFile { get; init; }
/// <summary>
/// Line range in the source file or patch.
/// </summary>
public LineRange? LineRange { get; init; }
/// <summary>
/// Creates a vulnerable symbol with minimal required fields.
/// </summary>
public static VulnerableSymbol Create(
CanonicalSymbol symbol,
VulnerabilityType type,
double confidence = 0.5) => new()
{
Symbol = symbol,
Type = type,
Confidence = Math.Clamp(confidence, 0.0, 1.0)
};
}
/// <summary>
/// Represents a line range in source code.
/// </summary>
/// <param name="Start">Start line (1-based).</param>
/// <param name="End">End line (1-based, inclusive).</param>
public readonly record struct LineRange(int Start, int End)
{
/// <summary>
/// Number of lines in the range.
/// </summary>
public int Length => End - Start + 1;
/// <summary>
/// Checks if a line number is within this range.
/// </summary>
public bool Contains(int line) => line >= Start && line <= End;
}

View File

@@ -0,0 +1,187 @@
// <copyright file="EvidenceUriBuilder.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.Reachability.Core;
/// <summary>
/// Builds stella:// URIs for evidence references.
/// </summary>
public sealed class EvidenceUriBuilder
{
/// <summary>
/// Builds a ReachGraph URI for an artifact.
/// </summary>
/// <param name="digest">Artifact digest (sha256:...).</param>
/// <returns>stella://reachgraph/{digest}</returns>
public string BuildReachGraphUri(string digest)
{
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
return $"stella://reachgraph/{NormalizeDigest(digest)}";
}
/// <summary>
/// Builds a ReachGraph slice URI for a specific symbol.
/// </summary>
/// <param name="digest">Artifact digest.</param>
/// <param name="symbolId">Symbol canonical ID.</param>
/// <returns>stella://reachgraph/{digest}/slice?symbol={symbolId}</returns>
public string BuildReachGraphSliceUri(string digest, string symbolId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
ArgumentException.ThrowIfNullOrWhiteSpace(symbolId);
return $"stella://reachgraph/{NormalizeDigest(digest)}/slice?symbol={Uri.EscapeDataString(symbolId)}";
}
/// <summary>
/// Builds a runtime facts URI for an artifact.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="artifactDigest">Artifact digest.</param>
/// <returns>stella://signals/runtime/{tenantId}/{digest}</returns>
public string BuildRuntimeFactsUri(string tenantId, string artifactDigest)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
return $"stella://signals/runtime/{Uri.EscapeDataString(tenantId)}/{NormalizeDigest(artifactDigest)}";
}
/// <summary>
/// Builds a runtime facts URI for a specific symbol.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="artifactDigest">Artifact digest.</param>
/// <param name="symbolId">Symbol canonical ID.</param>
/// <returns>stella://signals/runtime/{tenantId}/{digest}?symbol={symbolId}</returns>
public string BuildRuntimeFactsUri(string tenantId, string artifactDigest, string symbolId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
ArgumentException.ThrowIfNullOrWhiteSpace(symbolId);
return $"stella://signals/runtime/{Uri.EscapeDataString(tenantId)}/{NormalizeDigest(artifactDigest)}?symbol={Uri.EscapeDataString(symbolId)}";
}
/// <summary>
/// Builds a CVE mapping URI.
/// </summary>
/// <param name="cveId">CVE identifier.</param>
/// <returns>stella://cvemap/{cveId}</returns>
public string BuildCveMappingUri(string cveId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
return $"stella://cvemap/{Uri.EscapeDataString(cveId)}";
}
/// <summary>
/// Builds an attestation URI.
/// </summary>
/// <param name="digest">Attestation digest.</param>
/// <returns>stella://attestation/{digest}</returns>
public string BuildAttestationUri(string digest)
{
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
return $"stella://attestation/{NormalizeDigest(digest)}";
}
/// <summary>
/// Builds a hybrid result URI.
/// </summary>
/// <param name="contentDigest">Result content digest.</param>
/// <returns>stella://hybrid/{digest}</returns>
public string BuildHybridResultUri(string contentDigest)
{
ArgumentException.ThrowIfNullOrWhiteSpace(contentDigest);
return $"stella://hybrid/{NormalizeDigest(contentDigest)}";
}
private static string NormalizeDigest(string digest)
{
// Remove sha256: prefix if present for URL normalization
if (digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
return digest;
}
// Assume sha256 if no prefix
return $"sha256:{digest}";
}
}
/// <summary>
/// Parses stella:// URIs.
/// </summary>
public sealed record EvidenceUri
{
/// <summary>
/// Gets the URI scheme (always "stella").
/// </summary>
public string Scheme { get; init; } = "stella";
/// <summary>
/// Gets the evidence type (reachgraph, signals, cvemap, attestation, hybrid).
/// </summary>
public required string Type { get; init; }
/// <summary>
/// Gets the path segments.
/// </summary>
public required IReadOnlyList<string> Segments { get; init; }
/// <summary>
/// Gets query parameters.
/// </summary>
public IReadOnlyDictionary<string, string> Query { get; init; } =
new Dictionary<string, string>();
/// <summary>
/// Parses a stella:// URI.
/// </summary>
/// <param name="uri">The URI to parse.</param>
/// <returns>Parsed URI, or null if invalid.</returns>
public static EvidenceUri? Parse(string uri)
{
if (string.IsNullOrWhiteSpace(uri))
{
return null;
}
if (!uri.StartsWith("stella://", StringComparison.OrdinalIgnoreCase))
{
return null;
}
var withoutScheme = uri[9..]; // Remove "stella://"
var queryIndex = withoutScheme.IndexOf('?', StringComparison.Ordinal);
var path = queryIndex >= 0 ? withoutScheme[..queryIndex] : withoutScheme;
var query = queryIndex >= 0 ? withoutScheme[(queryIndex + 1)..] : string.Empty;
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length == 0)
{
return null;
}
var queryParams = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrEmpty(query))
{
foreach (var pair in query.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var eqIndex = pair.IndexOf('=', StringComparison.Ordinal);
if (eqIndex > 0)
{
var key = Uri.UnescapeDataString(pair[..eqIndex]);
var value = Uri.UnescapeDataString(pair[(eqIndex + 1)..]);
queryParams[key] = value;
}
}
}
return new EvidenceUri
{
Type = segments[0],
Segments = segments[1..].ToList(),
Query = queryParams
};
}
}

View File

@@ -0,0 +1,76 @@
// <copyright file="HybridQueryOptions.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.Reachability.Core;
/// <summary>
/// Options for hybrid reachability queries.
/// </summary>
public sealed record HybridQueryOptions
{
/// <summary>
/// Gets or sets the runtime observation window to consider.
/// Default: 7 days.
/// </summary>
public TimeSpan ObservationWindow { get; init; } = TimeSpan.FromDays(7);
/// <summary>
/// Gets or sets whether to include static analysis.
/// Default: true.
/// </summary>
public bool IncludeStatic { get; init; } = true;
/// <summary>
/// Gets or sets whether to include runtime analysis.
/// Default: true.
/// </summary>
public bool IncludeRuntime { get; init; } = true;
/// <summary>
/// Gets or sets the minimum confidence threshold for verdicts.
/// Results below this are marked as under_investigation.
/// Default: 0.5.
/// </summary>
public double MinConfidenceThreshold { get; init; } = 0.5;
/// <summary>
/// Gets or sets the timeout for queries.
/// Default: 30 seconds.
/// </summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Gets or sets whether to include full evidence bundles.
/// Default: true.
/// </summary>
public bool IncludeEvidence { get; init; } = true;
/// <summary>
/// Gets or sets the tenant ID for multi-tenant scenarios.
/// </summary>
public string? TenantId { get; init; }
/// <summary>
/// Gets default options.
/// </summary>
public static HybridQueryOptions Default { get; } = new();
/// <summary>
/// Gets options for static-only analysis.
/// </summary>
public static HybridQueryOptions StaticOnly { get; } = new()
{
IncludeStatic = true,
IncludeRuntime = false
};
/// <summary>
/// Gets options for runtime-only analysis.
/// </summary>
public static HybridQueryOptions RuntimeOnly { get; } = new()
{
IncludeStatic = false,
IncludeRuntime = true
};
}

View File

@@ -0,0 +1,236 @@
// <copyright file="HybridReachabilityResult.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.Reachability.Core;
/// <summary>
/// Result of hybrid reachability query combining static and runtime evidence.
/// </summary>
public sealed record HybridReachabilityResult
{
/// <summary>
/// Gets the queried symbol.
/// </summary>
public required SymbolRef Symbol { get; init; }
/// <summary>
/// Gets the artifact digest analyzed.
/// </summary>
public required string ArtifactDigest { get; init; }
/// <summary>
/// Gets the computed lattice state.
/// </summary>
public required LatticeState LatticeState { get; init; }
/// <summary>
/// Gets the confidence score (0.0-1.0).
/// </summary>
public required double Confidence { get; init; }
/// <summary>
/// Gets the static analysis result (if performed).
/// </summary>
public StaticReachabilityResult? StaticResult { get; init; }
/// <summary>
/// Gets the runtime analysis result (if performed).
/// </summary>
public RuntimeReachabilityResult? RuntimeResult { get; init; }
/// <summary>
/// Gets the VEX verdict recommendation.
/// </summary>
public required VerdictRecommendation Verdict { get; init; }
/// <summary>
/// Gets the evidence bundle for this result.
/// </summary>
public required EvidenceBundle Evidence { get; init; }
/// <summary>
/// Gets when this result was computed.
/// </summary>
public required DateTimeOffset ComputedAt { get; init; }
/// <summary>
/// Gets the content digest for deterministic verification.
/// SHA-256 of canonical JSON representation.
/// </summary>
public string ContentDigest => ComputeContentDigest();
private string ComputeContentDigest()
{
// Build canonical content for hashing
var content = new
{
symbol = Symbol.CanonicalId,
artifact = ArtifactDigest,
latticeState = LatticeState.ToString(),
confidence = Confidence.ToString("F6", CultureInfo.InvariantCulture),
verdict = new
{
status = Verdict.VexStatus,
justification = Verdict.Justification
},
evidenceUris = Evidence.Uris.OrderBy(u => u, StringComparer.Ordinal).ToArray()
};
// Use deterministic JSON serialization
var json = JsonSerializer.Serialize(content, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
});
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}
/// <summary>
/// VEX verdict recommendation based on reachability analysis.
/// </summary>
public sealed record VerdictRecommendation
{
/// <summary>
/// Gets the recommended VEX status.
/// </summary>
public required string VexStatus { get; init; }
/// <summary>
/// Gets the justification for the verdict.
/// </summary>
public string? Justification { get; init; }
/// <summary>
/// Gets additional status notes.
/// </summary>
public string? StatusNotes { get; init; }
/// <summary>
/// Gets whether manual review is recommended.
/// </summary>
public bool RequiresManualReview { get; init; }
/// <summary>
/// Creates a verdict for affected status.
/// </summary>
public static VerdictRecommendation Affected(string? notes = null) => new()
{
VexStatus = "affected",
StatusNotes = notes
};
/// <summary>
/// Creates a verdict for not_affected with unreachable justification.
/// </summary>
public static VerdictRecommendation NotAffectedUnreachable(string? notes = null) => new()
{
VexStatus = "not_affected",
Justification = "vulnerable_code_not_in_execute_path",
StatusNotes = notes
};
/// <summary>
/// Creates a verdict for under_investigation.
/// </summary>
public static VerdictRecommendation UnderInvestigation(string? notes = null) => new()
{
VexStatus = "under_investigation",
StatusNotes = notes
};
/// <summary>
/// Creates a verdict requiring manual review.
/// </summary>
public static VerdictRecommendation NeedsReview(string? notes = null) => new()
{
VexStatus = "under_investigation",
StatusNotes = notes,
RequiresManualReview = true
};
}
/// <summary>
/// Bundle of evidence supporting a reachability result.
/// </summary>
public sealed record EvidenceBundle
{
/// <summary>
/// Gets evidence URIs (stella:// scheme).
/// </summary>
public ImmutableArray<string> Uris { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Gets the static evidence if available.
/// </summary>
public StaticEvidence? Static { get; init; }
/// <summary>
/// Gets the runtime evidence if available.
/// </summary>
public RuntimeEvidence? Runtime { get; init; }
/// <summary>
/// Gets when evidence was collected.
/// </summary>
public required DateTimeOffset CollectedAt { get; init; }
/// <summary>
/// Creates an empty evidence bundle.
/// </summary>
public static EvidenceBundle Empty(DateTimeOffset collectedAt) => new()
{
CollectedAt = collectedAt
};
}
/// <summary>
/// Static analysis evidence.
/// </summary>
public sealed record StaticEvidence
{
/// <summary>
/// Gets the ReachGraph digest used.
/// </summary>
public required string ReachGraphDigest { get; init; }
/// <summary>
/// Gets the graph slice URI.
/// </summary>
public required string SliceUri { get; init; }
/// <summary>
/// Gets the call path nodes if available.
/// </summary>
public ImmutableArray<string> CallPath { get; init; } = ImmutableArray<string>.Empty;
}
/// <summary>
/// Runtime observation evidence.
/// </summary>
public sealed record RuntimeEvidence
{
/// <summary>
/// Gets the Signals facts URI.
/// </summary>
public required string FactsUri { get; init; }
/// <summary>
/// Gets the observation window.
/// </summary>
public required TimeSpan ObservationWindow { get; init; }
/// <summary>
/// Gets sample trace IDs if available.
/// </summary>
public ImmutableArray<string> SampleTraceIds { get; init; } = ImmutableArray<string>.Empty;
}

View File

@@ -0,0 +1,80 @@
// <copyright file="IReachGraphAdapter.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.Reachability.Core;
/// <summary>
/// Adapter interface for ReachGraph integration.
/// </summary>
public interface IReachGraphAdapter
{
/// <summary>
/// Queries static reachability from the call graph.
/// </summary>
/// <param name="symbol">Symbol to query.</param>
/// <param name="artifactDigest">Artifact digest.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Static reachability result.</returns>
Task<StaticReachabilityResult> QueryAsync(
SymbolRef symbol,
string artifactDigest,
CancellationToken ct);
/// <summary>
/// Checks if a ReachGraph exists for the artifact.
/// </summary>
/// <param name="artifactDigest">Artifact digest.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if graph exists.</returns>
Task<bool> HasGraphAsync(string artifactDigest, CancellationToken ct);
/// <summary>
/// Gets metadata about the ReachGraph.
/// </summary>
/// <param name="artifactDigest">Artifact digest.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Graph metadata.</returns>
Task<ReachGraphMetadata?> GetMetadataAsync(string artifactDigest, CancellationToken ct);
}
/// <summary>
/// Metadata about a ReachGraph.
/// </summary>
public sealed record ReachGraphMetadata
{
/// <summary>
/// Gets the artifact digest.
/// </summary>
public required string ArtifactDigest { get; init; }
/// <summary>
/// Gets when the graph was built.
/// </summary>
public required DateTimeOffset BuiltAt { get; init; }
/// <summary>
/// Gets the graph digest.
/// </summary>
public required string GraphDigest { get; init; }
/// <summary>
/// Gets the number of nodes in the graph.
/// </summary>
public int NodeCount { get; init; }
/// <summary>
/// Gets the number of edges in the graph.
/// </summary>
public int EdgeCount { get; init; }
/// <summary>
/// Gets the entrypoint count.
/// </summary>
public int EntrypointCount { get; init; }
/// <summary>
/// Gets the analyzer version that built this graph.
/// </summary>
public string? AnalyzerVersion { get; init; }
}

View File

@@ -0,0 +1,119 @@
// <copyright file="IReachabilityIndex.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.Reachability.Core;
/// <summary>
/// Unified facade for hybrid reachability queries combining static call-graph
/// analysis with runtime execution evidence.
/// </summary>
public interface IReachabilityIndex
{
/// <summary>
/// Query static reachability from call graph.
/// </summary>
/// <param name="symbol">Symbol to query.</param>
/// <param name="artifactDigest">Target artifact digest (sha256:...).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Static reachability result.</returns>
Task<StaticReachabilityResult> QueryStaticAsync(
SymbolRef symbol,
string artifactDigest,
CancellationToken ct);
/// <summary>
/// Query runtime reachability from observed facts.
/// </summary>
/// <param name="symbol">Symbol to query.</param>
/// <param name="artifactDigest">Target artifact digest.</param>
/// <param name="observationWindow">Time window to consider.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Runtime reachability result.</returns>
Task<RuntimeReachabilityResult> QueryRuntimeAsync(
SymbolRef symbol,
string artifactDigest,
TimeSpan observationWindow,
CancellationToken ct);
/// <summary>
/// Query hybrid reachability combining static and runtime evidence.
/// </summary>
/// <param name="symbol">Symbol to query.</param>
/// <param name="artifactDigest">Target artifact digest.</param>
/// <param name="options">Query options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Hybrid reachability result with verdict recommendation.</returns>
Task<HybridReachabilityResult> QueryHybridAsync(
SymbolRef symbol,
string artifactDigest,
HybridQueryOptions options,
CancellationToken ct);
/// <summary>
/// Batch query for multiple symbols (CVE vulnerability analysis).
/// </summary>
/// <param name="symbols">Symbols to query.</param>
/// <param name="artifactDigest">Target artifact digest.</param>
/// <param name="options">Query options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Results for all symbols.</returns>
Task<IReadOnlyList<HybridReachabilityResult>> QueryBatchAsync(
IEnumerable<SymbolRef> symbols,
string artifactDigest,
HybridQueryOptions options,
CancellationToken ct);
}
/// <summary>
/// Service for replaying and verifying reachability query determinism.
/// </summary>
public interface IReachabilityReplayService
{
/// <summary>
/// Replays a query and verifies the result matches the expected digest.
/// </summary>
/// <param name="symbol">Symbol to query.</param>
/// <param name="artifactDigest">Artifact digest.</param>
/// <param name="expectedContentDigest">Expected result content digest.</param>
/// <param name="options">Query options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if replay matches, false if diverged.</returns>
Task<ReplayVerificationResult> VerifyAsync(
SymbolRef symbol,
string artifactDigest,
string expectedContentDigest,
HybridQueryOptions options,
CancellationToken ct);
}
/// <summary>
/// Result of a replay verification.
/// </summary>
public sealed record ReplayVerificationResult
{
/// <summary>
/// Gets whether the replay matched the expected digest.
/// </summary>
public required bool IsMatch { get; init; }
/// <summary>
/// Gets the expected content digest.
/// </summary>
public required string ExpectedDigest { get; init; }
/// <summary>
/// Gets the actual content digest from replay.
/// </summary>
public required string ActualDigest { get; init; }
/// <summary>
/// Gets the replayed result.
/// </summary>
public required HybridReachabilityResult ReplayedResult { get; init; }
/// <summary>
/// Gets divergence details if not a match.
/// </summary>
public IReadOnlyList<string>? Divergences { get; init; }
}

View File

@@ -0,0 +1,97 @@
// <copyright file="ISignalsAdapter.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.Reachability.Core;
/// <summary>
/// Adapter interface for Signals runtime facts integration.
/// </summary>
public interface ISignalsAdapter
{
/// <summary>
/// Queries runtime observation facts.
/// </summary>
/// <param name="symbol">Symbol to query.</param>
/// <param name="artifactDigest">Artifact digest.</param>
/// <param name="observationWindow">Time window to search.</param>
/// <param name="tenantId">Optional tenant ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Runtime reachability result.</returns>
Task<RuntimeReachabilityResult> QueryAsync(
SymbolRef symbol,
string artifactDigest,
TimeSpan observationWindow,
string? tenantId,
CancellationToken ct);
/// <summary>
/// Checks if runtime facts exist for the artifact.
/// </summary>
/// <param name="artifactDigest">Artifact digest.</param>
/// <param name="tenantId">Optional tenant ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if facts exist.</returns>
Task<bool> HasFactsAsync(
string artifactDigest,
string? tenantId,
CancellationToken ct);
/// <summary>
/// Gets metadata about available runtime facts.
/// </summary>
/// <param name="artifactDigest">Artifact digest.</param>
/// <param name="tenantId">Optional tenant ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Facts metadata.</returns>
Task<SignalsMetadata?> GetMetadataAsync(
string artifactDigest,
string? tenantId,
CancellationToken ct);
}
/// <summary>
/// Metadata about available Signals runtime facts.
/// </summary>
public sealed record SignalsMetadata
{
/// <summary>
/// Gets the artifact digest.
/// </summary>
public required string ArtifactDigest { get; init; }
/// <summary>
/// Gets the tenant ID.
/// </summary>
public string? TenantId { get; init; }
/// <summary>
/// Gets the earliest observation time.
/// </summary>
public required DateTimeOffset EarliestObservation { get; init; }
/// <summary>
/// Gets the latest observation time.
/// </summary>
public required DateTimeOffset LatestObservation { get; init; }
/// <summary>
/// Gets the total number of observed symbols.
/// </summary>
public int SymbolCount { get; init; }
/// <summary>
/// Gets total observation count across all symbols.
/// </summary>
public long TotalObservations { get; init; }
/// <summary>
/// Gets the environments with observations.
/// </summary>
public IReadOnlyList<string> Environments { get; init; } = [];
/// <summary>
/// Gets the agent version that collected facts.
/// </summary>
public string? AgentVersion { get; init; }
}

View File

@@ -0,0 +1,84 @@
// <copyright file="LatticeState.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.Reachability.Core;
/// <summary>
/// 8-state reachability lattice model.
/// States are ordered by evidence strength, progressing from Unknown
/// toward confirmed states as more evidence accumulates.
/// </summary>
/// <remarks>
/// <para>The lattice represents the progression of confidence in reachability:</para>
/// <code>
/// X (Contested)
/// / \
/// CR (Confirmed CU (Confirmed
/// Reachable) Unreachable)
/// | \ / |
/// | \ / |
/// RO (Runtime RU (Runtime
/// Observed) Unobserved)
/// | |
/// | |
/// SR (Static SU (Static
/// Reachable) Unreachable)
/// \ /
/// \ /
/// U (Unknown)
/// </code>
/// </remarks>
public enum LatticeState
{
/// <summary>
/// No analysis performed. Initial state for all symbols.
/// VEX: under_investigation, Confidence: 0.00-0.29
/// </summary>
Unknown = 0,
/// <summary>
/// Static call graph analysis shows path exists from entrypoint.
/// VEX: under_investigation, Confidence: 0.30-0.49
/// </summary>
StaticReachable = 1,
/// <summary>
/// Static call graph analysis proves no path exists.
/// VEX: not_affected (vulnerable_code_not_in_execute_path), Confidence: 0.50-0.69
/// </summary>
StaticUnreachable = 2,
/// <summary>
/// Symbol execution was observed at runtime within observation window.
/// VEX: affected, Confidence: 0.70-0.89
/// </summary>
RuntimeObserved = 3,
/// <summary>
/// Runtime observation window passed with no execution detected.
/// VEX: not_affected (vulnerable_code_not_in_execute_path), Confidence: 0.70-0.89
/// </summary>
RuntimeUnobserved = 4,
/// <summary>
/// Multiple independent sources confirm reachability.
/// Static + Runtime both confirm execution path exists.
/// VEX: affected, Confidence: 0.90-1.00
/// </summary>
ConfirmedReachable = 5,
/// <summary>
/// Multiple independent sources confirm unreachability.
/// Static proves no path AND runtime observed no execution.
/// VEX: not_affected (vulnerable_code_not_in_execute_path), Confidence: 0.90-1.00
/// </summary>
ConfirmedUnreachable = 6,
/// <summary>
/// Evidence conflict detected - requires manual review.
/// Example: Static says unreachable but runtime observed execution.
/// VEX: under_investigation (requires_manual_review), Confidence: N/A
/// </summary>
Contested = 7
}

View File

@@ -0,0 +1,266 @@
// <copyright file="ReachabilityIndex.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
namespace StellaOps.Reachability.Core;
/// <summary>
/// Default implementation of <see cref="IReachabilityIndex"/>.
/// </summary>
public sealed class ReachabilityIndex : IReachabilityIndex
{
private readonly IReachGraphAdapter _reachGraphAdapter;
private readonly ISignalsAdapter _signalsAdapter;
private readonly ConfidenceCalculator _confidenceCalculator;
private readonly EvidenceUriBuilder _evidenceUriBuilder;
private readonly TimeProvider _timeProvider;
/// <summary>
/// Initializes a new instance of the <see cref="ReachabilityIndex"/> class.
/// </summary>
public ReachabilityIndex(
IReachGraphAdapter reachGraphAdapter,
ISignalsAdapter signalsAdapter,
TimeProvider timeProvider,
ConfidenceWeights? confidenceWeights = null)
{
ArgumentNullException.ThrowIfNull(reachGraphAdapter);
ArgumentNullException.ThrowIfNull(signalsAdapter);
ArgumentNullException.ThrowIfNull(timeProvider);
_reachGraphAdapter = reachGraphAdapter;
_signalsAdapter = signalsAdapter;
_timeProvider = timeProvider;
_confidenceCalculator = new ConfidenceCalculator(confidenceWeights);
_evidenceUriBuilder = new EvidenceUriBuilder();
}
/// <inheritdoc/>
public async Task<StaticReachabilityResult> QueryStaticAsync(
SymbolRef symbol,
string artifactDigest,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(symbol);
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
return await _reachGraphAdapter.QueryAsync(symbol, artifactDigest, ct)
.ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task<RuntimeReachabilityResult> QueryRuntimeAsync(
SymbolRef symbol,
string artifactDigest,
TimeSpan observationWindow,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(symbol);
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
return await _signalsAdapter.QueryAsync(
symbol,
artifactDigest,
observationWindow,
tenantId: null,
ct).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task<HybridReachabilityResult> QueryHybridAsync(
SymbolRef symbol,
string artifactDigest,
HybridQueryOptions options,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(symbol);
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
ArgumentNullException.ThrowIfNull(options);
var now = _timeProvider.GetUtcNow();
// Query static and runtime in parallel when both enabled
StaticReachabilityResult? staticResult = null;
RuntimeReachabilityResult? runtimeResult = null;
var tasks = new List<Task>();
if (options.IncludeStatic)
{
tasks.Add(Task.Run(async () =>
{
staticResult = await _reachGraphAdapter.QueryAsync(symbol, artifactDigest, ct)
.ConfigureAwait(false);
}, ct));
}
if (options.IncludeRuntime)
{
tasks.Add(Task.Run(async () =>
{
runtimeResult = await _signalsAdapter.QueryAsync(
symbol,
artifactDigest,
options.ObservationWindow,
options.TenantId,
ct).ConfigureAwait(false);
}, ct));
}
await Task.WhenAll(tasks).ConfigureAwait(false);
// Combine evidence using lattice
var (latticeState, _) = ReachabilityLattice.Combine(staticResult, runtimeResult);
// Calculate confidence
var confidence = _confidenceCalculator.Calculate(latticeState, staticResult, runtimeResult);
// Build verdict
var verdict = BuildVerdict(latticeState, confidence, options.MinConfidenceThreshold);
// Build evidence bundle
var evidence = BuildEvidence(symbol, artifactDigest, staticResult, runtimeResult, options, now);
return new HybridReachabilityResult
{
Symbol = symbol,
ArtifactDigest = artifactDigest,
LatticeState = latticeState,
Confidence = confidence,
StaticResult = staticResult,
RuntimeResult = runtimeResult,
Verdict = verdict,
Evidence = evidence,
ComputedAt = now
};
}
/// <inheritdoc/>
public async Task<IReadOnlyList<HybridReachabilityResult>> QueryBatchAsync(
IEnumerable<SymbolRef> symbols,
string artifactDigest,
HybridQueryOptions options,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(symbols);
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
ArgumentNullException.ThrowIfNull(options);
var symbolList = symbols.ToList();
var results = new List<HybridReachabilityResult>(symbolList.Count);
// Process sequentially to avoid overwhelming adapters
// Could be parallelized with SemaphoreSlim if performance is critical
foreach (var symbol in symbolList)
{
ct.ThrowIfCancellationRequested();
var result = await QueryHybridAsync(symbol, artifactDigest, options, ct)
.ConfigureAwait(false);
results.Add(result);
}
return results;
}
private static VerdictRecommendation BuildVerdict(
LatticeState state,
double confidence,
double minConfidence)
{
// Below minimum confidence = under investigation
if (confidence < minConfidence && state != LatticeState.Contested)
{
return VerdictRecommendation.UnderInvestigation(
$"Confidence {confidence:P0} below threshold {minConfidence:P0}");
}
return state switch
{
LatticeState.Unknown =>
VerdictRecommendation.UnderInvestigation("No analysis data available"),
LatticeState.StaticReachable =>
VerdictRecommendation.UnderInvestigation(
"Static analysis shows reachable path; runtime confirmation pending"),
LatticeState.StaticUnreachable =>
VerdictRecommendation.NotAffectedUnreachable(
"Static analysis proves no execution path exists"),
LatticeState.RuntimeObserved =>
VerdictRecommendation.Affected(
"Symbol execution observed at runtime"),
LatticeState.RuntimeUnobserved =>
VerdictRecommendation.NotAffectedUnreachable(
"No execution observed during observation window"),
LatticeState.ConfirmedReachable =>
VerdictRecommendation.Affected(
"Reachability confirmed by static analysis and runtime observation"),
LatticeState.ConfirmedUnreachable =>
VerdictRecommendation.NotAffectedUnreachable(
"Unreachability confirmed by static analysis and runtime observation"),
LatticeState.Contested =>
VerdictRecommendation.NeedsReview(
"Evidence conflict detected - static and runtime results disagree"),
_ => VerdictRecommendation.UnderInvestigation("Unknown state")
};
}
private EvidenceBundle BuildEvidence(
SymbolRef symbol,
string artifactDigest,
StaticReachabilityResult? staticResult,
RuntimeReachabilityResult? runtimeResult,
HybridQueryOptions options,
DateTimeOffset now)
{
var urisBuilder = ImmutableArray.CreateBuilder<string>();
StaticEvidence? staticEvidence = null;
if (staticResult is not null)
{
var sliceUri = _evidenceUriBuilder.BuildReachGraphSliceUri(artifactDigest, symbol.CanonicalId);
urisBuilder.Add(sliceUri);
urisBuilder.AddRange(staticResult.EvidenceUris);
staticEvidence = new StaticEvidence
{
ReachGraphDigest = artifactDigest,
SliceUri = sliceUri,
CallPath = ImmutableArray<string>.Empty // Would come from detailed query
};
}
RuntimeEvidence? runtimeEvidence = null;
if (runtimeResult is not null)
{
var factsUri = options.TenantId is not null
? _evidenceUriBuilder.BuildRuntimeFactsUri(options.TenantId, artifactDigest, symbol.CanonicalId)
: _evidenceUriBuilder.BuildRuntimeFactsUri("default", artifactDigest, symbol.CanonicalId);
urisBuilder.Add(factsUri);
urisBuilder.AddRange(runtimeResult.EvidenceUris);
runtimeEvidence = new RuntimeEvidence
{
FactsUri = factsUri,
ObservationWindow = runtimeResult.ObservationWindow,
SampleTraceIds = ImmutableArray<string>.Empty // Would come from detailed query
};
}
return new EvidenceBundle
{
Uris = urisBuilder.ToImmutable(),
Static = staticEvidence,
Runtime = runtimeEvidence,
CollectedAt = now
};
}
}

View File

@@ -0,0 +1,181 @@
// <copyright file="ReachabilityLattice.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Frozen;
namespace StellaOps.Reachability.Core;
/// <summary>
/// State machine implementing the 8-state reachability lattice.
/// Handles evidence accumulation and state transitions.
/// </summary>
public sealed class ReachabilityLattice
{
private static readonly FrozenDictionary<(LatticeState From, EvidenceType Evidence), LatticeTransition> Transitions;
static ReachabilityLattice()
{
var transitions = new Dictionary<(LatticeState, EvidenceType), LatticeTransition>
{
// From Unknown
[(LatticeState.Unknown, EvidenceType.StaticReachable)] =
new(LatticeState.StaticReachable, 0.30),
[(LatticeState.Unknown, EvidenceType.StaticUnreachable)] =
new(LatticeState.StaticUnreachable, 0.40),
[(LatticeState.Unknown, EvidenceType.RuntimeObserved)] =
new(LatticeState.RuntimeObserved, 0.50),
[(LatticeState.Unknown, EvidenceType.RuntimeUnobserved)] =
new(LatticeState.RuntimeUnobserved, 0.30),
// From StaticReachable
[(LatticeState.StaticReachable, EvidenceType.RuntimeObserved)] =
new(LatticeState.ConfirmedReachable, 0.30),
[(LatticeState.StaticReachable, EvidenceType.RuntimeUnobserved)] =
new(LatticeState.RuntimeUnobserved, 0.20),
// From StaticUnreachable
[(LatticeState.StaticUnreachable, EvidenceType.RuntimeObserved)] =
new(LatticeState.Contested, -0.20), // Conflict!
[(LatticeState.StaticUnreachable, EvidenceType.RuntimeUnobserved)] =
new(LatticeState.ConfirmedUnreachable, 0.20),
// From RuntimeObserved
[(LatticeState.RuntimeObserved, EvidenceType.StaticReachable)] =
new(LatticeState.ConfirmedReachable, 0.20),
[(LatticeState.RuntimeObserved, EvidenceType.StaticUnreachable)] =
new(LatticeState.Contested, -0.20), // Conflict!
// From RuntimeUnobserved
[(LatticeState.RuntimeUnobserved, EvidenceType.StaticReachable)] =
new(LatticeState.StaticReachable, 0.10),
[(LatticeState.RuntimeUnobserved, EvidenceType.StaticUnreachable)] =
new(LatticeState.ConfirmedUnreachable, 0.20),
// From ConfirmedReachable - stays confirmed
[(LatticeState.ConfirmedReachable, EvidenceType.StaticReachable)] =
new(LatticeState.ConfirmedReachable, 0.05),
[(LatticeState.ConfirmedReachable, EvidenceType.RuntimeObserved)] =
new(LatticeState.ConfirmedReachable, 0.05),
[(LatticeState.ConfirmedReachable, EvidenceType.StaticUnreachable)] =
new(LatticeState.Contested, -0.30),
[(LatticeState.ConfirmedReachable, EvidenceType.RuntimeUnobserved)] =
new(LatticeState.Contested, -0.20),
// From ConfirmedUnreachable - stays confirmed
[(LatticeState.ConfirmedUnreachable, EvidenceType.StaticUnreachable)] =
new(LatticeState.ConfirmedUnreachable, 0.05),
[(LatticeState.ConfirmedUnreachable, EvidenceType.RuntimeUnobserved)] =
new(LatticeState.ConfirmedUnreachable, 0.05),
[(LatticeState.ConfirmedUnreachable, EvidenceType.StaticReachable)] =
new(LatticeState.Contested, -0.30),
[(LatticeState.ConfirmedUnreachable, EvidenceType.RuntimeObserved)] =
new(LatticeState.Contested, -0.30),
// From Contested - stays contested until manual resolution
[(LatticeState.Contested, EvidenceType.StaticReachable)] =
new(LatticeState.Contested, 0.0),
[(LatticeState.Contested, EvidenceType.StaticUnreachable)] =
new(LatticeState.Contested, 0.0),
[(LatticeState.Contested, EvidenceType.RuntimeObserved)] =
new(LatticeState.Contested, 0.0),
[(LatticeState.Contested, EvidenceType.RuntimeUnobserved)] =
new(LatticeState.Contested, 0.0),
};
Transitions = transitions.ToFrozenDictionary();
}
private LatticeState _currentState = LatticeState.Unknown;
private double _confidence;
/// <summary>
/// Gets the current lattice state.
/// </summary>
public LatticeState CurrentState => _currentState;
/// <summary>
/// Gets the accumulated confidence score.
/// </summary>
public double Confidence => Math.Clamp(_confidence, 0.0, 1.0);
/// <summary>
/// Applies new evidence to the lattice.
/// </summary>
/// <param name="evidence">The evidence type.</param>
/// <returns>The resulting transition, or null if no transition defined.</returns>
public LatticeTransition? ApplyEvidence(EvidenceType evidence)
{
var key = (_currentState, evidence);
if (Transitions.TryGetValue(key, out var transition))
{
_currentState = transition.ToState;
_confidence += transition.ConfidenceDelta;
return transition;
}
return null;
}
/// <summary>
/// Combines evidence from static and runtime analysis.
/// </summary>
public static (LatticeState State, double Confidence) Combine(
StaticReachabilityResult? staticResult,
RuntimeReachabilityResult? runtimeResult)
{
var lattice = new ReachabilityLattice();
if (staticResult is not null)
{
var staticEvidence = staticResult.IsReachable
? EvidenceType.StaticReachable
: EvidenceType.StaticUnreachable;
lattice.ApplyEvidence(staticEvidence);
}
if (runtimeResult is not null)
{
var runtimeEvidence = runtimeResult.WasObserved
? EvidenceType.RuntimeObserved
: EvidenceType.RuntimeUnobserved;
lattice.ApplyEvidence(runtimeEvidence);
}
return (lattice.CurrentState, lattice.Confidence);
}
/// <summary>
/// Resets the lattice to initial state.
/// </summary>
public void Reset()
{
_currentState = LatticeState.Unknown;
_confidence = 0.0;
}
}
/// <summary>
/// Type of evidence being applied to the lattice.
/// </summary>
public enum EvidenceType
{
/// <summary>Static analysis found a reachable path.</summary>
StaticReachable,
/// <summary>Static analysis proved no path exists.</summary>
StaticUnreachable,
/// <summary>Runtime observed symbol execution.</summary>
RuntimeObserved,
/// <summary>Runtime observation window passed with no execution.</summary>
RuntimeUnobserved
}
/// <summary>
/// Represents a state transition in the lattice.
/// </summary>
/// <param name="ToState">The resulting state.</param>
/// <param name="ConfidenceDelta">Change in confidence score.</param>
public sealed record LatticeTransition(LatticeState ToState, double ConfidenceDelta);

View File

@@ -0,0 +1,130 @@
// <copyright file="RuntimeReachabilityResult.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
namespace StellaOps.Reachability.Core;
/// <summary>
/// Result of runtime observation analysis.
/// </summary>
public sealed record RuntimeReachabilityResult
{
/// <summary>
/// Gets the queried symbol.
/// </summary>
public required SymbolRef Symbol { get; init; }
/// <summary>
/// Gets the artifact digest analyzed.
/// </summary>
public required string ArtifactDigest { get; init; }
/// <summary>
/// Gets whether the symbol was observed executing at runtime.
/// </summary>
public required bool WasObserved { get; init; }
/// <summary>
/// Gets the observation window used for this query.
/// </summary>
public required TimeSpan ObservationWindow { get; init; }
/// <summary>
/// Gets the start of the observation window.
/// </summary>
public required DateTimeOffset WindowStart { get; init; }
/// <summary>
/// Gets the end of the observation window.
/// </summary>
public required DateTimeOffset WindowEnd { get; init; }
/// <summary>
/// Gets the number of times this symbol was executed.
/// </summary>
public long HitCount { get; init; }
/// <summary>
/// Gets when the symbol was first observed (if any).
/// </summary>
public DateTimeOffset? FirstSeen { get; init; }
/// <summary>
/// Gets when the symbol was last observed (if any).
/// </summary>
public DateTimeOffset? LastSeen { get; init; }
/// <summary>
/// Gets execution contexts where this symbol was observed.
/// </summary>
public ImmutableArray<ExecutionContext> Contexts { get; init; } = ImmutableArray<ExecutionContext>.Empty;
/// <summary>
/// Gets the evidence URIs supporting this result.
/// </summary>
public ImmutableArray<string> EvidenceUris { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Gets the traffic profile during observation.
/// </summary>
public TrafficProfile? Traffic { get; init; }
/// <summary>
/// Gets the agent that collected this data.
/// </summary>
public string? AgentVersion { get; init; }
}
/// <summary>
/// Context in which a symbol execution was observed.
/// </summary>
public sealed record ExecutionContext
{
/// <summary>
/// Gets the container ID where execution occurred.
/// </summary>
public string? ContainerId { get; init; }
/// <summary>
/// Gets the process ID.
/// </summary>
public int? ProcessId { get; init; }
/// <summary>
/// Gets the HTTP route if applicable.
/// </summary>
public string? Route { get; init; }
/// <summary>
/// Gets the environment name (production, staging, etc.).
/// </summary>
public string? Environment { get; init; }
/// <summary>
/// Gets the frequency ratio (0.0-1.0) of executions in this context.
/// </summary>
public double Frequency { get; init; }
}
/// <summary>
/// Traffic profile during observation window.
/// </summary>
public sealed record TrafficProfile
{
/// <summary>
/// Gets total request count during observation.
/// </summary>
public long RequestCount { get; init; }
/// <summary>
/// Gets the percentile of traffic coverage (e.g., "p95", "p99").
/// </summary>
public string? Percentile { get; init; }
/// <summary>
/// Gets the environments included in observation.
/// </summary>
public ImmutableArray<string> Environments { get; init; } = ImmutableArray<string>.Empty;
}

View File

@@ -0,0 +1,46 @@
// <copyright file="ServiceCollectionExtensions.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Reachability.Core;
/// <summary>
/// Extension methods for registering Reachability Core services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds Reachability Core services.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configureWeights">Optional confidence weight configuration.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddReachabilityCore(
this IServiceCollection services,
Action<ConfidenceWeights>? configureWeights = null)
{
ArgumentNullException.ThrowIfNull(services);
services.TryAddSingleton(TimeProvider.System);
services.TryAddSingleton<EvidenceUriBuilder>();
if (configureWeights is not null)
{
var weights = new ConfidenceWeights();
configureWeights(weights);
services.TryAddSingleton(weights);
}
else
{
services.TryAddSingleton(ConfidenceWeights.Default);
}
services.TryAddSingleton<ConfidenceCalculator>();
services.TryAddSingleton<IReachabilityIndex, ReachabilityIndex>();
return services;
}
}

View File

@@ -0,0 +1,91 @@
// <copyright file="StaticReachabilityResult.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
namespace StellaOps.Reachability.Core;
/// <summary>
/// Result of static call-graph reachability analysis.
/// </summary>
public sealed record StaticReachabilityResult
{
/// <summary>
/// Gets the queried symbol.
/// </summary>
public required SymbolRef Symbol { get; init; }
/// <summary>
/// Gets the artifact digest analyzed.
/// </summary>
public required string ArtifactDigest { get; init; }
/// <summary>
/// Gets whether a path exists from any entrypoint to this symbol.
/// </summary>
public required bool IsReachable { get; init; }
/// <summary>
/// Gets the number of distinct paths to this symbol.
/// </summary>
public int PathCount { get; init; }
/// <summary>
/// Gets the shortest path length (call depth) from entrypoint.
/// Null if not reachable.
/// </summary>
public int? ShortestPathLength { get; init; }
/// <summary>
/// Gets the entrypoints that can reach this symbol.
/// </summary>
public ImmutableArray<string> Entrypoints { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Gets guard conditions that must be true for reachability.
/// Example: feature flags, configuration checks.
/// </summary>
public ImmutableArray<GuardCondition> Guards { get; init; } = ImmutableArray<GuardCondition>.Empty;
/// <summary>
/// Gets the evidence URIs supporting this result.
/// </summary>
public ImmutableArray<string> EvidenceUris { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Gets the analysis timestamp.
/// </summary>
public required DateTimeOffset AnalyzedAt { get; init; }
/// <summary>
/// Gets the analyzer version that produced this result.
/// </summary>
public string? AnalyzerVersion { get; init; }
}
/// <summary>
/// A guard condition that affects reachability.
/// </summary>
public sealed record GuardCondition
{
/// <summary>
/// Gets the type of guard (FeatureFlag, Config, Argument, etc.).
/// </summary>
public required string Type { get; init; }
/// <summary>
/// Gets the guard key/name.
/// </summary>
public required string Key { get; init; }
/// <summary>
/// Gets the value required for reachability.
/// </summary>
public string? Value { get; init; }
/// <summary>
/// Gets the expression if more complex than key=value.
/// </summary>
public string? Expression { get; init; }
}

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,127 @@
// <copyright file="SymbolRef.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Reachability.Core;
/// <summary>
/// Canonical reference to a symbol for reachability queries.
/// </summary>
public sealed record SymbolRef
{
/// <summary>
/// Gets the Package URL (PURL) of the component containing this symbol.
/// Example: pkg:npm/lodash@4.17.21
/// </summary>
public required string Purl { get; init; }
/// <summary>
/// Gets the namespace (package/module) containing the symbol.
/// Example: "lodash" or "System.Text.Json"
/// </summary>
public required string Namespace { get; init; }
/// <summary>
/// Gets the type (class/struct) containing the symbol.
/// Use "_" for module-level functions without a class.
/// </summary>
public required string Type { get; init; }
/// <summary>
/// Gets the method/function name.
/// </summary>
public required string Method { get; init; }
/// <summary>
/// Gets the parameter signature.
/// Example: "(object, string)" or "(int, int)"
/// </summary>
public string Signature { get; init; } = string.Empty;
/// <summary>
/// Gets the canonical identifier (SHA-256 hash of normalized symbol).
/// </summary>
public string CanonicalId => ComputeCanonicalId();
/// <summary>
/// Gets the display-friendly fully qualified name.
/// </summary>
public string DisplayName => Type == "_"
? $"{Namespace}.{Method}{Signature}"
: $"{Namespace}.{Type}.{Method}{Signature}";
/// <summary>
/// Creates a SymbolRef from a fully qualified method name.
/// </summary>
/// <param name="purl">Package URL.</param>
/// <param name="fullyQualifiedName">e.g., "Namespace.Type.Method(params)"</param>
public static SymbolRef FromFullyQualified(string purl, string fullyQualifiedName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(purl);
ArgumentException.ThrowIfNullOrWhiteSpace(fullyQualifiedName);
var signature = string.Empty;
var nameWithoutSig = fullyQualifiedName;
var parenIndex = fullyQualifiedName.IndexOf('(', StringComparison.Ordinal);
if (parenIndex >= 0)
{
signature = fullyQualifiedName[parenIndex..];
nameWithoutSig = fullyQualifiedName[..parenIndex];
}
var parts = nameWithoutSig.Split('.');
if (parts.Length < 2)
{
return new SymbolRef
{
Purl = purl,
Namespace = "_",
Type = "_",
Method = nameWithoutSig,
Signature = signature
};
}
var method = parts[^1];
var typeOrNamespace = parts[^2];
// Heuristic: if second-to-last starts with uppercase, it's likely a type
if (parts.Length >= 3 && char.IsUpper(typeOrNamespace[0]))
{
var ns = string.Join(".", parts[..^2]);
return new SymbolRef
{
Purl = purl,
Namespace = ns,
Type = typeOrNamespace,
Method = method,
Signature = signature
};
}
return new SymbolRef
{
Purl = purl,
Namespace = string.Join(".", parts[..^1]),
Type = "_",
Method = method,
Signature = signature
};
}
private string ComputeCanonicalId()
{
// Canonical format: purl|namespace|type|method|signature
var canonical = string.Create(
CultureInfo.InvariantCulture,
$"{Purl}|{Namespace}|{Type}|{Method}|{Signature}");
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical));
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,140 @@
// <copyright file="CanonicalSymbol.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Reachability.Core.Symbols;
/// <summary>
/// Canonicalized symbol in portable format for cross-source matching.
/// Sprint: SPRINT_20260109_009_002 Task: Implement canonical symbol model
/// </summary>
/// <remarks>
/// All string fields are normalized to lowercase for case-insensitive matching.
/// The CanonicalId provides a stable SHA-256 hash for exact matching.
/// </remarks>
public sealed record CanonicalSymbol
{
/// <summary>
/// Package URL (e.g., pkg:npm/lodash@4.17.21).
/// May be null if package cannot be determined.
/// </summary>
public string? Purl { get; init; }
/// <summary>
/// Namespace/package (lowercase, dot-separated).
/// Example: "org.apache.log4j.core.lookup"
/// </summary>
public required string Namespace { get; init; }
/// <summary>
/// Type/class name (lowercase).
/// Example: "jndilookup"
/// Use "_" for languages without types (JS module-level functions).
/// </summary>
public required string Type { get; init; }
/// <summary>
/// Method/function name (lowercase).
/// Example: "lookup"
/// </summary>
public required string Method { get; init; }
/// <summary>
/// Simplified signature (lowercase, type names only).
/// Example: "(string)" or "(object, string, cancellationtoken)"
/// </summary>
public required string Signature { get; init; }
/// <summary>
/// Canonical ID: SHA-256 of "{purl}|{namespace}|{type}|{method}|{signature}".
/// Provides stable identity across sources.
/// </summary>
public required string CanonicalId { get; init; }
/// <summary>
/// Human-readable display name.
/// Example: "org.apache.log4j.core.lookup.JndiLookup.lookup(String)"
/// </summary>
public required string DisplayName { get; init; }
/// <summary>
/// Original raw symbol for debugging.
/// </summary>
public string? OriginalSymbol { get; init; }
/// <summary>
/// Source that produced this canonical symbol.
/// </summary>
public required SymbolSource Source { get; init; }
/// <summary>
/// Creates a canonical symbol with computed canonical ID.
/// </summary>
public static CanonicalSymbol Create(
string @namespace,
string type,
string method,
string signature,
SymbolSource source,
string? purl = null,
string? originalSymbol = null)
{
var ns = @namespace.ToLowerInvariant();
var t = type.ToLowerInvariant();
var m = method.ToLowerInvariant();
var sig = signature.ToLowerInvariant();
var p = purl?.ToLowerInvariant();
var canonicalId = ComputeCanonicalId(p, ns, t, m, sig);
var displayName = BuildDisplayName(ns, t, m, sig);
return new CanonicalSymbol
{
Purl = p,
Namespace = ns,
Type = t,
Method = m,
Signature = sig,
CanonicalId = canonicalId,
DisplayName = displayName,
OriginalSymbol = originalSymbol,
Source = source
};
}
/// <summary>
/// Computes a deterministic canonical ID from symbol components.
/// </summary>
public static string ComputeCanonicalId(
string? purl,
string @namespace,
string type,
string method,
string signature)
{
var input = $"{purl ?? string.Empty}|{@namespace}|{type}|{method}|{signature}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexStringLower(hash);
}
/// <summary>
/// Builds a human-readable display name.
/// </summary>
private static string BuildDisplayName(
string @namespace,
string type,
string method,
string signature)
{
if (type == "_")
{
// Module-level function
return $"{@namespace}.{method}{signature}";
}
return $"{@namespace}.{type}.{method}{signature}";
}
}

View File

@@ -0,0 +1,203 @@
// <copyright file="DotNetSymbolNormalizer.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Text.RegularExpressions;
namespace StellaOps.Reachability.Core.Symbols;
/// <summary>
/// Normalizes .NET symbols from Roslyn, IL metadata, ETW, and EventPipe.
/// Sprint: SPRINT_20260109_009_002 Task: Implement .NET normalizer
/// </summary>
public sealed partial class DotNetSymbolNormalizer : ISymbolNormalizer
{
private static readonly HashSet<SymbolSource> Sources =
[
SymbolSource.Roslyn,
SymbolSource.ILMetadata,
SymbolSource.EtwClr,
SymbolSource.EventPipe
];
/// <inheritdoc/>
public IReadOnlySet<SymbolSource> SupportedSources => Sources;
/// <inheritdoc/>
public bool CanNormalize(SymbolSource source) => Sources.Contains(source);
/// <inheritdoc/>
public CanonicalSymbol? Normalize(RawSymbol raw)
{
TryNormalize(raw, out var canonical, out _);
return canonical;
}
/// <inheritdoc/>
public bool TryNormalize(RawSymbol raw, out CanonicalSymbol? canonical, out string? error)
{
canonical = null;
error = null;
if (string.IsNullOrWhiteSpace(raw.Value))
{
error = "Symbol value is empty";
return false;
}
// Try different .NET symbol formats
if (TryParseFullSignature(raw, out canonical))
return true;
if (TryParseRoslynFormat(raw, out canonical))
return true;
if (TryParseEtwFormat(raw, out canonical))
return true;
if (TryParseSimpleFormat(raw, out canonical))
return true;
error = $"Cannot parse .NET symbol: {raw.Value}";
return false;
}
/// <summary>
/// Parses: "System.Void Namespace.Type::Method(Params)"
/// </summary>
private static bool TryParseFullSignature(RawSymbol raw, out CanonicalSymbol? canonical)
{
canonical = null;
// Pattern: [ReturnType] Namespace.Type::Method(Params)
var match = FullSignatureRegex().Match(raw.Value);
if (!match.Success)
return false;
var fullType = match.Groups["fulltype"].Value;
var method = match.Groups["method"].Value;
var @params = match.Groups["params"].Value;
// Split namespace from type
var lastDot = fullType.LastIndexOf('.');
if (lastDot <= 0)
return false;
var ns = fullType[..lastDot];
var type = fullType[(lastDot + 1)..];
var signature = SimplifySignature(@params);
canonical = CanonicalSymbol.Create(ns, type, method, signature, raw.Source, raw.Purl, raw.Value);
return true;
}
/// <summary>
/// Parses: "Namespace.Type.Method(Params)" (Roslyn format)
/// </summary>
private static bool TryParseRoslynFormat(RawSymbol raw, out CanonicalSymbol? canonical)
{
canonical = null;
// Pattern: Namespace.Type.Method(Params) or Namespace.Type.Method
var match = RoslynFormatRegex().Match(raw.Value);
if (!match.Success)
return false;
var parts = match.Groups["fullpath"].Value.Split('.');
if (parts.Length < 2)
return false;
var method = parts[^1];
var type = parts[^2];
var ns = string.Join(".", parts[..^2]);
var signature = SimplifySignature(match.Groups["params"].Value);
canonical = CanonicalSymbol.Create(ns, type, method, signature, raw.Source, raw.Purl, raw.Value);
return true;
}
/// <summary>
/// Parses: "MethodID=0x... ModuleID=0x..." (ETW format)
/// This is a simplified parse that extracts any method name present.
/// </summary>
private static bool TryParseEtwFormat(RawSymbol raw, out CanonicalSymbol? canonical)
{
canonical = null;
// ETW symbols are often just IDs without names - we need resolved symbols
if (raw.Value.Contains("MethodID=", StringComparison.OrdinalIgnoreCase))
{
// Check metadata for resolved name
if (raw.Metadata?.TryGetValue("ResolvedName", out var resolved) == true &&
!string.IsNullOrWhiteSpace(resolved))
{
var resolvedRaw = new RawSymbol(resolved, raw.Source, raw.Purl, raw.Metadata);
return TryParseFullSignature(resolvedRaw, out canonical) ||
TryParseRoslynFormat(resolvedRaw, out canonical);
}
}
return false;
}
/// <summary>
/// Parses: "Namespace.Type::Method" (simple double-colon format)
/// </summary>
private static bool TryParseSimpleFormat(RawSymbol raw, out CanonicalSymbol? canonical)
{
canonical = null;
// Pattern: Namespace.Type::Method
var match = SimpleFormatRegex().Match(raw.Value);
if (!match.Success)
return false;
var fullType = match.Groups["fulltype"].Value;
var method = match.Groups["method"].Value;
var lastDot = fullType.LastIndexOf('.');
if (lastDot <= 0)
return false;
var ns = fullType[..lastDot];
var type = fullType[(lastDot + 1)..];
canonical = CanonicalSymbol.Create(ns, type, method, "()", raw.Source, raw.Purl, raw.Value);
return true;
}
/// <summary>
/// Simplifies a parameter list to just type names.
/// </summary>
private static string SimplifySignature(string fullParams)
{
if (string.IsNullOrWhiteSpace(fullParams))
return "()";
// Split by comma, take last segment of each type name
var parts = fullParams.Split(',')
.Select(p =>
{
var trimmed = p.Trim();
// Handle generics: List<String> -> list
var genericIndex = trimmed.IndexOf('<');
if (genericIndex > 0)
trimmed = trimmed[..genericIndex];
// Take last part after dots
var lastDot = trimmed.LastIndexOf('.');
return (lastDot >= 0 ? trimmed[(lastDot + 1)..] : trimmed).ToLowerInvariant();
})
.Where(p => !string.IsNullOrEmpty(p));
return $"({string.Join(", ", parts)})";
}
[GeneratedRegex(@"^(?:[\w.<>]+\s+)?(?<fulltype>[\w.]+)::(?<method>\w+)\((?<params>[^)]*)\)$")]
private static partial Regex FullSignatureRegex();
[GeneratedRegex(@"^(?<fullpath>[\w.]+)(?:\((?<params>[^)]*)\))?$")]
private static partial Regex RoslynFormatRegex();
[GeneratedRegex(@"^(?<fulltype>[\w.]+)::(?<method>\w+)$")]
private static partial Regex SimpleFormatRegex();
}

View File

@@ -0,0 +1,67 @@
// <copyright file="ISymbolCanonicalizer.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.Reachability.Core.Symbols;
/// <summary>
/// Canonicalizes symbols from various sources into a portable format.
/// Sprint: SPRINT_20260109_009_002 Task: Create canonicalizer interface
/// </summary>
public interface ISymbolCanonicalizer
{
/// <summary>
/// Canonicalize a raw symbol to portable format.
/// </summary>
/// <param name="raw">Raw symbol from source.</param>
/// <returns>Canonical symbol with stable ID, or null if parsing fails.</returns>
CanonicalSymbol? Canonicalize(RawSymbol raw);
/// <summary>
/// Tries to canonicalize a raw symbol.
/// </summary>
/// <param name="raw">The raw symbol to canonicalize.</param>
/// <param name="canonical">The resulting canonical symbol if successful.</param>
/// <param name="error">Error message if canonicalization failed.</param>
/// <returns>True if canonicalization succeeded.</returns>
bool TryCanonicalize(RawSymbol raw, out CanonicalSymbol? canonical, out string? error);
/// <summary>
/// Match two canonical symbols with configurable tolerance.
/// </summary>
/// <param name="a">First symbol.</param>
/// <param name="b">Second symbol.</param>
/// <param name="options">Match options (null for default).</param>
/// <returns>Match result with confidence score.</returns>
SymbolMatchResult Match(CanonicalSymbol a, CanonicalSymbol b, SymbolMatchOptions? options = null);
/// <summary>
/// Batch canonicalize symbols.
/// </summary>
/// <param name="symbols">Raw symbols to canonicalize.</param>
/// <returns>Successfully canonicalized symbols (failed ones are omitted).</returns>
IReadOnlyList<CanonicalSymbol> CanonicalizeBatch(IEnumerable<RawSymbol> symbols);
/// <summary>
/// Batch canonicalize symbols with error tracking.
/// </summary>
/// <param name="symbols">Raw symbols to canonicalize.</param>
/// <returns>Results with both successes and failures.</returns>
CanonicalizationBatchResult CanonicalizeBatchWithErrors(IEnumerable<RawSymbol> symbols);
}
/// <summary>
/// Result of batch canonicalization.
/// </summary>
/// <param name="Successful">Successfully canonicalized symbols.</param>
/// <param name="Failed">Failed canonicalizations with error messages.</param>
public sealed record CanonicalizationBatchResult(
IReadOnlyList<CanonicalSymbol> Successful,
IReadOnlyList<CanonicalizationFailure> Failed);
/// <summary>
/// A failed canonicalization.
/// </summary>
/// <param name="Raw">The raw symbol that failed.</param>
/// <param name="Error">Error message explaining the failure.</param>
public sealed record CanonicalizationFailure(RawSymbol Raw, string Error);

View File

@@ -0,0 +1,38 @@
// <copyright file="ISymbolNormalizer.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.Reachability.Core.Symbols;
/// <summary>
/// Normalizes symbols from a specific source to canonical format.
/// Sprint: SPRINT_20260109_009_002 Task: Create normalizer interface
/// </summary>
public interface ISymbolNormalizer
{
/// <summary>
/// Gets the symbol sources this normalizer handles.
/// </summary>
IReadOnlySet<SymbolSource> SupportedSources { get; }
/// <summary>
/// Checks if this normalizer can handle the given source.
/// </summary>
bool CanNormalize(SymbolSource source);
/// <summary>
/// Normalizes a raw symbol to canonical format.
/// </summary>
/// <param name="raw">The raw symbol to normalize.</param>
/// <returns>The canonical symbol, or null if parsing fails.</returns>
CanonicalSymbol? Normalize(RawSymbol raw);
/// <summary>
/// Tries to normalize a raw symbol, returning success status.
/// </summary>
/// <param name="raw">The raw symbol to normalize.</param>
/// <param name="canonical">The resulting canonical symbol if successful.</param>
/// <param name="error">Error message if normalization failed.</param>
/// <returns>True if normalization succeeded.</returns>
bool TryNormalize(RawSymbol raw, out CanonicalSymbol? canonical, out string? error);
}

View File

@@ -0,0 +1,229 @@
// <copyright file="JavaSymbolNormalizer.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Text;
using System.Text.RegularExpressions;
namespace StellaOps.Reachability.Core.Symbols;
/// <summary>
/// Normalizes Java symbols from ASM bytecode, JFR, and JVMTI.
/// Sprint: SPRINT_20260109_009_002 Task: Implement Java normalizer
/// </summary>
public sealed partial class JavaSymbolNormalizer : ISymbolNormalizer
{
private static readonly HashSet<SymbolSource> Sources =
[
SymbolSource.JavaAsm,
SymbolSource.JavaJfr,
SymbolSource.JavaJvmti
];
/// <inheritdoc/>
public IReadOnlySet<SymbolSource> SupportedSources => Sources;
/// <inheritdoc/>
public bool CanNormalize(SymbolSource source) => Sources.Contains(source);
/// <inheritdoc/>
public CanonicalSymbol? Normalize(RawSymbol raw)
{
TryNormalize(raw, out var canonical, out _);
return canonical;
}
/// <inheritdoc/>
public bool TryNormalize(RawSymbol raw, out CanonicalSymbol? canonical, out string? error)
{
canonical = null;
error = null;
if (string.IsNullOrWhiteSpace(raw.Value))
{
error = "Symbol value is empty";
return false;
}
// Try different Java symbol formats
if (TryParseAsmFormat(raw, out canonical))
return true;
if (TryParseJfrFormat(raw, out canonical))
return true;
if (TryParsePatchFormat(raw, out canonical))
return true;
error = $"Cannot parse Java symbol: {raw.Value}";
return false;
}
/// <summary>
/// Parses: "org/apache/log4j/core/lookup/JndiLookup.lookup(Ljava/lang/String;)Ljava/lang/String;"
/// </summary>
private static bool TryParseAsmFormat(RawSymbol raw, out CanonicalSymbol? canonical)
{
canonical = null;
// Pattern: package/path/Class.method(descriptor)returnType
var match = AsmFormatRegex().Match(raw.Value);
if (!match.Success)
return false;
var pkgPath = match.Groups["pkg"].Value;
var className = match.Groups["class"].Value;
var method = match.Groups["method"].Value;
var descriptor = match.Groups["desc"].Value;
var ns = pkgPath.Replace('/', '.').ToLowerInvariant();
var type = className.ToLowerInvariant();
var signature = ParseJvmDescriptor(descriptor);
canonical = CanonicalSymbol.Create(ns, type, method, signature, raw.Source, raw.Purl, raw.Value);
return true;
}
/// <summary>
/// Parses: "org.apache.log4j.core.lookup.JndiLookup.lookup(String)" (JFR format)
/// </summary>
private static bool TryParseJfrFormat(RawSymbol raw, out CanonicalSymbol? canonical)
{
canonical = null;
// Pattern: package.Class.method(SimpleTypes)
var match = JfrFormatRegex().Match(raw.Value);
if (!match.Success)
return false;
var parts = match.Groups["fullpath"].Value.Split('.');
if (parts.Length < 2)
return false;
var method = parts[^1];
var type = parts[^2];
var ns = string.Join(".", parts[..^2]);
var @params = match.Groups["params"].Value;
// JFR uses simple type names already
var signature = SimplifyJfrParams(@params);
canonical = CanonicalSymbol.Create(ns, type, method, signature, raw.Source, raw.Purl, raw.Value);
return true;
}
/// <summary>
/// Parses: "org.apache.logging.log4j.core.lookup.JndiLookup#lookup" (patch analysis format)
/// </summary>
private static bool TryParsePatchFormat(RawSymbol raw, out CanonicalSymbol? canonical)
{
canonical = null;
// Pattern: package.Class#method
var match = PatchFormatRegex().Match(raw.Value);
if (!match.Success)
return false;
var parts = match.Groups["fullpath"].Value.Split('.');
if (parts.Length < 2)
return false;
var type = parts[^1];
var ns = string.Join(".", parts[..^1]);
var method = match.Groups["method"].Value;
canonical = CanonicalSymbol.Create(ns, type, method, "()", raw.Source, raw.Purl, raw.Value);
return true;
}
/// <summary>
/// Parses a JVM type descriptor into simple type names.
/// </summary>
private static string ParseJvmDescriptor(string descriptor)
{
if (string.IsNullOrEmpty(descriptor))
return "()";
var types = new List<string>();
var i = 0;
while (i < descriptor.Length)
{
var (type, consumed) = ParseOneJvmType(descriptor, i);
if (consumed == 0)
break;
types.Add(type);
i += consumed;
}
return $"({string.Join(", ", types)})";
}
/// <summary>
/// Parses a single JVM type from a descriptor.
/// </summary>
private static (string Type, int Consumed) ParseOneJvmType(string descriptor, int start)
{
if (start >= descriptor.Length)
return (string.Empty, 0);
var c = descriptor[start];
return c switch
{
'B' => ("byte", 1),
'C' => ("char", 1),
'D' => ("double", 1),
'F' => ("float", 1),
'I' => ("int", 1),
'J' => ("long", 1),
'S' => ("short", 1),
'Z' => ("boolean", 1),
'V' => ("void", 1),
'[' => ParseArrayType(descriptor, start),
'L' => ParseObjectType(descriptor, start),
_ => (string.Empty, 0)
};
}
private static (string Type, int Consumed) ParseArrayType(string descriptor, int start)
{
var (elementType, consumed) = ParseOneJvmType(descriptor, start + 1);
return ($"{elementType}[]", consumed + 1);
}
private static (string Type, int Consumed) ParseObjectType(string descriptor, int start)
{
// Lpackage/Class; -> class
var semi = descriptor.IndexOf(';', start);
if (semi < 0)
return (string.Empty, 0);
var fullPath = descriptor[(start + 1)..semi];
var lastSlash = fullPath.LastIndexOf('/');
var className = lastSlash >= 0 ? fullPath[(lastSlash + 1)..] : fullPath;
return (className.ToLowerInvariant(), semi - start + 1);
}
private static string SimplifyJfrParams(string @params)
{
if (string.IsNullOrWhiteSpace(@params))
return "()";
var parts = @params.Split(',')
.Select(p => p.Trim().ToLowerInvariant())
.Where(p => !string.IsNullOrEmpty(p));
return $"({string.Join(", ", parts)})";
}
[GeneratedRegex(@"^(?<pkg>[\w/]+)/(?<class>\w+)\.(?<method>\w+)\((?<desc>[^)]*)\)")]
private static partial Regex AsmFormatRegex();
[GeneratedRegex(@"^(?<fullpath>[\w.]+)(?:\((?<params>[^)]*)\))?$")]
private static partial Regex JfrFormatRegex();
[GeneratedRegex(@"^(?<fullpath>[\w.]+)#(?<method>\w+)$")]
private static partial Regex PatchFormatRegex();
}

View File

@@ -0,0 +1,32 @@
// <copyright file="RawSymbol.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.Reachability.Core.Symbols;
/// <summary>
/// Raw symbol input before canonicalization.
/// Sprint: SPRINT_20260109_009_002 Task: Implement raw symbol model
/// </summary>
/// <param name="Value">The raw symbol string as received from the source.</param>
/// <param name="Source">The source that produced this symbol.</param>
/// <param name="Purl">Optional package URL for the symbol's owning package.</param>
/// <param name="Metadata">Optional additional metadata from the source.</param>
public sealed record RawSymbol(
string Value,
SymbolSource Source,
string? Purl = null,
IReadOnlyDictionary<string, string>? Metadata = null)
{
/// <summary>
/// Creates a raw symbol from a value and source.
/// </summary>
public static RawSymbol Create(string value, SymbolSource source) =>
new(value, source);
/// <summary>
/// Creates a raw symbol with a package URL.
/// </summary>
public static RawSymbol Create(string value, SymbolSource source, string purl) =>
new(value, source, purl);
}

View File

@@ -0,0 +1,98 @@
// <copyright file="SymbolCanonicalizer.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.Reachability.Core.Symbols;
/// <summary>
/// Main canonicalizer that delegates to source-specific normalizers.
/// Sprint: SPRINT_20260109_009_002 Task: Implement symbol canonicalizer
/// </summary>
public sealed class SymbolCanonicalizer : ISymbolCanonicalizer
{
private readonly IReadOnlyList<ISymbolNormalizer> _normalizers;
private readonly SymbolMatcher _matcher;
/// <summary>
/// Initializes a new instance with default normalizers.
/// </summary>
public SymbolCanonicalizer()
: this([new DotNetSymbolNormalizer(), new JavaSymbolNormalizer()])
{
}
/// <summary>
/// Initializes a new instance with custom normalizers.
/// </summary>
public SymbolCanonicalizer(IEnumerable<ISymbolNormalizer> normalizers)
{
_normalizers = normalizers.ToList();
_matcher = new SymbolMatcher();
}
/// <inheritdoc/>
public CanonicalSymbol? Canonicalize(RawSymbol raw)
{
TryCanonicalize(raw, out var canonical, out _);
return canonical;
}
/// <inheritdoc/>
public bool TryCanonicalize(RawSymbol raw, out CanonicalSymbol? canonical, out string? error)
{
canonical = null;
error = null;
// Find a normalizer that supports this source
var normalizer = _normalizers.FirstOrDefault(n => n.CanNormalize(raw.Source));
if (normalizer == null)
{
// Try all normalizers as fallback
foreach (var n in _normalizers)
{
if (n.TryNormalize(raw, out canonical, out _))
return true;
}
error = $"No normalizer found for source {raw.Source}";
return false;
}
return normalizer.TryNormalize(raw, out canonical, out error);
}
/// <inheritdoc/>
public SymbolMatchResult Match(CanonicalSymbol a, CanonicalSymbol b, SymbolMatchOptions? options = null)
{
return _matcher.Match(a, b, options);
}
/// <inheritdoc/>
public IReadOnlyList<CanonicalSymbol> CanonicalizeBatch(IEnumerable<RawSymbol> symbols)
{
var result = CanonicalizeBatchWithErrors(symbols);
return result.Successful;
}
/// <inheritdoc/>
public CanonicalizationBatchResult CanonicalizeBatchWithErrors(IEnumerable<RawSymbol> symbols)
{
var successful = new List<CanonicalSymbol>();
var failed = new List<CanonicalizationFailure>();
foreach (var raw in symbols)
{
if (TryCanonicalize(raw, out var canonical, out var error) && canonical != null)
{
successful.Add(canonical);
}
else
{
failed.Add(new CanonicalizationFailure(raw, error ?? "Unknown error"));
}
}
return new CanonicalizationBatchResult(successful, failed);
}
}

View File

@@ -0,0 +1,75 @@
// <copyright file="SymbolMatchOptions.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.Reachability.Core.Symbols;
/// <summary>
/// Options for symbol matching behavior.
/// Sprint: SPRINT_20260109_009_002 Task: Implement match options
/// </summary>
public sealed record SymbolMatchOptions
{
/// <summary>
/// Minimum confidence threshold for a fuzzy match to be accepted.
/// Default: 0.7 (70%).
/// </summary>
public double MinimumConfidence { get; init; } = 0.7;
/// <summary>
/// Minimum similarity threshold for namespace matching.
/// Default: 0.8 (80%).
/// </summary>
public double NamespaceThreshold { get; init; } = 0.8;
/// <summary>
/// Minimum similarity threshold for type matching.
/// Default: 0.9 (90%).
/// </summary>
public double TypeThreshold { get; init; } = 0.9;
/// <summary>
/// Minimum similarity threshold for signature matching.
/// Default: 0.6 (60%) - signatures may differ due to overloads.
/// </summary>
public double SignatureThreshold { get; init; } = 0.6;
/// <summary>
/// Whether to consider PURL in matching.
/// Default: true.
/// </summary>
public bool ConsiderPurl { get; init; } = true;
/// <summary>
/// Whether to allow cross-source matching (e.g., Roslyn to EtwClr).
/// Default: true.
/// </summary>
public bool AllowCrossSourceMatching { get; init; } = true;
/// <summary>
/// Default options with reasonable thresholds.
/// </summary>
public static SymbolMatchOptions Default { get; } = new();
/// <summary>
/// Strict options requiring higher confidence.
/// </summary>
public static SymbolMatchOptions Strict { get; } = new()
{
MinimumConfidence = 0.9,
NamespaceThreshold = 0.95,
TypeThreshold = 0.95,
SignatureThreshold = 0.8
};
/// <summary>
/// Lenient options accepting lower confidence.
/// </summary>
public static SymbolMatchOptions Lenient { get; } = new()
{
MinimumConfidence = 0.5,
NamespaceThreshold = 0.6,
TypeThreshold = 0.7,
SignatureThreshold = 0.4
};
}

View File

@@ -0,0 +1,56 @@
// <copyright file="SymbolMatchResult.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.Reachability.Core.Symbols;
/// <summary>
/// Result of matching two canonical symbols.
/// Sprint: SPRINT_20260109_009_002 Task: Implement match result model
/// </summary>
/// <param name="MatchType">Type of match found.</param>
/// <param name="Confidence">Confidence score (0.0 to 1.0).</param>
/// <param name="Reason">Human-readable explanation of match/no-match.</param>
public sealed record SymbolMatchResult(
SymbolMatchType MatchType,
double Confidence,
string? Reason = null)
{
/// <summary>
/// Whether a match was found (exact or fuzzy).
/// </summary>
public bool IsMatch => MatchType is SymbolMatchType.Exact or SymbolMatchType.Fuzzy;
/// <summary>
/// Creates an exact match result.
/// </summary>
public static SymbolMatchResult Exact(double confidence = 1.0) =>
new(SymbolMatchType.Exact, confidence, "Exact canonical ID match");
/// <summary>
/// Creates a fuzzy match result.
/// </summary>
public static SymbolMatchResult Fuzzy(double confidence, string reason) =>
new(SymbolMatchType.Fuzzy, confidence, reason);
/// <summary>
/// Creates a no-match result.
/// </summary>
public static SymbolMatchResult NoMatch(string? reason = null) =>
new(SymbolMatchType.NoMatch, 0.0, reason ?? "No match found");
}
/// <summary>
/// Type of symbol match.
/// </summary>
public enum SymbolMatchType
{
/// <summary>No match found.</summary>
NoMatch = 0,
/// <summary>Exact canonical ID match.</summary>
Exact = 1,
/// <summary>Fuzzy match based on components.</summary>
Fuzzy = 2
}

View File

@@ -0,0 +1,228 @@
// <copyright file="SymbolMatcher.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.Reachability.Core.Symbols;
/// <summary>
/// Matches canonical symbols with configurable tolerance.
/// Sprint: SPRINT_20260109_009_002 Task: Implement symbol matcher
/// </summary>
public sealed class SymbolMatcher
{
/// <summary>
/// Matches two canonical symbols.
/// </summary>
public SymbolMatchResult Match(CanonicalSymbol a, CanonicalSymbol b, SymbolMatchOptions? options = null)
{
options ??= SymbolMatchOptions.Default;
// 1. Exact canonical ID match
if (a.CanonicalId == b.CanonicalId)
{
return SymbolMatchResult.Exact();
}
// 2. Check PURL mismatch (if both have PURLs and they differ, likely no match)
if (options.ConsiderPurl &&
!string.IsNullOrEmpty(a.Purl) &&
!string.IsNullOrEmpty(b.Purl) &&
a.Purl != b.Purl)
{
return SymbolMatchResult.NoMatch("Different PURLs indicate different packages");
}
// 3. Namespace + Type + Method match (signature may differ due to overloads)
if (a.Namespace == b.Namespace && a.Type == b.Type && a.Method == b.Method)
{
var sigSimilarity = ComputeSignatureSimilarity(a.Signature, b.Signature);
if (sigSimilarity >= options.SignatureThreshold)
{
var confidence = 0.8 + sigSimilarity * 0.15;
return SymbolMatchResult.Fuzzy(confidence, "Same namespace, type, and method with similar signature");
}
// Same method but different signature - might be overload
return SymbolMatchResult.Fuzzy(0.7, "Same namespace, type, and method (possible overload)");
}
// 4. Method name match with namespace/type similarity
if (a.Method == b.Method)
{
var nsSimilarity = ComputeNamespaceSimilarity(a.Namespace, b.Namespace);
var typeSimilarity = ComputeLevenshteinSimilarity(a.Type, b.Type);
if (nsSimilarity >= options.NamespaceThreshold && typeSimilarity >= options.TypeThreshold)
{
var confidence = 0.5 + nsSimilarity * 0.2 + typeSimilarity * 0.2;
if (confidence >= options.MinimumConfidence)
{
return SymbolMatchResult.Fuzzy(confidence, "Same method with similar namespace and type");
}
}
}
// 5. No match
return SymbolMatchResult.NoMatch();
}
/// <summary>
/// Computes similarity between two namespaces.
/// Uses segment-based comparison.
/// </summary>
private static double ComputeNamespaceSimilarity(string a, string b)
{
if (a == b)
return 1.0;
var aParts = a.Split('.');
var bParts = b.Split('.');
// Count matching segments from the start
var matchingStart = 0;
var minLen = Math.Min(aParts.Length, bParts.Length);
for (var i = 0; i < minLen; i++)
{
if (aParts[i] == bParts[i])
matchingStart++;
else
break;
}
// Count matching segments from the end
var matchingEnd = 0;
for (var i = 0; i < minLen - matchingStart; i++)
{
if (aParts[^(i + 1)] == bParts[^(i + 1)])
matchingEnd++;
else
break;
}
var totalMatching = matchingStart + matchingEnd;
var maxLen = Math.Max(aParts.Length, bParts.Length);
return (double)totalMatching / maxLen;
}
/// <summary>
/// Computes similarity between two signatures.
/// </summary>
private static double ComputeSignatureSimilarity(string a, string b)
{
if (a == b)
return 1.0;
// Extract parameter types
var aTypes = ExtractParamTypes(a);
var bTypes = ExtractParamTypes(b);
if (aTypes.Count == 0 && bTypes.Count == 0)
return 1.0;
if (aTypes.Count != bTypes.Count)
return 0.5; // Different arity
// Compare each parameter
var matches = aTypes.Zip(bTypes)
.Count(p => p.First == p.Second || IsCompatibleType(p.First, p.Second));
return (double)matches / aTypes.Count;
}
/// <summary>
/// Extracts parameter types from a signature string.
/// </summary>
private static List<string> ExtractParamTypes(string signature)
{
// "(string, int)" -> ["string", "int"]
var inner = signature.Trim('(', ')');
if (string.IsNullOrWhiteSpace(inner))
return [];
return inner.Split(',')
.Select(s => s.Trim())
.Where(s => !string.IsNullOrEmpty(s))
.ToList();
}
/// <summary>
/// Checks if two types are compatible (e.g., String vs string).
/// </summary>
private static bool IsCompatibleType(string a, string b)
{
// Common type aliases
var aliases = new Dictionary<string, string>
{
["string"] = "string",
["system.string"] = "string",
["java.lang.string"] = "string",
["int"] = "int",
["int32"] = "int",
["system.int32"] = "int",
["integer"] = "int",
["bool"] = "bool",
["boolean"] = "bool",
["system.boolean"] = "bool",
["object"] = "object",
["system.object"] = "object",
["java.lang.object"] = "object"
};
var normalizedA = aliases.GetValueOrDefault(a, a);
var normalizedB = aliases.GetValueOrDefault(b, b);
return normalizedA == normalizedB;
}
/// <summary>
/// Computes Levenshtein distance-based similarity.
/// </summary>
private static double ComputeLevenshteinSimilarity(string a, string b)
{
if (a == b)
return 1.0;
var distance = LevenshteinDistance(a, b);
var maxLen = Math.Max(a.Length, b.Length);
if (maxLen == 0)
return 1.0;
return 1.0 - (double)distance / maxLen;
}
/// <summary>
/// Computes Levenshtein edit distance.
/// </summary>
private static int LevenshteinDistance(string a, string b)
{
if (string.IsNullOrEmpty(a))
return b?.Length ?? 0;
if (string.IsNullOrEmpty(b))
return a.Length;
var m = a.Length;
var n = b.Length;
var dp = new int[m + 1, n + 1];
for (var i = 0; i <= m; i++)
dp[i, 0] = i;
for (var j = 0; j <= n; j++)
dp[0, j] = j;
for (var i = 1; i <= m; i++)
{
for (var j = 1; j <= n; j++)
{
var cost = a[i - 1] == b[j - 1] ? 0 : 1;
dp[i, j] = Math.Min(
Math.Min(dp[i - 1, j] + 1, dp[i, j - 1] + 1),
dp[i - 1, j - 1] + cost);
}
}
return dp[m, n];
}
}

View File

@@ -0,0 +1,71 @@
// <copyright file="SymbolSource.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.Reachability.Core.Symbols;
/// <summary>
/// Source of symbol information.
/// Sprint: SPRINT_20260109_009_002 Task: Implement symbol source enum
/// </summary>
public enum SymbolSource
{
/// <summary>Unknown source.</summary>
Unknown = 0,
// .NET sources (10-19)
/// <summary>Roslyn semantic analysis.</summary>
Roslyn = 10,
/// <summary>IL metadata reflection.</summary>
ILMetadata = 11,
/// <summary>ETW CLR provider.</summary>
EtwClr = 12,
/// <summary>.NET EventPipe.</summary>
EventPipe = 13,
// Java sources (20-29)
/// <summary>ASM bytecode analysis.</summary>
JavaAsm = 20,
/// <summary>Java Flight Recorder.</summary>
JavaJfr = 21,
/// <summary>JVMTI agent.</summary>
JavaJvmti = 22,
// Native sources (30-39)
/// <summary>ELF symbol table.</summary>
ElfSymtab = 30,
/// <summary>PE export table.</summary>
PeExport = 31,
/// <summary>DWARF debug info.</summary>
Dwarf = 32,
/// <summary>PDB debug info.</summary>
Pdb = 33,
/// <summary>eBPF uprobe.</summary>
EbpfUprobe = 34,
// Script sources (40-49)
/// <summary>V8 profiler (Node.js).</summary>
V8Profiler = 40,
/// <summary>Python sys.settrace.</summary>
PythonTrace = 41,
/// <summary>PHP Xdebug.</summary>
PhpXdebug = 42,
// Manual/derived sources (50-59)
/// <summary>Patch analysis extraction.</summary>
PatchAnalysis = 50,
/// <summary>Manual curation.</summary>
ManualCuration = 51
}

View File

@@ -0,0 +1,24 @@
# Replay.Core Tests Charter
## Mission
- Validate Replay.Core behavior with deterministic unit and integration tests.
## Responsibilities
- Keep test inputs deterministic (fixed IDs, timestamps, and seeded data).
- Tag tests with TestCategories and avoid running integration work in unit suites.
- Avoid culture-dependent parsing in fixtures.
## Required Reading
- docs/modules/replay/architecture.md
- docs/modules/platform/architecture-overview.md
## Working Directory & Scope
- Primary: src/__Libraries/StellaOps.Replay.Core.Tests
## Testing Expectations
- Cover manifest validation, exporter behavior, proof serialization, and determinism paths.
- Ensure tests do not rely on ambient environment or system time.
## Working Agreement
- Update sprint status in docs/implplan/SPRINT_*.md and local TASKS.md.
- Keep outputs deterministic and ASCII-only in comments and logs.

View File

@@ -0,0 +1,10 @@
# Replay.Core Tests Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0106-M | DONE | Revalidated 2026-01-08; maintainability audit for Replay.Core.Tests. |
| AUDIT-0106-T | DONE | Revalidated 2026-01-08; test coverage audit for Replay.Core.Tests. |
| AUDIT-0106-A | TODO | Pending approval (revalidated 2026-01-08). |

View File

@@ -0,0 +1,10 @@
# Replay.Core Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0107-M | DONE | Revalidated 2026-01-08; maintainability audit for Replay.Core. |
| AUDIT-0107-T | DONE | Revalidated 2026-01-08; test coverage audit for Replay.Core. |
| AUDIT-0107-A | TODO | Pending approval (revalidated 2026-01-08). |

View File

@@ -0,0 +1,27 @@
# Replay Library Charter
## Mission
- Provide deterministic replay execution and snapshot loading for verification and audit workflows.
## Responsibilities
- Preserve determinism with injected time and invariant parsing.
- Validate snapshot digests for length, format, and path safety.
- Keep production dependencies free of test-only libraries.
- Maintain offline-first behavior and stable outputs.
## Required Reading
- docs/modules/replay/architecture.md
- docs/modules/replay/replay-proof-schema.md
- docs/modules/platform/architecture-overview.md
## Working Directory and Scope
- Primary: src/__Libraries/StellaOps.Replay
- Tests: src/__Libraries/__Tests/StellaOps.Replay.Tests
## Testing Expectations
- Cover replay failure timestamps, digest validation failures, and loader path safety.
- Use deterministic inputs (fixed timestamps, IDs, and seeded data).
## Working Agreement
- Update sprint status in docs/implplan/SPRINT_*.md and local TASKS.md.
- Keep outputs deterministic and ASCII-only in logs and comments.

View File

@@ -0,0 +1,10 @@
# Replay Library Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0108-M | DONE | Revalidated 2026-01-08; maintainability audit for Replay library. |
| AUDIT-0108-T | DONE | Revalidated 2026-01-08; test coverage audit for Replay library. |
| AUDIT-0108-A | TODO | Pending approval (revalidated 2026-01-08). |

View File

@@ -0,0 +1,24 @@
# Resolver Tests Charter
## Mission
- Validate deterministic resolver behavior, graph validation rules, and digest stability.
## Responsibilities
- Keep test inputs deterministic (fixed IDs, timestamps, and seeded data).
- Avoid culture-dependent parsing and non-ASCII literals in fixtures.
- Keep property-based tests bounded and reproducible.
## Required Reading
- docs/modules/reach-graph/architecture.md
- docs/modules/platform/architecture-overview.md
## Working Directory and Scope
- Primary: src/__Libraries/StellaOps.Resolver.Tests
## Testing Expectations
- Cover cycle detection, graph normalization, and digest invariants.
- Assert deterministic ordering and stable hashes across runs.
## Working Agreement
- Update sprint status in docs/implplan/SPRINT_*.md and local TASKS.md.
- Keep outputs deterministic and ASCII-only in logs and comments.

View File

@@ -0,0 +1,10 @@
# Resolver Tests Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0109-M | DONE | Revalidated 2026-01-08; maintainability audit for Resolver tests. |
| AUDIT-0109-T | DONE | Revalidated 2026-01-08; test coverage audit for Resolver tests. |
| AUDIT-0109-A | TODO | Pending approval (revalidated 2026-01-08). |

View File

@@ -0,0 +1,25 @@
# Resolver Library Charter
## Mission
- Provide deterministic graph resolution with stable ordering, digests, and validation.
## Responsibilities
- Enforce determinism by avoiding ambient time and culture-sensitive behavior.
- Maintain canonical serialization and stable ordering guarantees.
- Keep outputs ASCII-only in logs and comments.
## Required Reading
- docs/modules/reach-graph/architecture.md
- docs/modules/platform/architecture-overview.md
## Working Directory and Scope
- Primary: src/__Libraries/StellaOps.Resolver
- Tests: src/__Libraries/StellaOps.Resolver.Tests
## Testing Expectations
- Cover graph validation, traversal ordering, digest stability, and resolvedAt handling.
- Validate NFC normalization and deterministic ordering across runs.
## Working Agreement
- Update sprint status in docs/implplan/SPRINT_*.md and local TASKS.md.
- Keep outputs deterministic and ASCII-only in logs and comments.

View File

@@ -0,0 +1,10 @@
# Resolver Library Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0110-M | DONE | Revalidated 2026-01-08; maintainability audit for Resolver library. |
| AUDIT-0110-T | DONE | Revalidated 2026-01-08; test coverage audit for Resolver library. |
| AUDIT-0110-A | TODO | Pending approval (revalidated 2026-01-08). |

View File

@@ -0,0 +1,10 @@
# Signals.Contracts Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0111-M | DONE | Revalidated 2026-01-08; maintainability audit for Signals.Contracts. |
| AUDIT-0111-T | DONE | Revalidated 2026-01-08; test coverage audit for Signals.Contracts. |
| AUDIT-0111-A | TODO | Pending approval (revalidated 2026-01-08). |

View File

@@ -346,18 +346,18 @@ public sealed class Spdx3Parser : ISpdx3Parser
}
// Parse config source digests
var configSourceDigests = ImmutableArray<Spdx3Hash>.Empty;
var configSourceDigests = ImmutableArray<Spdx3BuildHash>.Empty;
if (element.TryGetProperty("build_configSourceDigest", out var digestsElement) &&
digestsElement.ValueKind == JsonValueKind.Array)
{
var digests = new List<Spdx3Hash>();
var digests = new List<Spdx3BuildHash>();
foreach (var digestEl in digestsElement.EnumerateArray())
{
if (digestEl.ValueKind == JsonValueKind.Object)
{
var algorithm = GetStringProperty(digestEl, "algorithm") ?? "sha256";
var hashValue = GetStringProperty(digestEl, "hashValue") ?? string.Empty;
digests.Add(new Spdx3Hash { Algorithm = algorithm, HashValue = hashValue });
digests.Add(new Spdx3BuildHash { Algorithm = algorithm, HashValue = hashValue });
}
}
configSourceDigests = digests.ToImmutableArray();

View File

@@ -0,0 +1,10 @@
# SPDX3 Library Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0112-M | DONE | Revalidated 2026-01-08; maintainability audit for SPDX3 library. |
| AUDIT-0112-T | DONE | Revalidated 2026-01-08; test coverage audit for SPDX3 library. |
| AUDIT-0112-A | TODO | Pending approval (revalidated 2026-01-08). |

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);
}
}