This commit is contained in:
master
2026-01-07 10:25:34 +02:00
726 changed files with 147397 additions and 1364 deletions

View File

@@ -22,6 +22,7 @@ internal sealed class AdvisoryTaskWorker : BackgroundService
private readonly AdvisoryPipelineMetrics _metrics;
private readonly IAdvisoryPipelineExecutor _executor;
private readonly TimeProvider _timeProvider;
private readonly Func<double> _jitterSource;
private readonly ILogger<AdvisoryTaskWorker> _logger;
private int _consecutiveErrors;
@@ -32,7 +33,8 @@ internal sealed class AdvisoryTaskWorker : BackgroundService
AdvisoryPipelineMetrics metrics,
IAdvisoryPipelineExecutor executor,
TimeProvider timeProvider,
ILogger<AdvisoryTaskWorker> logger)
ILogger<AdvisoryTaskWorker> logger,
Func<double>? jitterSource = null)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
@@ -40,6 +42,7 @@ internal sealed class AdvisoryTaskWorker : BackgroundService
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_jitterSource = jitterSource ?? Random.Shared.NextDouble;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -146,8 +149,8 @@ internal sealed class AdvisoryTaskWorker : BackgroundService
// Exponential backoff: base * 2^(errorCount-1), capped at max
var backoff = Math.Min(BaseRetryDelaySeconds * Math.Pow(2, errorCount - 1), MaxRetryDelaySeconds);
// Add jitter (+/- JitterFactor percent)
var jitter = backoff * JitterFactor * (2 * Random.Shared.NextDouble() - 1);
// Add jitter (+/- JitterFactor percent) using injectable source for testability
var jitter = backoff * JitterFactor * (2 * _jitterSource() - 1);
return Math.Max(BaseRetryDelaySeconds, backoff + jitter);
}

View File

@@ -12,6 +12,8 @@ namespace StellaOps.AdvisoryAI.Tests;
/// Sprint: SPRINT_20251226_015_AI_zastava_companion
/// Task: ZASTAVA-19
/// </summary>
[Trait("Category", TestCategories.Integration)]
[Trait("BlastRadius", TestCategories.BlastRadius.Advisories)]
public sealed class ExplanationGeneratorIntegrationTests
{
[Trait("Category", TestCategories.Unit)]

View File

@@ -26,6 +26,7 @@ public sealed class AirGapTelemetry
private readonly Queue<(string Tenant, long Sequence)> _evictionQueue = new();
private readonly object _cacheLock = new();
private readonly int _maxTenantEntries;
private readonly int _maxEvictionQueueSize;
private long _sequence;
private readonly ObservableGauge<long> _anchorAgeGauge;
@@ -36,6 +37,8 @@ public sealed class AirGapTelemetry
{
var maxEntries = options.Value.MaxTenantEntries;
_maxTenantEntries = maxEntries > 0 ? maxEntries : 1000;
// Bound eviction queue to 3x tenant entries to prevent unbounded memory growth
_maxEvictionQueueSize = _maxTenantEntries * 3;
_logger = logger;
_anchorAgeGauge = Meter.CreateObservableGauge("airgap_time_anchor_age_seconds", ObserveAges);
_budgetGauge = Meter.CreateObservableGauge("airgap_staleness_budget_seconds", ObserveBudgets);
@@ -146,6 +149,7 @@ public sealed class AirGapTelemetry
private void TrimCache()
{
// Evict stale tenant entries when cache is over limit
while (_latestByTenant.Count > _maxTenantEntries && _evictionQueue.Count > 0)
{
var (tenant, sequence) = _evictionQueue.Dequeue();
@@ -154,6 +158,19 @@ public sealed class AirGapTelemetry
_latestByTenant.TryRemove(tenant, out _);
}
}
// Trim eviction queue to prevent unbounded memory growth
// Discard stale entries that no longer match current tenant state
while (_evictionQueue.Count > _maxEvictionQueueSize)
{
var (tenant, sequence) = _evictionQueue.Dequeue();
// Only actually evict if this is still the current entry for the tenant
if (_latestByTenant.TryGetValue(tenant, out var entry) && entry.Sequence == sequence)
{
_latestByTenant.TryRemove(tenant, out _);
}
// Otherwise the queue entry is stale and can be discarded
}
}
private readonly record struct TelemetryEntry(long Age, long Budget, long Sequence);

View File

@@ -209,20 +209,19 @@ public sealed record EvidenceGraphMetadata
/// </summary>
public sealed class EvidenceGraphSerializer
{
// Use default escaping for deterministic output (no UnsafeRelaxedJsonEscaping)
private static readonly JsonSerializerOptions SerializerOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private static readonly JsonSerializerOptions PrettySerializerOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
/// <summary>

View File

@@ -4,6 +4,7 @@
// Part of Step 3: Normalization
// =============================================================================
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
@@ -225,7 +226,9 @@ public static class JsonNormalizer
char.IsDigit(value[3]) &&
value[4] == '-')
{
return DateTimeOffset.TryParse(value, out _);
// Use InvariantCulture for deterministic parsing
return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture,
DateTimeStyles.RoundtripKind, out _);
}
return false;

View File

@@ -16,11 +16,10 @@ namespace StellaOps.AirGap.Importer.Reconciliation.Parsers;
/// </summary>
public sealed class CycloneDxParser : ISbomParser
{
private static readonly JsonSerializerOptions JsonOptions = new()
private static readonly JsonDocumentOptions DocumentOptions = new()
{
PropertyNameCaseInsensitive = true,
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip
CommentHandling = JsonCommentHandling.Skip
};
public SbomFormat DetectFormat(string filePath)
@@ -87,7 +86,7 @@ public sealed class CycloneDxParser : ISbomParser
try
{
using var document = await JsonDocument.ParseAsync(stream, default, cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, DocumentOptions, cancellationToken);
var root = document.RootElement;
// Validate bomFormat

View File

@@ -14,11 +14,10 @@ namespace StellaOps.AirGap.Importer.Reconciliation.Parsers;
/// </summary>
public sealed class DsseAttestationParser : IAttestationParser
{
private static readonly JsonSerializerOptions JsonOptions = new()
private static readonly JsonDocumentOptions DocumentOptions = new()
{
PropertyNameCaseInsensitive = true,
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip
CommentHandling = JsonCommentHandling.Skip
};
public bool IsAttestation(string filePath)
@@ -92,7 +91,7 @@ public sealed class DsseAttestationParser : IAttestationParser
try
{
using var document = await JsonDocument.ParseAsync(stream, default, cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, DocumentOptions, cancellationToken);
var root = document.RootElement;
// Parse DSSE envelope

View File

@@ -11,7 +11,7 @@ namespace StellaOps.AirGap.Importer.Reconciliation.Parsers;
/// <summary>
/// Transforms SBOMs into a canonical form for deterministic hashing and comparison.
/// Applies normalization rules per advisory §5 step 3.
/// Applies normalization rules per advisory section 5 step 3.
/// </summary>
public sealed class SbomNormalizer
{

View File

@@ -15,11 +15,10 @@ namespace StellaOps.AirGap.Importer.Reconciliation.Parsers;
/// </summary>
public sealed class SpdxParser : ISbomParser
{
private static readonly JsonSerializerOptions JsonOptions = new()
private static readonly JsonDocumentOptions DocumentOptions = new()
{
PropertyNameCaseInsensitive = true,
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip
CommentHandling = JsonCommentHandling.Skip
};
public SbomFormat DetectFormat(string filePath)
@@ -84,7 +83,7 @@ public sealed class SpdxParser : ISbomParser
try
{
using var document = await JsonDocument.ParseAsync(stream, default, cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, DocumentOptions, cancellationToken);
var root = document.RootElement;
// Validate spdxVersion

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using System.Text;
namespace StellaOps.AirGap.Importer.Validation;
@@ -14,7 +15,9 @@ internal static class DssePreAuthenticationEncoding
}
var payloadTypeByteCount = Encoding.UTF8.GetByteCount(payloadType);
var header = $"{Prefix} {payloadTypeByteCount} {payloadType} {payload.Length} ";
// Use InvariantCulture to ensure ASCII decimal digits per DSSE spec
var header = string.Create(CultureInfo.InvariantCulture,
$"{Prefix} {payloadTypeByteCount} {payloadType} {payload.Length} ");
var headerBytes = Encoding.UTF8.GetBytes(header);
var buffer = new byte[headerBytes.Length + payload.Length];

View File

@@ -128,7 +128,14 @@ public sealed class RuleBundleValidator
var digestErrors = new List<string>();
foreach (var file in manifest.Files)
{
var filePath = Path.Combine(request.BundleDirectory, file.Name);
// Validate path to prevent traversal attacks
if (!PathValidation.IsSafeRelativePath(file.Name))
{
digestErrors.Add($"unsafe-path:{file.Name}");
continue;
}
var filePath = PathValidation.SafeCombine(request.BundleDirectory, file.Name);
if (!File.Exists(filePath))
{
digestErrors.Add($"file-missing:{file.Name}");
@@ -345,3 +352,81 @@ internal sealed class RuleBundleFileEntry
public string Digest { get; set; } = string.Empty;
public long SizeBytes { get; set; }
}
/// <summary>
/// Utility methods for path validation and security.
/// </summary>
internal static class PathValidation
{
/// <summary>
/// Validates that a relative path does not escape the bundle root.
/// </summary>
public static bool IsSafeRelativePath(string? relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath))
{
return false;
}
// Check for absolute paths
if (Path.IsPathRooted(relativePath))
{
return false;
}
// Check for path traversal sequences
var normalized = relativePath.Replace('\\', '/');
var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries);
var depth = 0;
foreach (var segment in segments)
{
if (segment == "..")
{
depth--;
if (depth < 0)
{
return false;
}
}
else if (segment != ".")
{
depth++;
}
}
// Also check for null bytes
if (relativePath.Contains('\0'))
{
return false;
}
return true;
}
/// <summary>
/// Combines a root path with a relative path, validating that the result does not escape the root.
/// </summary>
public static string SafeCombine(string rootPath, string relativePath)
{
if (!IsSafeRelativePath(relativePath))
{
throw new ArgumentException(
$"Invalid relative path: path traversal or absolute path detected in '{relativePath}'",
nameof(relativePath));
}
var combined = Path.GetFullPath(Path.Combine(rootPath, relativePath));
var normalizedRoot = Path.GetFullPath(rootPath);
// Ensure the combined path starts with the root path
if (!combined.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException(
$"Path '{relativePath}' escapes root directory",
nameof(relativePath));
}
return combined;
}
}

View File

@@ -83,80 +83,6 @@ public sealed class HttpClientUsageAnalyzerTests
Assert.DoesNotContain(diagnostics, d => d.Id == HttpClientUsageAnalyzer.DiagnosticId);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CodeFix_RewritesToFactoryCall()
{
const string source = """
using System.Net.Http;
namespace Sample.Service;
public sealed class Demo
{
public void Run()
{
var client = new HttpClient();
}
}
""";
const string expected = """
using System.Net.Http;
namespace Sample.Service;
public sealed class Demo
{
public void Run()
{
var client = global::StellaOps.AirGap.Policy.EgressHttpClientFactory.Create(egressPolicy: default(global::StellaOps.AirGap.Policy.IEgressPolicy) /* TODO: provide IEgressPolicy instance */, request: new global::StellaOps.AirGap.Policy.EgressRequest(component: "REPLACE_COMPONENT", destination: new global::System.Uri("https://replace-with-endpoint"), intent: "REPLACE_INTENT"));
}
}
""";
var updated = await ApplyCodeFixAsync(source, assemblyName: "Sample.Service");
Assert.Equal(expected.ReplaceLineEndings(), updated.ReplaceLineEndings());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CodeFix_PreservesHttpClientArguments()
{
const string source = """
using System.Net.Http;
namespace Sample.Service;
public sealed class Demo
{
public void Run()
{
var handler = new HttpClientHandler();
var client = new HttpClient(handler, disposeHandler: false);
}
}
""";
const string expected = """
using System.Net.Http;
namespace Sample.Service;
public sealed class Demo
{
public void Run()
{
var handler = new HttpClientHandler();
var client = global::StellaOps.AirGap.Policy.EgressHttpClientFactory.Create(egressPolicy: default(global::StellaOps.AirGap.Policy.IEgressPolicy) /* TODO: provide IEgressPolicy instance */, request: new global::StellaOps.AirGap.Policy.EgressRequest(component: "REPLACE_COMPONENT", destination: new global::System.Uri("https://replace-with-endpoint"), intent: "REPLACE_INTENT"), clientFactory: () => new global::System.Net.Http.HttpClient(handler, disposeHandler: false));
}
}
""";
var updated = await ApplyCodeFixAsync(source, assemblyName: "Sample.Service");
Assert.Equal(expected.ReplaceLineEndings(), updated.ReplaceLineEndings());
}
private static async Task<ImmutableArray<Diagnostic>> AnalyzeAsync(string source, string assemblyName)
{
var compilation = CSharpCompilation.Create(
@@ -174,53 +100,6 @@ public sealed class HttpClientUsageAnalyzerTests
return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync();
}
private static async Task<string> ApplyCodeFixAsync(string source, string assemblyName)
{
using var workspace = new AdhocWorkspace();
var projectId = ProjectId.CreateNewId();
var documentId = DocumentId.CreateNewId(projectId);
var stubDocumentId = DocumentId.CreateNewId(projectId);
var solution = workspace.CurrentSolution
.AddProject(projectId, "TestProject", "TestProject", LanguageNames.CSharp)
.WithProjectCompilationOptions(projectId, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary))
.WithProjectAssemblyName(projectId, assemblyName)
.AddMetadataReferences(projectId, CreateMetadataReferences())
.AddDocument(documentId, "Test.cs", SourceText.From(source))
.AddDocument(stubDocumentId, "PolicyStubs.cs", SourceText.From(PolicyStubSource));
var project = solution.GetProject(projectId)!;
var document = solution.GetDocument(documentId)!;
var compilation = await project.GetCompilationAsync();
var analyzer = new HttpClientUsageAnalyzer();
var diagnostics = await compilation!.WithAnalyzers(ImmutableArray.Create<DiagnosticAnalyzer>(analyzer))
.GetAnalyzerDiagnosticsAsync();
var diagnostic = Assert.Single(diagnostics);
var codeFixProvider = new HttpClientUsageCodeFixProvider();
var actions = new List<CodeAction>();
var context = new CodeFixContext(
document,
diagnostic,
(action, _) => actions.Add(action),
CancellationToken.None);
await codeFixProvider.RegisterCodeFixesAsync(context);
var action = Assert.Single(actions);
var operations = await action.GetOperationsAsync(CancellationToken.None);
foreach (var operation in operations)
{
operation.Apply(workspace, CancellationToken.None);
}
var updatedDocument = workspace.CurrentSolution.GetDocument(documentId)!;
var updatedText = await updatedDocument.GetTextAsync();
return updatedText.ToString();
}
private static IEnumerable<MetadataReference> CreateMetadataReferences()
{
yield return MetadataReference.CreateFromFile(typeof(object).GetTypeInfo().Assembly.Location);

View File

@@ -276,165 +276,6 @@ public sealed class PolicyAnalyzerRoslynTests
#region AIRGAP-5100-006: Golden Generated Code Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CodeFix_GeneratesExpectedFactoryCall()
{
const string source = """
using System.Net.Http;
namespace Sample.Service;
public sealed class Demo
{
public void Run()
{
var client = new HttpClient();
}
}
""";
const string expectedGolden = """
using System.Net.Http;
namespace Sample.Service;
public sealed class Demo
{
public void Run()
{
var client = global::StellaOps.AirGap.Policy.EgressHttpClientFactory.Create(egressPolicy: default(global::StellaOps.AirGap.Policy.IEgressPolicy) /* TODO: provide IEgressPolicy instance */, request: new global::StellaOps.AirGap.Policy.EgressRequest(component: "REPLACE_COMPONENT", destination: new global::System.Uri("https://replace-with-endpoint"), intent: "REPLACE_INTENT"));
}
}
""";
var fixedCode = await ApplyCodeFixAsync(source, assemblyName: "Sample.Service");
fixedCode.ReplaceLineEndings().Should().Be(expectedGolden.ReplaceLineEndings(),
"Code fix should match golden output exactly");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CodeFix_PreservesTrivia()
{
const string source = """
using System.Net.Http;
namespace Sample.Service;
public sealed class Demo
{
public void Run()
{
// Important: this client handles external requests
var client = new HttpClient(); // end of line comment
}
}
""";
var fixedCode = await ApplyCodeFixAsync(source, assemblyName: "Sample.Service");
// The code fix preserves the trivia from the original node
fixedCode.Should().Contain("// Important: this client handles external requests",
"Leading comment should be preserved");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CodeFix_DeterministicOutput()
{
const string source = """
using System.Net.Http;
namespace Sample.Determinism;
public sealed class Demo
{
public void Run()
{
var client = new HttpClient();
}
}
""";
// Apply code fix multiple times
var result1 = await ApplyCodeFixAsync(source, assemblyName: "Sample.Determinism");
var result2 = await ApplyCodeFixAsync(source, assemblyName: "Sample.Determinism");
var result3 = await ApplyCodeFixAsync(source, assemblyName: "Sample.Determinism");
result1.Should().Be(result2, "Code fix should be deterministic");
result2.Should().Be(result3, "Code fix should be deterministic");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CodeFix_ContainsRequiredPlaceholders()
{
const string source = """
using System.Net.Http;
namespace Sample.Service;
public sealed class Demo
{
public void Run()
{
var client = new HttpClient();
}
}
""";
var fixedCode = await ApplyCodeFixAsync(source, assemblyName: "Sample.Service");
// Verify all required placeholders are present for developer to fill in
fixedCode.Should().Contain("EgressHttpClientFactory.Create");
fixedCode.Should().Contain("egressPolicy:");
fixedCode.Should().Contain("IEgressPolicy");
fixedCode.Should().Contain("EgressRequest");
fixedCode.Should().Contain("component:");
fixedCode.Should().Contain("REPLACE_COMPONENT");
fixedCode.Should().Contain("destination:");
fixedCode.Should().Contain("intent:");
fixedCode.Should().Contain("REPLACE_INTENT");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CodeFix_UsesFullyQualifiedNames()
{
const string source = """
using System.Net.Http;
namespace Sample.Service;
public sealed class Demo
{
public void Run()
{
var client = new HttpClient();
}
}
""";
var fixedCode = await ApplyCodeFixAsync(source, assemblyName: "Sample.Service");
// Verify fully qualified names are used to avoid namespace conflicts
fixedCode.Should().Contain("global::StellaOps.AirGap.Policy.EgressHttpClientFactory");
fixedCode.Should().Contain("global::StellaOps.AirGap.Policy.EgressRequest");
fixedCode.Should().Contain("global::System.Uri");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FixAllProvider_IsWellKnownBatchFixer()
{
var provider = new HttpClientUsageCodeFixProvider();
var fixAllProvider = provider.GetFixAllProvider();
fixAllProvider.Should().Be(WellKnownFixAllProviders.BatchFixer,
"Should use batch fixer for efficient multi-fix application");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Analyzer_SupportedDiagnostics_ContainsExpectedId()
@@ -446,20 +287,6 @@ public sealed class PolicyAnalyzerRoslynTests
supportedDiagnostics[0].Id.Should().Be("AIRGAP001");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CodeFixProvider_FixableDiagnosticIds_MatchesAnalyzer()
{
var analyzer = new HttpClientUsageAnalyzer();
var codeFixProvider = new HttpClientUsageCodeFixProvider();
var analyzerIds = analyzer.SupportedDiagnostics.Select(d => d.Id).ToHashSet();
var fixableIds = codeFixProvider.FixableDiagnosticIds.ToHashSet();
fixableIds.Should().BeSubsetOf(analyzerIds,
"Code fix provider should only fix diagnostics reported by the analyzer");
}
#endregion
#region Test Helpers
@@ -481,53 +308,6 @@ public sealed class PolicyAnalyzerRoslynTests
return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync();
}
private static async Task<string> ApplyCodeFixAsync(string source, string assemblyName)
{
using var workspace = new AdhocWorkspace();
var projectId = ProjectId.CreateNewId();
var documentId = DocumentId.CreateNewId(projectId);
var stubDocumentId = DocumentId.CreateNewId(projectId);
var solution = workspace.CurrentSolution
.AddProject(projectId, "TestProject", "TestProject", LanguageNames.CSharp)
.WithProjectCompilationOptions(projectId, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary))
.WithProjectAssemblyName(projectId, assemblyName)
.AddMetadataReferences(projectId, CreateMetadataReferences())
.AddDocument(documentId, "Test.cs", SourceText.From(source))
.AddDocument(stubDocumentId, "PolicyStubs.cs", SourceText.From(PolicyStubSource));
var project = solution.GetProject(projectId)!;
var document = solution.GetDocument(documentId)!;
var compilation = await project.GetCompilationAsync();
var analyzer = new HttpClientUsageAnalyzer();
var diagnostics = await compilation!.WithAnalyzers(ImmutableArray.Create<DiagnosticAnalyzer>(analyzer))
.GetAnalyzerDiagnosticsAsync();
var diagnostic = diagnostics.Single(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId);
var codeFixProvider = new HttpClientUsageCodeFixProvider();
var actions = new List<CodeAction>();
var context = new CodeFixContext(
document,
diagnostic,
(action, _) => actions.Add(action),
CancellationToken.None);
await codeFixProvider.RegisterCodeFixesAsync(context);
var action = actions.Single();
var operations = await action.GetOperationsAsync(CancellationToken.None);
foreach (var operation in operations)
{
operation.Apply(workspace, CancellationToken.None);
}
var updatedDocument = workspace.CurrentSolution.GetDocument(documentId)!;
var updatedText = await updatedDocument.GetTextAsync();
return updatedText.ToString();
}
private static IEnumerable<MetadataReference> CreateMetadataReferences()
{
// Core runtime references

View File

@@ -1,125 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Composition;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace StellaOps.AirGap.Policy.Analyzers;
/// <summary>
/// Offers a remediation template that routes HttpClient creation through the shared EgressPolicy factory.
/// </summary>
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(HttpClientUsageCodeFixProvider))]
[Shared]
public sealed class HttpClientUsageCodeFixProvider : CodeFixProvider
{
private const string Title = "Use EgressHttpClientFactory.Create(...)";
/// <inheritdoc/>
public override ImmutableArray<string> FixableDiagnosticIds
=> ImmutableArray.Create(HttpClientUsageAnalyzer.DiagnosticId);
/// <inheritdoc/>
public override FixAllProvider GetFixAllProvider()
=> WellKnownFixAllProviders.BatchFixer;
/// <inheritdoc/>
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
if (context.Document is null)
{
return;
}
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
if (root is null)
{
return;
}
var diagnostic = context.Diagnostics[0];
var node = root.FindNode(diagnostic.Location.SourceSpan);
if (node is not ObjectCreationExpressionSyntax objectCreation)
{
return;
}
context.RegisterCodeFix(
CodeAction.Create(
Title,
cancellationToken => ReplaceWithFactoryCallAsync(context.Document, objectCreation, cancellationToken),
equivalenceKey: Title),
diagnostic);
}
private static async Task<Document> ReplaceWithFactoryCallAsync(Document document, ObjectCreationExpressionSyntax creation, CancellationToken cancellationToken)
{
var replacementExpression = BuildReplacementExpression(creation);
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
if (root is null)
{
return document;
}
var updatedRoot = root.ReplaceNode(creation, replacementExpression.WithTriviaFrom(creation));
return document.WithSyntaxRoot(updatedRoot);
}
private static ExpressionSyntax BuildReplacementExpression(ObjectCreationExpressionSyntax creation)
{
var requestExpression = SyntaxFactory.ParseExpression(
"new global::StellaOps.AirGap.Policy.EgressRequest(" +
"component: \"REPLACE_COMPONENT\", " +
"destination: new global::System.Uri(\"https://replace-with-endpoint\"), " +
"intent: \"REPLACE_INTENT\")");
var egressPolicyExpression = SyntaxFactory.ParseExpression(
"default(global::StellaOps.AirGap.Policy.IEgressPolicy)");
var arguments = new List<ArgumentSyntax>
{
SyntaxFactory.Argument(egressPolicyExpression)
.WithNameColon(SyntaxFactory.NameColon("egressPolicy"))
.WithTrailingTrivia(
SyntaxFactory.Space,
SyntaxFactory.Comment("/* TODO: provide IEgressPolicy instance */")),
SyntaxFactory.Argument(requestExpression)
.WithNameColon(SyntaxFactory.NameColon("request"))
};
if (ShouldUseClientFactory(creation))
{
var clientFactoryLambda = SyntaxFactory.ParenthesizedLambdaExpression(
SyntaxFactory.ParameterList(),
CreateHttpClientExpression(creation));
arguments.Add(
SyntaxFactory.Argument(clientFactoryLambda)
.WithNameColon(SyntaxFactory.NameColon("clientFactory")));
}
return SyntaxFactory.InvocationExpression(
SyntaxFactory.ParseExpression("global::StellaOps.AirGap.Policy.EgressHttpClientFactory.Create"))
.WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(arguments)));
}
private static bool ShouldUseClientFactory(ObjectCreationExpressionSyntax creation)
=> (creation.ArgumentList?.Arguments.Count ?? 0) > 0 || creation.Initializer is not null;
private static ObjectCreationExpressionSyntax CreateHttpClientExpression(ObjectCreationExpressionSyntax creation)
{
var httpClientType = SyntaxFactory.ParseTypeName("global::System.Net.Http.HttpClient");
var arguments = creation.ArgumentList ?? SyntaxFactory.ArgumentList();
return SyntaxFactory.ObjectCreationExpression(httpClientType)
.WithArgumentList(arguments)
.WithInitializer(creation.Initializer);
}
}

View File

@@ -16,7 +16,6 @@
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" PrivateAssets="all" />
</ItemGroup>
</Project>

View File

@@ -8,6 +8,8 @@ public sealed class TimeTelemetry
{
private static readonly Meter Meter = new("StellaOps.AirGap.Time", "1.0.0");
private const int MaxEntries = 1024;
// Bound eviction queue to 3x max entries to prevent unbounded memory growth
private const int MaxEvictionQueueSize = MaxEntries * 3;
private readonly ConcurrentDictionary<string, Snapshot> _latest = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentQueue<string> _evictionQueue = new();
@@ -71,10 +73,20 @@ public sealed class TimeTelemetry
private void TrimCache()
{
// Evict tenant entries when cache is over limit
while (_latest.Count > MaxEntries && _evictionQueue.TryDequeue(out var candidate))
{
_latest.TryRemove(candidate, out _);
}
// Trim eviction queue to prevent unbounded memory growth
// Discard stale entries that may no longer be in the cache
while (_evictionQueue.Count > MaxEvictionQueueSize && _evictionQueue.TryDequeue(out var stale))
{
// If the tenant is still in cache, try to remove it
// (this helps when we have many updates to the same tenant)
_latest.TryRemove(stale, out _);
}
}
public sealed record Snapshot(long AgeSeconds, bool IsWarning, bool IsBreach);

View File

@@ -195,7 +195,15 @@ public sealed class KnowledgeSnapshotImporter : IKnowledgeSnapshotImporter
{
try
{
var filePath = Path.Combine(bundleDir, entry.RelativePath.Replace('/', Path.DirectorySeparatorChar));
// Validate path to prevent traversal attacks
if (!PathValidation.IsSafeRelativePath(entry.RelativePath))
{
result.Failed++;
result.Errors.Add($"Unsafe path detected: {entry.RelativePath}");
continue;
}
var filePath = PathValidation.SafeCombine(bundleDir, entry.RelativePath);
if (!File.Exists(filePath))
{
result.Failed++;
@@ -250,7 +258,15 @@ public sealed class KnowledgeSnapshotImporter : IKnowledgeSnapshotImporter
{
try
{
var filePath = Path.Combine(bundleDir, entry.RelativePath.Replace('/', Path.DirectorySeparatorChar));
// Validate path to prevent traversal attacks
if (!PathValidation.IsSafeRelativePath(entry.RelativePath))
{
result.Failed++;
result.Errors.Add($"Unsafe path detected: {entry.RelativePath}");
continue;
}
var filePath = PathValidation.SafeCombine(bundleDir, entry.RelativePath);
if (!File.Exists(filePath))
{
result.Failed++;
@@ -305,7 +321,15 @@ public sealed class KnowledgeSnapshotImporter : IKnowledgeSnapshotImporter
{
try
{
var filePath = Path.Combine(bundleDir, entry.RelativePath.Replace('/', Path.DirectorySeparatorChar));
// Validate path to prevent traversal attacks
if (!PathValidation.IsSafeRelativePath(entry.RelativePath))
{
result.Failed++;
result.Errors.Add($"Unsafe path detected: {entry.RelativePath}");
continue;
}
var filePath = PathValidation.SafeCombine(bundleDir, entry.RelativePath);
if (!File.Exists(filePath))
{
result.Failed++;
@@ -349,9 +373,52 @@ public sealed class KnowledgeSnapshotImporter : IKnowledgeSnapshotImporter
private static async Task ExtractBundleAsync(string bundlePath, string targetDir, CancellationToken ct)
{
var normalizedTargetDir = Path.GetFullPath(targetDir);
await using var fileStream = File.OpenRead(bundlePath);
await using var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress);
await TarFile.ExtractToDirectoryAsync(gzipStream, targetDir, overwriteFiles: true, ct);
await using var tarReader = new TarReader(gzipStream, leaveOpen: false);
while (await tarReader.GetNextEntryAsync(copyData: true, ct) is { } entry)
{
if (string.IsNullOrEmpty(entry.Name))
{
continue;
}
// Validate entry path to prevent traversal attacks
if (!PathValidation.IsSafeRelativePath(entry.Name))
{
throw new InvalidOperationException($"Unsafe tar entry path detected: {entry.Name}");
}
var destinationPath = Path.GetFullPath(Path.Combine(normalizedTargetDir, entry.Name));
// Verify the path is within the target directory
if (!destinationPath.StartsWith(normalizedTargetDir, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Tar entry path escapes target directory: {entry.Name}");
}
// Create directory if needed
var entryDir = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrEmpty(entryDir))
{
Directory.CreateDirectory(entryDir);
}
// Extract based on entry type
if (entry.EntryType == TarEntryType.Directory)
{
Directory.CreateDirectory(destinationPath);
}
else if (entry.EntryType == TarEntryType.RegularFile ||
entry.EntryType == TarEntryType.V7RegularFile)
{
await entry.ExtractToFileAsync(destinationPath, overwrite: true, ct);
}
// Skip symbolic links and other special entry types for security
}
}
private sealed class ModuleImportResult

View File

@@ -5,6 +5,7 @@
// Description: Signs snapshot manifests using DSSE format for integrity verification.
// -----------------------------------------------------------------------------
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -196,8 +197,9 @@ public sealed class SnapshotManifestSigner : ISnapshotManifestSigner
{
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
var prefixBytes = Encoding.UTF8.GetBytes(PreAuthenticationEncodingPrefix);
var typeLenStr = typeBytes.Length.ToString();
var payloadLenStr = payload.Length.ToString();
// Use InvariantCulture to ensure ASCII decimal digits per DSSE spec
var typeLenStr = typeBytes.Length.ToString(CultureInfo.InvariantCulture);
var payloadLenStr = payload.Length.ToString(CultureInfo.InvariantCulture);
var totalLen = prefixBytes.Length + 1 +
typeLenStr.Length + 1 +

View File

@@ -178,39 +178,15 @@ public sealed class TimeAnchorService : ITimeAnchorService
CancellationToken cancellationToken)
{
// Roughtime is a cryptographic time synchronization protocol
// This is a placeholder implementation - full implementation would use a Roughtime client
// Full implementation requires a Roughtime client library
var serverUrl = request.Source?["roughtime:".Length..] ?? "roughtime.cloudflare.com:2003";
// For now, fallback to local with indication of intended source
var anchorTime = _timeProvider.GetUtcNow();
var anchorData = new RoughtimeAnchorData
{
Timestamp = anchorTime,
Server = serverUrl,
Midpoint = anchorTime.ToUnixTimeSeconds(),
Radius = 1000000, // 1 second radius in microseconds
Nonce = _guidProvider.NewGuid().ToString("N"),
MerkleRoot = request.MerkleRoot
};
var anchorJson = JsonSerializer.Serialize(anchorData, JsonOptions);
var anchorBytes = Encoding.UTF8.GetBytes(anchorJson);
var tokenDigest = $"sha256:{Convert.ToHexString(SHA256.HashData(anchorBytes)).ToLowerInvariant()}";
await Task.CompletedTask;
return new TimeAnchorResult
{
Success = true,
Content = new TimeAnchorContent
{
AnchorTime = anchorTime,
Source = $"roughtime:{serverUrl}",
TokenDigest = tokenDigest
},
TokenBytes = anchorBytes,
Warning = "Roughtime client not implemented; using simulated response"
};
// Per no-silent-stubs rule: unimplemented paths must fail explicitly
return TimeAnchorResult.Failed(
$"Roughtime time anchor source '{serverUrl}' is not implemented. " +
"Use 'local' source or implement Roughtime client integration.");
}
private async Task<TimeAnchorResult> CreateRfc3161AnchorAsync(
@@ -218,37 +194,15 @@ public sealed class TimeAnchorService : ITimeAnchorService
CancellationToken cancellationToken)
{
// RFC 3161 is the Internet X.509 PKI Time-Stamp Protocol (TSP)
// This is a placeholder implementation - full implementation would use a TSA client
// Full implementation requires a TSA client library
var tsaUrl = request.Source?["rfc3161:".Length..] ?? "http://timestamp.digicert.com";
var anchorTime = _timeProvider.GetUtcNow();
var anchorData = new Rfc3161AnchorData
{
Timestamp = anchorTime,
TsaUrl = tsaUrl,
SerialNumber = _guidProvider.NewGuid().ToString("N"),
PolicyOid = "2.16.840.1.114412.2.1", // DigiCert timestamp policy
MerkleRoot = request.MerkleRoot
};
var anchorJson = JsonSerializer.Serialize(anchorData, JsonOptions);
var anchorBytes = Encoding.UTF8.GetBytes(anchorJson);
var tokenDigest = $"sha256:{Convert.ToHexString(SHA256.HashData(anchorBytes)).ToLowerInvariant()}";
await Task.CompletedTask;
return new TimeAnchorResult
{
Success = true,
Content = new TimeAnchorContent
{
AnchorTime = anchorTime,
Source = $"rfc3161:{tsaUrl}",
TokenDigest = tokenDigest
},
TokenBytes = anchorBytes,
Warning = "RFC 3161 TSA client not implemented; using simulated response"
};
// Per no-silent-stubs rule: unimplemented paths must fail explicitly
return TimeAnchorResult.Failed(
$"RFC 3161 time anchor source '{tsaUrl}' is not implemented. " +
"Use 'local' source or implement RFC 3161 TSA client integration.");
}
private sealed record LocalAnchorData

View File

@@ -0,0 +1,150 @@
// <copyright file="AirGapSyncServiceCollectionExtensions.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using StellaOps.AirGap.Sync.Services;
using StellaOps.AirGap.Sync.Stores;
using StellaOps.AirGap.Sync.Transport;
using StellaOps.Determinism;
using StellaOps.HybridLogicalClock;
namespace StellaOps.AirGap.Sync;
/// <summary>
/// Extension methods for registering air-gap sync services.
/// </summary>
public static class AirGapSyncServiceCollectionExtensions
{
/// <summary>
/// Adds air-gap sync services to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="nodeId">The node identifier for this instance.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddAirGapSyncServices(
this IServiceCollection services,
string nodeId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(nodeId);
// Core services
services.TryAddSingleton<IConflictResolver, ConflictResolver>();
services.TryAddSingleton<IHlcMergeService, HlcMergeService>();
services.TryAddSingleton<IAirGapBundleImporter, AirGapBundleImporter>();
// Register in-memory HLC state store for offline operation
services.TryAddSingleton<IHlcStateStore, InMemoryHlcStateStore>();
// Register HLC clock with node ID
services.TryAddSingleton<IHybridLogicalClock>(sp =>
{
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
var stateStore = sp.GetRequiredService<IHlcStateStore>();
var logger = sp.GetRequiredService<ILogger<HybridLogicalClock.HybridLogicalClock>>();
return new HybridLogicalClock.HybridLogicalClock(timeProvider, nodeId, stateStore, logger);
});
// Register deterministic GUID provider
services.TryAddSingleton<IGuidProvider>(SystemGuidProvider.Instance);
// File-based store (can be overridden)
services.TryAddSingleton<IOfflineJobLogStore, FileBasedOfflineJobLogStore>();
// Offline HLC manager
services.TryAddSingleton<IOfflineHlcManager, OfflineHlcManager>();
// Bundle exporter
services.TryAddSingleton<IAirGapBundleExporter, AirGapBundleExporter>();
return services;
}
/// <summary>
/// Adds air-gap sync services with custom options.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="nodeId">The node identifier for this instance.</param>
/// <param name="configureOptions">Action to configure file-based store options.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddAirGapSyncServices(
this IServiceCollection services,
string nodeId,
Action<FileBasedOfflineJobLogStoreOptions> configureOptions)
{
// Configure file-based store options
services.Configure(configureOptions);
return services.AddAirGapSyncServices(nodeId);
}
/// <summary>
/// Adds the air-gap sync service for importing bundles to the central scheduler.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
/// <remarks>
/// This requires ISyncSchedulerLogRepository to be registered separately,
/// as it depends on the Scheduler.Persistence module.
/// </remarks>
public static IServiceCollection AddAirGapSyncImportService(this IServiceCollection services)
{
services.TryAddScoped<IAirGapSyncService, AirGapSyncService>();
return services;
}
/// <summary>
/// Adds file-based transport for job sync bundles.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFileBasedJobSyncTransport(this IServiceCollection services)
{
services.TryAddSingleton<IJobSyncTransport, FileBasedJobSyncTransport>();
return services;
}
/// <summary>
/// Adds file-based transport for job sync bundles with custom options.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configureOptions">Action to configure transport options.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFileBasedJobSyncTransport(
this IServiceCollection services,
Action<FileBasedJobSyncTransportOptions> configureOptions)
{
services.Configure(configureOptions);
return services.AddFileBasedJobSyncTransport();
}
/// <summary>
/// Adds Router-based transport for job sync bundles.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
/// <remarks>
/// Requires IRouterJobSyncClient to be registered separately.
/// </remarks>
public static IServiceCollection AddRouterJobSyncTransport(this IServiceCollection services)
{
services.TryAddSingleton<IJobSyncTransport, RouterJobSyncTransport>();
return services;
}
/// <summary>
/// Adds Router-based transport for job sync bundles with custom options.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configureOptions">Action to configure transport options.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddRouterJobSyncTransport(
this IServiceCollection services,
Action<RouterJobSyncTransportOptions> configureOptions)
{
services.Configure(configureOptions);
return services.AddRouterJobSyncTransport();
}
}

View File

@@ -0,0 +1,51 @@
// <copyright file="AirGapBundle.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.AirGap.Sync.Models;
/// <summary>
/// Represents an air-gap bundle containing job logs from one or more offline nodes.
/// </summary>
public sealed record AirGapBundle
{
/// <summary>
/// Gets the unique bundle identifier.
/// </summary>
public required Guid BundleId { get; init; }
/// <summary>
/// Gets the tenant ID for this bundle.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Gets when the bundle was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Gets the node ID that created this bundle.
/// </summary>
public required string CreatedByNodeId { get; init; }
/// <summary>
/// Gets the job logs from each offline node.
/// </summary>
public required IReadOnlyList<NodeJobLog> JobLogs { get; init; }
/// <summary>
/// Gets the bundle manifest digest for integrity verification.
/// </summary>
public required string ManifestDigest { get; init; }
/// <summary>
/// Gets the optional DSSE signature over the manifest.
/// </summary>
public string? Signature { get; init; }
/// <summary>
/// Gets the key ID used for signing (if signed).
/// </summary>
public string? SignedBy { get; init; }
}

View File

@@ -0,0 +1,68 @@
// <copyright file="ConflictResolution.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.AirGap.Sync.Models;
/// <summary>
/// Result of conflict resolution for a job ID.
/// </summary>
public sealed record ConflictResolution
{
/// <summary>
/// Gets the type of conflict detected.
/// </summary>
public required ConflictType Type { get; init; }
/// <summary>
/// Gets the resolution strategy applied.
/// </summary>
public required ResolutionStrategy Resolution { get; init; }
/// <summary>
/// Gets the selected entry (when resolution is not Error).
/// </summary>
public OfflineJobLogEntry? SelectedEntry { get; init; }
/// <summary>
/// Gets the entries that were dropped.
/// </summary>
public IReadOnlyList<OfflineJobLogEntry>? DroppedEntries { get; init; }
/// <summary>
/// Gets the error message (when resolution is Error).
/// </summary>
public string? Error { get; init; }
}
/// <summary>
/// Types of conflicts that can occur during merge.
/// </summary>
public enum ConflictType
{
/// <summary>
/// Same JobId with different HLC timestamps but identical payload.
/// </summary>
DuplicateTimestamp,
/// <summary>
/// Same JobId with different payloads - indicates a bug.
/// </summary>
PayloadMismatch
}
/// <summary>
/// Strategies for resolving conflicts.
/// </summary>
public enum ResolutionStrategy
{
/// <summary>
/// Take the entry with the earliest HLC timestamp.
/// </summary>
TakeEarliest,
/// <summary>
/// Fail the merge - conflict cannot be resolved.
/// </summary>
Error
}

View File

@@ -0,0 +1,87 @@
// <copyright file="MergeResult.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using StellaOps.HybridLogicalClock;
namespace StellaOps.AirGap.Sync.Models;
/// <summary>
/// Result of merging job logs from multiple offline nodes.
/// </summary>
public sealed record MergeResult
{
/// <summary>
/// Gets the merged entries in HLC total order.
/// </summary>
public required IReadOnlyList<MergedJobEntry> MergedEntries { get; init; }
/// <summary>
/// Gets duplicate entries that were dropped during merge.
/// </summary>
public required IReadOnlyList<DuplicateEntry> Duplicates { get; init; }
/// <summary>
/// Gets the merged chain head (final link after merge).
/// </summary>
public byte[]? MergedChainHead { get; init; }
/// <summary>
/// Gets the source node IDs that contributed to this merge.
/// </summary>
public required IReadOnlyList<string> SourceNodes { get; init; }
}
/// <summary>
/// A job entry after merge with unified chain link.
/// </summary>
public sealed class MergedJobEntry
{
/// <summary>
/// Gets or sets the source node ID that created this entry.
/// </summary>
public required string SourceNodeId { get; set; }
/// <summary>
/// Gets or sets the HLC timestamp.
/// </summary>
public required HlcTimestamp THlc { get; set; }
/// <summary>
/// Gets or sets the job ID.
/// </summary>
public required Guid JobId { get; set; }
/// <summary>
/// Gets or sets the partition key.
/// </summary>
public string? PartitionKey { get; set; }
/// <summary>
/// Gets or sets the serialized payload.
/// </summary>
public required string Payload { get; set; }
/// <summary>
/// Gets or sets the payload hash.
/// </summary>
public required byte[] PayloadHash { get; set; }
/// <summary>
/// Gets or sets the original chain link from the source node.
/// </summary>
public required byte[] OriginalLink { get; set; }
/// <summary>
/// Gets or sets the merged chain link (computed during merge).
/// </summary>
public byte[]? MergedLink { get; set; }
}
/// <summary>
/// Represents a duplicate entry dropped during merge.
/// </summary>
public sealed record DuplicateEntry(
Guid JobId,
string NodeId,
HlcTimestamp THlc);

View File

@@ -0,0 +1,33 @@
// <copyright file="NodeJobLog.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using StellaOps.HybridLogicalClock;
namespace StellaOps.AirGap.Sync.Models;
/// <summary>
/// Represents the job log from a single offline node.
/// </summary>
public sealed record NodeJobLog
{
/// <summary>
/// Gets the node identifier.
/// </summary>
public required string NodeId { get; init; }
/// <summary>
/// Gets the last HLC timestamp in this log.
/// </summary>
public required HlcTimestamp LastHlc { get; init; }
/// <summary>
/// Gets the chain head (last link) in this log.
/// </summary>
public required byte[] ChainHead { get; init; }
/// <summary>
/// Gets the job log entries in HLC order.
/// </summary>
public required IReadOnlyList<OfflineJobLogEntry> Entries { get; init; }
}

View File

@@ -0,0 +1,58 @@
// <copyright file="OfflineJobLogEntry.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using StellaOps.HybridLogicalClock;
namespace StellaOps.AirGap.Sync.Models;
/// <summary>
/// Represents a job log entry created while operating offline.
/// </summary>
public sealed record OfflineJobLogEntry
{
/// <summary>
/// Gets the node ID that created this entry.
/// </summary>
public required string NodeId { get; init; }
/// <summary>
/// Gets the HLC timestamp when the job was enqueued.
/// </summary>
public required HlcTimestamp THlc { get; init; }
/// <summary>
/// Gets the deterministic job ID.
/// </summary>
public required Guid JobId { get; init; }
/// <summary>
/// Gets the partition key (if any).
/// </summary>
public string? PartitionKey { get; init; }
/// <summary>
/// Gets the serialized job payload.
/// </summary>
public required string Payload { get; init; }
/// <summary>
/// Gets the SHA-256 hash of the canonical payload.
/// </summary>
public required byte[] PayloadHash { get; init; }
/// <summary>
/// Gets the previous chain link (null for first entry).
/// </summary>
public byte[]? PrevLink { get; init; }
/// <summary>
/// Gets the chain link: Hash(prev_link || job_id || t_hlc || payload_hash).
/// </summary>
public required byte[] Link { get; init; }
/// <summary>
/// Gets the wall-clock time when the entry was created (informational only).
/// </summary>
public DateTimeOffset EnqueuedAt { get; init; }
}

View File

@@ -0,0 +1,72 @@
// <copyright file="SyncResult.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
namespace StellaOps.AirGap.Sync.Models;
/// <summary>
/// Result of syncing an air-gap bundle to the central scheduler.
/// </summary>
public sealed record SyncResult
{
/// <summary>
/// Gets the bundle ID that was synced.
/// </summary>
public required Guid BundleId { get; init; }
/// <summary>
/// Gets the total number of entries in the bundle.
/// </summary>
public required int TotalInBundle { get; init; }
/// <summary>
/// Gets the number of entries appended to the scheduler log.
/// </summary>
public required int Appended { get; init; }
/// <summary>
/// Gets the number of duplicate entries skipped.
/// </summary>
public required int Duplicates { get; init; }
/// <summary>
/// Gets the number of entries that already existed (idempotency).
/// </summary>
public int AlreadyExisted { get; init; }
/// <summary>
/// Gets the new chain head after sync.
/// </summary>
public byte[]? NewChainHead { get; init; }
/// <summary>
/// Gets any warnings generated during sync.
/// </summary>
public IReadOnlyList<string>? Warnings { get; init; }
}
/// <summary>
/// Result of an offline enqueue operation.
/// </summary>
public sealed record OfflineEnqueueResult
{
/// <summary>
/// Gets the HLC timestamp assigned.
/// </summary>
public required StellaOps.HybridLogicalClock.HlcTimestamp THlc { get; init; }
/// <summary>
/// Gets the deterministic job ID.
/// </summary>
public required Guid JobId { get; init; }
/// <summary>
/// Gets the chain link computed.
/// </summary>
public required byte[] Link { get; init; }
/// <summary>
/// Gets the node ID that created this entry.
/// </summary>
public required string NodeId { get; init; }
}

View File

@@ -0,0 +1,270 @@
// <copyright file="AirGapBundleExporter.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.AirGap.Sync.Models;
using StellaOps.AirGap.Sync.Stores;
using StellaOps.Canonical.Json;
using StellaOps.Determinism;
namespace StellaOps.AirGap.Sync.Services;
/// <summary>
/// Interface for air-gap bundle export operations.
/// </summary>
public interface IAirGapBundleExporter
{
/// <summary>
/// Exports an air-gap bundle containing offline job logs.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="nodeIds">The node IDs to include (null for current node only).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The exported bundle.</returns>
Task<AirGapBundle> ExportAsync(
string tenantId,
IReadOnlyList<string>? nodeIds = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Exports an air-gap bundle to a file.
/// </summary>
/// <param name="bundle">The bundle to export.</param>
/// <param name="outputPath">The output file path.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task ExportToFileAsync(
AirGapBundle bundle,
string outputPath,
CancellationToken cancellationToken = default);
/// <summary>
/// Exports an air-gap bundle to a JSON string.
/// </summary>
/// <param name="bundle">The bundle to export.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The JSON string representation.</returns>
Task<string> ExportToStringAsync(
AirGapBundle bundle,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Service for exporting air-gap bundles.
/// </summary>
public sealed class AirGapBundleExporter : IAirGapBundleExporter
{
private readonly IOfflineJobLogStore _jobLogStore;
private readonly IOfflineHlcManager _hlcManager;
private readonly IGuidProvider _guidProvider;
private readonly TimeProvider _timeProvider;
private readonly ILogger<AirGapBundleExporter> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>
/// Initializes a new instance of the <see cref="AirGapBundleExporter"/> class.
/// </summary>
public AirGapBundleExporter(
IOfflineJobLogStore jobLogStore,
IOfflineHlcManager hlcManager,
IGuidProvider guidProvider,
TimeProvider timeProvider,
ILogger<AirGapBundleExporter> logger)
{
_jobLogStore = jobLogStore ?? throw new ArgumentNullException(nameof(jobLogStore));
_hlcManager = hlcManager ?? throw new ArgumentNullException(nameof(hlcManager));
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc/>
public async Task<AirGapBundle> ExportAsync(
string tenantId,
IReadOnlyList<string>? nodeIds = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var effectiveNodeIds = nodeIds ?? new[] { _hlcManager.NodeId };
_logger.LogInformation(
"Exporting air-gap bundle for tenant {TenantId} with {NodeCount} nodes",
tenantId, effectiveNodeIds.Count);
var jobLogs = new List<NodeJobLog>();
foreach (var nodeId in effectiveNodeIds)
{
cancellationToken.ThrowIfCancellationRequested();
var nodeLog = await _jobLogStore.GetNodeJobLogAsync(nodeId, cancellationToken)
.ConfigureAwait(false);
if (nodeLog is not null && nodeLog.Entries.Count > 0)
{
jobLogs.Add(nodeLog);
_logger.LogDebug(
"Added node {NodeId} with {EntryCount} entries to bundle",
nodeId, nodeLog.Entries.Count);
}
}
if (jobLogs.Count == 0)
{
_logger.LogWarning("No offline job logs found for export");
}
var bundle = new AirGapBundle
{
BundleId = _guidProvider.NewGuid(),
TenantId = tenantId,
CreatedAt = _timeProvider.GetUtcNow(),
CreatedByNodeId = _hlcManager.NodeId,
JobLogs = jobLogs,
ManifestDigest = ComputeManifestDigest(jobLogs)
};
_logger.LogInformation(
"Created bundle {BundleId} with {LogCount} node logs, {TotalEntries} total entries",
bundle.BundleId, jobLogs.Count, jobLogs.Sum(l => l.Entries.Count));
return bundle;
}
/// <inheritdoc/>
public async Task ExportToFileAsync(
AirGapBundle bundle,
string outputPath,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(bundle);
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
var dto = ToExportDto(bundle);
var json = JsonSerializer.Serialize(dto, JsonOptions);
var directory = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
await File.WriteAllTextAsync(outputPath, json, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Exported bundle {BundleId} to {OutputPath}",
bundle.BundleId, outputPath);
}
/// <inheritdoc/>
public Task<string> ExportToStringAsync(
AirGapBundle bundle,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(bundle);
cancellationToken.ThrowIfCancellationRequested();
var dto = ToExportDto(bundle);
var json = JsonSerializer.Serialize(dto, JsonOptions);
_logger.LogDebug(
"Exported bundle {BundleId} to string ({Length} chars)",
bundle.BundleId, json.Length);
return Task.FromResult(json);
}
private static string ComputeManifestDigest(IReadOnlyList<NodeJobLog> jobLogs)
{
// Create manifest of all chain heads for integrity
var manifest = jobLogs
.OrderBy(l => l.NodeId, StringComparer.Ordinal)
.Select(l => new
{
l.NodeId,
LastHlc = l.LastHlc.ToSortableString(),
ChainHead = Convert.ToHexString(l.ChainHead)
})
.ToList();
var json = CanonJson.Serialize(manifest);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
}
private static AirGapBundleExportDto ToExportDto(AirGapBundle bundle) => new()
{
BundleId = bundle.BundleId,
TenantId = bundle.TenantId,
CreatedAt = bundle.CreatedAt,
CreatedByNodeId = bundle.CreatedByNodeId,
ManifestDigest = bundle.ManifestDigest,
Signature = bundle.Signature,
SignedBy = bundle.SignedBy,
JobLogs = bundle.JobLogs.Select(ToNodeJobLogDto).ToList()
};
private static NodeJobLogExportDto ToNodeJobLogDto(NodeJobLog log) => new()
{
NodeId = log.NodeId,
LastHlc = log.LastHlc.ToSortableString(),
ChainHead = Convert.ToBase64String(log.ChainHead),
Entries = log.Entries.Select(ToEntryDto).ToList()
};
private static OfflineJobLogEntryExportDto ToEntryDto(OfflineJobLogEntry entry) => new()
{
NodeId = entry.NodeId,
THlc = entry.THlc.ToSortableString(),
JobId = entry.JobId,
PartitionKey = entry.PartitionKey,
Payload = entry.Payload,
PayloadHash = Convert.ToBase64String(entry.PayloadHash),
PrevLink = entry.PrevLink is not null ? Convert.ToBase64String(entry.PrevLink) : null,
Link = Convert.ToBase64String(entry.Link),
EnqueuedAt = entry.EnqueuedAt
};
// Export DTOs
private sealed record AirGapBundleExportDto
{
public required Guid BundleId { get; init; }
public required string TenantId { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required string CreatedByNodeId { get; init; }
public required string ManifestDigest { get; init; }
public string? Signature { get; init; }
public string? SignedBy { get; init; }
public required IReadOnlyList<NodeJobLogExportDto> JobLogs { get; init; }
}
private sealed record NodeJobLogExportDto
{
public required string NodeId { get; init; }
public required string LastHlc { get; init; }
public required string ChainHead { get; init; }
public required IReadOnlyList<OfflineJobLogEntryExportDto> Entries { get; init; }
}
private sealed record OfflineJobLogEntryExportDto
{
public required string NodeId { get; init; }
public required string THlc { get; init; }
public required Guid JobId { get; init; }
public string? PartitionKey { get; init; }
public required string Payload { get; init; }
public required string PayloadHash { get; init; }
public string? PrevLink { get; init; }
public required string Link { get; init; }
public DateTimeOffset EnqueuedAt { get; init; }
}
}

View File

@@ -0,0 +1,316 @@
// <copyright file="AirGapBundleImporter.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.AirGap.Sync.Models;
using StellaOps.Canonical.Json;
using StellaOps.HybridLogicalClock;
namespace StellaOps.AirGap.Sync.Services;
/// <summary>
/// Interface for air-gap bundle import operations.
/// </summary>
public interface IAirGapBundleImporter
{
/// <summary>
/// Imports an air-gap bundle from a file.
/// </summary>
/// <param name="inputPath">The input file path.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The imported bundle.</returns>
Task<AirGapBundle> ImportFromFileAsync(
string inputPath,
CancellationToken cancellationToken = default);
/// <summary>
/// Validates a bundle's integrity.
/// </summary>
/// <param name="bundle">The bundle to validate.</param>
/// <returns>Validation result with any issues found.</returns>
BundleValidationResult Validate(AirGapBundle bundle);
/// <summary>
/// Imports an air-gap bundle from a JSON string.
/// </summary>
/// <param name="json">The JSON string representation.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The imported bundle.</returns>
Task<AirGapBundle> ImportFromStringAsync(
string json,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of bundle validation.
/// </summary>
public sealed record BundleValidationResult
{
/// <summary>
/// Gets whether the bundle is valid.
/// </summary>
public required bool IsValid { get; init; }
/// <summary>
/// Gets validation issues found.
/// </summary>
public required IReadOnlyList<string> Issues { get; init; }
}
/// <summary>
/// Service for importing air-gap bundles.
/// </summary>
public sealed class AirGapBundleImporter : IAirGapBundleImporter
{
private readonly ILogger<AirGapBundleImporter> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
/// <summary>
/// Initializes a new instance of the <see cref="AirGapBundleImporter"/> class.
/// </summary>
public AirGapBundleImporter(ILogger<AirGapBundleImporter> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc/>
public async Task<AirGapBundle> ImportFromFileAsync(
string inputPath,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(inputPath);
if (!File.Exists(inputPath))
{
throw new FileNotFoundException($"Bundle file not found: {inputPath}", inputPath);
}
_logger.LogInformation("Importing air-gap bundle from {InputPath}", inputPath);
var json = await File.ReadAllTextAsync(inputPath, cancellationToken).ConfigureAwait(false);
var dto = JsonSerializer.Deserialize<AirGapBundleImportDto>(json, JsonOptions);
if (dto is null)
{
throw new InvalidOperationException("Failed to deserialize bundle file");
}
var bundle = FromImportDto(dto);
_logger.LogInformation(
"Imported bundle {BundleId} from {InputPath}: {LogCount} node logs, {TotalEntries} total entries",
bundle.BundleId, inputPath, bundle.JobLogs.Count, bundle.JobLogs.Sum(l => l.Entries.Count));
return bundle;
}
/// <inheritdoc/>
public Task<AirGapBundle> ImportFromStringAsync(
string json,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(json);
cancellationToken.ThrowIfCancellationRequested();
_logger.LogDebug("Importing air-gap bundle from string ({Length} chars)", json.Length);
var dto = JsonSerializer.Deserialize<AirGapBundleImportDto>(json, JsonOptions);
if (dto is null)
{
throw new InvalidOperationException("Failed to deserialize bundle JSON");
}
var bundle = FromImportDto(dto);
_logger.LogInformation(
"Imported bundle {BundleId} from string: {LogCount} node logs, {TotalEntries} total entries",
bundle.BundleId, bundle.JobLogs.Count, bundle.JobLogs.Sum(l => l.Entries.Count));
return Task.FromResult(bundle);
}
/// <inheritdoc/>
public BundleValidationResult Validate(AirGapBundle bundle)
{
ArgumentNullException.ThrowIfNull(bundle);
var issues = new List<string>();
// 1. Validate manifest digest
var computedDigest = ComputeManifestDigest(bundle.JobLogs);
if (!string.Equals(computedDigest, bundle.ManifestDigest, StringComparison.Ordinal))
{
issues.Add($"Manifest digest mismatch: expected {bundle.ManifestDigest}, computed {computedDigest}");
}
// 2. Validate each node log's chain integrity
foreach (var nodeLog in bundle.JobLogs)
{
var nodeIssues = ValidateNodeLog(nodeLog);
issues.AddRange(nodeIssues);
}
// 3. Validate chain heads match last entry links
foreach (var nodeLog in bundle.JobLogs)
{
if (nodeLog.Entries.Count > 0)
{
var lastEntry = nodeLog.Entries[^1];
if (!ByteArrayEquals(nodeLog.ChainHead, lastEntry.Link))
{
issues.Add($"Node {nodeLog.NodeId}: chain head doesn't match last entry link");
}
}
}
var isValid = issues.Count == 0;
if (!isValid)
{
_logger.LogWarning(
"Bundle {BundleId} validation failed with {IssueCount} issues",
bundle.BundleId, issues.Count);
}
else
{
_logger.LogDebug("Bundle {BundleId} validation passed", bundle.BundleId);
}
return new BundleValidationResult
{
IsValid = isValid,
Issues = issues
};
}
private static IEnumerable<string> ValidateNodeLog(NodeJobLog nodeLog)
{
byte[]? expectedPrevLink = null;
for (var i = 0; i < nodeLog.Entries.Count; i++)
{
var entry = nodeLog.Entries[i];
// Verify prev_link matches expected
if (!ByteArrayEquals(entry.PrevLink, expectedPrevLink))
{
yield return $"Node {nodeLog.NodeId}, entry {i}: prev_link mismatch";
}
// Recompute and verify link
var computedLink = OfflineHlcManager.ComputeLink(
entry.PrevLink,
entry.JobId,
entry.THlc,
entry.PayloadHash);
if (!ByteArrayEquals(entry.Link, computedLink))
{
yield return $"Node {nodeLog.NodeId}, entry {i} (JobId {entry.JobId}): link mismatch";
}
expectedPrevLink = entry.Link;
}
}
private static string ComputeManifestDigest(IReadOnlyList<NodeJobLog> jobLogs)
{
var manifest = jobLogs
.OrderBy(l => l.NodeId, StringComparer.Ordinal)
.Select(l => new
{
l.NodeId,
LastHlc = l.LastHlc.ToSortableString(),
ChainHead = Convert.ToHexString(l.ChainHead)
})
.ToList();
var json = CanonJson.Serialize(manifest);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
}
private static bool ByteArrayEquals(byte[]? a, byte[]? b)
{
if (a is null && b is null) return true;
if (a is null || b is null) return false;
return a.AsSpan().SequenceEqual(b);
}
private static AirGapBundle FromImportDto(AirGapBundleImportDto dto) => new()
{
BundleId = dto.BundleId,
TenantId = dto.TenantId,
CreatedAt = dto.CreatedAt,
CreatedByNodeId = dto.CreatedByNodeId,
ManifestDigest = dto.ManifestDigest,
Signature = dto.Signature,
SignedBy = dto.SignedBy,
JobLogs = dto.JobLogs.Select(FromNodeJobLogDto).ToList()
};
private static NodeJobLog FromNodeJobLogDto(NodeJobLogImportDto dto) => new()
{
NodeId = dto.NodeId,
LastHlc = HlcTimestamp.Parse(dto.LastHlc),
ChainHead = Convert.FromBase64String(dto.ChainHead),
Entries = dto.Entries.Select(FromEntryDto).ToList()
};
private static OfflineJobLogEntry FromEntryDto(OfflineJobLogEntryImportDto dto) => new()
{
NodeId = dto.NodeId,
THlc = HlcTimestamp.Parse(dto.THlc),
JobId = dto.JobId,
PartitionKey = dto.PartitionKey,
Payload = dto.Payload,
PayloadHash = Convert.FromBase64String(dto.PayloadHash),
PrevLink = dto.PrevLink is not null ? Convert.FromBase64String(dto.PrevLink) : null,
Link = Convert.FromBase64String(dto.Link),
EnqueuedAt = dto.EnqueuedAt
};
// Import DTOs
private sealed record AirGapBundleImportDto
{
public required Guid BundleId { get; init; }
public required string TenantId { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required string CreatedByNodeId { get; init; }
public required string ManifestDigest { get; init; }
public string? Signature { get; init; }
public string? SignedBy { get; init; }
public required IReadOnlyList<NodeJobLogImportDto> JobLogs { get; init; }
}
private sealed record NodeJobLogImportDto
{
public required string NodeId { get; init; }
public required string LastHlc { get; init; }
public required string ChainHead { get; init; }
public required IReadOnlyList<OfflineJobLogEntryImportDto> Entries { get; init; }
}
private sealed record OfflineJobLogEntryImportDto
{
public required string NodeId { get; init; }
public required string THlc { get; init; }
public required Guid JobId { get; init; }
public string? PartitionKey { get; init; }
public required string Payload { get; init; }
public required string PayloadHash { get; init; }
public string? PrevLink { get; init; }
public required string Link { get; init; }
public DateTimeOffset EnqueuedAt { get; init; }
}
}

View File

@@ -0,0 +1,198 @@
// <copyright file="AirGapSyncService.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using Microsoft.Extensions.Logging;
using StellaOps.AirGap.Sync.Models;
using StellaOps.HybridLogicalClock;
namespace StellaOps.AirGap.Sync.Services;
/// <summary>
/// Interface for the scheduler log repository used by sync.
/// </summary>
/// <remarks>
/// This is a subset of the full ISchedulerLogRepository to avoid circular dependencies.
/// Implementations should delegate to the actual repository.
/// </remarks>
public interface ISyncSchedulerLogRepository
{
/// <summary>
/// Gets the chain head for a tenant/partition.
/// </summary>
Task<(byte[]? Link, string? THlc)> GetChainHeadAsync(
string tenantId,
string? partitionKey = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets an entry by job ID.
/// </summary>
Task<bool> ExistsByJobIdAsync(
string tenantId,
Guid jobId,
CancellationToken cancellationToken = default);
/// <summary>
/// Inserts a synced entry.
/// </summary>
Task InsertSyncedEntryAsync(
string tenantId,
string tHlc,
string? partitionKey,
Guid jobId,
byte[] payloadHash,
byte[]? prevLink,
byte[] link,
string sourceNodeId,
Guid syncedFromBundle,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Interface for air-gap sync operations.
/// </summary>
public interface IAirGapSyncService
{
/// <summary>
/// Syncs offline jobs from an air-gap bundle to the central scheduler.
/// </summary>
/// <param name="bundle">The bundle to sync.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The sync result.</returns>
Task<SyncResult> SyncFromBundleAsync(
AirGapBundle bundle,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Service for syncing air-gap bundles to the central scheduler.
/// </summary>
public sealed class AirGapSyncService : IAirGapSyncService
{
private readonly IHlcMergeService _mergeService;
private readonly ISyncSchedulerLogRepository _schedulerLogRepo;
private readonly IHybridLogicalClock _hlc;
private readonly ILogger<AirGapSyncService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="AirGapSyncService"/> class.
/// </summary>
public AirGapSyncService(
IHlcMergeService mergeService,
ISyncSchedulerLogRepository schedulerLogRepo,
IHybridLogicalClock hlc,
ILogger<AirGapSyncService> logger)
{
_mergeService = mergeService ?? throw new ArgumentNullException(nameof(mergeService));
_schedulerLogRepo = schedulerLogRepo ?? throw new ArgumentNullException(nameof(schedulerLogRepo));
_hlc = hlc ?? throw new ArgumentNullException(nameof(hlc));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc/>
public async Task<SyncResult> SyncFromBundleAsync(
AirGapBundle bundle,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(bundle);
_logger.LogInformation(
"Starting sync from bundle {BundleId} with {LogCount} node logs for tenant {TenantId}",
bundle.BundleId, bundle.JobLogs.Count, bundle.TenantId);
// 1. Merge all offline logs
var merged = await _mergeService.MergeAsync(bundle.JobLogs, cancellationToken)
.ConfigureAwait(false);
if (merged.MergedEntries.Count == 0)
{
_logger.LogInformation("Bundle {BundleId} has no entries to sync", bundle.BundleId);
return new SyncResult
{
BundleId = bundle.BundleId,
TotalInBundle = 0,
Appended = 0,
Duplicates = 0,
AlreadyExisted = 0
};
}
// 2. Get current scheduler chain head
var (currentLink, _) = await _schedulerLogRepo.GetChainHeadAsync(
bundle.TenantId,
cancellationToken: cancellationToken).ConfigureAwait(false);
// 3. For each merged entry, update HLC clock (receive)
// This ensures central clock advances past all offline timestamps
foreach (var entry in merged.MergedEntries)
{
_hlc.Receive(entry.THlc);
}
// 4. Append merged entries to scheduler log
// Chain links recomputed to extend from current head
byte[]? prevLink = currentLink;
var appended = 0;
var alreadyExisted = 0;
var warnings = new List<string>();
foreach (var entry in merged.MergedEntries)
{
cancellationToken.ThrowIfCancellationRequested();
// Check if job already exists (idempotency)
var exists = await _schedulerLogRepo.ExistsByJobIdAsync(
bundle.TenantId,
entry.JobId,
cancellationToken).ConfigureAwait(false);
if (exists)
{
_logger.LogDebug(
"Job {JobId} already exists in scheduler log, skipping",
entry.JobId);
alreadyExisted++;
continue;
}
// Compute new chain link extending from current chain
var newLink = OfflineHlcManager.ComputeLink(
prevLink,
entry.JobId,
entry.THlc,
entry.PayloadHash);
// Insert the entry
await _schedulerLogRepo.InsertSyncedEntryAsync(
bundle.TenantId,
entry.THlc.ToSortableString(),
entry.PartitionKey,
entry.JobId,
entry.PayloadHash,
prevLink,
newLink,
entry.SourceNodeId,
bundle.BundleId,
cancellationToken).ConfigureAwait(false);
prevLink = newLink;
appended++;
}
_logger.LogInformation(
"Sync complete for bundle {BundleId}: {Appended} appended, {Duplicates} duplicates, {AlreadyExisted} already existed",
bundle.BundleId, appended, merged.Duplicates.Count, alreadyExisted);
return new SyncResult
{
BundleId = bundle.BundleId,
TotalInBundle = merged.MergedEntries.Count,
Appended = appended,
Duplicates = merged.Duplicates.Count,
AlreadyExisted = alreadyExisted,
NewChainHead = prevLink,
Warnings = warnings.Count > 0 ? warnings : null
};
}
}

View File

@@ -0,0 +1,114 @@
// <copyright file="ConflictResolver.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using Microsoft.Extensions.Logging;
using StellaOps.AirGap.Sync.Models;
namespace StellaOps.AirGap.Sync.Services;
/// <summary>
/// Interface for conflict resolution during merge.
/// </summary>
public interface IConflictResolver
{
/// <summary>
/// Resolves conflicts when the same JobId appears in multiple entries.
/// </summary>
/// <param name="jobId">The conflicting job ID.</param>
/// <param name="conflicting">The conflicting entries with their source nodes.</param>
/// <returns>The resolution result.</returns>
ConflictResolution Resolve(
Guid jobId,
IReadOnlyList<(string NodeId, OfflineJobLogEntry Entry)> conflicting);
}
/// <summary>
/// Resolves conflicts during HLC merge operations.
/// </summary>
public sealed class ConflictResolver : IConflictResolver
{
private readonly ILogger<ConflictResolver> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ConflictResolver"/> class.
/// </summary>
public ConflictResolver(ILogger<ConflictResolver> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc/>
public ConflictResolution Resolve(
Guid jobId,
IReadOnlyList<(string NodeId, OfflineJobLogEntry Entry)> conflicting)
{
ArgumentNullException.ThrowIfNull(conflicting);
if (conflicting.Count == 0)
{
throw new ArgumentException("Conflicting list cannot be empty", nameof(conflicting));
}
if (conflicting.Count == 1)
{
// No conflict
return new ConflictResolution
{
Type = ConflictType.DuplicateTimestamp,
Resolution = ResolutionStrategy.TakeEarliest,
SelectedEntry = conflicting[0].Entry,
DroppedEntries = Array.Empty<OfflineJobLogEntry>()
};
}
// Verify payloads are actually different
var uniquePayloads = conflicting
.Select(c => Convert.ToHexString(c.Entry.PayloadHash))
.Distinct()
.ToList();
if (uniquePayloads.Count == 1)
{
// Same payload, different HLC timestamps - not a real conflict
// Take the earliest HLC (preserves causality)
var sorted = conflicting
.OrderBy(c => c.Entry.THlc.PhysicalTime)
.ThenBy(c => c.Entry.THlc.LogicalCounter)
.ThenBy(c => c.Entry.THlc.NodeId, StringComparer.Ordinal)
.ToList();
var earliest = sorted[0];
var dropped = sorted.Skip(1).Select(s => s.Entry).ToList();
_logger.LogDebug(
"Resolved duplicate timestamp conflict for JobId {JobId}: selected entry from node {NodeId} at {THlc}, dropped {DroppedCount} duplicates",
jobId, earliest.NodeId, earliest.Entry.THlc, dropped.Count);
return new ConflictResolution
{
Type = ConflictType.DuplicateTimestamp,
Resolution = ResolutionStrategy.TakeEarliest,
SelectedEntry = earliest.Entry,
DroppedEntries = dropped
};
}
// Actual conflict: same JobId, different payloads
// This indicates a bug in deterministic ID computation
var nodeIds = string.Join(", ", conflicting.Select(c => c.NodeId));
var payloadHashes = string.Join(", ", conflicting.Select(c => Convert.ToHexString(c.Entry.PayloadHash)[..16] + "..."));
_logger.LogError(
"Payload mismatch conflict for JobId {JobId}: different payloads from nodes [{NodeIds}] with hashes [{PayloadHashes}]",
jobId, nodeIds, payloadHashes);
return new ConflictResolution
{
Type = ConflictType.PayloadMismatch,
Resolution = ResolutionStrategy.Error,
Error = $"JobId {jobId} has conflicting payloads from nodes: {nodeIds}. " +
"This indicates a bug in deterministic job ID computation or payload tampering."
};
}
}

View File

@@ -0,0 +1,169 @@
// <copyright file="HlcMergeService.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using Microsoft.Extensions.Logging;
using StellaOps.AirGap.Sync.Models;
namespace StellaOps.AirGap.Sync.Services;
/// <summary>
/// Interface for HLC-based merge operations.
/// </summary>
public interface IHlcMergeService
{
/// <summary>
/// Merges job logs from multiple offline nodes into a unified, HLC-ordered stream.
/// </summary>
/// <param name="nodeLogs">The node logs to merge.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The merge result.</returns>
Task<MergeResult> MergeAsync(
IReadOnlyList<NodeJobLog> nodeLogs,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Service for merging job logs from multiple offline nodes using HLC total ordering.
/// </summary>
public sealed class HlcMergeService : IHlcMergeService
{
private readonly IConflictResolver _conflictResolver;
private readonly ILogger<HlcMergeService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="HlcMergeService"/> class.
/// </summary>
public HlcMergeService(
IConflictResolver conflictResolver,
ILogger<HlcMergeService> logger)
{
_conflictResolver = conflictResolver ?? throw new ArgumentNullException(nameof(conflictResolver));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc/>
public Task<MergeResult> MergeAsync(
IReadOnlyList<NodeJobLog> nodeLogs,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(nodeLogs);
cancellationToken.ThrowIfCancellationRequested();
if (nodeLogs.Count == 0)
{
return Task.FromResult(new MergeResult
{
MergedEntries = Array.Empty<MergedJobEntry>(),
Duplicates = Array.Empty<DuplicateEntry>(),
SourceNodes = Array.Empty<string>()
});
}
_logger.LogInformation(
"Starting merge of {NodeCount} node logs with {TotalEntries} total entries",
nodeLogs.Count,
nodeLogs.Sum(l => l.Entries.Count));
// 1. Collect all entries from all nodes
var allEntries = nodeLogs
.SelectMany(log => log.Entries.Select(e => (log.NodeId, Entry: e)))
.ToList();
// 2. Sort by HLC total order: (PhysicalTime, LogicalCounter, NodeId, JobId)
var sorted = allEntries
.OrderBy(x => x.Entry.THlc.PhysicalTime)
.ThenBy(x => x.Entry.THlc.LogicalCounter)
.ThenBy(x => x.Entry.THlc.NodeId, StringComparer.Ordinal)
.ThenBy(x => x.Entry.JobId)
.ToList();
// 3. Group by JobId to detect duplicates
var groupedByJobId = sorted.GroupBy(x => x.Entry.JobId).ToList();
var deduplicated = new List<MergedJobEntry>();
var duplicates = new List<DuplicateEntry>();
foreach (var group in groupedByJobId)
{
var entries = group.ToList();
if (entries.Count == 1)
{
// No conflict - add directly
var (nodeId, entry) = entries[0];
deduplicated.Add(CreateMergedEntry(nodeId, entry));
}
else
{
// Multiple entries with same JobId - resolve conflict
var resolution = _conflictResolver.Resolve(group.Key, entries);
if (resolution.Resolution == ResolutionStrategy.Error)
{
_logger.LogError(
"Conflict resolution failed for JobId {JobId}: {Error}",
group.Key, resolution.Error);
throw new InvalidOperationException(resolution.Error);
}
// Add the selected entry
if (resolution.SelectedEntry is not null)
{
var sourceEntry = entries.First(e => e.Entry == resolution.SelectedEntry);
deduplicated.Add(CreateMergedEntry(sourceEntry.NodeId, resolution.SelectedEntry));
}
// Record duplicates
foreach (var dropped in resolution.DroppedEntries ?? Array.Empty<OfflineJobLogEntry>())
{
var sourceEntry = entries.First(e => e.Entry == dropped);
duplicates.Add(new DuplicateEntry(dropped.JobId, sourceEntry.NodeId, dropped.THlc));
}
}
}
// 4. Sort deduplicated entries by HLC order
deduplicated = deduplicated
.OrderBy(x => x.THlc.PhysicalTime)
.ThenBy(x => x.THlc.LogicalCounter)
.ThenBy(x => x.THlc.NodeId, StringComparer.Ordinal)
.ThenBy(x => x.JobId)
.ToList();
// 5. Recompute unified chain
byte[]? prevLink = null;
foreach (var entry in deduplicated)
{
entry.MergedLink = OfflineHlcManager.ComputeLink(
prevLink,
entry.JobId,
entry.THlc,
entry.PayloadHash);
prevLink = entry.MergedLink;
}
_logger.LogInformation(
"Merge complete: {MergedCount} entries, {DuplicateCount} duplicates dropped",
deduplicated.Count, duplicates.Count);
return Task.FromResult(new MergeResult
{
MergedEntries = deduplicated,
Duplicates = duplicates,
MergedChainHead = prevLink,
SourceNodes = nodeLogs.Select(l => l.NodeId).ToList()
});
}
private static MergedJobEntry CreateMergedEntry(string nodeId, OfflineJobLogEntry entry) => new()
{
SourceNodeId = nodeId,
THlc = entry.THlc,
JobId = entry.JobId,
PartitionKey = entry.PartitionKey,
Payload = entry.Payload,
PayloadHash = entry.PayloadHash,
OriginalLink = entry.Link
};
}

View File

@@ -0,0 +1,172 @@
// <copyright file="OfflineHlcManager.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using StellaOps.AirGap.Sync.Models;
using StellaOps.AirGap.Sync.Stores;
using StellaOps.Canonical.Json;
using StellaOps.Determinism;
using StellaOps.HybridLogicalClock;
namespace StellaOps.AirGap.Sync.Services;
/// <summary>
/// Interface for offline HLC management.
/// </summary>
public interface IOfflineHlcManager
{
/// <summary>
/// Enqueues a job locally while offline, maintaining the local chain.
/// </summary>
/// <typeparam name="T">The payload type.</typeparam>
/// <param name="payload">The job payload.</param>
/// <param name="idempotencyKey">The idempotency key for deterministic job ID.</param>
/// <param name="partitionKey">Optional partition key.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The enqueue result.</returns>
Task<OfflineEnqueueResult> EnqueueOfflineAsync<T>(
T payload,
string idempotencyKey,
string? partitionKey = null,
CancellationToken cancellationToken = default) where T : notnull;
/// <summary>
/// Gets the current node's job log for export.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The node job log, or null if empty.</returns>
Task<NodeJobLog?> GetNodeJobLogAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets the node ID.
/// </summary>
string NodeId { get; }
}
/// <summary>
/// Manages HLC operations for offline/air-gap scenarios.
/// </summary>
public sealed class OfflineHlcManager : IOfflineHlcManager
{
private readonly IHybridLogicalClock _hlc;
private readonly IOfflineJobLogStore _jobLogStore;
private readonly IGuidProvider _guidProvider;
private readonly ILogger<OfflineHlcManager> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="OfflineHlcManager"/> class.
/// </summary>
public OfflineHlcManager(
IHybridLogicalClock hlc,
IOfflineJobLogStore jobLogStore,
IGuidProvider guidProvider,
ILogger<OfflineHlcManager> logger)
{
_hlc = hlc ?? throw new ArgumentNullException(nameof(hlc));
_jobLogStore = jobLogStore ?? throw new ArgumentNullException(nameof(jobLogStore));
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc/>
public string NodeId => _hlc.NodeId;
/// <inheritdoc/>
public async Task<OfflineEnqueueResult> EnqueueOfflineAsync<T>(
T payload,
string idempotencyKey,
string? partitionKey = null,
CancellationToken cancellationToken = default) where T : notnull
{
ArgumentNullException.ThrowIfNull(payload);
ArgumentException.ThrowIfNullOrWhiteSpace(idempotencyKey);
// 1. Generate HLC timestamp
var tHlc = _hlc.Tick();
// 2. Compute deterministic job ID from idempotency key
var jobId = ComputeDeterministicJobId(idempotencyKey);
// 3. Serialize and hash payload
var payloadJson = CanonJson.Serialize(payload);
var payloadHash = SHA256.HashData(Encoding.UTF8.GetBytes(payloadJson));
// 4. Get previous chain link
var prevLink = await _jobLogStore.GetLastLinkAsync(NodeId, cancellationToken)
.ConfigureAwait(false);
// 5. Compute chain link
var link = ComputeLink(prevLink, jobId, tHlc, payloadHash);
// 6. Create and store entry
var entry = new OfflineJobLogEntry
{
NodeId = NodeId,
THlc = tHlc,
JobId = jobId,
PartitionKey = partitionKey,
Payload = payloadJson,
PayloadHash = payloadHash,
PrevLink = prevLink,
Link = link,
EnqueuedAt = DateTimeOffset.UtcNow
};
await _jobLogStore.AppendAsync(entry, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Enqueued offline job {JobId} with HLC {THlc} on node {NodeId}",
jobId, tHlc, NodeId);
return new OfflineEnqueueResult
{
THlc = tHlc,
JobId = jobId,
Link = link,
NodeId = NodeId
};
}
/// <inheritdoc/>
public Task<NodeJobLog?> GetNodeJobLogAsync(CancellationToken cancellationToken = default)
=> _jobLogStore.GetNodeJobLogAsync(NodeId, cancellationToken);
/// <summary>
/// Computes deterministic job ID from idempotency key.
/// </summary>
private Guid ComputeDeterministicJobId(string idempotencyKey)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(idempotencyKey));
// Use first 16 bytes of SHA-256 as deterministic GUID
return new Guid(hash.AsSpan(0, 16));
}
/// <summary>
/// Computes chain link: Hash(prev_link || job_id || t_hlc || payload_hash).
/// </summary>
internal static byte[] ComputeLink(
byte[]? prevLink,
Guid jobId,
HlcTimestamp tHlc,
byte[] payloadHash)
{
using var hasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
// Previous link (or 32 zero bytes for first entry)
hasher.AppendData(prevLink ?? new byte[32]);
// Job ID as bytes
hasher.AppendData(jobId.ToByteArray());
// HLC timestamp as UTF-8 bytes
hasher.AppendData(Encoding.UTF8.GetBytes(tHlc.ToSortableString()));
// Payload hash
hasher.AppendData(payloadHash);
return hasher.GetHashAndReset();
}
}

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>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.HybridLogicalClock\StellaOps.HybridLogicalClock.csproj" />
<ProjectReference Include="..\..\..\Scheduler\__Libraries\StellaOps.Scheduler.Models\StellaOps.Scheduler.Models.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,246 @@
// <copyright file="FileBasedOfflineJobLogStore.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Sync.Models;
using StellaOps.Canonical.Json;
using StellaOps.HybridLogicalClock;
namespace StellaOps.AirGap.Sync.Stores;
/// <summary>
/// Options for the file-based offline job log store.
/// </summary>
public sealed class FileBasedOfflineJobLogStoreOptions
{
/// <summary>
/// Gets or sets the directory for storing offline job logs.
/// </summary>
public string DataDirectory { get; set; } = "./offline-job-logs";
}
/// <summary>
/// File-based implementation of <see cref="IOfflineJobLogStore"/> for air-gap scenarios.
/// </summary>
public sealed class FileBasedOfflineJobLogStore : IOfflineJobLogStore
{
private readonly IOptions<FileBasedOfflineJobLogStoreOptions> _options;
private readonly ILogger<FileBasedOfflineJobLogStore> _logger;
private readonly SemaphoreSlim _lock = new(1, 1);
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>
/// Initializes a new instance of the <see cref="FileBasedOfflineJobLogStore"/> class.
/// </summary>
public FileBasedOfflineJobLogStore(
IOptions<FileBasedOfflineJobLogStoreOptions> options,
ILogger<FileBasedOfflineJobLogStore> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
EnsureDirectoryExists();
}
/// <inheritdoc/>
public async Task AppendAsync(OfflineJobLogEntry entry, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entry);
await _lock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var filePath = GetNodeLogFilePath(entry.NodeId);
var dto = ToDto(entry);
var line = JsonSerializer.Serialize(dto, JsonOptions);
await File.AppendAllTextAsync(filePath, line + Environment.NewLine, cancellationToken)
.ConfigureAwait(false);
_logger.LogDebug(
"Appended offline job entry {JobId} for node {NodeId}",
entry.JobId, entry.NodeId);
}
finally
{
_lock.Release();
}
}
/// <inheritdoc/>
public async Task<IReadOnlyList<OfflineJobLogEntry>> GetEntriesAsync(
string nodeId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(nodeId);
var filePath = GetNodeLogFilePath(nodeId);
if (!File.Exists(filePath))
{
return Array.Empty<OfflineJobLogEntry>();
}
await _lock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var lines = await File.ReadAllLinesAsync(filePath, cancellationToken).ConfigureAwait(false);
var entries = new List<OfflineJobLogEntry>(lines.Length);
foreach (var line in lines)
{
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
var dto = JsonSerializer.Deserialize<OfflineJobLogEntryDto>(line, JsonOptions);
if (dto is not null)
{
entries.Add(FromDto(dto));
}
}
// Return in HLC order
return entries.OrderBy(e => e.THlc).ToList();
}
finally
{
_lock.Release();
}
}
/// <inheritdoc/>
public async Task<byte[]?> GetLastLinkAsync(string nodeId, CancellationToken cancellationToken = default)
{
var entries = await GetEntriesAsync(nodeId, cancellationToken).ConfigureAwait(false);
return entries.Count > 0 ? entries[^1].Link : null;
}
/// <inheritdoc/>
public async Task<NodeJobLog?> GetNodeJobLogAsync(string nodeId, CancellationToken cancellationToken = default)
{
var entries = await GetEntriesAsync(nodeId, cancellationToken).ConfigureAwait(false);
if (entries.Count == 0)
{
return null;
}
var lastEntry = entries[^1];
return new NodeJobLog
{
NodeId = nodeId,
LastHlc = lastEntry.THlc,
ChainHead = lastEntry.Link,
Entries = entries
};
}
/// <inheritdoc/>
public async Task<int> ClearEntriesAsync(
string nodeId,
string upToHlc,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(nodeId);
await _lock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var entries = await GetEntriesAsync(nodeId, cancellationToken).ConfigureAwait(false);
var remaining = entries
.Where(e => string.CompareOrdinal(e.THlc.ToSortableString(), upToHlc) > 0)
.ToList();
var cleared = entries.Count - remaining.Count;
if (remaining.Count == 0)
{
var filePath = GetNodeLogFilePath(nodeId);
if (File.Exists(filePath))
{
File.Delete(filePath);
}
}
else
{
// Rewrite with remaining entries
var filePath = GetNodeLogFilePath(nodeId);
var lines = remaining.Select(e => JsonSerializer.Serialize(ToDto(e), JsonOptions));
await File.WriteAllLinesAsync(filePath, lines, cancellationToken).ConfigureAwait(false);
}
_logger.LogInformation(
"Cleared {Count} offline job entries for node {NodeId} up to HLC {UpToHlc}",
cleared, nodeId, upToHlc);
return cleared;
}
finally
{
_lock.Release();
}
}
private string GetNodeLogFilePath(string nodeId)
{
var safeNodeId = nodeId.Replace('/', '_').Replace('\\', '_').Replace(':', '_');
return Path.Combine(_options.Value.DataDirectory, $"offline-jobs-{safeNodeId}.ndjson");
}
private void EnsureDirectoryExists()
{
var dir = _options.Value.DataDirectory;
if (!Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
_logger.LogInformation("Created offline job log directory: {Directory}", dir);
}
}
private static OfflineJobLogEntryDto ToDto(OfflineJobLogEntry entry) => new()
{
NodeId = entry.NodeId,
THlc = entry.THlc.ToSortableString(),
JobId = entry.JobId,
PartitionKey = entry.PartitionKey,
Payload = entry.Payload,
PayloadHash = Convert.ToBase64String(entry.PayloadHash),
PrevLink = entry.PrevLink is not null ? Convert.ToBase64String(entry.PrevLink) : null,
Link = Convert.ToBase64String(entry.Link),
EnqueuedAt = entry.EnqueuedAt
};
private static OfflineJobLogEntry FromDto(OfflineJobLogEntryDto dto) => new()
{
NodeId = dto.NodeId,
THlc = HlcTimestamp.Parse(dto.THlc),
JobId = dto.JobId,
PartitionKey = dto.PartitionKey,
Payload = dto.Payload,
PayloadHash = Convert.FromBase64String(dto.PayloadHash),
PrevLink = dto.PrevLink is not null ? Convert.FromBase64String(dto.PrevLink) : null,
Link = Convert.FromBase64String(dto.Link),
EnqueuedAt = dto.EnqueuedAt
};
private sealed record OfflineJobLogEntryDto
{
public required string NodeId { get; init; }
public required string THlc { get; init; }
public required Guid JobId { get; init; }
public string? PartitionKey { get; init; }
public required string Payload { get; init; }
public required string PayloadHash { get; init; }
public string? PrevLink { get; init; }
public required string Link { get; init; }
public DateTimeOffset EnqueuedAt { get; init; }
}
}

View File

@@ -0,0 +1,58 @@
// <copyright file="IOfflineJobLogStore.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using StellaOps.AirGap.Sync.Models;
namespace StellaOps.AirGap.Sync.Stores;
/// <summary>
/// Interface for storing offline job log entries.
/// </summary>
public interface IOfflineJobLogStore
{
/// <summary>
/// Appends an entry to the offline job log.
/// </summary>
/// <param name="entry">The entry to append.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task AppendAsync(OfflineJobLogEntry entry, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all entries for a node.
/// </summary>
/// <param name="nodeId">The node ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>All entries in HLC order.</returns>
Task<IReadOnlyList<OfflineJobLogEntry>> GetEntriesAsync(
string nodeId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the last chain link for a node.
/// </summary>
/// <param name="nodeId">The node ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The last link, or null if no entries exist.</returns>
Task<byte[]?> GetLastLinkAsync(string nodeId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the node job log for export.
/// </summary>
/// <param name="nodeId">The node ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The complete node job log.</returns>
Task<NodeJobLog?> GetNodeJobLogAsync(string nodeId, CancellationToken cancellationToken = default);
/// <summary>
/// Clears entries for a node after successful sync.
/// </summary>
/// <param name="nodeId">The node ID.</param>
/// <param name="upToHlc">Clear entries up to and including this HLC timestamp.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Number of entries cleared.</returns>
Task<int> ClearEntriesAsync(
string nodeId,
string upToHlc,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,161 @@
// <copyright file="AirGapSyncMetrics.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Diagnostics.Metrics;
using StellaOps.AirGap.Sync.Models;
namespace StellaOps.AirGap.Sync.Telemetry;
/// <summary>
/// Metrics for air-gap sync operations.
/// </summary>
public static class AirGapSyncMetrics
{
private const string NodeIdTag = "node_id";
private const string TenantIdTag = "tenant_id";
private const string ConflictTypeTag = "conflict_type";
private static readonly Meter Meter = new("StellaOps.AirGap.Sync");
// Counters
private static readonly Counter<long> BundlesExportedCounter = Meter.CreateCounter<long>(
"airgap_bundles_exported_total",
unit: "{bundle}",
description: "Total number of air-gap bundles exported");
private static readonly Counter<long> BundlesImportedCounter = Meter.CreateCounter<long>(
"airgap_bundles_imported_total",
unit: "{bundle}",
description: "Total number of air-gap bundles imported");
private static readonly Counter<long> JobsSyncedCounter = Meter.CreateCounter<long>(
"airgap_jobs_synced_total",
unit: "{job}",
description: "Total number of jobs synced from air-gap bundles");
private static readonly Counter<long> DuplicatesDroppedCounter = Meter.CreateCounter<long>(
"airgap_duplicates_dropped_total",
unit: "{duplicate}",
description: "Total number of duplicate entries dropped during merge");
private static readonly Counter<long> MergeConflictsCounter = Meter.CreateCounter<long>(
"airgap_merge_conflicts_total",
unit: "{conflict}",
description: "Total number of merge conflicts by type");
private static readonly Counter<long> OfflineEnqueuesCounter = Meter.CreateCounter<long>(
"airgap_offline_enqueues_total",
unit: "{enqueue}",
description: "Total number of offline enqueue operations");
// Histograms
private static readonly Histogram<double> BundleSizeHistogram = Meter.CreateHistogram<double>(
"airgap_bundle_size_bytes",
unit: "By",
description: "Size of air-gap bundles in bytes");
private static readonly Histogram<double> SyncDurationHistogram = Meter.CreateHistogram<double>(
"airgap_sync_duration_seconds",
unit: "s",
description: "Duration of air-gap sync operations");
private static readonly Histogram<int> MergeEntriesHistogram = Meter.CreateHistogram<int>(
"airgap_merge_entries_count",
unit: "{entry}",
description: "Number of entries in merge operations");
/// <summary>
/// Records a bundle export.
/// </summary>
/// <param name="nodeId">The node ID that exported.</param>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="entryCount">Number of entries in the bundle.</param>
public static void RecordBundleExported(string nodeId, string tenantId, int entryCount)
{
BundlesExportedCounter.Add(1,
new KeyValuePair<string, object?>(NodeIdTag, nodeId),
new KeyValuePair<string, object?>(TenantIdTag, tenantId));
MergeEntriesHistogram.Record(entryCount,
new KeyValuePair<string, object?>(NodeIdTag, nodeId));
}
/// <summary>
/// Records a bundle import.
/// </summary>
/// <param name="nodeId">The node ID that imported.</param>
/// <param name="tenantId">The tenant ID.</param>
public static void RecordBundleImported(string nodeId, string tenantId)
{
BundlesImportedCounter.Add(1,
new KeyValuePair<string, object?>(NodeIdTag, nodeId),
new KeyValuePair<string, object?>(TenantIdTag, tenantId));
}
/// <summary>
/// Records jobs synced from a bundle.
/// </summary>
/// <param name="nodeId">The node ID.</param>
/// <param name="count">Number of jobs synced.</param>
public static void RecordJobsSynced(string nodeId, int count)
{
JobsSyncedCounter.Add(count,
new KeyValuePair<string, object?>(NodeIdTag, nodeId));
}
/// <summary>
/// Records duplicates dropped during merge.
/// </summary>
/// <param name="nodeId">The node ID.</param>
/// <param name="count">Number of duplicates dropped.</param>
public static void RecordDuplicatesDropped(string nodeId, int count)
{
if (count > 0)
{
DuplicatesDroppedCounter.Add(count,
new KeyValuePair<string, object?>(NodeIdTag, nodeId));
}
}
/// <summary>
/// Records a merge conflict.
/// </summary>
/// <param name="conflictType">The type of conflict.</param>
public static void RecordMergeConflict(ConflictType conflictType)
{
MergeConflictsCounter.Add(1,
new KeyValuePair<string, object?>(ConflictTypeTag, conflictType.ToString()));
}
/// <summary>
/// Records an offline enqueue operation.
/// </summary>
/// <param name="nodeId">The node ID.</param>
public static void RecordOfflineEnqueue(string nodeId)
{
OfflineEnqueuesCounter.Add(1,
new KeyValuePair<string, object?>(NodeIdTag, nodeId));
}
/// <summary>
/// Records bundle size.
/// </summary>
/// <param name="nodeId">The node ID.</param>
/// <param name="sizeBytes">Size in bytes.</param>
public static void RecordBundleSize(string nodeId, long sizeBytes)
{
BundleSizeHistogram.Record(sizeBytes,
new KeyValuePair<string, object?>(NodeIdTag, nodeId));
}
/// <summary>
/// Records sync duration.
/// </summary>
/// <param name="nodeId">The node ID.</param>
/// <param name="durationSeconds">Duration in seconds.</param>
public static void RecordSyncDuration(string nodeId, double durationSeconds)
{
SyncDurationHistogram.Record(durationSeconds,
new KeyValuePair<string, object?>(NodeIdTag, nodeId));
}
}

View File

@@ -0,0 +1,221 @@
// <copyright file="FileBasedJobSyncTransport.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Sync.Models;
using StellaOps.AirGap.Sync.Services;
using StellaOps.AirGap.Sync.Telemetry;
namespace StellaOps.AirGap.Sync.Transport;
/// <summary>
/// File-based transport for job sync bundles in air-gapped scenarios.
/// </summary>
public sealed class FileBasedJobSyncTransport : IJobSyncTransport
{
private readonly IAirGapBundleExporter _exporter;
private readonly IAirGapBundleImporter _importer;
private readonly FileBasedJobSyncTransportOptions _options;
private readonly ILogger<FileBasedJobSyncTransport> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="FileBasedJobSyncTransport"/> class.
/// </summary>
public FileBasedJobSyncTransport(
IAirGapBundleExporter exporter,
IAirGapBundleImporter importer,
IOptions<FileBasedJobSyncTransportOptions> options,
ILogger<FileBasedJobSyncTransport> logger)
{
_exporter = exporter ?? throw new ArgumentNullException(nameof(exporter));
_importer = importer ?? throw new ArgumentNullException(nameof(importer));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc/>
public string TransportId => "file";
/// <inheritdoc/>
public async Task<JobSyncSendResult> SendBundleAsync(
AirGapBundle bundle,
string destination,
CancellationToken cancellationToken = default)
{
var startTime = DateTimeOffset.UtcNow;
try
{
// Ensure destination directory exists
var destPath = Path.IsPathRooted(destination)
? destination
: Path.Combine(_options.OutputDirectory, destination);
Directory.CreateDirectory(destPath);
// Export to file
var filePath = Path.Combine(destPath, $"job-sync-{bundle.BundleId:N}.json");
await _exporter.ExportToFileAsync(bundle, filePath, cancellationToken)
.ConfigureAwait(false);
var fileInfo = new FileInfo(filePath);
var sizeBytes = fileInfo.Exists ? fileInfo.Length : 0;
_logger.LogInformation(
"Exported job sync bundle {BundleId} to {Path} ({Size} bytes)",
bundle.BundleId,
filePath,
sizeBytes);
AirGapSyncMetrics.RecordBundleSize(bundle.CreatedByNodeId, sizeBytes);
return new JobSyncSendResult
{
Success = true,
BundleId = bundle.BundleId,
Destination = filePath,
TransmittedAt = startTime,
SizeBytes = sizeBytes
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to export job sync bundle {BundleId}", bundle.BundleId);
return new JobSyncSendResult
{
Success = false,
BundleId = bundle.BundleId,
Destination = destination,
Error = ex.Message,
TransmittedAt = startTime
};
}
}
/// <inheritdoc/>
public async Task<AirGapBundle?> ReceiveBundleAsync(
string source,
CancellationToken cancellationToken = default)
{
try
{
var sourcePath = Path.IsPathRooted(source)
? source
: Path.Combine(_options.InputDirectory, source);
if (!File.Exists(sourcePath))
{
_logger.LogWarning("Job sync bundle file not found: {Path}", sourcePath);
return null;
}
var bundle = await _importer.ImportFromFileAsync(sourcePath, cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation(
"Imported job sync bundle {BundleId} from {Path}",
bundle.BundleId,
sourcePath);
return bundle;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to import job sync bundle from {Source}", source);
return null;
}
}
/// <inheritdoc/>
public Task<IReadOnlyList<BundleInfo>> ListAvailableBundlesAsync(
string source,
CancellationToken cancellationToken = default)
{
var sourcePath = Path.IsPathRooted(source)
? source
: Path.Combine(_options.InputDirectory, source);
var bundles = new List<BundleInfo>();
if (!Directory.Exists(sourcePath))
{
return Task.FromResult<IReadOnlyList<BundleInfo>>(bundles);
}
var files = Directory.GetFiles(sourcePath, "job-sync-*.json");
foreach (var file in files)
{
try
{
// Quick parse to extract bundle metadata
var json = File.ReadAllText(file);
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.TryGetProperty("bundleId", out var bundleIdProp) &&
root.TryGetProperty("tenantId", out var tenantIdProp) &&
root.TryGetProperty("createdByNodeId", out var nodeIdProp) &&
root.TryGetProperty("createdAt", out var createdAtProp))
{
var entryCount = 0;
if (root.TryGetProperty("jobLogs", out var jobLogs))
{
foreach (var log in jobLogs.EnumerateArray())
{
if (log.TryGetProperty("entries", out var entries))
{
entryCount += entries.GetArrayLength();
}
}
}
bundles.Add(new BundleInfo
{
BundleId = Guid.Parse(bundleIdProp.GetString()!),
TenantId = tenantIdProp.GetString()!,
SourceNodeId = nodeIdProp.GetString()!,
CreatedAt = DateTimeOffset.Parse(createdAtProp.GetString()!),
EntryCount = entryCount,
SizeBytes = new FileInfo(file).Length
});
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse bundle metadata from {File}", file);
}
}
return Task.FromResult<IReadOnlyList<BundleInfo>>(
bundles.OrderByDescending(b => b.CreatedAt).ToList());
}
}
/// <summary>
/// Options for file-based job sync transport.
/// </summary>
public sealed class FileBasedJobSyncTransportOptions
{
/// <summary>
/// Gets or sets the output directory for exporting bundles.
/// </summary>
public string OutputDirectory { get; set; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"stellaops",
"airgap",
"outbox");
/// <summary>
/// Gets or sets the input directory for importing bundles.
/// </summary>
public string InputDirectory { get; set; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"stellaops",
"airgap",
"inbox");
}

View File

@@ -0,0 +1,123 @@
// <copyright file="IJobSyncTransport.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using StellaOps.AirGap.Sync.Models;
namespace StellaOps.AirGap.Sync.Transport;
/// <summary>
/// Transport abstraction for job sync bundles.
/// Enables bundle transfer over various transports (file, Router messaging, etc.).
/// </summary>
public interface IJobSyncTransport
{
/// <summary>
/// Gets the transport identifier.
/// </summary>
string TransportId { get; }
/// <summary>
/// Sends a job sync bundle to a destination.
/// </summary>
/// <param name="bundle">The bundle to send.</param>
/// <param name="destination">The destination identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The send result.</returns>
Task<JobSyncSendResult> SendBundleAsync(
AirGapBundle bundle,
string destination,
CancellationToken cancellationToken = default);
/// <summary>
/// Receives a job sync bundle from a source.
/// </summary>
/// <param name="source">The source identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The received bundle, or null if not available.</returns>
Task<AirGapBundle?> ReceiveBundleAsync(
string source,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists available bundles from a source.
/// </summary>
/// <param name="source">The source identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of available bundle identifiers.</returns>
Task<IReadOnlyList<BundleInfo>> ListAvailableBundlesAsync(
string source,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of sending a job sync bundle.
/// </summary>
public sealed record JobSyncSendResult
{
/// <summary>
/// Gets a value indicating whether the send was successful.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Gets the bundle ID.
/// </summary>
public required Guid BundleId { get; init; }
/// <summary>
/// Gets the destination where the bundle was sent.
/// </summary>
public required string Destination { get; init; }
/// <summary>
/// Gets the error message if the send failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Gets the transmission timestamp.
/// </summary>
public DateTimeOffset TransmittedAt { get; init; }
/// <summary>
/// Gets the size of the transmitted data in bytes.
/// </summary>
public long SizeBytes { get; init; }
}
/// <summary>
/// Information about an available bundle.
/// </summary>
public sealed record BundleInfo
{
/// <summary>
/// Gets the bundle ID.
/// </summary>
public required Guid BundleId { get; init; }
/// <summary>
/// Gets the tenant ID.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Gets the source node ID.
/// </summary>
public required string SourceNodeId { get; init; }
/// <summary>
/// Gets the creation timestamp.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Gets the entry count in the bundle.
/// </summary>
public int EntryCount { get; init; }
/// <summary>
/// Gets the bundle size in bytes.
/// </summary>
public long SizeBytes { get; init; }
}

View File

@@ -0,0 +1,272 @@
// <copyright file="RouterJobSyncTransport.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Sync.Models;
using StellaOps.AirGap.Sync.Services;
using StellaOps.AirGap.Sync.Telemetry;
namespace StellaOps.AirGap.Sync.Transport;
/// <summary>
/// Router-based transport for job sync bundles when network is available.
/// This transport uses the Router messaging infrastructure for real-time sync.
/// </summary>
public sealed class RouterJobSyncTransport : IJobSyncTransport
{
private readonly IAirGapBundleExporter _exporter;
private readonly IAirGapBundleImporter _importer;
private readonly IRouterJobSyncClient _routerClient;
private readonly RouterJobSyncTransportOptions _options;
private readonly ILogger<RouterJobSyncTransport> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="RouterJobSyncTransport"/> class.
/// </summary>
public RouterJobSyncTransport(
IAirGapBundleExporter exporter,
IAirGapBundleImporter importer,
IRouterJobSyncClient routerClient,
IOptions<RouterJobSyncTransportOptions> options,
ILogger<RouterJobSyncTransport> logger)
{
_exporter = exporter ?? throw new ArgumentNullException(nameof(exporter));
_importer = importer ?? throw new ArgumentNullException(nameof(importer));
_routerClient = routerClient ?? throw new ArgumentNullException(nameof(routerClient));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc/>
public string TransportId => "router";
/// <inheritdoc/>
public async Task<JobSyncSendResult> SendBundleAsync(
AirGapBundle bundle,
string destination,
CancellationToken cancellationToken = default)
{
var startTime = DateTimeOffset.UtcNow;
try
{
// Serialize bundle
var json = await _exporter.ExportToStringAsync(bundle, cancellationToken)
.ConfigureAwait(false);
var payload = Encoding.UTF8.GetBytes(json);
_logger.LogDebug(
"Sending job sync bundle {BundleId} to {Destination} ({Size} bytes)",
bundle.BundleId,
destination,
payload.Length);
// Send via Router
var response = await _routerClient.SendJobSyncBundleAsync(
destination,
bundle.BundleId,
bundle.TenantId,
payload,
_options.SendTimeout,
cancellationToken).ConfigureAwait(false);
if (response.Success)
{
AirGapSyncMetrics.RecordBundleSize(bundle.CreatedByNodeId, payload.Length);
_logger.LogInformation(
"Sent job sync bundle {BundleId} to {Destination}",
bundle.BundleId,
destination);
}
else
{
_logger.LogWarning(
"Failed to send job sync bundle {BundleId} to {Destination}: {Error}",
bundle.BundleId,
destination,
response.Error);
}
return new JobSyncSendResult
{
Success = response.Success,
BundleId = bundle.BundleId,
Destination = destination,
Error = response.Error,
TransmittedAt = startTime,
SizeBytes = payload.Length
};
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Error sending job sync bundle {BundleId} to {Destination}",
bundle.BundleId,
destination);
return new JobSyncSendResult
{
Success = false,
BundleId = bundle.BundleId,
Destination = destination,
Error = ex.Message,
TransmittedAt = startTime
};
}
}
/// <inheritdoc/>
public async Task<AirGapBundle?> ReceiveBundleAsync(
string source,
CancellationToken cancellationToken = default)
{
try
{
var response = await _routerClient.ReceiveJobSyncBundleAsync(
source,
_options.ReceiveTimeout,
cancellationToken).ConfigureAwait(false);
if (response.Payload is null || response.Payload.Length == 0)
{
_logger.LogDebug("No bundle available from {Source}", source);
return null;
}
var json = Encoding.UTF8.GetString(response.Payload);
var bundle = await _importer.ImportFromStringAsync(json, cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation(
"Received job sync bundle {BundleId} from {Source}",
bundle.BundleId,
source);
return bundle;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error receiving job sync bundle from {Source}", source);
return null;
}
}
/// <inheritdoc/>
public async Task<IReadOnlyList<BundleInfo>> ListAvailableBundlesAsync(
string source,
CancellationToken cancellationToken = default)
{
try
{
var response = await _routerClient.ListAvailableBundlesAsync(
source,
_options.ListTimeout,
cancellationToken).ConfigureAwait(false);
return response.Bundles;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error listing available bundles from {Source}", source);
return Array.Empty<BundleInfo>();
}
}
}
/// <summary>
/// Options for Router-based job sync transport.
/// </summary>
public sealed class RouterJobSyncTransportOptions
{
/// <summary>
/// Gets or sets the timeout for send operations.
/// </summary>
public TimeSpan SendTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Gets or sets the timeout for receive operations.
/// </summary>
public TimeSpan ReceiveTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Gets or sets the timeout for list operations.
/// </summary>
public TimeSpan ListTimeout { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>
/// Gets or sets the service endpoint for job sync.
/// </summary>
public string ServiceEndpoint { get; set; } = "scheduler.job-sync";
}
/// <summary>
/// Client interface for Router job sync operations.
/// </summary>
public interface IRouterJobSyncClient
{
/// <summary>
/// Sends a job sync bundle via the Router.
/// </summary>
Task<RouterSendResponse> SendJobSyncBundleAsync(
string destination,
Guid bundleId,
string tenantId,
byte[] payload,
TimeSpan timeout,
CancellationToken cancellationToken = default);
/// <summary>
/// Receives a job sync bundle via the Router.
/// </summary>
Task<RouterReceiveResponse> ReceiveJobSyncBundleAsync(
string source,
TimeSpan timeout,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists available bundles via the Router.
/// </summary>
Task<RouterListResponse> ListAvailableBundlesAsync(
string source,
TimeSpan timeout,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Response from a Router send operation.
/// </summary>
public sealed record RouterSendResponse
{
/// <summary>Gets a value indicating whether the send was successful.</summary>
public bool Success { get; init; }
/// <summary>Gets the error message if failed.</summary>
public string? Error { get; init; }
}
/// <summary>
/// Response from a Router receive operation.
/// </summary>
public sealed record RouterReceiveResponse
{
/// <summary>Gets the received payload.</summary>
public byte[]? Payload { get; init; }
/// <summary>Gets the bundle ID.</summary>
public Guid? BundleId { get; init; }
}
/// <summary>
/// Response from a Router list operation.
/// </summary>
public sealed record RouterListResponse
{
/// <summary>Gets the available bundles.</summary>
public IReadOnlyList<BundleInfo> Bundles { get; init; } = Array.Empty<BundleInfo>();
}

View File

@@ -22,6 +22,9 @@ namespace StellaOps.AirGap.Bundle.Tests;
/// Task AIRGAP-5100-016: Export bundle (online env) → import bundle (offline env) → verify data integrity
/// Task AIRGAP-5100-017: Policy export → policy import → policy evaluation → verify identical verdict
/// </summary>
[Trait("Category", TestCategories.Integration)]
[Trait("BlastRadius", TestCategories.BlastRadius.Integrations)]
[Trait("BlastRadius", TestCategories.BlastRadius.Persistence)]
public sealed class AirGapIntegrationTests : IDisposable
{
private readonly string _tempRoot;

View File

@@ -0,0 +1,342 @@
// <copyright file="ConflictResolverTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.AirGap.Sync.Models;
using StellaOps.AirGap.Sync.Services;
using StellaOps.HybridLogicalClock;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.AirGap.Sync.Tests;
/// <summary>
/// Unit tests for <see cref="ConflictResolver"/>.
/// </summary>
[Trait("Category", TestCategories.Unit)]
public sealed class ConflictResolverTests
{
private readonly ConflictResolver _sut;
public ConflictResolverTests()
{
_sut = new ConflictResolver(NullLogger<ConflictResolver>.Instance);
}
#region Single Entry Tests
[Fact]
public void Resolve_SingleEntry_ReturnsDuplicateTimestampWithTakeEarliest()
{
// Arrange
var jobId = Guid.Parse("11111111-1111-1111-1111-111111111111");
var entry = CreateEntry("node-a", 100, 0, jobId);
var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>
{
("node-a", entry)
};
// Act
var result = _sut.Resolve(jobId, conflicting);
// Assert
result.Type.Should().Be(ConflictType.DuplicateTimestamp);
result.Resolution.Should().Be(ResolutionStrategy.TakeEarliest);
result.SelectedEntry.Should().Be(entry);
result.DroppedEntries.Should().BeEmpty();
result.Error.Should().BeNull();
}
#endregion
#region Duplicate Timestamp Tests (Same Payload)
[Fact]
public void Resolve_TwoEntriesSamePayload_TakesEarliest()
{
// Arrange
var jobId = Guid.Parse("22222222-2222-2222-2222-222222222222");
var payloadHash = CreatePayloadHash(0xAA);
var entryA = CreateEntryWithPayloadHash("node-a", 100, 0, jobId, payloadHash);
var entryB = CreateEntryWithPayloadHash("node-b", 200, 0, jobId, payloadHash);
var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>
{
("node-a", entryA),
("node-b", entryB)
};
// Act
var result = _sut.Resolve(jobId, conflicting);
// Assert
result.Type.Should().Be(ConflictType.DuplicateTimestamp);
result.Resolution.Should().Be(ResolutionStrategy.TakeEarliest);
result.SelectedEntry.Should().Be(entryA);
result.DroppedEntries.Should().ContainSingle().Which.Should().Be(entryB);
}
[Fact]
public void Resolve_TwoEntriesSamePayload_TakesEarliest_WhenSecondComesFirst()
{
// Arrange - Earlier entry is second in list
var jobId = Guid.Parse("33333333-3333-3333-3333-333333333333");
var payloadHash = CreatePayloadHash(0xBB);
var entryA = CreateEntryWithPayloadHash("node-a", 200, 0, jobId, payloadHash);
var entryB = CreateEntryWithPayloadHash("node-b", 100, 0, jobId, payloadHash); // Earlier
var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>
{
("node-a", entryA),
("node-b", entryB)
};
// Act
var result = _sut.Resolve(jobId, conflicting);
// Assert - Should take entryB (earlier)
result.Type.Should().Be(ConflictType.DuplicateTimestamp);
result.Resolution.Should().Be(ResolutionStrategy.TakeEarliest);
result.SelectedEntry.Should().Be(entryB);
result.DroppedEntries.Should().ContainSingle().Which.Should().Be(entryA);
}
[Fact]
public void Resolve_ThreeEntriesSamePayload_TakesEarliestDropsTwo()
{
// Arrange
var jobId = Guid.Parse("44444444-4444-4444-4444-444444444444");
var payloadHash = CreatePayloadHash(0xCC);
var entryA = CreateEntryWithPayloadHash("node-a", 150, 0, jobId, payloadHash);
var entryB = CreateEntryWithPayloadHash("node-b", 100, 0, jobId, payloadHash); // Earliest
var entryC = CreateEntryWithPayloadHash("node-c", 200, 0, jobId, payloadHash);
var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>
{
("node-a", entryA),
("node-b", entryB),
("node-c", entryC)
};
// Act
var result = _sut.Resolve(jobId, conflicting);
// Assert
result.Type.Should().Be(ConflictType.DuplicateTimestamp);
result.Resolution.Should().Be(ResolutionStrategy.TakeEarliest);
result.SelectedEntry.Should().Be(entryB);
result.DroppedEntries.Should().HaveCount(2);
}
[Fact]
public void Resolve_SamePhysicalTime_UsesLogicalCounter()
{
// Arrange
var jobId = Guid.Parse("55555555-5555-5555-5555-555555555555");
var payloadHash = CreatePayloadHash(0xDD);
var entryA = CreateEntryWithPayloadHash("node-a", 100, 2, jobId, payloadHash); // Higher counter
var entryB = CreateEntryWithPayloadHash("node-b", 100, 1, jobId, payloadHash); // Earlier
var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>
{
("node-a", entryA),
("node-b", entryB)
};
// Act
var result = _sut.Resolve(jobId, conflicting);
// Assert
result.SelectedEntry.Should().Be(entryB); // Lower logical counter
result.DroppedEntries.Should().ContainSingle().Which.Should().Be(entryA);
}
[Fact]
public void Resolve_SamePhysicalTimeAndCounter_UsesNodeId()
{
// Arrange
var jobId = Guid.Parse("66666666-6666-6666-6666-666666666666");
var payloadHash = CreatePayloadHash(0xEE);
var entryA = CreateEntryWithPayloadHash("alpha-node", 100, 0, jobId, payloadHash);
var entryB = CreateEntryWithPayloadHash("beta-node", 100, 0, jobId, payloadHash);
var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>
{
("beta-node", entryB),
("alpha-node", entryA)
};
// Act
var result = _sut.Resolve(jobId, conflicting);
// Assert - "alpha-node" < "beta-node" alphabetically
result.SelectedEntry.Should().Be(entryA);
result.DroppedEntries.Should().ContainSingle().Which.Should().Be(entryB);
}
#endregion
#region Payload Mismatch Tests
[Fact]
public void Resolve_DifferentPayloads_ReturnsError()
{
// Arrange
var jobId = Guid.Parse("77777777-7777-7777-7777-777777777777");
var payloadHashA = CreatePayloadHash(0x01);
var payloadHashB = CreatePayloadHash(0x02);
var entryA = CreateEntryWithPayloadHash("node-a", 100, 0, jobId, payloadHashA);
var entryB = CreateEntryWithPayloadHash("node-b", 200, 0, jobId, payloadHashB);
var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>
{
("node-a", entryA),
("node-b", entryB)
};
// Act
var result = _sut.Resolve(jobId, conflicting);
// Assert
result.Type.Should().Be(ConflictType.PayloadMismatch);
result.Resolution.Should().Be(ResolutionStrategy.Error);
result.Error.Should().NotBeNullOrEmpty();
result.Error.Should().Contain(jobId.ToString());
result.Error.Should().Contain("conflicting payloads");
result.SelectedEntry.Should().BeNull();
result.DroppedEntries.Should().BeNull();
}
[Fact]
public void Resolve_ThreeDifferentPayloads_ReturnsError()
{
// Arrange
var jobId = Guid.Parse("88888888-8888-8888-8888-888888888888");
var entryA = CreateEntryWithPayloadHash("node-a", 100, 0, jobId, CreatePayloadHash(0x01));
var entryB = CreateEntryWithPayloadHash("node-b", 200, 0, jobId, CreatePayloadHash(0x02));
var entryC = CreateEntryWithPayloadHash("node-c", 300, 0, jobId, CreatePayloadHash(0x03));
var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>
{
("node-a", entryA),
("node-b", entryB),
("node-c", entryC)
};
// Act
var result = _sut.Resolve(jobId, conflicting);
// Assert
result.Type.Should().Be(ConflictType.PayloadMismatch);
result.Resolution.Should().Be(ResolutionStrategy.Error);
}
[Fact]
public void Resolve_TwoSameOneUnique_ReturnsError()
{
// Arrange - 2 entries with same payload, 1 with different
var jobId = Guid.Parse("99999999-9999-9999-9999-999999999999");
var sharedPayload = CreatePayloadHash(0xAA);
var uniquePayload = CreatePayloadHash(0xBB);
var entryA = CreateEntryWithPayloadHash("node-a", 100, 0, jobId, sharedPayload);
var entryB = CreateEntryWithPayloadHash("node-b", 200, 0, jobId, sharedPayload);
var entryC = CreateEntryWithPayloadHash("node-c", 300, 0, jobId, uniquePayload);
var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>
{
("node-a", entryA),
("node-b", entryB),
("node-c", entryC)
};
// Act
var result = _sut.Resolve(jobId, conflicting);
// Assert - Should be error due to different payloads
result.Type.Should().Be(ConflictType.PayloadMismatch);
result.Resolution.Should().Be(ResolutionStrategy.Error);
}
#endregion
#region Edge Cases
[Fact]
public void Resolve_NullConflicting_ThrowsArgumentNullException()
{
// Arrange
var jobId = Guid.NewGuid();
// Act & Assert
var act = () => _sut.Resolve(jobId, null!);
act.Should().Throw<ArgumentNullException>()
.WithParameterName("conflicting");
}
[Fact]
public void Resolve_EmptyConflicting_ThrowsArgumentException()
{
// Arrange
var jobId = Guid.NewGuid();
var conflicting = new List<(string NodeId, OfflineJobLogEntry Entry)>();
// Act & Assert
var act = () => _sut.Resolve(jobId, conflicting);
act.Should().Throw<ArgumentException>()
.WithParameterName("conflicting");
}
#endregion
#region Helper Methods
private static byte[] CreatePayloadHash(byte prefix)
{
var hash = new byte[32];
hash[0] = prefix;
return hash;
}
private static OfflineJobLogEntry CreateEntry(string nodeId, long physicalTime, int logicalCounter, Guid jobId)
{
var payloadHash = new byte[32];
jobId.ToByteArray().CopyTo(payloadHash, 0);
return CreateEntryWithPayloadHash(nodeId, physicalTime, logicalCounter, jobId, payloadHash);
}
private static OfflineJobLogEntry CreateEntryWithPayloadHash(
string nodeId, long physicalTime, int logicalCounter, Guid jobId, byte[] payloadHash)
{
var hlc = new HlcTimestamp
{
PhysicalTime = physicalTime,
NodeId = nodeId,
LogicalCounter = logicalCounter
};
return new OfflineJobLogEntry
{
NodeId = nodeId,
THlc = hlc,
JobId = jobId,
Payload = $"{{\"id\":\"{jobId}\"}}",
PayloadHash = payloadHash,
Link = new byte[32],
EnqueuedAt = DateTimeOffset.UtcNow
};
}
#endregion
}

View File

@@ -0,0 +1,451 @@
// <copyright file="HlcMergeServiceTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.AirGap.Sync.Models;
using StellaOps.AirGap.Sync.Services;
using StellaOps.HybridLogicalClock;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.AirGap.Sync.Tests;
/// <summary>
/// Unit tests for <see cref="HlcMergeService"/>.
/// </summary>
[Trait("Category", TestCategories.Unit)]
public sealed class HlcMergeServiceTests
{
private readonly HlcMergeService _sut;
private readonly ConflictResolver _conflictResolver;
public HlcMergeServiceTests()
{
_conflictResolver = new ConflictResolver(NullLogger<ConflictResolver>.Instance);
_sut = new HlcMergeService(_conflictResolver, NullLogger<HlcMergeService>.Instance);
}
#region OMP-014: Merge Algorithm Correctness
[Fact]
public async Task MergeAsync_EmptyInput_ReturnsEmptyResult()
{
// Arrange
var nodeLogs = new List<NodeJobLog>();
// Act
var result = await _sut.MergeAsync(nodeLogs);
// Assert
result.MergedEntries.Should().BeEmpty();
result.Duplicates.Should().BeEmpty();
result.SourceNodes.Should().BeEmpty();
result.MergedChainHead.Should().BeNull();
}
[Fact]
public async Task MergeAsync_SingleNode_PreservesOrder()
{
// Arrange
var nodeLog = CreateNodeLog("node-a", new[]
{
CreateEntry("node-a", 100, 0, Guid.Parse("11111111-1111-1111-1111-111111111111")),
CreateEntry("node-a", 200, 0, Guid.Parse("22222222-2222-2222-2222-222222222222")),
CreateEntry("node-a", 300, 0, Guid.Parse("33333333-3333-3333-3333-333333333333"))
});
// Act
var result = await _sut.MergeAsync(new[] { nodeLog });
// Assert
result.MergedEntries.Should().HaveCount(3);
result.MergedEntries[0].JobId.Should().Be(Guid.Parse("11111111-1111-1111-1111-111111111111"));
result.MergedEntries[1].JobId.Should().Be(Guid.Parse("22222222-2222-2222-2222-222222222222"));
result.MergedEntries[2].JobId.Should().Be(Guid.Parse("33333333-3333-3333-3333-333333333333"));
result.Duplicates.Should().BeEmpty();
result.SourceNodes.Should().ContainSingle().Which.Should().Be("node-a");
}
[Fact]
public async Task MergeAsync_TwoNodes_MergesByHlcOrder()
{
// Arrange - Two nodes with interleaved HLC timestamps
// Node A: T=100, T=102
// Node B: T=101, T=103
// Expected order: 100, 101, 102, 103
var nodeA = CreateNodeLog("node-a", new[]
{
CreateEntry("node-a", 100, 0, Guid.Parse("aaaaaaaa-0001-0000-0000-000000000000")),
CreateEntry("node-a", 102, 0, Guid.Parse("aaaaaaaa-0003-0000-0000-000000000000"))
});
var nodeB = CreateNodeLog("node-b", new[]
{
CreateEntry("node-b", 101, 0, Guid.Parse("bbbbbbbb-0002-0000-0000-000000000000")),
CreateEntry("node-b", 103, 0, Guid.Parse("bbbbbbbb-0004-0000-0000-000000000000"))
});
// Act
var result = await _sut.MergeAsync(new[] { nodeA, nodeB });
// Assert
result.MergedEntries.Should().HaveCount(4);
result.MergedEntries[0].THlc.PhysicalTime.Should().Be(100);
result.MergedEntries[1].THlc.PhysicalTime.Should().Be(101);
result.MergedEntries[2].THlc.PhysicalTime.Should().Be(102);
result.MergedEntries[3].THlc.PhysicalTime.Should().Be(103);
result.SourceNodes.Should().HaveCount(2);
}
[Fact]
public async Task MergeAsync_SamePhysicalTime_OrdersByLogicalCounter()
{
// Arrange - Same physical time, different logical counters
var nodeA = CreateNodeLog("node-a", new[]
{
CreateEntry("node-a", 100, 0, Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001")),
CreateEntry("node-a", 100, 2, Guid.Parse("aaaaaaaa-0000-0000-0000-000000000003"))
});
var nodeB = CreateNodeLog("node-b", new[]
{
CreateEntry("node-b", 100, 1, Guid.Parse("bbbbbbbb-0000-0000-0000-000000000002")),
CreateEntry("node-b", 100, 3, Guid.Parse("bbbbbbbb-0000-0000-0000-000000000004"))
});
// Act
var result = await _sut.MergeAsync(new[] { nodeA, nodeB });
// Assert
result.MergedEntries.Should().HaveCount(4);
result.MergedEntries[0].THlc.LogicalCounter.Should().Be(0);
result.MergedEntries[1].THlc.LogicalCounter.Should().Be(1);
result.MergedEntries[2].THlc.LogicalCounter.Should().Be(2);
result.MergedEntries[3].THlc.LogicalCounter.Should().Be(3);
}
[Fact]
public async Task MergeAsync_SameTimeAndCounter_OrdersByNodeId()
{
// Arrange - Same physical time and counter, different node IDs
var nodeA = CreateNodeLog("alpha-node", new[]
{
CreateEntry("alpha-node", 100, 0, Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001"))
});
var nodeB = CreateNodeLog("beta-node", new[]
{
CreateEntry("beta-node", 100, 0, Guid.Parse("bbbbbbbb-0000-0000-0000-000000000002"))
});
// Act
var result = await _sut.MergeAsync(new[] { nodeA, nodeB });
// Assert - "alpha-node" < "beta-node" alphabetically
result.MergedEntries.Should().HaveCount(2);
result.MergedEntries[0].SourceNodeId.Should().Be("alpha-node");
result.MergedEntries[1].SourceNodeId.Should().Be("beta-node");
}
[Fact]
public async Task MergeAsync_RecomputesUnifiedChain()
{
// Arrange
var nodeLog = CreateNodeLog("node-a", new[]
{
CreateEntry("node-a", 100, 0, Guid.Parse("11111111-1111-1111-1111-111111111111")),
CreateEntry("node-a", 200, 0, Guid.Parse("22222222-2222-2222-2222-222222222222"))
});
// Act
var result = await _sut.MergeAsync(new[] { nodeLog });
// Assert - Chain should be recomputed
result.MergedEntries.Should().HaveCount(2);
result.MergedEntries[0].MergedLink.Should().NotBeNull();
result.MergedEntries[1].MergedLink.Should().NotBeNull();
result.MergedChainHead.Should().NotBeNull();
// First entry's link should be computed from null prev_link
result.MergedEntries[0].MergedLink.Should().HaveCount(32);
// Chain head should equal last entry's merged link
result.MergedChainHead.Should().BeEquivalentTo(result.MergedEntries[1].MergedLink);
}
#endregion
#region OMP-015: Duplicate Detection
[Fact]
public async Task MergeAsync_DuplicateJobId_SamePayload_TakesEarliest()
{
// Arrange - Same job ID (same payload hash) from two nodes
var jobId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd");
var payloadHash = new byte[32];
payloadHash[0] = 0xAA;
var nodeA = CreateNodeLog("node-a", new[]
{
CreateEntryWithPayloadHash("node-a", 100, 0, jobId, payloadHash)
});
var nodeB = CreateNodeLog("node-b", new[]
{
CreateEntryWithPayloadHash("node-b", 105, 0, jobId, payloadHash)
});
// Act
var result = await _sut.MergeAsync(new[] { nodeA, nodeB });
// Assert - Should take earliest (T=100 from node-a)
result.MergedEntries.Should().ContainSingle();
result.MergedEntries[0].SourceNodeId.Should().Be("node-a");
result.MergedEntries[0].THlc.PhysicalTime.Should().Be(100);
// Should report duplicate
result.Duplicates.Should().ContainSingle();
result.Duplicates[0].JobId.Should().Be(jobId);
result.Duplicates[0].NodeId.Should().Be("node-b");
result.Duplicates[0].THlc.PhysicalTime.Should().Be(105);
}
[Fact]
public async Task MergeAsync_TriplicateJobId_SamePayload_TakesEarliest()
{
// Arrange - Same job ID from three nodes
var jobId = Guid.Parse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
var payloadHash = new byte[32];
payloadHash[0] = 0xBB;
var nodeA = CreateNodeLog("node-a", new[]
{
CreateEntryWithPayloadHash("node-a", 200, 0, jobId, payloadHash)
});
var nodeB = CreateNodeLog("node-b", new[]
{
CreateEntryWithPayloadHash("node-b", 100, 0, jobId, payloadHash) // Earliest
});
var nodeC = CreateNodeLog("node-c", new[]
{
CreateEntryWithPayloadHash("node-c", 150, 0, jobId, payloadHash)
});
// Act
var result = await _sut.MergeAsync(new[] { nodeA, nodeB, nodeC });
// Assert - Should take earliest (T=100 from node-b)
result.MergedEntries.Should().ContainSingle();
result.MergedEntries[0].SourceNodeId.Should().Be("node-b");
result.MergedEntries[0].THlc.PhysicalTime.Should().Be(100);
// Should report two duplicates
result.Duplicates.Should().HaveCount(2);
}
[Fact]
public async Task MergeAsync_DuplicateJobId_DifferentPayload_ThrowsError()
{
// Arrange - Same job ID but different payload hashes (indicates bug)
var jobId = Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff");
var payloadHashA = new byte[32];
payloadHashA[0] = 0x01;
var payloadHashB = new byte[32];
payloadHashB[0] = 0x02;
var nodeA = CreateNodeLog("node-a", new[]
{
CreateEntryWithPayloadHash("node-a", 100, 0, jobId, payloadHashA)
});
var nodeB = CreateNodeLog("node-b", new[]
{
CreateEntryWithPayloadHash("node-b", 105, 0, jobId, payloadHashB)
});
// Act & Assert - Should throw because payloads differ
var act = () => _sut.MergeAsync(new[] { nodeA, nodeB });
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*conflicting payloads*");
}
#endregion
#region OMP-018: Multi-Node Merge
[Fact]
public async Task MergeAsync_ThreeNodes_MergesCorrectly()
{
// Arrange - Three nodes with various timestamps
var nodeA = CreateNodeLog("node-a", new[]
{
CreateEntry("node-a", 100, 0, Guid.Parse("aaaaaaaa-0001-0000-0000-000000000000")),
CreateEntry("node-a", 400, 0, Guid.Parse("aaaaaaaa-0007-0000-0000-000000000000"))
});
var nodeB = CreateNodeLog("node-b", new[]
{
CreateEntry("node-b", 200, 0, Guid.Parse("bbbbbbbb-0002-0000-0000-000000000000")),
CreateEntry("node-b", 500, 0, Guid.Parse("bbbbbbbb-0008-0000-0000-000000000000"))
});
var nodeC = CreateNodeLog("node-c", new[]
{
CreateEntry("node-c", 300, 0, Guid.Parse("cccccccc-0003-0000-0000-000000000000")),
CreateEntry("node-c", 600, 0, Guid.Parse("cccccccc-0009-0000-0000-000000000000"))
});
// Act
var result = await _sut.MergeAsync(new[] { nodeA, nodeB, nodeC });
// Assert
result.MergedEntries.Should().HaveCount(6);
result.MergedEntries.Select(e => e.THlc.PhysicalTime).Should()
.BeInAscendingOrder();
result.MergedEntries.Select(e => e.THlc.PhysicalTime).Should()
.ContainInOrder(100L, 200L, 300L, 400L, 500L, 600L);
result.SourceNodes.Should().HaveCount(3);
}
[Fact]
public async Task MergeAsync_ManyNodes_PreservesTotalOrder()
{
// Arrange - 5 nodes with 2 entries each
var nodes = new List<NodeJobLog>();
for (int i = 0; i < 5; i++)
{
var nodeId = $"node-{i:D2}";
nodes.Add(CreateNodeLog(nodeId, new[]
{
CreateEntry(nodeId, 100 + i * 10, 0, Guid.NewGuid()),
CreateEntry(nodeId, 150 + i * 10, 0, Guid.NewGuid())
}));
}
// Act
var result = await _sut.MergeAsync(nodes);
// Assert
result.MergedEntries.Should().HaveCount(10);
result.MergedEntries.Select(e => e.THlc.PhysicalTime).Should()
.BeInAscendingOrder();
}
#endregion
#region OMP-019: Determinism Tests
[Fact]
public async Task MergeAsync_SameInput_ProducesSameOutput()
{
// Arrange
var nodeA = CreateNodeLog("node-a", new[]
{
CreateEntry("node-a", 100, 0, Guid.Parse("aaaaaaaa-0001-0000-0000-000000000000")),
CreateEntry("node-a", 300, 0, Guid.Parse("aaaaaaaa-0003-0000-0000-000000000000"))
});
var nodeB = CreateNodeLog("node-b", new[]
{
CreateEntry("node-b", 200, 0, Guid.Parse("bbbbbbbb-0002-0000-0000-000000000000")),
CreateEntry("node-b", 400, 0, Guid.Parse("bbbbbbbb-0004-0000-0000-000000000000"))
});
// Act - Run merge twice
var result1 = await _sut.MergeAsync(new[] { nodeA, nodeB });
var result2 = await _sut.MergeAsync(new[] { nodeA, nodeB });
// Assert - Results should be identical
result1.MergedEntries.Should().HaveCount(result2.MergedEntries.Count);
for (int i = 0; i < result1.MergedEntries.Count; i++)
{
result1.MergedEntries[i].JobId.Should().Be(result2.MergedEntries[i].JobId);
result1.MergedEntries[i].THlc.Should().Be(result2.MergedEntries[i].THlc);
result1.MergedEntries[i].MergedLink.Should().BeEquivalentTo(result2.MergedEntries[i].MergedLink);
}
result1.MergedChainHead.Should().BeEquivalentTo(result2.MergedChainHead);
}
[Fact]
public async Task MergeAsync_InputOrderIndependent_ProducesSameOutput()
{
// Arrange
var nodeA = CreateNodeLog("node-a", new[]
{
CreateEntry("node-a", 100, 0, Guid.Parse("aaaaaaaa-0001-0000-0000-000000000000"))
});
var nodeB = CreateNodeLog("node-b", new[]
{
CreateEntry("node-b", 200, 0, Guid.Parse("bbbbbbbb-0002-0000-0000-000000000000"))
});
// Act - Merge in different orders
var result1 = await _sut.MergeAsync(new[] { nodeA, nodeB });
var result2 = await _sut.MergeAsync(new[] { nodeB, nodeA });
// Assert - Results should be identical regardless of input order
result1.MergedEntries.Select(e => e.JobId).Should()
.BeEquivalentTo(result2.MergedEntries.Select(e => e.JobId));
result1.MergedChainHead.Should().BeEquivalentTo(result2.MergedChainHead);
}
#endregion
#region Helper Methods
private static NodeJobLog CreateNodeLog(string nodeId, IEnumerable<OfflineJobLogEntry> entries)
{
var entryList = entries.ToList();
var lastEntry = entryList.LastOrDefault();
return new NodeJobLog
{
NodeId = nodeId,
Entries = entryList,
LastHlc = lastEntry?.THlc ?? new HlcTimestamp { PhysicalTime = 0, NodeId = nodeId, LogicalCounter = 0 },
ChainHead = lastEntry?.Link ?? new byte[32]
};
}
private static OfflineJobLogEntry CreateEntry(string nodeId, long physicalTime, int logicalCounter, Guid jobId)
{
var payloadHash = new byte[32];
jobId.ToByteArray().CopyTo(payloadHash, 0);
var hlc = new HlcTimestamp
{
PhysicalTime = physicalTime,
NodeId = nodeId,
LogicalCounter = logicalCounter
};
return new OfflineJobLogEntry
{
NodeId = nodeId,
THlc = hlc,
JobId = jobId,
Payload = $"{{\"id\":\"{jobId}\"}}",
PayloadHash = payloadHash,
Link = new byte[32],
EnqueuedAt = DateTimeOffset.UtcNow
};
}
private static OfflineJobLogEntry CreateEntryWithPayloadHash(
string nodeId, long physicalTime, int logicalCounter, Guid jobId, byte[] payloadHash)
{
var hlc = new HlcTimestamp
{
PhysicalTime = physicalTime,
NodeId = nodeId,
LogicalCounter = logicalCounter
};
return new OfflineJobLogEntry
{
NodeId = nodeId,
THlc = hlc,
JobId = jobId,
Payload = $"{{\"id\":\"{jobId}\"}}",
PayloadHash = payloadHash,
Link = new byte[32],
EnqueuedAt = DateTimeOffset.UtcNow
};
}
#endregion
}

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.AirGap.Sync\StellaOps.AirGap.Sync.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,295 @@
// <copyright file="DsseVerifierTests.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace StellaOps.Attestation.Tests;
/// <summary>
/// Unit tests for DsseVerifier.
/// Sprint: SPRINT_20260105_002_001_REPLAY, Tasks RPL-006 through RPL-010.
/// </summary>
[Trait("Category", "Unit")]
public class DsseVerifierTests
{
private readonly DsseVerifier _verifier;
public DsseVerifierTests()
{
_verifier = new DsseVerifier(NullLogger<DsseVerifier>.Instance);
}
[Fact]
public async Task VerifyAsync_WithValidEcdsaSignature_ReturnsSuccess()
{
// Arrange
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var (envelope, publicKeyPem) = CreateSignedEnvelope(ecdsa);
// Act
var result = await _verifier.VerifyAsync(envelope, publicKeyPem, TestContext.Current.CancellationToken);
// Assert
result.IsValid.Should().BeTrue();
result.ValidSignatureCount.Should().Be(1);
result.TotalSignatureCount.Should().Be(1);
result.PayloadType.Should().Be("https://in-toto.io/Statement/v1");
result.Issues.Should().BeEmpty();
}
[Fact]
public async Task VerifyAsync_WithInvalidSignature_ReturnsFail()
{
// Arrange
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var (envelope, _) = CreateSignedEnvelope(ecdsa);
// Use a different key for verification
using var differentKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var differentPublicKeyPem = ExportPublicKeyPem(differentKey);
// Act
var result = await _verifier.VerifyAsync(envelope, differentPublicKeyPem, TestContext.Current.CancellationToken);
// Assert
result.IsValid.Should().BeFalse();
result.ValidSignatureCount.Should().Be(0);
result.Issues.Should().NotBeEmpty();
}
[Fact]
public async Task VerifyAsync_WithMalformedJson_ReturnsParseError()
{
// Arrange
var malformedJson = "{ not valid json }";
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var publicKeyPem = ExportPublicKeyPem(ecdsa);
// Act
var result = await _verifier.VerifyAsync(malformedJson, publicKeyPem, TestContext.Current.CancellationToken);
// Assert
result.IsValid.Should().BeFalse();
result.Issues.Should().Contain(i => i.Contains("envelope_parse_error"));
}
[Fact]
public async Task VerifyAsync_WithMissingPayload_ReturnsFail()
{
// Arrange
var envelope = JsonSerializer.Serialize(new
{
payloadType = "https://in-toto.io/Statement/v1",
signatures = new[] { new { keyId = "key-001", sig = "YWJj" } }
});
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var publicKeyPem = ExportPublicKeyPem(ecdsa);
// Act
var result = await _verifier.VerifyAsync(envelope, publicKeyPem, TestContext.Current.CancellationToken);
// Assert
result.IsValid.Should().BeFalse();
result.Issues.Should().Contain(i => i.Contains("envelope_missing_payload"));
}
[Fact]
public async Task VerifyAsync_WithMissingSignatures_ReturnsFail()
{
// Arrange
var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes("{}"));
var envelope = JsonSerializer.Serialize(new
{
payloadType = "https://in-toto.io/Statement/v1",
payload,
signatures = Array.Empty<object>()
});
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var publicKeyPem = ExportPublicKeyPem(ecdsa);
// Act
var result = await _verifier.VerifyAsync(envelope, publicKeyPem, TestContext.Current.CancellationToken);
// Assert
result.IsValid.Should().BeFalse();
result.Issues.Should().Contain("envelope_missing_signatures");
}
[Fact]
public async Task VerifyAsync_WithNoTrustedKeys_ReturnsFail()
{
// Arrange
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var (envelope, _) = CreateSignedEnvelope(ecdsa);
// Act
var result = await _verifier.VerifyAsync(envelope, Array.Empty<string>(), TestContext.Current.CancellationToken);
// Assert
result.IsValid.Should().BeFalse();
result.Issues.Should().Contain("no_trusted_keys_provided");
}
[Fact]
public async Task VerifyAsync_WithMultipleTrustedKeys_SucceedsWithMatchingKey()
{
// Arrange
using var signingKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using var otherKey1 = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using var otherKey2 = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var (envelope, signingKeyPem) = CreateSignedEnvelope(signingKey);
var trustedKeys = new[]
{
ExportPublicKeyPem(otherKey1),
signingKeyPem,
ExportPublicKeyPem(otherKey2),
};
// Act
var result = await _verifier.VerifyAsync(envelope, trustedKeys, TestContext.Current.CancellationToken);
// Assert
result.IsValid.Should().BeTrue();
result.ValidSignatureCount.Should().Be(1);
}
[Fact]
public async Task VerifyAsync_WithKeyResolver_UsesResolverForVerification()
{
// Arrange
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var (envelope, publicKeyPem) = CreateSignedEnvelope(ecdsa);
Task<string?> KeyResolver(string? keyId, CancellationToken ct)
{
return Task.FromResult<string?>(publicKeyPem);
}
// Act
var result = await _verifier.VerifyAsync(envelope, KeyResolver, TestContext.Current.CancellationToken);
// Assert
result.IsValid.Should().BeTrue();
}
[Fact]
public async Task VerifyAsync_WithKeyResolverReturningNull_ReturnsFail()
{
// Arrange
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var (envelope, _) = CreateSignedEnvelope(ecdsa);
static Task<string?> KeyResolver(string? keyId, CancellationToken ct)
{
return Task.FromResult<string?>(null);
}
// Act
var result = await _verifier.VerifyAsync(envelope, KeyResolver, TestContext.Current.CancellationToken);
// Assert
result.IsValid.Should().BeFalse();
result.Issues.Should().Contain(i => i.Contains("key_not_found"));
}
[Fact]
public async Task VerifyAsync_ReturnsPayloadHash()
{
// Arrange
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var (envelope, publicKeyPem) = CreateSignedEnvelope(ecdsa);
// Act
var result = await _verifier.VerifyAsync(envelope, publicKeyPem, TestContext.Current.CancellationToken);
// Assert
result.PayloadHash.Should().StartWith("sha256:");
result.PayloadHash.Should().HaveLength("sha256:".Length + 64);
}
[Fact]
public async Task VerifyAsync_ThrowsOnNullEnvelope()
{
// Arrange
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var publicKeyPem = ExportPublicKeyPem(ecdsa);
// Act & Assert - null envelope throws ArgumentNullException
await Assert.ThrowsAsync<ArgumentNullException>(
() => _verifier.VerifyAsync(null!, publicKeyPem, TestContext.Current.CancellationToken));
// Empty envelope throws ArgumentException (whitespace check)
await Assert.ThrowsAsync<ArgumentException>(
() => _verifier.VerifyAsync("", publicKeyPem, TestContext.Current.CancellationToken));
}
[Fact]
public async Task VerifyAsync_ThrowsOnNullKeys()
{
// Arrange
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var (envelope, _) = CreateSignedEnvelope(ecdsa);
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(
() => _verifier.VerifyAsync(envelope, (IEnumerable<string>)null!, TestContext.Current.CancellationToken));
await Assert.ThrowsAsync<ArgumentNullException>(
() => _verifier.VerifyAsync(envelope, (Func<string?, CancellationToken, Task<string?>>)null!, TestContext.Current.CancellationToken));
}
private static (string EnvelopeJson, string PublicKeyPem) CreateSignedEnvelope(ECDsa signingKey)
{
var payloadType = "https://in-toto.io/Statement/v1";
var payloadContent = "{\"_type\":\"https://in-toto.io/Statement/v1\",\"subject\":[]}";
var payloadBytes = Encoding.UTF8.GetBytes(payloadContent);
var payloadBase64 = Convert.ToBase64String(payloadBytes);
// Compute PAE
var pae = DsseHelper.PreAuthenticationEncoding(payloadType, payloadBytes);
// Sign
var signatureBytes = signingKey.SignData(pae, HashAlgorithmName.SHA256);
var signatureBase64 = Convert.ToBase64String(signatureBytes);
// Build envelope
var envelope = JsonSerializer.Serialize(new
{
payloadType,
payload = payloadBase64,
signatures = new[]
{
new { keyId = "test-key-001", sig = signatureBase64 }
}
});
var publicKeyPem = ExportPublicKeyPem(signingKey);
return (envelope, publicKeyPem);
}
private static string ExportPublicKeyPem(ECDsa key)
{
var publicKeyBytes = key.ExportSubjectPublicKeyInfo();
var base64 = Convert.ToBase64String(publicKeyBytes);
var builder = new StringBuilder();
builder.AppendLine("-----BEGIN PUBLIC KEY-----");
for (var i = 0; i < base64.Length; i += 64)
{
builder.AppendLine(base64.Substring(i, Math.Min(64, base64.Length - i)));
}
builder.AppendLine("-----END PUBLIC KEY-----");
return builder.ToString();
}
}

View File

@@ -0,0 +1,301 @@
// <copyright file="DsseVerifier.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace StellaOps.Attestation;
/// <summary>
/// Implementation of DSSE signature verification.
/// Uses the existing DsseHelper for PAE computation.
/// </summary>
public sealed class DsseVerifier : IDsseVerifier
{
private readonly ILogger<DsseVerifier> _logger;
/// <summary>
/// JSON serializer options for parsing DSSE envelopes.
/// </summary>
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
public DsseVerifier(ILogger<DsseVerifier> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public Task<DsseVerificationResult> VerifyAsync(
string envelopeJson,
string publicKeyPem,
CancellationToken cancellationToken = default)
{
return VerifyAsync(envelopeJson, new[] { publicKeyPem }, cancellationToken);
}
/// <inheritdoc />
public async Task<DsseVerificationResult> VerifyAsync(
string envelopeJson,
IEnumerable<string> trustedKeysPem,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(envelopeJson);
ArgumentNullException.ThrowIfNull(trustedKeysPem);
var trustedKeys = trustedKeysPem.ToList();
if (trustedKeys.Count == 0)
{
return DsseVerificationResult.Failure(0, ImmutableArray.Create("no_trusted_keys_provided"));
}
return await VerifyWithAllKeysAsync(envelopeJson, trustedKeys, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<DsseVerificationResult> VerifyAsync(
string envelopeJson,
Func<string?, CancellationToken, Task<string?>> keyResolver,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(envelopeJson);
ArgumentNullException.ThrowIfNull(keyResolver);
// Parse the envelope
DsseEnvelopeDto? envelope;
try
{
envelope = JsonSerializer.Deserialize<DsseEnvelopeDto>(envelopeJson, JsonOptions);
if (envelope is null)
{
return DsseVerificationResult.ParseError("Failed to deserialize envelope");
}
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to parse DSSE envelope JSON");
return DsseVerificationResult.ParseError(ex.Message);
}
if (string.IsNullOrWhiteSpace(envelope.Payload))
{
return DsseVerificationResult.Failure(0, ImmutableArray.Create("envelope_missing_payload"));
}
if (envelope.Signatures is null || envelope.Signatures.Count == 0)
{
return DsseVerificationResult.Failure(0, ImmutableArray.Create("envelope_missing_signatures"));
}
// Decode payload
byte[] payloadBytes;
try
{
payloadBytes = Convert.FromBase64String(envelope.Payload);
}
catch (FormatException)
{
return DsseVerificationResult.Failure(envelope.Signatures.Count, ImmutableArray.Create("payload_invalid_base64"));
}
// Compute PAE for signature verification
var payloadType = envelope.PayloadType ?? "https://in-toto.io/Statement/v1";
var pae = DsseHelper.PreAuthenticationEncoding(payloadType, payloadBytes);
// Verify each signature
var verifiedKeyIds = new List<string>();
var issues = new List<string>();
foreach (var sig in envelope.Signatures)
{
if (string.IsNullOrWhiteSpace(sig.Sig))
{
issues.Add($"signature_{sig.KeyId ?? "unknown"}_empty");
continue;
}
// Resolve the public key for this signature
var publicKeyPem = await keyResolver(sig.KeyId, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(publicKeyPem))
{
issues.Add($"key_not_found_{sig.KeyId ?? "unknown"}");
continue;
}
// Verify the signature
try
{
var signatureBytes = Convert.FromBase64String(sig.Sig);
if (VerifySignature(pae, signatureBytes, publicKeyPem))
{
verifiedKeyIds.Add(sig.KeyId ?? "unknown");
_logger.LogDebug("DSSE signature verified for keyId: {KeyId}", sig.KeyId ?? "unknown");
}
else
{
issues.Add($"signature_invalid_{sig.KeyId ?? "unknown"}");
}
}
catch (FormatException)
{
issues.Add($"signature_invalid_base64_{sig.KeyId ?? "unknown"}");
}
catch (CryptographicException ex)
{
issues.Add($"signature_crypto_error_{sig.KeyId ?? "unknown"}: {ex.Message}");
}
}
// Compute payload hash for result
var payloadHash = $"sha256:{Convert.ToHexString(SHA256.HashData(payloadBytes)).ToLowerInvariant()}";
if (verifiedKeyIds.Count > 0)
{
return DsseVerificationResult.Success(
verifiedKeyIds.Count,
envelope.Signatures.Count,
verifiedKeyIds.ToImmutableArray(),
payloadType,
payloadHash);
}
return new DsseVerificationResult
{
IsValid = false,
ValidSignatureCount = 0,
TotalSignatureCount = envelope.Signatures.Count,
VerifiedKeyIds = ImmutableArray<string>.Empty,
PayloadType = payloadType,
PayloadHash = payloadHash,
Issues = issues.ToImmutableArray(),
};
}
/// <summary>
/// Verifies against all trusted keys, returning success if any key validates any signature.
/// </summary>
private async Task<DsseVerificationResult> VerifyWithAllKeysAsync(
string envelopeJson,
List<string> trustedKeys,
CancellationToken cancellationToken)
{
// Parse envelope first to get signature keyIds
DsseEnvelopeDto? envelope;
try
{
envelope = JsonSerializer.Deserialize<DsseEnvelopeDto>(envelopeJson, JsonOptions);
if (envelope is null)
{
return DsseVerificationResult.ParseError("Failed to deserialize envelope");
}
}
catch (JsonException ex)
{
return DsseVerificationResult.ParseError(ex.Message);
}
if (envelope.Signatures is null || envelope.Signatures.Count == 0)
{
return DsseVerificationResult.Failure(0, ImmutableArray.Create("envelope_missing_signatures"));
}
// Try each trusted key
var allIssues = new List<string>();
foreach (var key in trustedKeys)
{
var keyIndex = trustedKeys.IndexOf(key);
async Task<string?> SingleKeyResolver(string? keyId, CancellationToken ct)
{
await Task.CompletedTask.ConfigureAwait(false);
return key;
}
var result = await VerifyAsync(envelopeJson, SingleKeyResolver, cancellationToken).ConfigureAwait(false);
if (result.IsValid)
{
return result;
}
// Collect issues for debugging
foreach (var issue in result.Issues)
{
allIssues.Add($"key{keyIndex}: {issue}");
}
}
return DsseVerificationResult.Failure(envelope.Signatures.Count, allIssues.ToImmutableArray());
}
/// <summary>
/// Verifies a signature against PAE using the provided public key.
/// Supports ECDSA P-256 and RSA keys.
/// </summary>
private bool VerifySignature(byte[] pae, byte[] signature, string publicKeyPem)
{
// Try ECDSA first (most common for Sigstore/Fulcio)
try
{
using var ecdsa = ECDsa.Create();
ecdsa.ImportFromPem(publicKeyPem);
return ecdsa.VerifyData(pae, signature, HashAlgorithmName.SHA256);
}
catch (CryptographicException)
{
// Not an ECDSA key, try RSA
}
// Try RSA
try
{
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem);
return rsa.VerifyData(pae, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}
catch (CryptographicException)
{
// Not an RSA key either
}
// Try Ed25519 if available (.NET 9+)
try
{
// Ed25519 support via System.Security.Cryptography
// Note: Ed25519 verification requires different handling
// For now, we log and return false - can be extended later
_logger.LogDebug("Ed25519 signature verification not yet implemented");
return false;
}
catch
{
// Ed25519 not available
}
return false;
}
/// <summary>
/// DTO for deserializing DSSE envelope JSON.
/// </summary>
private sealed class DsseEnvelopeDto
{
public string? PayloadType { get; set; }
public string? Payload { get; set; }
public List<DsseSignatureDto>? Signatures { get; set; }
}
/// <summary>
/// DTO for DSSE signature.
/// </summary>
private sealed class DsseSignatureDto
{
public string? KeyId { get; set; }
public string? Sig { get; set; }
}
}

View File

@@ -0,0 +1,151 @@
// <copyright file="IDsseVerifier.cs" company="Stella Operations">
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
namespace StellaOps.Attestation;
/// <summary>
/// Interface for verifying DSSE (Dead Simple Signing Envelope) signatures.
/// </summary>
public interface IDsseVerifier
{
/// <summary>
/// Verifies a DSSE envelope against a public key.
/// </summary>
/// <param name="envelopeJson">The serialized DSSE envelope JSON.</param>
/// <param name="publicKeyPem">The PEM-encoded public key for verification.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Verification result containing status and details.</returns>
Task<DsseVerificationResult> VerifyAsync(
string envelopeJson,
string publicKeyPem,
CancellationToken cancellationToken = default);
/// <summary>
/// Verifies a DSSE envelope against multiple trusted public keys.
/// Returns success if at least one signature is valid.
/// </summary>
/// <param name="envelopeJson">The serialized DSSE envelope JSON.</param>
/// <param name="trustedKeysPem">Collection of PEM-encoded public keys.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Verification result containing status and details.</returns>
Task<DsseVerificationResult> VerifyAsync(
string envelopeJson,
IEnumerable<string> trustedKeysPem,
CancellationToken cancellationToken = default);
/// <summary>
/// Verifies a DSSE envelope using a key resolver function.
/// </summary>
/// <param name="envelopeJson">The serialized DSSE envelope JSON.</param>
/// <param name="keyResolver">Function to resolve public key by key ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Verification result containing status and details.</returns>
Task<DsseVerificationResult> VerifyAsync(
string envelopeJson,
Func<string?, CancellationToken, Task<string?>> keyResolver,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of DSSE signature verification.
/// </summary>
public sealed record DsseVerificationResult
{
/// <summary>
/// Whether the verification succeeded (at least one valid signature).
/// </summary>
public required bool IsValid { get; init; }
/// <summary>
/// Number of signatures that passed verification.
/// </summary>
public required int ValidSignatureCount { get; init; }
/// <summary>
/// Total number of signatures in the envelope.
/// </summary>
public required int TotalSignatureCount { get; init; }
/// <summary>
/// Key IDs of signatures that passed verification.
/// </summary>
public required ImmutableArray<string> VerifiedKeyIds { get; init; }
/// <summary>
/// Key ID used for the primary verified signature (first one that passed).
/// </summary>
public string? PrimaryKeyId { get; init; }
/// <summary>
/// Payload type from the envelope.
/// </summary>
public string? PayloadType { get; init; }
/// <summary>
/// SHA-256 hash of the payload.
/// </summary>
public string? PayloadHash { get; init; }
/// <summary>
/// Issues encountered during verification.
/// </summary>
public required ImmutableArray<string> Issues { get; init; }
/// <summary>
/// Creates a successful verification result.
/// </summary>
public static DsseVerificationResult Success(
int validCount,
int totalCount,
ImmutableArray<string> verifiedKeyIds,
string? payloadType = null,
string? payloadHash = null)
{
return new DsseVerificationResult
{
IsValid = true,
ValidSignatureCount = validCount,
TotalSignatureCount = totalCount,
VerifiedKeyIds = verifiedKeyIds,
PrimaryKeyId = verifiedKeyIds.Length > 0 ? verifiedKeyIds[0] : null,
PayloadType = payloadType,
PayloadHash = payloadHash,
Issues = ImmutableArray<string>.Empty,
};
}
/// <summary>
/// Creates a failed verification result.
/// </summary>
public static DsseVerificationResult Failure(
int totalCount,
ImmutableArray<string> issues)
{
return new DsseVerificationResult
{
IsValid = false,
ValidSignatureCount = 0,
TotalSignatureCount = totalCount,
VerifiedKeyIds = ImmutableArray<string>.Empty,
Issues = issues,
};
}
/// <summary>
/// Creates a failure result for a parsing error.
/// </summary>
public static DsseVerificationResult ParseError(string message)
{
return new DsseVerificationResult
{
IsValid = false,
ValidSignatureCount = 0,
TotalSignatureCount = 0,
VerifiedKeyIds = ImmutableArray<string>.Empty,
Issues = ImmutableArray.Create($"envelope_parse_error: {message}"),
};
}
}

View File

@@ -6,6 +6,10 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Attestor.Envelope/StellaOps.Attestor.Envelope.csproj" />
</ItemGroup>

View File

@@ -0,0 +1,267 @@
// -----------------------------------------------------------------------------
// AttestationChainBuilderTests.cs
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
// Task: T014
// Description: Unit tests for attestation chain builder.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Attestor.Core.Chain;
using Xunit;
namespace StellaOps.Attestor.Core.Tests.Chain;
[Trait("Category", "Unit")]
public class AttestationChainBuilderTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly InMemoryAttestationLinkStore _linkStore;
private readonly AttestationChainValidator _validator;
private readonly AttestationChainBuilder _builder;
public AttestationChainBuilderTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 6, 12, 0, 0, TimeSpan.Zero));
_linkStore = new InMemoryAttestationLinkStore();
_validator = new AttestationChainValidator(_timeProvider);
_builder = new AttestationChainBuilder(_linkStore, _validator, _timeProvider);
}
[Fact]
public async Task ExtractLinksAsync_AttestationMaterials_CreatesLinks()
{
// Arrange
var sourceId = "sha256:source";
var materials = new[]
{
InTotoMaterial.ForAttestation("sha256:target1", PredicateTypes.SbomAttestation),
InTotoMaterial.ForAttestation("sha256:target2", PredicateTypes.VexAttestation)
};
// Act
var result = await _builder.ExtractLinksAsync(sourceId, materials);
// Assert
result.IsSuccess.Should().BeTrue();
result.LinksCreated.Should().HaveCount(2);
result.Errors.Should().BeEmpty();
_linkStore.Count.Should().Be(2);
}
[Fact]
public async Task ExtractLinksAsync_NonAttestationMaterials_SkipsThem()
{
// Arrange
var sourceId = "sha256:source";
var materials = new[]
{
InTotoMaterial.ForAttestation("sha256:target", PredicateTypes.SbomAttestation),
InTotoMaterial.ForImage("registry.io/image", "sha256:imagehash"),
InTotoMaterial.ForGitCommit("https://github.com/org/repo", "abc123def456")
};
// Act
var result = await _builder.ExtractLinksAsync(sourceId, materials);
// Assert
result.IsSuccess.Should().BeTrue();
result.LinksCreated.Should().HaveCount(1);
result.SkippedMaterialsCount.Should().Be(2);
}
[Fact]
public async Task ExtractLinksAsync_DuplicateMaterial_ReportsError()
{
// Arrange
var sourceId = "sha256:source";
var materials = new[]
{
InTotoMaterial.ForAttestation("sha256:target", PredicateTypes.SbomAttestation),
InTotoMaterial.ForAttestation("sha256:target", PredicateTypes.SbomAttestation) // Duplicate
};
// Act
var result = await _builder.ExtractLinksAsync(sourceId, materials);
// Assert
result.IsSuccess.Should().BeFalse();
result.LinksCreated.Should().HaveCount(1);
result.Errors.Should().HaveCount(1);
result.Errors[0].Should().Contain("Duplicate");
}
[Fact]
public async Task ExtractLinksAsync_SelfReference_ReportsError()
{
// Arrange
var sourceId = "sha256:source";
var materials = new[]
{
InTotoMaterial.ForAttestation("sha256:source", PredicateTypes.SbomAttestation) // Self-link
};
// Act
var result = await _builder.ExtractLinksAsync(sourceId, materials);
// Assert
result.IsSuccess.Should().BeFalse();
result.LinksCreated.Should().BeEmpty();
result.Errors.Should().NotBeEmpty();
result.Errors.Should().Contain(e => e.Contains("Self-links"));
}
[Fact]
public async Task CreateLinkAsync_ValidLink_CreatesSuccessfully()
{
// Arrange
var sourceId = "sha256:source";
var targetId = "sha256:target";
// Act
var result = await _builder.CreateLinkAsync(sourceId, targetId);
// Assert
result.IsSuccess.Should().BeTrue();
result.LinksCreated.Should().HaveCount(1);
result.LinksCreated[0].SourceAttestationId.Should().Be(sourceId);
result.LinksCreated[0].TargetAttestationId.Should().Be(targetId);
}
[Fact]
public async Task CreateLinkAsync_WouldCreateCycle_Fails()
{
// Arrange - Create A -> B
await _builder.CreateLinkAsync("sha256:A", "sha256:B");
// Act - Try to create B -> A (would create cycle)
var result = await _builder.CreateLinkAsync("sha256:B", "sha256:A");
// Assert
result.IsSuccess.Should().BeFalse();
result.LinksCreated.Should().BeEmpty();
result.Errors.Should().Contain("Link would create a circular reference");
}
[Fact]
public async Task CreateLinkAsync_WithMetadata_IncludesMetadata()
{
// Arrange
var metadata = new LinkMetadata
{
Reason = "Test dependency",
Annotations = ImmutableDictionary<string, string>.Empty.Add("key", "value")
};
// Act
var result = await _builder.CreateLinkAsync(
"sha256:source",
"sha256:target",
metadata: metadata);
// Assert
result.IsSuccess.Should().BeTrue();
result.LinksCreated[0].Metadata.Should().NotBeNull();
result.LinksCreated[0].Metadata!.Reason.Should().Be("Test dependency");
}
[Fact]
public async Task LinkLayerAttestationsAsync_CreatesLayerLinks()
{
// Arrange
var parentId = "sha256:parent";
var layerRefs = new[]
{
new LayerAttestationRef
{
LayerIndex = 0,
LayerDigest = "sha256:layer0",
AttestationId = "sha256:layer0-att"
},
new LayerAttestationRef
{
LayerIndex = 1,
LayerDigest = "sha256:layer1",
AttestationId = "sha256:layer1-att"
}
};
// Act
var result = await _builder.LinkLayerAttestationsAsync(parentId, layerRefs);
// Assert
result.IsSuccess.Should().BeTrue();
result.LinksCreated.Should().HaveCount(2);
_linkStore.Count.Should().Be(2);
var links = _linkStore.GetAll().ToList();
links.Should().AllSatisfy(l =>
{
l.SourceAttestationId.Should().Be(parentId);
l.Metadata.Should().NotBeNull();
l.Metadata!.Annotations.Should().ContainKey("layerIndex");
});
}
[Fact]
public async Task LinkLayerAttestationsAsync_PreservesLayerOrder()
{
// Arrange
var parentId = "sha256:parent";
var layerRefs = new[]
{
new LayerAttestationRef { LayerIndex = 2, LayerDigest = "sha256:l2", AttestationId = "sha256:att2" },
new LayerAttestationRef { LayerIndex = 0, LayerDigest = "sha256:l0", AttestationId = "sha256:att0" },
new LayerAttestationRef { LayerIndex = 1, LayerDigest = "sha256:l1", AttestationId = "sha256:att1" }
};
// Act
var result = await _builder.LinkLayerAttestationsAsync(parentId, layerRefs);
// Assert
result.IsSuccess.Should().BeTrue();
result.LinksCreated.Should().HaveCount(3);
// Links should be created in layer order
result.LinksCreated[0].Metadata!.Annotations["layerIndex"].Should().Be("0");
result.LinksCreated[1].Metadata!.Annotations["layerIndex"].Should().Be("1");
result.LinksCreated[2].Metadata!.Annotations["layerIndex"].Should().Be("2");
}
[Fact]
public async Task ExtractLinksAsync_EmptyMaterials_ReturnsSuccess()
{
// Arrange
var sourceId = "sha256:source";
var materials = Array.Empty<InTotoMaterial>();
// Act
var result = await _builder.ExtractLinksAsync(sourceId, materials);
// Assert
result.IsSuccess.Should().BeTrue();
result.LinksCreated.Should().BeEmpty();
result.SkippedMaterialsCount.Should().Be(0);
}
[Fact]
public async Task ExtractLinksAsync_DifferentLinkTypes_CreatesCorrectType()
{
// Arrange
var sourceId = "sha256:source";
var materials = new[]
{
InTotoMaterial.ForAttestation("sha256:target", PredicateTypes.SbomAttestation)
};
// Act
var result = await _builder.ExtractLinksAsync(
sourceId,
materials,
linkType: AttestationLinkType.Supersedes);
// Assert
result.IsSuccess.Should().BeTrue();
result.LinksCreated[0].LinkType.Should().Be(AttestationLinkType.Supersedes);
}
}

View File

@@ -0,0 +1,323 @@
// -----------------------------------------------------------------------------
// AttestationChainValidatorTests.cs
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
// Task: T006
// Description: Unit tests for attestation chain validation.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Attestor.Core.Chain;
using Xunit;
namespace StellaOps.Attestor.Core.Tests.Chain;
[Trait("Category", "Unit")]
public class AttestationChainValidatorTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly AttestationChainValidator _validator;
public AttestationChainValidatorTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 6, 12, 0, 0, TimeSpan.Zero));
_validator = new AttestationChainValidator(_timeProvider);
}
[Fact]
public void ValidateLink_SelfLink_ReturnsInvalid()
{
// Arrange
var link = CreateLink("sha256:abc123", "sha256:abc123");
// Act
var result = _validator.ValidateLink(link, []);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain("Self-links are not allowed");
}
[Fact]
public void ValidateLink_DuplicateLink_ReturnsInvalid()
{
// Arrange
var existingLink = CreateLink("sha256:source", "sha256:target");
var newLink = CreateLink("sha256:source", "sha256:target");
// Act
var result = _validator.ValidateLink(newLink, [existingLink]);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain("Duplicate link already exists");
}
[Fact]
public void ValidateLink_WouldCreateCycle_ReturnsInvalid()
{
// Arrange - A -> B exists, adding B -> A would create cycle
var existingLinks = new List<AttestationLink>
{
CreateLink("sha256:A", "sha256:B")
};
var newLink = CreateLink("sha256:B", "sha256:A");
// Act
var result = _validator.ValidateLink(newLink, existingLinks);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain("Link would create a circular reference");
}
[Fact]
public void ValidateLink_WouldCreateIndirectCycle_ReturnsInvalid()
{
// Arrange - A -> B -> C exists, adding C -> A would create cycle
var existingLinks = new List<AttestationLink>
{
CreateLink("sha256:A", "sha256:B"),
CreateLink("sha256:B", "sha256:C")
};
var newLink = CreateLink("sha256:C", "sha256:A");
// Act
var result = _validator.ValidateLink(newLink, existingLinks);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain("Link would create a circular reference");
}
[Fact]
public void ValidateLink_ValidLink_ReturnsValid()
{
// Arrange
var existingLinks = new List<AttestationLink>
{
CreateLink("sha256:A", "sha256:B")
};
var newLink = CreateLink("sha256:B", "sha256:C");
// Act
var result = _validator.ValidateLink(newLink, existingLinks);
// Assert
result.IsValid.Should().BeTrue();
result.Errors.Should().BeEmpty();
}
[Fact]
public void ValidateChain_EmptyChain_ReturnsInvalid()
{
// Arrange
var chain = new AttestationChain
{
RootAttestationId = "sha256:root",
ArtifactDigest = "sha256:artifact",
Nodes = [],
Links = [],
IsComplete = true,
ResolvedAt = _timeProvider.GetUtcNow()
};
// Act
var result = _validator.ValidateChain(chain);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain("Chain has no nodes");
}
[Fact]
public void ValidateChain_MissingRoot_ReturnsInvalid()
{
// Arrange
var chain = new AttestationChain
{
RootAttestationId = "sha256:missing",
ArtifactDigest = "sha256:artifact",
Nodes = [CreateNode("sha256:other", depth: 0)],
Links = [],
IsComplete = true,
ResolvedAt = _timeProvider.GetUtcNow()
};
// Act
var result = _validator.ValidateChain(chain);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain("Root attestation not found in chain nodes");
}
[Fact]
public void ValidateChain_DuplicateNodes_ReturnsInvalid()
{
// Arrange
var chain = new AttestationChain
{
RootAttestationId = "sha256:root",
ArtifactDigest = "sha256:artifact",
Nodes =
[
CreateNode("sha256:root", depth: 0),
CreateNode("sha256:root", depth: 1) // Duplicate
],
Links = [],
IsComplete = true,
ResolvedAt = _timeProvider.GetUtcNow()
};
// Act
var result = _validator.ValidateChain(chain);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("Duplicate nodes"));
}
[Fact]
public void ValidateChain_LinkToMissingNode_ReturnsInvalid()
{
// Arrange
var chain = new AttestationChain
{
RootAttestationId = "sha256:root",
ArtifactDigest = "sha256:artifact",
Nodes = [CreateNode("sha256:root", depth: 0)],
Links = [CreateLink("sha256:root", "sha256:missing")],
IsComplete = true,
ResolvedAt = _timeProvider.GetUtcNow()
};
// Act
var result = _validator.ValidateChain(chain);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("not found in nodes"));
}
[Fact]
public void ValidateChain_ValidSimpleChain_ReturnsValid()
{
// Arrange - Simple chain: Policy -> VEX -> SBOM (linear)
var chain = new AttestationChain
{
RootAttestationId = "sha256:policy",
ArtifactDigest = "sha256:artifact",
Nodes =
[
CreateNode("sha256:policy", depth: 0, PredicateTypes.PolicyEvaluation),
CreateNode("sha256:vex", depth: 1, PredicateTypes.VexAttestation),
CreateNode("sha256:sbom", depth: 2, PredicateTypes.SbomAttestation)
],
Links =
[
CreateLink("sha256:policy", "sha256:vex"),
CreateLink("sha256:vex", "sha256:sbom")
],
IsComplete = true,
ResolvedAt = _timeProvider.GetUtcNow()
};
// Act
var result = _validator.ValidateChain(chain);
// Assert
result.IsValid.Should().BeTrue();
result.Errors.Should().BeEmpty();
}
[Fact]
public void ValidateChain_ChainWithCycle_ReturnsInvalid()
{
// Arrange - A -> B -> A (cycle)
var chain = new AttestationChain
{
RootAttestationId = "sha256:A",
ArtifactDigest = "sha256:artifact",
Nodes =
[
CreateNode("sha256:A", depth: 0),
CreateNode("sha256:B", depth: 1)
],
Links =
[
CreateLink("sha256:A", "sha256:B"),
CreateLink("sha256:B", "sha256:A") // Creates cycle
],
IsComplete = true,
ResolvedAt = _timeProvider.GetUtcNow()
};
// Act
var result = _validator.ValidateChain(chain);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain("Chain contains circular references");
}
[Fact]
public void ValidateChain_DAGStructure_ReturnsValid()
{
// Arrange - DAG where SBOM has multiple parents (valid)
// Policy -> VEX -> SBOM
// Policy -> SBOM (direct dependency too)
var chain = new AttestationChain
{
RootAttestationId = "sha256:policy",
ArtifactDigest = "sha256:artifact",
Nodes =
[
CreateNode("sha256:policy", depth: 0),
CreateNode("sha256:vex", depth: 1),
CreateNode("sha256:sbom", depth: 1) // Same depth as VEX since it's also directly linked
],
Links =
[
CreateLink("sha256:policy", "sha256:vex"),
CreateLink("sha256:policy", "sha256:sbom"),
CreateLink("sha256:vex", "sha256:sbom")
],
IsComplete = true,
ResolvedAt = _timeProvider.GetUtcNow()
};
// Act
var result = _validator.ValidateChain(chain);
// Assert - DAG is valid, just not a pure tree
result.IsValid.Should().BeTrue();
}
private static AttestationLink CreateLink(string source, string target)
{
return new AttestationLink
{
SourceAttestationId = source,
TargetAttestationId = target,
LinkType = AttestationLinkType.DependsOn,
CreatedAt = DateTimeOffset.UtcNow
};
}
private static AttestationChainNode CreateNode(
string attestationId,
int depth,
string predicateType = "Test@1")
{
return new AttestationChainNode
{
AttestationId = attestationId,
PredicateType = predicateType,
SubjectDigest = "sha256:subject",
Depth = depth,
CreatedAt = DateTimeOffset.UtcNow
};
}
}

View File

@@ -0,0 +1,363 @@
// -----------------------------------------------------------------------------
// AttestationLinkResolverTests.cs
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
// Task: T010-T012
// Description: Unit tests for attestation chain resolution.
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Attestor.Core.Chain;
using Xunit;
namespace StellaOps.Attestor.Core.Tests.Chain;
[Trait("Category", "Unit")]
public class AttestationLinkResolverTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly InMemoryAttestationLinkStore _linkStore;
private readonly InMemoryAttestationNodeProvider _nodeProvider;
private readonly AttestationLinkResolver _resolver;
public AttestationLinkResolverTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 6, 12, 0, 0, TimeSpan.Zero));
_linkStore = new InMemoryAttestationLinkStore();
_nodeProvider = new InMemoryAttestationNodeProvider();
_resolver = new AttestationLinkResolver(_linkStore, _nodeProvider, _timeProvider);
}
[Fact]
public async Task ResolveChainAsync_NoRootFound_ReturnsIncompleteChain()
{
// Arrange
var request = new AttestationChainRequest
{
ArtifactDigest = "sha256:unknown"
};
// Act
var result = await _resolver.ResolveChainAsync(request);
// Assert
result.IsComplete.Should().BeFalse();
result.RootAttestationId.Should().BeEmpty();
result.ValidationErrors.Should().Contain("No root attestation found for artifact");
}
[Fact]
public async Task ResolveChainAsync_SingleNode_ReturnsCompleteChain()
{
// Arrange
var artifactDigest = "sha256:artifact123";
var rootNode = CreateNode("sha256:root", PredicateTypes.PolicyEvaluation, artifactDigest);
_nodeProvider.AddNode(rootNode);
_nodeProvider.SetArtifactRoot(artifactDigest, "sha256:root");
var request = new AttestationChainRequest { ArtifactDigest = artifactDigest };
// Act
var result = await _resolver.ResolveChainAsync(request);
// Assert
result.IsComplete.Should().BeTrue();
result.RootAttestationId.Should().Be("sha256:root");
result.Nodes.Should().HaveCount(1);
result.Links.Should().BeEmpty();
}
[Fact]
public async Task ResolveChainAsync_LinearChain_ResolvesAllNodes()
{
// Arrange - Policy -> VEX -> SBOM
var artifactDigest = "sha256:artifact123";
var policyNode = CreateNode("sha256:policy", PredicateTypes.PolicyEvaluation, artifactDigest);
var vexNode = CreateNode("sha256:vex", PredicateTypes.VexAttestation, artifactDigest);
var sbomNode = CreateNode("sha256:sbom", PredicateTypes.SbomAttestation, artifactDigest);
_nodeProvider.AddNode(policyNode);
_nodeProvider.AddNode(vexNode);
_nodeProvider.AddNode(sbomNode);
_nodeProvider.SetArtifactRoot(artifactDigest, "sha256:policy");
await _linkStore.StoreAsync(CreateLink("sha256:policy", "sha256:vex"));
await _linkStore.StoreAsync(CreateLink("sha256:vex", "sha256:sbom"));
var request = new AttestationChainRequest { ArtifactDigest = artifactDigest };
// Act
var result = await _resolver.ResolveChainAsync(request);
// Assert
result.IsComplete.Should().BeTrue();
result.Nodes.Should().HaveCount(3);
result.Links.Should().HaveCount(2);
result.Nodes[0].AttestationId.Should().Be("sha256:policy");
result.Nodes[0].Depth.Should().Be(0);
result.Nodes[1].AttestationId.Should().Be("sha256:vex");
result.Nodes[1].Depth.Should().Be(1);
result.Nodes[2].AttestationId.Should().Be("sha256:sbom");
result.Nodes[2].Depth.Should().Be(2);
}
[Fact]
public async Task ResolveChainAsync_DAGStructure_ResolvesAllNodes()
{
// Arrange - Policy -> VEX, Policy -> SBOM, VEX -> SBOM (DAG)
var artifactDigest = "sha256:artifact123";
var policyNode = CreateNode("sha256:policy", PredicateTypes.PolicyEvaluation, artifactDigest);
var vexNode = CreateNode("sha256:vex", PredicateTypes.VexAttestation, artifactDigest);
var sbomNode = CreateNode("sha256:sbom", PredicateTypes.SbomAttestation, artifactDigest);
_nodeProvider.AddNode(policyNode);
_nodeProvider.AddNode(vexNode);
_nodeProvider.AddNode(sbomNode);
_nodeProvider.SetArtifactRoot(artifactDigest, "sha256:policy");
await _linkStore.StoreAsync(CreateLink("sha256:policy", "sha256:vex"));
await _linkStore.StoreAsync(CreateLink("sha256:policy", "sha256:sbom"));
await _linkStore.StoreAsync(CreateLink("sha256:vex", "sha256:sbom"));
var request = new AttestationChainRequest { ArtifactDigest = artifactDigest };
// Act
var result = await _resolver.ResolveChainAsync(request);
// Assert
result.IsComplete.Should().BeTrue();
result.Nodes.Should().HaveCount(3);
result.Links.Should().HaveCount(3);
}
[Fact]
public async Task ResolveChainAsync_MissingNode_ReturnsIncompleteWithMissingIds()
{
// Arrange
var artifactDigest = "sha256:artifact123";
var policyNode = CreateNode("sha256:policy", PredicateTypes.PolicyEvaluation, artifactDigest);
_nodeProvider.AddNode(policyNode);
_nodeProvider.SetArtifactRoot(artifactDigest, "sha256:policy");
// Link to non-existent node
await _linkStore.StoreAsync(CreateLink("sha256:policy", "sha256:missing"));
var request = new AttestationChainRequest { ArtifactDigest = artifactDigest };
// Act
var result = await _resolver.ResolveChainAsync(request);
// Assert
result.IsComplete.Should().BeFalse();
result.MissingAttestations.Should().Contain("sha256:missing");
}
[Fact]
public async Task ResolveChainAsync_MaxDepthReached_StopsTraversal()
{
// Arrange - Deep chain: A -> B -> C -> D -> E
var artifactDigest = "sha256:artifact123";
var nodes = new[] { "A", "B", "C", "D", "E" }
.Select(id => CreateNode($"sha256:{id}", "Test@1", artifactDigest))
.ToList();
foreach (var node in nodes)
{
_nodeProvider.AddNode(node);
}
_nodeProvider.SetArtifactRoot(artifactDigest, "sha256:A");
await _linkStore.StoreAsync(CreateLink("sha256:A", "sha256:B"));
await _linkStore.StoreAsync(CreateLink("sha256:B", "sha256:C"));
await _linkStore.StoreAsync(CreateLink("sha256:C", "sha256:D"));
await _linkStore.StoreAsync(CreateLink("sha256:D", "sha256:E"));
var request = new AttestationChainRequest
{
ArtifactDigest = artifactDigest,
MaxDepth = 2 // Should stop at C
};
// Act
var result = await _resolver.ResolveChainAsync(request);
// Assert
result.Nodes.Should().HaveCount(3); // A, B, C
result.Nodes.Select(n => n.AttestationId).Should().Contain("sha256:A");
result.Nodes.Select(n => n.AttestationId).Should().Contain("sha256:B");
result.Nodes.Select(n => n.AttestationId).Should().Contain("sha256:C");
result.Nodes.Select(n => n.AttestationId).Should().NotContain("sha256:D");
}
[Fact]
public async Task ResolveChainAsync_ExcludesLayers_WhenNotRequested()
{
// Arrange
var artifactDigest = "sha256:artifact123";
var policyNode = CreateNode("sha256:policy", PredicateTypes.PolicyEvaluation, artifactDigest);
var layerNode = CreateNode("sha256:layer", PredicateTypes.LayerSbom, artifactDigest) with
{
IsLayerAttestation = true,
LayerIndex = 0
};
_nodeProvider.AddNode(policyNode);
_nodeProvider.AddNode(layerNode);
_nodeProvider.SetArtifactRoot(artifactDigest, "sha256:policy");
await _linkStore.StoreAsync(CreateLink("sha256:policy", "sha256:layer"));
var request = new AttestationChainRequest
{
ArtifactDigest = artifactDigest,
IncludeLayers = false
};
// Act
var result = await _resolver.ResolveChainAsync(request);
// Assert
result.Nodes.Should().HaveCount(1);
result.Nodes[0].AttestationId.Should().Be("sha256:policy");
}
[Fact]
public async Task GetUpstreamAsync_ReturnsParentNodes()
{
// Arrange - Policy -> VEX -> SBOM
var policyNode = CreateNode("sha256:policy", PredicateTypes.PolicyEvaluation, "sha256:art");
var vexNode = CreateNode("sha256:vex", PredicateTypes.VexAttestation, "sha256:art");
var sbomNode = CreateNode("sha256:sbom", PredicateTypes.SbomAttestation, "sha256:art");
_nodeProvider.AddNode(policyNode);
_nodeProvider.AddNode(vexNode);
_nodeProvider.AddNode(sbomNode);
await _linkStore.StoreAsync(CreateLink("sha256:policy", "sha256:vex"));
await _linkStore.StoreAsync(CreateLink("sha256:vex", "sha256:sbom"));
// Act - Get upstream (parents) of SBOM
var result = await _resolver.GetUpstreamAsync("sha256:sbom");
// Assert
result.Should().HaveCount(2);
result.Select(n => n.AttestationId).Should().Contain("sha256:vex");
result.Select(n => n.AttestationId).Should().Contain("sha256:policy");
}
[Fact]
public async Task GetDownstreamAsync_ReturnsChildNodes()
{
// Arrange - Policy -> VEX -> SBOM
var policyNode = CreateNode("sha256:policy", PredicateTypes.PolicyEvaluation, "sha256:art");
var vexNode = CreateNode("sha256:vex", PredicateTypes.VexAttestation, "sha256:art");
var sbomNode = CreateNode("sha256:sbom", PredicateTypes.SbomAttestation, "sha256:art");
_nodeProvider.AddNode(policyNode);
_nodeProvider.AddNode(vexNode);
_nodeProvider.AddNode(sbomNode);
await _linkStore.StoreAsync(CreateLink("sha256:policy", "sha256:vex"));
await _linkStore.StoreAsync(CreateLink("sha256:vex", "sha256:sbom"));
// Act - Get downstream (children) of Policy
var result = await _resolver.GetDownstreamAsync("sha256:policy");
// Assert
result.Should().HaveCount(2);
result.Select(n => n.AttestationId).Should().Contain("sha256:vex");
result.Select(n => n.AttestationId).Should().Contain("sha256:sbom");
}
[Fact]
public async Task GetLinksAsync_ReturnsAllLinks()
{
// Arrange
await _linkStore.StoreAsync(CreateLink("sha256:A", "sha256:B"));
await _linkStore.StoreAsync(CreateLink("sha256:B", "sha256:C"));
await _linkStore.StoreAsync(CreateLink("sha256:D", "sha256:B")); // B is target
// Act
var allLinks = await _resolver.GetLinksAsync("sha256:B", LinkDirection.Both);
var outgoing = await _resolver.GetLinksAsync("sha256:B", LinkDirection.Outgoing);
var incoming = await _resolver.GetLinksAsync("sha256:B", LinkDirection.Incoming);
// Assert
allLinks.Should().HaveCount(3);
outgoing.Should().HaveCount(1);
outgoing[0].TargetAttestationId.Should().Be("sha256:C");
incoming.Should().HaveCount(2);
}
[Fact]
public async Task AreLinkedAsync_DirectLink_ReturnsTrue()
{
// Arrange
await _linkStore.StoreAsync(CreateLink("sha256:A", "sha256:B"));
// Act
var result = await _resolver.AreLinkedAsync("sha256:A", "sha256:B");
// Assert
result.Should().BeTrue();
}
[Fact]
public async Task AreLinkedAsync_IndirectLink_ReturnsTrue()
{
// Arrange - A -> B -> C
await _linkStore.StoreAsync(CreateLink("sha256:A", "sha256:B"));
await _linkStore.StoreAsync(CreateLink("sha256:B", "sha256:C"));
// Act
var result = await _resolver.AreLinkedAsync("sha256:A", "sha256:C");
// Assert
result.Should().BeTrue();
}
[Fact]
public async Task AreLinkedAsync_NoLink_ReturnsFalse()
{
// Arrange - A -> B, C -> D (separate)
await _linkStore.StoreAsync(CreateLink("sha256:A", "sha256:B"));
await _linkStore.StoreAsync(CreateLink("sha256:C", "sha256:D"));
// Act
var result = await _resolver.AreLinkedAsync("sha256:A", "sha256:D");
// Assert
result.Should().BeFalse();
}
private static AttestationChainNode CreateNode(
string attestationId,
string predicateType,
string subjectDigest)
{
return new AttestationChainNode
{
AttestationId = attestationId,
PredicateType = predicateType,
SubjectDigest = subjectDigest,
Depth = 0,
CreatedAt = DateTimeOffset.UtcNow
};
}
private static AttestationLink CreateLink(string source, string target)
{
return new AttestationLink
{
SourceAttestationId = source,
TargetAttestationId = target,
LinkType = AttestationLinkType.DependsOn,
CreatedAt = DateTimeOffset.UtcNow
};
}
}

View File

@@ -0,0 +1,323 @@
// -----------------------------------------------------------------------------
// ChainResolverDirectionalTests.cs
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
// Task: T025
// Description: Tests for directional chain resolution (upstream/downstream/full).
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Attestor.Core.Chain;
using Xunit;
namespace StellaOps.Attestor.Core.Tests.Chain;
[Trait("Category", "Unit")]
public class ChainResolverDirectionalTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly InMemoryAttestationLinkStore _linkStore;
private readonly InMemoryAttestationNodeProvider _nodeProvider;
private readonly AttestationLinkResolver _resolver;
public ChainResolverDirectionalTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 6, 12, 0, 0, TimeSpan.Zero));
_linkStore = new InMemoryAttestationLinkStore();
_nodeProvider = new InMemoryAttestationNodeProvider();
_resolver = new AttestationLinkResolver(_linkStore, _nodeProvider, _timeProvider);
}
[Fact]
public async Task ResolveUpstreamAsync_StartNodeNotFound_ReturnsNull()
{
// Act
var result = await _resolver.ResolveUpstreamAsync("sha256:unknown");
// Assert
result.Should().BeNull();
}
[Fact]
public async Task ResolveUpstreamAsync_NoUpstreamLinks_ReturnsChainWithStartNodeOnly()
{
// Arrange
var startNode = CreateNode("sha256:start", "SBOM", "sha256:artifact");
_nodeProvider.AddNode(startNode);
// Act
var result = await _resolver.ResolveUpstreamAsync("sha256:start");
// Assert
result.Should().NotBeNull();
result!.Nodes.Should().HaveCount(1);
result.Nodes[0].AttestationId.Should().Be("sha256:start");
}
[Fact]
public async Task ResolveUpstreamAsync_WithUpstreamLinks_ReturnsChain()
{
// Arrange
// Chain: verdict -> vex -> sbom (start)
var sbomNode = CreateNode("sha256:sbom", "SBOM", "sha256:artifact");
var vexNode = CreateNode("sha256:vex", "VEX", "sha256:artifact");
var verdictNode = CreateNode("sha256:verdict", "Verdict", "sha256:artifact");
_nodeProvider.AddNode(sbomNode);
_nodeProvider.AddNode(vexNode);
_nodeProvider.AddNode(verdictNode);
// Links: verdict depends on vex, vex depends on sbom
await _linkStore.StoreAsync(CreateLink("sha256:verdict", "sha256:vex"));
await _linkStore.StoreAsync(CreateLink("sha256:vex", "sha256:sbom"));
// Act - get upstream from sbom (should get vex and verdict)
var result = await _resolver.ResolveUpstreamAsync("sha256:sbom");
// Assert
result.Should().NotBeNull();
result!.Nodes.Should().HaveCount(3);
result.Nodes.Should().Contain(n => n.AttestationId == "sha256:sbom");
result.Nodes.Should().Contain(n => n.AttestationId == "sha256:vex");
result.Nodes.Should().Contain(n => n.AttestationId == "sha256:verdict");
}
[Fact]
public async Task ResolveDownstreamAsync_StartNodeNotFound_ReturnsNull()
{
// Act
var result = await _resolver.ResolveDownstreamAsync("sha256:unknown");
// Assert
result.Should().BeNull();
}
[Fact]
public async Task ResolveDownstreamAsync_NoDownstreamLinks_ReturnsChainWithStartNodeOnly()
{
// Arrange
var startNode = CreateNode("sha256:start", "Verdict", "sha256:artifact");
_nodeProvider.AddNode(startNode);
// Act
var result = await _resolver.ResolveDownstreamAsync("sha256:start");
// Assert
result.Should().NotBeNull();
result!.Nodes.Should().HaveCount(1);
result.Nodes[0].AttestationId.Should().Be("sha256:start");
}
[Fact]
public async Task ResolveDownstreamAsync_WithDownstreamLinks_ReturnsChain()
{
// Arrange
// Chain: verdict -> vex -> sbom
var verdictNode = CreateNode("sha256:verdict", "Verdict", "sha256:artifact");
var vexNode = CreateNode("sha256:vex", "VEX", "sha256:artifact");
var sbomNode = CreateNode("sha256:sbom", "SBOM", "sha256:artifact");
_nodeProvider.AddNode(verdictNode);
_nodeProvider.AddNode(vexNode);
_nodeProvider.AddNode(sbomNode);
await _linkStore.StoreAsync(CreateLink("sha256:verdict", "sha256:vex"));
await _linkStore.StoreAsync(CreateLink("sha256:vex", "sha256:sbom"));
// Act - get downstream from verdict (should get vex and sbom)
var result = await _resolver.ResolveDownstreamAsync("sha256:verdict");
// Assert
result.Should().NotBeNull();
result!.Nodes.Should().HaveCount(3);
result.Nodes.Should().Contain(n => n.AttestationId == "sha256:verdict");
result.Nodes.Should().Contain(n => n.AttestationId == "sha256:vex");
result.Nodes.Should().Contain(n => n.AttestationId == "sha256:sbom");
}
[Fact]
public async Task ResolveFullChainAsync_StartNodeNotFound_ReturnsNull()
{
// Act
var result = await _resolver.ResolveFullChainAsync("sha256:unknown");
// Assert
result.Should().BeNull();
}
[Fact]
public async Task ResolveFullChainAsync_ReturnsAllRelatedNodes()
{
// Arrange
// Chain: policy -> verdict -> vex -> sbom
var policyNode = CreateNode("sha256:policy", "Policy", "sha256:artifact");
var verdictNode = CreateNode("sha256:verdict", "Verdict", "sha256:artifact");
var vexNode = CreateNode("sha256:vex", "VEX", "sha256:artifact");
var sbomNode = CreateNode("sha256:sbom", "SBOM", "sha256:artifact");
_nodeProvider.AddNode(policyNode);
_nodeProvider.AddNode(verdictNode);
_nodeProvider.AddNode(vexNode);
_nodeProvider.AddNode(sbomNode);
await _linkStore.StoreAsync(CreateLink("sha256:policy", "sha256:verdict"));
await _linkStore.StoreAsync(CreateLink("sha256:verdict", "sha256:vex"));
await _linkStore.StoreAsync(CreateLink("sha256:vex", "sha256:sbom"));
// Act - get full chain from vex (middle of chain)
var result = await _resolver.ResolveFullChainAsync("sha256:vex");
// Assert
result.Should().NotBeNull();
result!.Nodes.Should().HaveCount(4);
result.Links.Should().HaveCount(3);
}
[Fact]
public async Task ResolveUpstreamAsync_RespectsMaxDepth()
{
// Arrange - create chain of depth 5
var nodes = Enumerable.Range(0, 6)
.Select(i => CreateNode($"sha256:node{i}", "SBOM", "sha256:artifact"))
.ToList();
foreach (var node in nodes)
{
_nodeProvider.AddNode(node);
}
// Link chain: node5 -> node4 -> node3 -> node2 -> node1 -> node0
for (int i = 5; i > 0; i--)
{
await _linkStore.StoreAsync(CreateLink($"sha256:node{i}", $"sha256:node{i - 1}"));
}
// Act - resolve upstream from node0 with depth 2
var result = await _resolver.ResolveUpstreamAsync("sha256:node0", maxDepth: 2);
// Assert - should get node0, node1, node2 (depth 0, 1, 2)
result.Should().NotBeNull();
result!.Nodes.Should().HaveCount(3);
result.Nodes.Should().Contain(n => n.AttestationId == "sha256:node0");
result.Nodes.Should().Contain(n => n.AttestationId == "sha256:node1");
result.Nodes.Should().Contain(n => n.AttestationId == "sha256:node2");
}
[Fact]
public async Task ResolveDownstreamAsync_RespectsMaxDepth()
{
// Arrange - create chain of depth 5
var nodes = Enumerable.Range(0, 6)
.Select(i => CreateNode($"sha256:node{i}", "SBOM", "sha256:artifact"))
.ToList();
foreach (var node in nodes)
{
_nodeProvider.AddNode(node);
}
// Link chain: node0 -> node1 -> node2 -> node3 -> node4 -> node5
for (int i = 0; i < 5; i++)
{
await _linkStore.StoreAsync(CreateLink($"sha256:node{i}", $"sha256:node{i + 1}"));
}
// Act - resolve downstream from node0 with depth 2
var result = await _resolver.ResolveDownstreamAsync("sha256:node0", maxDepth: 2);
// Assert - should get node0, node1, node2 (depth 0, 1, 2)
result.Should().NotBeNull();
result!.Nodes.Should().HaveCount(3);
result.Nodes.Should().Contain(n => n.AttestationId == "sha256:node0");
result.Nodes.Should().Contain(n => n.AttestationId == "sha256:node1");
result.Nodes.Should().Contain(n => n.AttestationId == "sha256:node2");
}
[Fact]
public async Task ResolveFullChainAsync_MarksRootAndLeafNodes()
{
// Arrange
// Chain: root -> middle -> leaf
var rootNode = CreateNode("sha256:root", "Verdict", "sha256:artifact");
var middleNode = CreateNode("sha256:middle", "VEX", "sha256:artifact");
var leafNode = CreateNode("sha256:leaf", "SBOM", "sha256:artifact");
_nodeProvider.AddNode(rootNode);
_nodeProvider.AddNode(middleNode);
_nodeProvider.AddNode(leafNode);
await _linkStore.StoreAsync(CreateLink("sha256:root", "sha256:middle"));
await _linkStore.StoreAsync(CreateLink("sha256:middle", "sha256:leaf"));
// Act
var result = await _resolver.ResolveFullChainAsync("sha256:middle");
// Assert
result.Should().NotBeNull();
var root = result!.Nodes.FirstOrDefault(n => n.AttestationId == "sha256:root");
var middle = result.Nodes.FirstOrDefault(n => n.AttestationId == "sha256:middle");
var leaf = result.Nodes.FirstOrDefault(n => n.AttestationId == "sha256:leaf");
root.Should().NotBeNull();
root!.IsRoot.Should().BeTrue();
root.IsLeaf.Should().BeFalse();
leaf.Should().NotBeNull();
leaf!.IsLeaf.Should().BeTrue();
}
[Fact]
public async Task GetBySubjectAsync_ReturnsNodesForSubject()
{
// Arrange
var node1 = CreateNode("sha256:att1", "SBOM", "sha256:artifact1");
var node2 = CreateNode("sha256:att2", "VEX", "sha256:artifact1");
var node3 = CreateNode("sha256:att3", "SBOM", "sha256:artifact2");
_nodeProvider.AddNode(node1);
_nodeProvider.AddNode(node2);
_nodeProvider.AddNode(node3);
// Act
var result = await _nodeProvider.GetBySubjectAsync("sha256:artifact1");
// Assert
result.Should().HaveCount(2);
result.Should().Contain(n => n.AttestationId == "sha256:att1");
result.Should().Contain(n => n.AttestationId == "sha256:att2");
}
[Fact]
public async Task GetBySubjectAsync_NoMatches_ReturnsEmpty()
{
// Arrange
var node = CreateNode("sha256:att1", "SBOM", "sha256:artifact1");
_nodeProvider.AddNode(node);
// Act
var result = await _nodeProvider.GetBySubjectAsync("sha256:unknown");
// Assert
result.Should().BeEmpty();
}
private AttestationChainNode CreateNode(string attestationId, string predicateType, string subjectDigest)
{
return new AttestationChainNode
{
AttestationId = attestationId,
PredicateType = predicateType,
SubjectDigest = subjectDigest,
CreatedAt = _timeProvider.GetUtcNow(),
Depth = 0,
IsRoot = false,
IsLeaf = false,
IsLayerAttestation = false
};
}
private AttestationLink CreateLink(string sourceId, string targetId)
{
return new AttestationLink
{
SourceAttestationId = sourceId,
TargetAttestationId = targetId,
LinkType = AttestationLinkType.DependsOn,
CreatedAt = _timeProvider.GetUtcNow()
};
}
}

View File

@@ -0,0 +1,216 @@
// -----------------------------------------------------------------------------
// InMemoryAttestationLinkStoreTests.cs
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
// Task: T011
// Description: Unit tests for in-memory attestation link store.
// -----------------------------------------------------------------------------
using FluentAssertions;
using StellaOps.Attestor.Core.Chain;
using Xunit;
namespace StellaOps.Attestor.Core.Tests.Chain;
[Trait("Category", "Unit")]
public class InMemoryAttestationLinkStoreTests
{
private readonly InMemoryAttestationLinkStore _store;
public InMemoryAttestationLinkStoreTests()
{
_store = new InMemoryAttestationLinkStore();
}
[Fact]
public async Task StoreAsync_AddsLinkToStore()
{
// Arrange
var link = CreateLink("sha256:source", "sha256:target");
// Act
await _store.StoreAsync(link);
// Assert
_store.Count.Should().Be(1);
}
[Fact]
public async Task StoreAsync_DuplicateLink_DoesNotAddAgain()
{
// Arrange
var link1 = CreateLink("sha256:source", "sha256:target");
var link2 = CreateLink("sha256:source", "sha256:target");
// Act
await _store.StoreAsync(link1);
await _store.StoreAsync(link2);
// Assert
_store.Count.Should().Be(1);
}
[Fact]
public async Task GetBySourceAsync_ReturnsLinksFromSource()
{
// Arrange
await _store.StoreAsync(CreateLink("sha256:A", "sha256:B"));
await _store.StoreAsync(CreateLink("sha256:A", "sha256:C"));
await _store.StoreAsync(CreateLink("sha256:B", "sha256:C"));
// Act
var result = await _store.GetBySourceAsync("sha256:A");
// Assert
result.Should().HaveCount(2);
result.Select(l => l.TargetAttestationId).Should().Contain("sha256:B");
result.Select(l => l.TargetAttestationId).Should().Contain("sha256:C");
}
[Fact]
public async Task GetBySourceAsync_NoLinks_ReturnsEmpty()
{
// Act
var result = await _store.GetBySourceAsync("sha256:unknown");
// Assert
result.Should().BeEmpty();
}
[Fact]
public async Task GetByTargetAsync_ReturnsLinksToTarget()
{
// Arrange
await _store.StoreAsync(CreateLink("sha256:A", "sha256:C"));
await _store.StoreAsync(CreateLink("sha256:B", "sha256:C"));
await _store.StoreAsync(CreateLink("sha256:A", "sha256:B"));
// Act
var result = await _store.GetByTargetAsync("sha256:C");
// Assert
result.Should().HaveCount(2);
result.Select(l => l.SourceAttestationId).Should().Contain("sha256:A");
result.Select(l => l.SourceAttestationId).Should().Contain("sha256:B");
}
[Fact]
public async Task GetAsync_ReturnsSpecificLink()
{
// Arrange
var link = CreateLink("sha256:A", "sha256:B");
await _store.StoreAsync(link);
// Act
var result = await _store.GetAsync("sha256:A", "sha256:B");
// Assert
result.Should().NotBeNull();
result!.SourceAttestationId.Should().Be("sha256:A");
result.TargetAttestationId.Should().Be("sha256:B");
}
[Fact]
public async Task GetAsync_NonExistent_ReturnsNull()
{
// Act
var result = await _store.GetAsync("sha256:A", "sha256:B");
// Assert
result.Should().BeNull();
}
[Fact]
public async Task ExistsAsync_LinkExists_ReturnsTrue()
{
// Arrange
await _store.StoreAsync(CreateLink("sha256:A", "sha256:B"));
// Act
var result = await _store.ExistsAsync("sha256:A", "sha256:B");
// Assert
result.Should().BeTrue();
}
[Fact]
public async Task ExistsAsync_LinkDoesNotExist_ReturnsFalse()
{
// Act
var result = await _store.ExistsAsync("sha256:A", "sha256:B");
// Assert
result.Should().BeFalse();
}
[Fact]
public async Task DeleteByAttestationAsync_RemovesAllRelatedLinks()
{
// Arrange
await _store.StoreAsync(CreateLink("sha256:A", "sha256:B"));
await _store.StoreAsync(CreateLink("sha256:B", "sha256:C"));
await _store.StoreAsync(CreateLink("sha256:D", "sha256:B"));
// Act
await _store.DeleteByAttestationAsync("sha256:B");
// Assert
_store.Count.Should().Be(0); // All links involve B
}
[Fact]
public async Task StoreBatchAsync_AddsMultipleLinks()
{
// Arrange
var links = new[]
{
CreateLink("sha256:A", "sha256:B"),
CreateLink("sha256:B", "sha256:C"),
CreateLink("sha256:C", "sha256:D")
};
// Act
await _store.StoreBatchAsync(links);
// Assert
_store.Count.Should().Be(3);
}
[Fact]
public void Clear_RemovesAllLinks()
{
// Arrange
_store.StoreAsync(CreateLink("sha256:A", "sha256:B")).Wait();
_store.StoreAsync(CreateLink("sha256:B", "sha256:C")).Wait();
// Act
_store.Clear();
// Assert
_store.Count.Should().Be(0);
}
[Fact]
public async Task GetAll_ReturnsAllLinks()
{
// Arrange
await _store.StoreAsync(CreateLink("sha256:A", "sha256:B"));
await _store.StoreAsync(CreateLink("sha256:B", "sha256:C"));
// Act
var result = _store.GetAll();
// Assert
result.Should().HaveCount(2);
}
private static AttestationLink CreateLink(string source, string target)
{
return new AttestationLink
{
SourceAttestationId = source,
TargetAttestationId = target,
LinkType = AttestationLinkType.DependsOn,
CreatedAt = DateTimeOffset.UtcNow
};
}
}

View File

@@ -0,0 +1,342 @@
// -----------------------------------------------------------------------------
// LayerAttestationServiceTests.cs
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
// Task: T019
// Description: Unit tests for layer attestation service.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Attestor.Core.Chain;
using StellaOps.Attestor.Core.Layers;
using Xunit;
namespace StellaOps.Attestor.Core.Tests.Layers;
[Trait("Category", "Unit")]
public class LayerAttestationServiceTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly InMemoryLayerAttestationSigner _signer;
private readonly InMemoryLayerAttestationStore _store;
private readonly InMemoryAttestationLinkStore _linkStore;
private readonly AttestationChainValidator _validator;
private readonly AttestationChainBuilder _chainBuilder;
private readonly LayerAttestationService _service;
public LayerAttestationServiceTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 6, 12, 0, 0, TimeSpan.Zero));
_signer = new InMemoryLayerAttestationSigner(_timeProvider);
_store = new InMemoryLayerAttestationStore();
_linkStore = new InMemoryAttestationLinkStore();
_validator = new AttestationChainValidator(_timeProvider);
_chainBuilder = new AttestationChainBuilder(_linkStore, _validator, _timeProvider);
_service = new LayerAttestationService(_signer, _store, _linkStore, _chainBuilder, _timeProvider);
}
[Fact]
public async Task CreateLayerAttestationAsync_ValidRequest_ReturnsSuccess()
{
// Arrange
var request = CreateLayerRequest("sha256:image123", "sha256:layer0", 0);
// Act
var result = await _service.CreateLayerAttestationAsync(request);
// Assert
result.Success.Should().BeTrue();
result.LayerDigest.Should().Be("sha256:layer0");
result.LayerOrder.Should().Be(0);
result.AttestationId.Should().StartWith("sha256:");
result.EnvelopeDigest.Should().StartWith("sha256:");
result.Error.Should().BeNull();
}
[Fact]
public async Task CreateLayerAttestationAsync_StoresAttestation()
{
// Arrange
var request = CreateLayerRequest("sha256:image123", "sha256:layer0", 0);
// Act
await _service.CreateLayerAttestationAsync(request);
var stored = await _service.GetLayerAttestationAsync("sha256:image123", 0);
// Assert
stored.Should().NotBeNull();
stored!.LayerDigest.Should().Be("sha256:layer0");
}
[Fact]
public async Task CreateBatchLayerAttestationsAsync_MultipleLayers_AllSucceed()
{
// Arrange
var request = new BatchLayerAttestationRequest
{
ImageDigest = "sha256:image123",
ImageRef = "registry.io/app:latest",
Layers =
[
CreateLayerRequest("sha256:image123", "sha256:layer0", 0),
CreateLayerRequest("sha256:image123", "sha256:layer1", 1),
CreateLayerRequest("sha256:image123", "sha256:layer2", 2)
]
};
// Act
var result = await _service.CreateBatchLayerAttestationsAsync(request);
// Assert
result.AllSucceeded.Should().BeTrue();
result.SuccessCount.Should().Be(3);
result.FailedCount.Should().Be(0);
result.Layers.Should().HaveCount(3);
result.ProcessingTime.Should().BeGreaterThan(TimeSpan.Zero);
}
[Fact]
public async Task CreateBatchLayerAttestationsAsync_PreservesLayerOrder()
{
// Arrange - layers in reverse order
var request = new BatchLayerAttestationRequest
{
ImageDigest = "sha256:image123",
ImageRef = "registry.io/app:latest",
Layers =
[
CreateLayerRequest("sha256:image123", "sha256:layer2", 2),
CreateLayerRequest("sha256:image123", "sha256:layer0", 0),
CreateLayerRequest("sha256:image123", "sha256:layer1", 1)
]
};
// Act
var result = await _service.CreateBatchLayerAttestationsAsync(request);
// Assert - should be processed in order
result.Layers[0].LayerOrder.Should().Be(0);
result.Layers[1].LayerOrder.Should().Be(1);
result.Layers[2].LayerOrder.Should().Be(2);
}
[Fact]
public async Task CreateBatchLayerAttestationsAsync_WithLinkToParent_CreatesLinks()
{
// Arrange
var parentAttestationId = "sha256:parentattestation";
var request = new BatchLayerAttestationRequest
{
ImageDigest = "sha256:image123",
ImageRef = "registry.io/app:latest",
Layers =
[
CreateLayerRequest("sha256:image123", "sha256:layer0", 0),
CreateLayerRequest("sha256:image123", "sha256:layer1", 1)
],
LinkToParent = true,
ParentAttestationId = parentAttestationId
};
// Act
var result = await _service.CreateBatchLayerAttestationsAsync(request);
// Assert
result.LinksCreated.Should().Be(2);
_linkStore.Count.Should().Be(2);
}
[Fact]
public async Task CreateBatchLayerAttestationsAsync_WithoutLinkToParent_NoLinksCreated()
{
// Arrange
var request = new BatchLayerAttestationRequest
{
ImageDigest = "sha256:image123",
ImageRef = "registry.io/app:latest",
Layers =
[
CreateLayerRequest("sha256:image123", "sha256:layer0", 0)
],
LinkToParent = false
};
// Act
var result = await _service.CreateBatchLayerAttestationsAsync(request);
// Assert
result.LinksCreated.Should().Be(0);
_linkStore.Count.Should().Be(0);
}
[Fact]
public async Task GetLayerAttestationsAsync_MultipleLayers_ReturnsInOrder()
{
// Arrange - create out of order
await _service.CreateLayerAttestationAsync(
CreateLayerRequest("sha256:image123", "sha256:layer2", 2));
await _service.CreateLayerAttestationAsync(
CreateLayerRequest("sha256:image123", "sha256:layer0", 0));
await _service.CreateLayerAttestationAsync(
CreateLayerRequest("sha256:image123", "sha256:layer1", 1));
// Act
var results = await _service.GetLayerAttestationsAsync("sha256:image123");
// Assert
results.Should().HaveCount(3);
results[0].LayerOrder.Should().Be(0);
results[1].LayerOrder.Should().Be(1);
results[2].LayerOrder.Should().Be(2);
}
[Fact]
public async Task GetLayerAttestationsAsync_NoLayers_ReturnsEmpty()
{
// Act
var results = await _service.GetLayerAttestationsAsync("sha256:unknown");
// Assert
results.Should().BeEmpty();
}
[Fact]
public async Task GetLayerAttestationAsync_Exists_ReturnsResult()
{
// Arrange
await _service.CreateLayerAttestationAsync(
CreateLayerRequest("sha256:image123", "sha256:layer1", 1));
// Act
var result = await _service.GetLayerAttestationAsync("sha256:image123", 1);
// Assert
result.Should().NotBeNull();
result!.LayerOrder.Should().Be(1);
}
[Fact]
public async Task GetLayerAttestationAsync_NotExists_ReturnsNull()
{
// Act
var result = await _service.GetLayerAttestationAsync("sha256:image123", 99);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task VerifyLayerAttestationAsync_ValidAttestation_ReturnsValid()
{
// Arrange
var createResult = await _service.CreateLayerAttestationAsync(
CreateLayerRequest("sha256:image123", "sha256:layer0", 0));
// Act
var verifyResult = await _service.VerifyLayerAttestationAsync(createResult.AttestationId);
// Assert
verifyResult.IsValid.Should().BeTrue();
verifyResult.SignerIdentity.Should().Be("test-signer");
verifyResult.Errors.Should().BeEmpty();
}
[Fact]
public async Task VerifyLayerAttestationAsync_UnknownAttestation_ReturnsInvalid()
{
// Act
var result = await _service.VerifyLayerAttestationAsync("sha256:unknown");
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().NotBeEmpty();
}
[Fact]
public async Task CreateBatchLayerAttestationsAsync_EmptyLayers_ReturnsEmptyResult()
{
// Arrange
var request = new BatchLayerAttestationRequest
{
ImageDigest = "sha256:image123",
ImageRef = "registry.io/app:latest",
Layers = []
};
// Act
var result = await _service.CreateBatchLayerAttestationsAsync(request);
// Assert
result.AllSucceeded.Should().BeTrue();
result.SuccessCount.Should().Be(0);
result.Layers.Should().BeEmpty();
}
private static LayerAttestationRequest CreateLayerRequest(
string imageDigest,
string layerDigest,
int layerOrder)
{
return new LayerAttestationRequest
{
ImageDigest = imageDigest,
LayerDigest = layerDigest,
LayerOrder = layerOrder,
SbomDigest = $"sha256:sbom{layerOrder}",
SbomFormat = "cyclonedx"
};
}
}
[Trait("Category", "Unit")]
public class InMemoryLayerAttestationStoreTests
{
[Fact]
public async Task StoreAsync_NewEntry_StoresSuccessfully()
{
// Arrange
var store = new InMemoryLayerAttestationStore();
var result = CreateResult("sha256:layer0", 0);
// Act
await store.StoreAsync("sha256:image", result);
var retrieved = await store.GetAsync("sha256:image", 0);
// Assert
retrieved.Should().NotBeNull();
retrieved!.LayerDigest.Should().Be("sha256:layer0");
}
[Fact]
public async Task GetByImageAsync_MultipleLayers_ReturnsOrdered()
{
// Arrange
var store = new InMemoryLayerAttestationStore();
await store.StoreAsync("sha256:image", CreateResult("sha256:layer2", 2));
await store.StoreAsync("sha256:image", CreateResult("sha256:layer0", 0));
await store.StoreAsync("sha256:image", CreateResult("sha256:layer1", 1));
// Act
var results = await store.GetByImageAsync("sha256:image");
// Assert
results.Should().HaveCount(3);
results[0].LayerOrder.Should().Be(0);
results[1].LayerOrder.Should().Be(1);
results[2].LayerOrder.Should().Be(2);
}
private static LayerAttestationResult CreateResult(string layerDigest, int layerOrder)
{
return new LayerAttestationResult
{
LayerDigest = layerDigest,
LayerOrder = layerOrder,
AttestationId = $"sha256:att{layerOrder}",
EnvelopeDigest = $"sha256:env{layerOrder}",
Success = true,
CreatedAt = DateTimeOffset.UtcNow
};
}
}

View File

@@ -0,0 +1,243 @@
// -----------------------------------------------------------------------------
// AttestationChain.cs
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
// Task: T002
// Description: Model for ordered attestation chains with validation.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.Core.Chain;
/// <summary>
/// Represents an ordered chain of attestations forming a DAG.
/// </summary>
public sealed record AttestationChain
{
/// <summary>
/// The root attestation ID (typically the final verdict).
/// </summary>
[JsonPropertyName("rootAttestationId")]
[JsonPropertyOrder(0)]
public required string RootAttestationId { get; init; }
/// <summary>
/// The artifact digest this chain attests.
/// </summary>
[JsonPropertyName("artifactDigest")]
[JsonPropertyOrder(1)]
public required string ArtifactDigest { get; init; }
/// <summary>
/// All nodes in the chain, ordered by depth (root first).
/// </summary>
[JsonPropertyName("nodes")]
[JsonPropertyOrder(2)]
public required ImmutableArray<AttestationChainNode> Nodes { get; init; }
/// <summary>
/// All links between attestations in the chain.
/// </summary>
[JsonPropertyName("links")]
[JsonPropertyOrder(3)]
public required ImmutableArray<AttestationLink> Links { get; init; }
/// <summary>
/// Whether the chain is complete (no missing dependencies).
/// </summary>
[JsonPropertyName("isComplete")]
[JsonPropertyOrder(4)]
public required bool IsComplete { get; init; }
/// <summary>
/// When this chain was resolved.
/// </summary>
[JsonPropertyName("resolvedAt")]
[JsonPropertyOrder(5)]
public required DateTimeOffset ResolvedAt { get; init; }
/// <summary>
/// Maximum depth of the chain (0 = root only).
/// </summary>
[JsonPropertyName("maxDepth")]
[JsonPropertyOrder(6)]
public int MaxDepth => Nodes.Length > 0 ? Nodes.Max(n => n.Depth) : 0;
/// <summary>
/// Missing attestation IDs if chain is incomplete.
/// </summary>
[JsonPropertyName("missingAttestations")]
[JsonPropertyOrder(7)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ImmutableArray<string>? MissingAttestations { get; init; }
/// <summary>
/// Chain validation errors if any.
/// </summary>
[JsonPropertyName("validationErrors")]
[JsonPropertyOrder(8)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ImmutableArray<string>? ValidationErrors { get; init; }
/// <summary>
/// Gets all nodes at a specific depth.
/// </summary>
public IEnumerable<AttestationChainNode> GetNodesAtDepth(int depth) =>
Nodes.Where(n => n.Depth == depth);
/// <summary>
/// Gets the direct upstream (parent) attestations for a node.
/// </summary>
public IEnumerable<AttestationChainNode> GetUpstream(string attestationId) =>
Links.Where(l => l.SourceAttestationId == attestationId && l.LinkType == AttestationLinkType.DependsOn)
.Select(l => Nodes.FirstOrDefault(n => n.AttestationId == l.TargetAttestationId))
.Where(n => n is not null)!;
/// <summary>
/// Gets the direct downstream (child) attestations for a node.
/// </summary>
public IEnumerable<AttestationChainNode> GetDownstream(string attestationId) =>
Links.Where(l => l.TargetAttestationId == attestationId && l.LinkType == AttestationLinkType.DependsOn)
.Select(l => Nodes.FirstOrDefault(n => n.AttestationId == l.SourceAttestationId))
.Where(n => n is not null)!;
}
/// <summary>
/// A node in the attestation chain.
/// </summary>
public sealed record AttestationChainNode
{
/// <summary>
/// The attestation ID.
/// Format: sha256:{hash}
/// </summary>
[JsonPropertyName("attestationId")]
[JsonPropertyOrder(0)]
public required string AttestationId { get; init; }
/// <summary>
/// The in-toto predicate type of this attestation.
/// </summary>
[JsonPropertyName("predicateType")]
[JsonPropertyOrder(1)]
public required string PredicateType { get; init; }
/// <summary>
/// The subject digest this attestation refers to.
/// </summary>
[JsonPropertyName("subjectDigest")]
[JsonPropertyOrder(2)]
public required string SubjectDigest { get; init; }
/// <summary>
/// Depth in the chain (0 = root).
/// </summary>
[JsonPropertyName("depth")]
[JsonPropertyOrder(3)]
public required int Depth { get; init; }
/// <summary>
/// When this attestation was created.
/// </summary>
[JsonPropertyName("createdAt")]
[JsonPropertyOrder(4)]
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Signer identity (if available).
/// </summary>
[JsonPropertyName("signer")]
[JsonPropertyOrder(5)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Signer { get; init; }
/// <summary>
/// Human-readable label for display.
/// </summary>
[JsonPropertyName("label")]
[JsonPropertyOrder(6)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Label { get; init; }
/// <summary>
/// Whether this is a layer-specific attestation.
/// </summary>
[JsonPropertyName("isLayerAttestation")]
[JsonPropertyOrder(7)]
public bool IsLayerAttestation { get; init; }
/// <summary>
/// Layer index if this is a layer attestation.
/// </summary>
[JsonPropertyName("layerIndex")]
[JsonPropertyOrder(8)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? LayerIndex { get; init; }
/// <summary>
/// Whether this is a root node (no incoming links).
/// </summary>
[JsonPropertyName("isRoot")]
[JsonPropertyOrder(9)]
public bool IsRoot { get; init; }
/// <summary>
/// Whether this is a leaf node (no outgoing links).
/// </summary>
[JsonPropertyName("isLeaf")]
[JsonPropertyOrder(10)]
public bool IsLeaf { get; init; }
/// <summary>
/// Additional metadata for this node.
/// </summary>
[JsonPropertyName("metadata")]
[JsonPropertyOrder(11)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ImmutableDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to resolve an attestation chain.
/// </summary>
public sealed record AttestationChainRequest
{
/// <summary>
/// The artifact digest to get the chain for.
/// </summary>
public required string ArtifactDigest { get; init; }
/// <summary>
/// Maximum depth to traverse (default: 10).
/// </summary>
public int MaxDepth { get; init; } = 10;
/// <summary>
/// Whether to include layer attestations.
/// </summary>
public bool IncludeLayers { get; init; } = true;
/// <summary>
/// Specific predicate types to include (null = all).
/// </summary>
public ImmutableArray<string>? IncludePredicateTypes { get; init; }
/// <summary>
/// Tenant ID for access control.
/// </summary>
public string? TenantId { get; init; }
}
/// <summary>
/// Common predicate types for StellaOps attestations.
/// </summary>
public static class PredicateTypes
{
public const string SbomAttestation = "StellaOps.SBOMAttestation@1";
public const string VexAttestation = "StellaOps.VEXAttestation@1";
public const string PolicyEvaluation = "StellaOps.PolicyEvaluation@1";
public const string GateResult = "StellaOps.GateResult@1";
public const string ScanResult = "StellaOps.ScanResult@1";
public const string LayerSbom = "StellaOps.LayerSBOM@1";
}

View File

@@ -0,0 +1,345 @@
// -----------------------------------------------------------------------------
// AttestationChainBuilder.cs
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
// Task: T013
// Description: Builds attestation chains by extracting links from in-toto materials.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
namespace StellaOps.Attestor.Core.Chain;
/// <summary>
/// Builds attestation chains by extracting and storing links from attestation materials.
/// </summary>
public sealed class AttestationChainBuilder
{
private readonly IAttestationLinkStore _linkStore;
private readonly AttestationChainValidator _validator;
private readonly TimeProvider _timeProvider;
public AttestationChainBuilder(
IAttestationLinkStore linkStore,
AttestationChainValidator validator,
TimeProvider timeProvider)
{
_linkStore = linkStore;
_validator = validator;
_timeProvider = timeProvider;
}
/// <summary>
/// Extracts and stores links from an attestation's materials.
/// </summary>
/// <param name="attestationId">The source attestation ID.</param>
/// <param name="materials">The in-toto materials from the attestation.</param>
/// <param name="linkType">The type of link to create.</param>
/// <param name="metadata">Optional link metadata.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result of the link extraction.</returns>
public async Task<ChainBuildResult> ExtractLinksAsync(
string attestationId,
IEnumerable<InTotoMaterial> materials,
AttestationLinkType linkType = AttestationLinkType.DependsOn,
LinkMetadata? metadata = null,
CancellationToken cancellationToken = default)
{
var errors = new List<string>();
var linksCreated = new List<AttestationLink>();
var skippedCount = 0;
// Get existing links for validation
var existingLinks = await _linkStore.GetBySourceAsync(attestationId, cancellationToken)
.ConfigureAwait(false);
foreach (var material in materials)
{
// Extract attestation references from materials
var targetId = ExtractAttestationId(material);
if (targetId is null)
{
skippedCount++;
continue;
}
var link = new AttestationLink
{
SourceAttestationId = attestationId,
TargetAttestationId = targetId,
LinkType = linkType,
CreatedAt = _timeProvider.GetUtcNow(),
Metadata = metadata ?? ExtractMetadata(material)
};
// Validate before storing
var validationResult = _validator.ValidateLink(link, existingLinks.ToList());
if (!validationResult.IsValid)
{
foreach (var error in validationResult.Errors)
{
errors.Add($"Link {attestationId} -> {targetId}: {error}");
}
continue;
}
await _linkStore.StoreAsync(link, cancellationToken).ConfigureAwait(false);
linksCreated.Add(link);
// Update existing links for subsequent validations
existingLinks = existingLinks.Add(link);
}
return new ChainBuildResult
{
IsSuccess = errors.Count == 0,
LinksCreated = [.. linksCreated],
SkippedMaterialsCount = skippedCount,
Errors = [.. errors],
BuildCompletedAt = _timeProvider.GetUtcNow()
};
}
/// <summary>
/// Creates a direct link between two attestations.
/// </summary>
public async Task<ChainBuildResult> CreateLinkAsync(
string sourceId,
string targetId,
AttestationLinkType linkType = AttestationLinkType.DependsOn,
LinkMetadata? metadata = null,
CancellationToken cancellationToken = default)
{
// Get all relevant links for validation (from source for duplicates, from target for cycles)
var existingLinks = await GetAllRelevantLinksAsync(sourceId, targetId, cancellationToken)
.ConfigureAwait(false);
var link = new AttestationLink
{
SourceAttestationId = sourceId,
TargetAttestationId = targetId,
LinkType = linkType,
CreatedAt = _timeProvider.GetUtcNow(),
Metadata = metadata
};
var validationResult = _validator.ValidateLink(link, existingLinks);
if (!validationResult.IsValid)
{
return new ChainBuildResult
{
IsSuccess = false,
LinksCreated = [],
SkippedMaterialsCount = 0,
Errors = validationResult.Errors,
BuildCompletedAt = _timeProvider.GetUtcNow()
};
}
await _linkStore.StoreAsync(link, cancellationToken).ConfigureAwait(false);
return new ChainBuildResult
{
IsSuccess = true,
LinksCreated = [link],
SkippedMaterialsCount = 0,
Errors = [],
BuildCompletedAt = _timeProvider.GetUtcNow()
};
}
/// <summary>
/// Creates links for layer attestations.
/// </summary>
public async Task<ChainBuildResult> LinkLayerAttestationsAsync(
string parentAttestationId,
IEnumerable<LayerAttestationRef> layerRefs,
CancellationToken cancellationToken = default)
{
var errors = new List<string>();
var linksCreated = new List<AttestationLink>();
var existingLinks = await _linkStore.GetBySourceAsync(parentAttestationId, cancellationToken)
.ConfigureAwait(false);
foreach (var layerRef in layerRefs.OrderBy(l => l.LayerIndex))
{
var link = new AttestationLink
{
SourceAttestationId = parentAttestationId,
TargetAttestationId = layerRef.AttestationId,
LinkType = AttestationLinkType.DependsOn,
CreatedAt = _timeProvider.GetUtcNow(),
Metadata = new LinkMetadata
{
Reason = $"Layer {layerRef.LayerIndex} attestation",
Annotations = ImmutableDictionary<string, string>.Empty
.Add("layerIndex", layerRef.LayerIndex.ToString())
.Add("layerDigest", layerRef.LayerDigest)
}
};
var validationResult = _validator.ValidateLink(link, existingLinks.ToList());
if (!validationResult.IsValid)
{
errors.AddRange(validationResult.Errors.Select(e =>
$"Layer {layerRef.LayerIndex}: {e}"));
continue;
}
await _linkStore.StoreAsync(link, cancellationToken).ConfigureAwait(false);
linksCreated.Add(link);
existingLinks = existingLinks.Add(link);
}
return new ChainBuildResult
{
IsSuccess = errors.Count == 0,
LinksCreated = [.. linksCreated],
SkippedMaterialsCount = 0,
Errors = [.. errors],
BuildCompletedAt = _timeProvider.GetUtcNow()
};
}
/// <summary>
/// Extracts an attestation ID from a material reference.
/// </summary>
private static string? ExtractAttestationId(InTotoMaterial material)
{
// Check if this is an attestation reference
if (material.Uri.StartsWith(MaterialUriSchemes.Attestation, StringComparison.Ordinal))
{
// Format: attestation:sha256:{hash}
return material.Uri.Substring(MaterialUriSchemes.Attestation.Length);
}
// Check if digest contains attestation reference
if (material.Digest.TryGetValue("attestationId", out var attestationId))
{
return attestationId;
}
return null;
}
/// <summary>
/// Gets all links relevant for validating a new link (for duplicate and cycle detection).
/// Uses BFS to gather links reachable from the target for cycle detection.
/// </summary>
private async Task<List<AttestationLink>> GetAllRelevantLinksAsync(
string sourceId,
string targetId,
CancellationToken cancellationToken)
{
var links = new Dictionary<(string, string), AttestationLink>();
// Get links from source (for duplicate detection)
var sourceLinks = await _linkStore.GetBySourceAsync(sourceId, cancellationToken)
.ConfigureAwait(false);
foreach (var link in sourceLinks)
{
links[(link.SourceAttestationId, link.TargetAttestationId)] = link;
}
// BFS from target to gather links for cycle detection
var visited = new HashSet<string>();
var queue = new Queue<string>();
queue.Enqueue(targetId);
while (queue.Count > 0)
{
var current = queue.Dequeue();
if (!visited.Add(current))
{
continue;
}
var outgoing = await _linkStore.GetBySourceAsync(current, cancellationToken)
.ConfigureAwait(false);
foreach (var link in outgoing)
{
links[(link.SourceAttestationId, link.TargetAttestationId)] = link;
if (!visited.Contains(link.TargetAttestationId))
{
queue.Enqueue(link.TargetAttestationId);
}
}
}
return [.. links.Values];
}
/// <summary>
/// Extracts metadata from a material.
/// </summary>
private static LinkMetadata? ExtractMetadata(InTotoMaterial material)
{
if (material.Annotations is null || material.Annotations.Count == 0)
{
return null;
}
var reason = material.Annotations.TryGetValue("predicateType", out var predType)
? $"Depends on {predType}"
: null;
return new LinkMetadata
{
Reason = reason,
Annotations = material.Annotations
};
}
}
/// <summary>
/// Result of building chain links.
/// </summary>
public sealed record ChainBuildResult
{
/// <summary>
/// Whether all links were created successfully.
/// </summary>
public required bool IsSuccess { get; init; }
/// <summary>
/// Links that were created.
/// </summary>
public required ImmutableArray<AttestationLink> LinksCreated { get; init; }
/// <summary>
/// Number of materials skipped (not attestation references).
/// </summary>
public required int SkippedMaterialsCount { get; init; }
/// <summary>
/// Errors encountered during link creation.
/// </summary>
public required ImmutableArray<string> Errors { get; init; }
/// <summary>
/// When the build completed.
/// </summary>
public required DateTimeOffset BuildCompletedAt { get; init; }
}
/// <summary>
/// Reference to a layer attestation.
/// </summary>
public sealed record LayerAttestationRef
{
/// <summary>
/// The layer index (0-based).
/// </summary>
public required int LayerIndex { get; init; }
/// <summary>
/// The layer digest.
/// </summary>
public required string LayerDigest { get; init; }
/// <summary>
/// The attestation ID for this layer.
/// </summary>
public required string AttestationId { get; init; }
}

View File

@@ -0,0 +1,334 @@
// -----------------------------------------------------------------------------
// AttestationChainValidator.cs
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
// Task: T005
// Description: Validates attestation chain structure (DAG, no cycles).
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
namespace StellaOps.Attestor.Core.Chain;
/// <summary>
/// Validates attestation chain structure.
/// </summary>
public sealed class AttestationChainValidator
{
private readonly TimeProvider _timeProvider;
public AttestationChainValidator(TimeProvider timeProvider)
{
_timeProvider = timeProvider;
}
/// <summary>
/// Validates a proposed link before insertion.
/// </summary>
/// <param name="link">The link to validate.</param>
/// <param name="existingLinks">All existing links.</param>
/// <returns>Validation result.</returns>
public ChainValidationResult ValidateLink(
AttestationLink link,
IReadOnlyList<AttestationLink> existingLinks)
{
var errors = new List<string>();
// Check self-link
if (link.SourceAttestationId == link.TargetAttestationId)
{
errors.Add("Self-links are not allowed");
}
// Check for duplicate link
if (existingLinks.Any(l =>
l.SourceAttestationId == link.SourceAttestationId &&
l.TargetAttestationId == link.TargetAttestationId))
{
errors.Add("Duplicate link already exists");
}
// Check for circular reference
if (WouldCreateCycle(link, existingLinks))
{
errors.Add("Link would create a circular reference");
}
return new ChainValidationResult
{
IsValid = errors.Count == 0,
Errors = [.. errors],
ValidatedAt = _timeProvider.GetUtcNow()
};
}
/// <summary>
/// Validates an entire chain structure.
/// </summary>
/// <param name="chain">The chain to validate.</param>
/// <returns>Validation result.</returns>
public ChainValidationResult ValidateChain(AttestationChain chain)
{
var errors = new List<string>();
// Check for empty chain
if (chain.Nodes.Length == 0)
{
errors.Add("Chain has no nodes");
return new ChainValidationResult
{
IsValid = false,
Errors = [.. errors],
ValidatedAt = _timeProvider.GetUtcNow()
};
}
// Check root exists
if (!chain.Nodes.Any(n => n.AttestationId == chain.RootAttestationId))
{
errors.Add("Root attestation not found in chain nodes");
}
// Check for duplicate nodes
var nodeIds = chain.Nodes.Select(n => n.AttestationId).ToList();
var duplicateNodes = nodeIds.GroupBy(id => id).Where(g => g.Count() > 1).Select(g => g.Key).ToList();
if (duplicateNodes.Count > 0)
{
errors.Add($"Duplicate nodes found: {string.Join(", ", duplicateNodes)}");
}
// Check all link targets exist in nodes
var nodeIdSet = nodeIds.ToHashSet();
foreach (var link in chain.Links)
{
if (!nodeIdSet.Contains(link.SourceAttestationId))
{
errors.Add($"Link source {link.SourceAttestationId} not found in nodes");
}
if (!nodeIdSet.Contains(link.TargetAttestationId))
{
errors.Add($"Link target {link.TargetAttestationId} not found in nodes");
}
}
// Check for cycles in the chain
if (HasCycles(chain.Links.ToList()))
{
errors.Add("Chain contains circular references");
}
// Check depth consistency
if (!ValidateDepths(chain))
{
errors.Add("Node depths are inconsistent with link structure");
}
return new ChainValidationResult
{
IsValid = errors.Count == 0,
Errors = [.. errors],
ValidatedAt = _timeProvider.GetUtcNow()
};
}
/// <summary>
/// Checks if adding a link would create a cycle.
/// </summary>
private static bool WouldCreateCycle(
AttestationLink newLink,
IReadOnlyList<AttestationLink> existingLinks)
{
// Check if there's already a path from target to source
// If so, adding source -> target would create a cycle
var visited = new HashSet<string>();
var queue = new Queue<string>();
queue.Enqueue(newLink.TargetAttestationId);
while (queue.Count > 0)
{
var current = queue.Dequeue();
if (current == newLink.SourceAttestationId)
{
return true; // Found path from target back to source
}
if (!visited.Add(current))
{
continue; // Already visited
}
// Follow outgoing links from current
foreach (var link in existingLinks.Where(l => l.SourceAttestationId == current))
{
queue.Enqueue(link.TargetAttestationId);
}
}
return false;
}
/// <summary>
/// Checks if the links contain any cycles.
/// </summary>
private static bool HasCycles(IReadOnlyList<AttestationLink> links)
{
// Build adjacency list
var adjacency = new Dictionary<string, List<string>>();
var allNodes = new HashSet<string>();
foreach (var link in links)
{
allNodes.Add(link.SourceAttestationId);
allNodes.Add(link.TargetAttestationId);
if (!adjacency.ContainsKey(link.SourceAttestationId))
{
adjacency[link.SourceAttestationId] = [];
}
adjacency[link.SourceAttestationId].Add(link.TargetAttestationId);
}
// DFS to detect cycles
var white = new HashSet<string>(allNodes); // Not visited
var gray = new HashSet<string>(); // In progress
var black = new HashSet<string>(); // Completed
foreach (var node in allNodes)
{
if (white.Contains(node))
{
if (HasCycleDfs(node, adjacency, white, gray, black))
{
return true;
}
}
}
return false;
}
private static bool HasCycleDfs(
string node,
Dictionary<string, List<string>> adjacency,
HashSet<string> white,
HashSet<string> gray,
HashSet<string> black)
{
white.Remove(node);
gray.Add(node);
if (adjacency.TryGetValue(node, out var neighbors))
{
foreach (var neighbor in neighbors)
{
if (black.Contains(neighbor))
{
continue; // Already fully explored
}
if (gray.Contains(neighbor))
{
return true; // Back edge = cycle
}
if (HasCycleDfs(neighbor, adjacency, white, gray, black))
{
return true;
}
}
}
gray.Remove(node);
black.Add(node);
return false;
}
/// <summary>
/// Validates that node depths are consistent with link structure.
/// </summary>
private static bool ValidateDepths(AttestationChain chain)
{
// Root should be at depth 0
var root = chain.Nodes.FirstOrDefault(n => n.AttestationId == chain.RootAttestationId);
if (root is null || root.Depth != 0)
{
return false;
}
// Build expected depths from links
var expectedDepths = new Dictionary<string, int> { [chain.RootAttestationId] = 0 };
var queue = new Queue<string>();
queue.Enqueue(chain.RootAttestationId);
while (queue.Count > 0)
{
var current = queue.Dequeue();
var currentDepth = expectedDepths[current];
// Find all targets (dependencies) of current
foreach (var link in chain.Links.Where(l =>
l.SourceAttestationId == current &&
l.LinkType == AttestationLinkType.DependsOn))
{
var targetDepth = currentDepth + 1;
if (expectedDepths.TryGetValue(link.TargetAttestationId, out var existingDepth))
{
// If already assigned a depth, take the minimum
if (targetDepth < existingDepth)
{
expectedDepths[link.TargetAttestationId] = targetDepth;
}
}
else
{
expectedDepths[link.TargetAttestationId] = targetDepth;
queue.Enqueue(link.TargetAttestationId);
}
}
}
// Verify actual depths match expected
foreach (var node in chain.Nodes)
{
if (expectedDepths.TryGetValue(node.AttestationId, out var expectedDepth))
{
if (node.Depth != expectedDepth)
{
return false;
}
}
}
return true;
}
}
/// <summary>
/// Result of chain validation.
/// </summary>
public sealed record ChainValidationResult
{
/// <summary>
/// Whether validation passed.
/// </summary>
public required bool IsValid { get; init; }
/// <summary>
/// Validation errors if any.
/// </summary>
public required ImmutableArray<string> Errors { get; init; }
/// <summary>
/// When validation was performed.
/// </summary>
public required DateTimeOffset ValidatedAt { get; init; }
/// <summary>
/// Creates a successful validation result.
/// </summary>
public static ChainValidationResult Success(DateTimeOffset validatedAt) => new()
{
IsValid = true,
Errors = [],
ValidatedAt = validatedAt
};
}

View File

@@ -0,0 +1,143 @@
// -----------------------------------------------------------------------------
// AttestationLink.cs
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
// Task: T001
// Description: Model for links between attestations in a chain.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.Core.Chain;
/// <summary>
/// Represents a link between two attestations in an attestation chain.
/// </summary>
public sealed record AttestationLink
{
/// <summary>
/// The attestation ID of the source (dependent) attestation.
/// Format: sha256:{hash}
/// </summary>
[JsonPropertyName("sourceAttestationId")]
[JsonPropertyOrder(0)]
public required string SourceAttestationId { get; init; }
/// <summary>
/// The attestation ID of the target (dependency) attestation.
/// Format: sha256:{hash}
/// </summary>
[JsonPropertyName("targetAttestationId")]
[JsonPropertyOrder(1)]
public required string TargetAttestationId { get; init; }
/// <summary>
/// The type of relationship between the attestations.
/// </summary>
[JsonPropertyName("linkType")]
[JsonPropertyOrder(2)]
public required AttestationLinkType LinkType { get; init; }
/// <summary>
/// When this link was created.
/// </summary>
[JsonPropertyName("createdAt")]
[JsonPropertyOrder(3)]
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Optional metadata about the link.
/// </summary>
[JsonPropertyName("metadata")]
[JsonPropertyOrder(4)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public LinkMetadata? Metadata { get; init; }
}
/// <summary>
/// Types of links between attestations.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<AttestationLinkType>))]
public enum AttestationLinkType
{
/// <summary>
/// Target is a material/dependency for source.
/// Source attestation depends on target attestation.
/// </summary>
DependsOn,
/// <summary>
/// Source supersedes target (version update, correction).
/// Target is the previous version.
/// </summary>
Supersedes,
/// <summary>
/// Source aggregates multiple targets (batch attestation).
/// </summary>
Aggregates,
/// <summary>
/// Source is derived from target (transformation).
/// </summary>
DerivedFrom,
/// <summary>
/// Source verifies/validates target.
/// </summary>
Verifies
}
/// <summary>
/// Optional metadata for an attestation link.
/// </summary>
public sealed record LinkMetadata
{
/// <summary>
/// Human-readable description of the link.
/// </summary>
[JsonPropertyName("description")]
[JsonPropertyOrder(0)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Description { get; init; }
/// <summary>
/// Reason for creating this link.
/// </summary>
[JsonPropertyName("reason")]
[JsonPropertyOrder(1)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Reason { get; init; }
/// <summary>
/// The predicate type of the source attestation.
/// </summary>
[JsonPropertyName("sourcePredicateType")]
[JsonPropertyOrder(2)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? SourcePredicateType { get; init; }
/// <summary>
/// The predicate type of the target attestation.
/// </summary>
[JsonPropertyName("targetPredicateType")]
[JsonPropertyOrder(3)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? TargetPredicateType { get; init; }
/// <summary>
/// Who or what created this link.
/// </summary>
[JsonPropertyName("createdBy")]
[JsonPropertyOrder(4)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? CreatedBy { get; init; }
/// <summary>
/// Additional annotations for the link.
/// </summary>
[JsonPropertyName("annotations")]
[JsonPropertyOrder(5)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ImmutableDictionary<string, string>? Annotations { get; init; }
}

View File

@@ -0,0 +1,564 @@
// -----------------------------------------------------------------------------
// AttestationLinkResolver.cs
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
// Task: T008
// Description: Resolves attestation chains by traversing links.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
namespace StellaOps.Attestor.Core.Chain;
/// <summary>
/// Resolves attestation chains by traversing links in storage.
/// </summary>
public sealed class AttestationLinkResolver : IAttestationLinkResolver
{
private readonly IAttestationLinkStore _linkStore;
private readonly IAttestationNodeProvider _nodeProvider;
private readonly TimeProvider _timeProvider;
public AttestationLinkResolver(
IAttestationLinkStore linkStore,
IAttestationNodeProvider nodeProvider,
TimeProvider timeProvider)
{
_linkStore = linkStore;
_nodeProvider = nodeProvider;
_timeProvider = timeProvider;
}
/// <inheritdoc />
public async Task<AttestationChain> ResolveChainAsync(
AttestationChainRequest request,
CancellationToken cancellationToken = default)
{
// Find the root attestation for this artifact
var root = await FindRootAttestationAsync(request.ArtifactDigest, cancellationToken)
.ConfigureAwait(false);
if (root is null)
{
return new AttestationChain
{
RootAttestationId = string.Empty,
ArtifactDigest = request.ArtifactDigest,
Nodes = [],
Links = [],
IsComplete = false,
ResolvedAt = _timeProvider.GetUtcNow(),
ValidationErrors = ["No root attestation found for artifact"]
};
}
// Traverse the chain
var nodes = new Dictionary<string, AttestationChainNode>();
var links = new List<AttestationLink>();
var missingIds = new List<string>();
var queue = new Queue<(string AttestationId, int Depth)>();
nodes[root.AttestationId] = root;
queue.Enqueue((root.AttestationId, 0));
while (queue.Count > 0)
{
var (currentId, depth) = queue.Dequeue();
if (depth >= request.MaxDepth)
{
continue;
}
// Get outgoing links (dependencies)
var outgoingLinks = await _linkStore.GetBySourceAsync(currentId, cancellationToken)
.ConfigureAwait(false);
foreach (var link in outgoingLinks)
{
// Filter by predicate types if specified
if (link.LinkType != AttestationLinkType.DependsOn)
{
continue;
}
links.Add(link);
if (!nodes.ContainsKey(link.TargetAttestationId))
{
var targetNode = await _nodeProvider.GetNodeAsync(
link.TargetAttestationId,
cancellationToken).ConfigureAwait(false);
if (targetNode is not null)
{
// Skip layer attestations if not requested
if (!request.IncludeLayers && targetNode.IsLayerAttestation)
{
continue;
}
// Filter by predicate type if specified
if (request.IncludePredicateTypes is { } types &&
!types.Contains(targetNode.PredicateType))
{
continue;
}
var nodeWithDepth = targetNode with { Depth = depth + 1 };
nodes[link.TargetAttestationId] = nodeWithDepth;
queue.Enqueue((link.TargetAttestationId, depth + 1));
}
else
{
missingIds.Add(link.TargetAttestationId);
}
}
}
}
// Sort nodes by depth
var sortedNodes = nodes.Values
.OrderBy(n => n.Depth)
.ThenBy(n => n.AttestationId)
.ToImmutableArray();
return new AttestationChain
{
RootAttestationId = root.AttestationId,
ArtifactDigest = request.ArtifactDigest,
Nodes = sortedNodes,
Links = [.. links.Distinct()],
IsComplete = missingIds.Count == 0,
ResolvedAt = _timeProvider.GetUtcNow(),
MissingAttestations = missingIds.Count > 0 ? [.. missingIds] : null
};
}
/// <inheritdoc />
public async Task<ImmutableArray<AttestationChainNode>> GetUpstreamAsync(
string attestationId,
int maxDepth = 10,
CancellationToken cancellationToken = default)
{
var nodes = new Dictionary<string, AttestationChainNode>();
var queue = new Queue<(string AttestationId, int Depth)>();
queue.Enqueue((attestationId, 0));
while (queue.Count > 0)
{
var (currentId, depth) = queue.Dequeue();
if (depth >= maxDepth)
{
continue;
}
// Get incoming links (dependents - those that depend on this)
var incomingLinks = await _linkStore.GetByTargetAsync(currentId, cancellationToken)
.ConfigureAwait(false);
foreach (var link in incomingLinks.Where(l => l.LinkType == AttestationLinkType.DependsOn))
{
if (!nodes.ContainsKey(link.SourceAttestationId) && link.SourceAttestationId != attestationId)
{
var node = await _nodeProvider.GetNodeAsync(link.SourceAttestationId, cancellationToken)
.ConfigureAwait(false);
if (node is not null)
{
nodes[link.SourceAttestationId] = node with { Depth = depth + 1 };
queue.Enqueue((link.SourceAttestationId, depth + 1));
}
}
}
}
return [.. nodes.Values.OrderBy(n => n.Depth).ThenBy(n => n.AttestationId)];
}
/// <inheritdoc />
public async Task<ImmutableArray<AttestationChainNode>> GetDownstreamAsync(
string attestationId,
int maxDepth = 10,
CancellationToken cancellationToken = default)
{
var nodes = new Dictionary<string, AttestationChainNode>();
var queue = new Queue<(string AttestationId, int Depth)>();
queue.Enqueue((attestationId, 0));
while (queue.Count > 0)
{
var (currentId, depth) = queue.Dequeue();
if (depth >= maxDepth)
{
continue;
}
// Get outgoing links (dependencies)
var outgoingLinks = await _linkStore.GetBySourceAsync(currentId, cancellationToken)
.ConfigureAwait(false);
foreach (var link in outgoingLinks.Where(l => l.LinkType == AttestationLinkType.DependsOn))
{
if (!nodes.ContainsKey(link.TargetAttestationId) && link.TargetAttestationId != attestationId)
{
var node = await _nodeProvider.GetNodeAsync(link.TargetAttestationId, cancellationToken)
.ConfigureAwait(false);
if (node is not null)
{
nodes[link.TargetAttestationId] = node with { Depth = depth + 1 };
queue.Enqueue((link.TargetAttestationId, depth + 1));
}
}
}
}
return [.. nodes.Values.OrderBy(n => n.Depth).ThenBy(n => n.AttestationId)];
}
/// <inheritdoc />
public async Task<ImmutableArray<AttestationLink>> GetLinksAsync(
string attestationId,
LinkDirection direction = LinkDirection.Both,
CancellationToken cancellationToken = default)
{
var links = new List<AttestationLink>();
if (direction is LinkDirection.Outgoing or LinkDirection.Both)
{
var outgoing = await _linkStore.GetBySourceAsync(attestationId, cancellationToken)
.ConfigureAwait(false);
links.AddRange(outgoing);
}
if (direction is LinkDirection.Incoming or LinkDirection.Both)
{
var incoming = await _linkStore.GetByTargetAsync(attestationId, cancellationToken)
.ConfigureAwait(false);
links.AddRange(incoming);
}
return [.. links.Distinct()];
}
/// <inheritdoc />
public async Task<AttestationChainNode?> FindRootAttestationAsync(
string artifactDigest,
CancellationToken cancellationToken = default)
{
return await _nodeProvider.FindRootByArtifactAsync(artifactDigest, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> AreLinkedAsync(
string sourceId,
string targetId,
CancellationToken cancellationToken = default)
{
// Check direct link first
if (await _linkStore.ExistsAsync(sourceId, targetId, cancellationToken).ConfigureAwait(false))
{
return true;
}
// Check indirect path via BFS
var visited = new HashSet<string>();
var queue = new Queue<string>();
queue.Enqueue(sourceId);
while (queue.Count > 0)
{
var current = queue.Dequeue();
if (!visited.Add(current))
{
continue;
}
var outgoing = await _linkStore.GetBySourceAsync(current, cancellationToken)
.ConfigureAwait(false);
foreach (var link in outgoing)
{
if (link.TargetAttestationId == targetId)
{
return true;
}
if (!visited.Contains(link.TargetAttestationId))
{
queue.Enqueue(link.TargetAttestationId);
}
}
}
return false;
}
/// <inheritdoc />
public async Task<AttestationChain?> ResolveUpstreamAsync(
string attestationId,
int maxDepth = 5,
CancellationToken cancellationToken = default)
{
var startNode = await _nodeProvider.GetNodeAsync(attestationId, cancellationToken)
.ConfigureAwait(false);
if (startNode is null)
{
return null;
}
var nodes = new Dictionary<string, AttestationChainNode>
{
[attestationId] = startNode with { Depth = 0, IsRoot = false }
};
var links = new List<AttestationLink>();
var queue = new Queue<(string AttestationId, int Depth)>();
queue.Enqueue((attestationId, 0));
while (queue.Count > 0)
{
var (currentId, depth) = queue.Dequeue();
if (depth >= maxDepth)
{
continue;
}
// Get incoming links (those that depend on this attestation)
var incomingLinks = await _linkStore.GetByTargetAsync(currentId, cancellationToken)
.ConfigureAwait(false);
foreach (var link in incomingLinks)
{
links.Add(link);
if (!nodes.ContainsKey(link.SourceAttestationId))
{
var node = await _nodeProvider.GetNodeAsync(link.SourceAttestationId, cancellationToken)
.ConfigureAwait(false);
if (node is not null)
{
nodes[link.SourceAttestationId] = node with { Depth = depth + 1 };
queue.Enqueue((link.SourceAttestationId, depth + 1));
}
}
}
}
return BuildChainFromNodes(startNode, nodes, links);
}
/// <inheritdoc />
public async Task<AttestationChain?> ResolveDownstreamAsync(
string attestationId,
int maxDepth = 5,
CancellationToken cancellationToken = default)
{
var startNode = await _nodeProvider.GetNodeAsync(attestationId, cancellationToken)
.ConfigureAwait(false);
if (startNode is null)
{
return null;
}
var nodes = new Dictionary<string, AttestationChainNode>
{
[attestationId] = startNode with { Depth = 0, IsRoot = true }
};
var links = new List<AttestationLink>();
var queue = new Queue<(string AttestationId, int Depth)>();
queue.Enqueue((attestationId, 0));
while (queue.Count > 0)
{
var (currentId, depth) = queue.Dequeue();
if (depth >= maxDepth)
{
continue;
}
// Get outgoing links (dependencies)
var outgoingLinks = await _linkStore.GetBySourceAsync(currentId, cancellationToken)
.ConfigureAwait(false);
foreach (var link in outgoingLinks)
{
links.Add(link);
if (!nodes.ContainsKey(link.TargetAttestationId))
{
var node = await _nodeProvider.GetNodeAsync(link.TargetAttestationId, cancellationToken)
.ConfigureAwait(false);
if (node is not null)
{
nodes[link.TargetAttestationId] = node with { Depth = depth + 1 };
queue.Enqueue((link.TargetAttestationId, depth + 1));
}
}
}
}
return BuildChainFromNodes(startNode, nodes, links);
}
/// <inheritdoc />
public async Task<AttestationChain?> ResolveFullChainAsync(
string attestationId,
int maxDepth = 5,
CancellationToken cancellationToken = default)
{
var startNode = await _nodeProvider.GetNodeAsync(attestationId, cancellationToken)
.ConfigureAwait(false);
if (startNode is null)
{
return null;
}
var nodes = new Dictionary<string, AttestationChainNode>
{
[attestationId] = startNode with { Depth = 0 }
};
var links = new List<AttestationLink>();
var visited = new HashSet<string>();
var queue = new Queue<(string AttestationId, int Depth, bool IsUpstream)>();
// Traverse both directions
queue.Enqueue((attestationId, 0, true)); // Upstream
queue.Enqueue((attestationId, 0, false)); // Downstream
while (queue.Count > 0)
{
var (currentId, depth, isUpstream) = queue.Dequeue();
var visitKey = $"{currentId}:{(isUpstream ? "up" : "down")}";
if (!visited.Add(visitKey) || depth >= maxDepth)
{
continue;
}
if (isUpstream)
{
// Get incoming links
var incomingLinks = await _linkStore.GetByTargetAsync(currentId, cancellationToken)
.ConfigureAwait(false);
foreach (var link in incomingLinks)
{
if (!links.Any(l => l.SourceAttestationId == link.SourceAttestationId &&
l.TargetAttestationId == link.TargetAttestationId))
{
links.Add(link);
}
if (!nodes.ContainsKey(link.SourceAttestationId))
{
var node = await _nodeProvider.GetNodeAsync(link.SourceAttestationId, cancellationToken)
.ConfigureAwait(false);
if (node is not null)
{
nodes[link.SourceAttestationId] = node with { Depth = depth + 1 };
queue.Enqueue((link.SourceAttestationId, depth + 1, true));
}
}
}
}
else
{
// Get outgoing links
var outgoingLinks = await _linkStore.GetBySourceAsync(currentId, cancellationToken)
.ConfigureAwait(false);
foreach (var link in outgoingLinks)
{
if (!links.Any(l => l.SourceAttestationId == link.SourceAttestationId &&
l.TargetAttestationId == link.TargetAttestationId))
{
links.Add(link);
}
if (!nodes.ContainsKey(link.TargetAttestationId))
{
var node = await _nodeProvider.GetNodeAsync(link.TargetAttestationId, cancellationToken)
.ConfigureAwait(false);
if (node is not null)
{
nodes[link.TargetAttestationId] = node with { Depth = depth + 1 };
queue.Enqueue((link.TargetAttestationId, depth + 1, false));
}
}
}
}
}
return BuildChainFromNodes(startNode, nodes, links);
}
private AttestationChain BuildChainFromNodes(
AttestationChainNode startNode,
Dictionary<string, AttestationChainNode> nodes,
List<AttestationLink> links)
{
// Determine root and leaf nodes
var sourceIds = links.Select(l => l.SourceAttestationId).ToHashSet();
var targetIds = links.Select(l => l.TargetAttestationId).ToHashSet();
var updatedNodes = nodes.Values.Select(n =>
{
var hasIncoming = targetIds.Contains(n.AttestationId);
var hasOutgoing = sourceIds.Contains(n.AttestationId);
return n with
{
IsRoot = !hasIncoming || n.AttestationId == startNode.AttestationId,
IsLeaf = !hasOutgoing
};
}).OrderBy(n => n.Depth).ThenBy(n => n.AttestationId).ToImmutableArray();
return new AttestationChain
{
RootAttestationId = startNode.AttestationId,
ArtifactDigest = startNode.SubjectDigest,
Nodes = updatedNodes,
Links = [.. links.Distinct()],
IsComplete = true,
ResolvedAt = _timeProvider.GetUtcNow()
};
}
}
/// <summary>
/// Provides attestation node information for chain resolution.
/// </summary>
public interface IAttestationNodeProvider
{
/// <summary>
/// Gets an attestation node by ID.
/// </summary>
Task<AttestationChainNode?> GetNodeAsync(
string attestationId,
CancellationToken cancellationToken = default);
/// <summary>
/// Finds the root attestation for an artifact.
/// </summary>
Task<AttestationChainNode?> FindRootByArtifactAsync(
string artifactDigest,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all attestation nodes for a subject digest.
/// </summary>
Task<IReadOnlyList<AttestationChainNode>> GetBySubjectAsync(
string subjectDigest,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,61 @@
// -----------------------------------------------------------------------------
// DependencyInjectionRoutine.cs
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
// Description: DI registration for attestation chain services.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Attestor.Core.Chain;
/// <summary>
/// Dependency injection extensions for attestation chain services.
/// </summary>
public static class ChainDependencyInjectionRoutine
{
/// <summary>
/// Adds attestation chain services with in-memory stores (for testing/development).
/// </summary>
public static IServiceCollection AddAttestationChainInMemory(this IServiceCollection services)
{
services.TryAddSingleton(TimeProvider.System);
services.TryAddSingleton<InMemoryAttestationLinkStore>();
services.TryAddSingleton<IAttestationLinkStore>(sp => sp.GetRequiredService<InMemoryAttestationLinkStore>());
services.TryAddSingleton<InMemoryAttestationNodeProvider>();
services.TryAddSingleton<IAttestationNodeProvider>(sp => sp.GetRequiredService<InMemoryAttestationNodeProvider>());
services.TryAddSingleton<IAttestationLinkResolver, AttestationLinkResolver>();
services.TryAddSingleton<AttestationChainValidator>();
services.TryAddSingleton<AttestationChainBuilder>();
return services;
}
/// <summary>
/// Adds attestation chain validation services.
/// </summary>
public static IServiceCollection AddAttestationChainValidation(this IServiceCollection services)
{
services.TryAddSingleton(TimeProvider.System);
services.TryAddSingleton<AttestationChainValidator>();
return services;
}
/// <summary>
/// Adds attestation chain resolver with custom stores.
/// </summary>
public static IServiceCollection AddAttestationChainResolver<TLinkStore, TNodeProvider>(
this IServiceCollection services)
where TLinkStore : class, IAttestationLinkStore
where TNodeProvider : class, IAttestationNodeProvider
{
services.TryAddSingleton(TimeProvider.System);
services.TryAddSingleton<IAttestationLinkStore, TLinkStore>();
services.TryAddSingleton<IAttestationNodeProvider, TNodeProvider>();
services.TryAddSingleton<IAttestationLinkResolver, AttestationLinkResolver>();
services.TryAddSingleton<AttestationChainValidator>();
return services;
}
}

View File

@@ -0,0 +1,194 @@
// -----------------------------------------------------------------------------
// IAttestationLinkResolver.cs
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
// Task: T004
// Description: Interface for resolving attestation chains from any point.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
namespace StellaOps.Attestor.Core.Chain;
/// <summary>
/// Resolves attestation chains from storage.
/// </summary>
public interface IAttestationLinkResolver
{
/// <summary>
/// Resolves the full attestation chain for an artifact.
/// </summary>
/// <param name="request">Chain resolution request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Resolved attestation chain.</returns>
Task<AttestationChain> ResolveChainAsync(
AttestationChainRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all upstream (parent) attestations for an attestation.
/// </summary>
/// <param name="attestationId">The attestation ID.</param>
/// <param name="maxDepth">Maximum depth to traverse.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of upstream attestation nodes.</returns>
Task<ImmutableArray<AttestationChainNode>> GetUpstreamAsync(
string attestationId,
int maxDepth = 10,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all downstream (child) attestations for an attestation.
/// </summary>
/// <param name="attestationId">The attestation ID.</param>
/// <param name="maxDepth">Maximum depth to traverse.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of downstream attestation nodes.</returns>
Task<ImmutableArray<AttestationChainNode>> GetDownstreamAsync(
string attestationId,
int maxDepth = 10,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all links for an attestation.
/// </summary>
/// <param name="attestationId">The attestation ID.</param>
/// <param name="direction">Direction of links to return.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of attestation links.</returns>
Task<ImmutableArray<AttestationLink>> GetLinksAsync(
string attestationId,
LinkDirection direction = LinkDirection.Both,
CancellationToken cancellationToken = default);
/// <summary>
/// Finds the root attestation for an artifact.
/// </summary>
/// <param name="artifactDigest">The artifact digest.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The root attestation node, or null if not found.</returns>
Task<AttestationChainNode?> FindRootAttestationAsync(
string artifactDigest,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if two attestations are linked (directly or indirectly).
/// </summary>
/// <param name="sourceId">Source attestation ID.</param>
/// <param name="targetId">Target attestation ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if linked, false otherwise.</returns>
Task<bool> AreLinkedAsync(
string sourceId,
string targetId,
CancellationToken cancellationToken = default);
/// <summary>
/// Resolves the upstream chain starting from an attestation.
/// </summary>
/// <param name="attestationId">The starting attestation ID.</param>
/// <param name="maxDepth">Maximum traversal depth.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Chain containing upstream attestations, or null if not found.</returns>
Task<AttestationChain?> ResolveUpstreamAsync(
string attestationId,
int maxDepth = 5,
CancellationToken cancellationToken = default);
/// <summary>
/// Resolves the downstream chain starting from an attestation.
/// </summary>
/// <param name="attestationId">The starting attestation ID.</param>
/// <param name="maxDepth">Maximum traversal depth.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Chain containing downstream attestations, or null if not found.</returns>
Task<AttestationChain?> ResolveDownstreamAsync(
string attestationId,
int maxDepth = 5,
CancellationToken cancellationToken = default);
/// <summary>
/// Resolves the full chain (both directions) starting from an attestation.
/// </summary>
/// <param name="attestationId">The starting attestation ID.</param>
/// <param name="maxDepth">Maximum traversal depth in each direction.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Chain containing all related attestations, or null if not found.</returns>
Task<AttestationChain?> ResolveFullChainAsync(
string attestationId,
int maxDepth = 5,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Direction for querying links.
/// </summary>
public enum LinkDirection
{
/// <summary>
/// Get links where this attestation is the source (outgoing).
/// </summary>
Outgoing,
/// <summary>
/// Get links where this attestation is the target (incoming).
/// </summary>
Incoming,
/// <summary>
/// Get all links (both directions).
/// </summary>
Both
}
/// <summary>
/// Store for attestation links.
/// </summary>
public interface IAttestationLinkStore
{
/// <summary>
/// Stores a link between attestations.
/// </summary>
Task StoreAsync(AttestationLink link, CancellationToken cancellationToken = default);
/// <summary>
/// Stores multiple links.
/// </summary>
Task StoreBatchAsync(IEnumerable<AttestationLink> links, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all links where the attestation is the source.
/// </summary>
Task<ImmutableArray<AttestationLink>> GetBySourceAsync(
string sourceAttestationId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all links where the attestation is the target.
/// </summary>
Task<ImmutableArray<AttestationLink>> GetByTargetAsync(
string targetAttestationId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a specific link by source and target.
/// </summary>
Task<AttestationLink?> GetAsync(
string sourceId,
string targetId,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if a link exists.
/// </summary>
Task<bool> ExistsAsync(
string sourceId,
string targetId,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes all links for an attestation.
/// </summary>
Task DeleteByAttestationAsync(
string attestationId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,169 @@
// -----------------------------------------------------------------------------
// InMemoryAttestationLinkStore.cs
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
// Task: T007
// Description: In-memory implementation of attestation link store.
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using System.Collections.Immutable;
namespace StellaOps.Attestor.Core.Chain;
/// <summary>
/// In-memory implementation of <see cref="IAttestationLinkStore"/>.
/// Suitable for testing and single-instance scenarios.
/// </summary>
public sealed class InMemoryAttestationLinkStore : IAttestationLinkStore
{
private readonly ConcurrentDictionary<(string Source, string Target), AttestationLink> _links = new();
private readonly ConcurrentDictionary<string, ConcurrentBag<AttestationLink>> _bySource = new();
private readonly ConcurrentDictionary<string, ConcurrentBag<AttestationLink>> _byTarget = new();
/// <inheritdoc />
public Task StoreAsync(AttestationLink link, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var key = (link.SourceAttestationId, link.TargetAttestationId);
if (_links.TryAdd(key, link))
{
// Add to source index
var sourceBag = _bySource.GetOrAdd(link.SourceAttestationId, _ => []);
sourceBag.Add(link);
// Add to target index
var targetBag = _byTarget.GetOrAdd(link.TargetAttestationId, _ => []);
targetBag.Add(link);
}
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task StoreBatchAsync(IEnumerable<AttestationLink> links, CancellationToken cancellationToken = default)
{
foreach (var link in links)
{
cancellationToken.ThrowIfCancellationRequested();
await StoreAsync(link, cancellationToken).ConfigureAwait(false);
}
}
/// <inheritdoc />
public Task<ImmutableArray<AttestationLink>> GetBySourceAsync(
string sourceAttestationId,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (_bySource.TryGetValue(sourceAttestationId, out var links))
{
return Task.FromResult(links.Distinct().ToImmutableArray());
}
return Task.FromResult(ImmutableArray<AttestationLink>.Empty);
}
/// <inheritdoc />
public Task<ImmutableArray<AttestationLink>> GetByTargetAsync(
string targetAttestationId,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (_byTarget.TryGetValue(targetAttestationId, out var links))
{
return Task.FromResult(links.Distinct().ToImmutableArray());
}
return Task.FromResult(ImmutableArray<AttestationLink>.Empty);
}
/// <inheritdoc />
public Task<AttestationLink?> GetAsync(
string sourceId,
string targetId,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
_links.TryGetValue((sourceId, targetId), out var link);
return Task.FromResult(link);
}
/// <inheritdoc />
public Task<bool> ExistsAsync(
string sourceId,
string targetId,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(_links.ContainsKey((sourceId, targetId)));
}
/// <inheritdoc />
public Task DeleteByAttestationAsync(
string attestationId,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
// Remove from main dictionary and indexes
var keysToRemove = _links.Keys
.Where(k => k.Source == attestationId || k.Target == attestationId)
.ToList();
foreach (var key in keysToRemove)
{
_links.TryRemove(key, out _);
}
// Clean up indexes
_bySource.TryRemove(attestationId, out _);
_byTarget.TryRemove(attestationId, out _);
// Remove from other bags where this attestation appears as the other side
foreach (var kvp in _bySource)
{
// ConcurrentBag doesn't support removal, but we can rebuild
var filtered = kvp.Value.Where(l => l.TargetAttestationId != attestationId).ToList();
if (filtered.Count != kvp.Value.Count)
{
_bySource[kvp.Key] = new ConcurrentBag<AttestationLink>(filtered);
}
}
foreach (var kvp in _byTarget)
{
var filtered = kvp.Value.Where(l => l.SourceAttestationId != attestationId).ToList();
if (filtered.Count != kvp.Value.Count)
{
_byTarget[kvp.Key] = new ConcurrentBag<AttestationLink>(filtered);
}
}
return Task.CompletedTask;
}
/// <summary>
/// Gets all links in the store.
/// </summary>
public IReadOnlyCollection<AttestationLink> GetAll() => _links.Values.ToList();
/// <summary>
/// Clears all links from the store.
/// </summary>
public void Clear()
{
_links.Clear();
_bySource.Clear();
_byTarget.Clear();
}
/// <summary>
/// Gets the count of links in the store.
/// </summary>
public int Count => _links.Count;
}

View File

@@ -0,0 +1,105 @@
// -----------------------------------------------------------------------------
// InMemoryAttestationNodeProvider.cs
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
// Task: T009
// Description: In-memory implementation of attestation node provider.
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
namespace StellaOps.Attestor.Core.Chain;
/// <summary>
/// In-memory implementation of <see cref="IAttestationNodeProvider"/>.
/// Suitable for testing and single-instance scenarios.
/// </summary>
public sealed class InMemoryAttestationNodeProvider : IAttestationNodeProvider
{
private readonly ConcurrentDictionary<string, AttestationChainNode> _nodes = new();
private readonly ConcurrentDictionary<string, string> _artifactRoots = new();
/// <inheritdoc />
public Task<AttestationChainNode?> GetNodeAsync(
string attestationId,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
_nodes.TryGetValue(attestationId, out var node);
return Task.FromResult(node);
}
/// <inheritdoc />
public Task<AttestationChainNode?> FindRootByArtifactAsync(
string artifactDigest,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (_artifactRoots.TryGetValue(artifactDigest, out var rootId) &&
_nodes.TryGetValue(rootId, out var node))
{
return Task.FromResult<AttestationChainNode?>(node);
}
return Task.FromResult<AttestationChainNode?>(null);
}
/// <inheritdoc />
public Task<IReadOnlyList<AttestationChainNode>> GetBySubjectAsync(
string subjectDigest,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var nodes = _nodes.Values
.Where(n => n.SubjectDigest == subjectDigest)
.OrderByDescending(n => n.CreatedAt)
.ToList();
return Task.FromResult<IReadOnlyList<AttestationChainNode>>(nodes);
}
/// <summary>
/// Adds a node to the store.
/// </summary>
public void AddNode(AttestationChainNode node)
{
_nodes[node.AttestationId] = node;
}
/// <summary>
/// Sets the root attestation for an artifact.
/// </summary>
public void SetArtifactRoot(string artifactDigest, string rootAttestationId)
{
_artifactRoots[artifactDigest] = rootAttestationId;
}
/// <summary>
/// Removes a node from the store.
/// </summary>
public bool RemoveNode(string attestationId)
{
return _nodes.TryRemove(attestationId, out _);
}
/// <summary>
/// Gets all nodes in the store.
/// </summary>
public IReadOnlyCollection<AttestationChainNode> GetAll() => _nodes.Values.ToList();
/// <summary>
/// Clears all nodes from the store.
/// </summary>
public void Clear()
{
_nodes.Clear();
_artifactRoots.Clear();
}
/// <summary>
/// Gets the count of nodes in the store.
/// </summary>
public int Count => _nodes.Count;
}

View File

@@ -0,0 +1,193 @@
// -----------------------------------------------------------------------------
// InTotoStatementMaterials.cs
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
// Task: T003
// Description: Extension models for in-toto materials linking.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.Core.Chain;
/// <summary>
/// A material reference for in-toto statement linking.
/// Materials represent upstream attestations or artifacts that the statement depends on.
/// </summary>
public sealed record InTotoMaterial
{
/// <summary>
/// URI identifying the material.
/// For attestation references: attestation:sha256:{hash}
/// For artifacts: {registry}/{repository}@sha256:{hash}
/// </summary>
[JsonPropertyName("uri")]
[JsonPropertyOrder(0)]
public required string Uri { get; init; }
/// <summary>
/// Digest of the material.
/// </summary>
[JsonPropertyName("digest")]
[JsonPropertyOrder(1)]
public required ImmutableDictionary<string, string> Digest { get; init; }
/// <summary>
/// Optional annotations about the material.
/// </summary>
[JsonPropertyName("annotations")]
[JsonPropertyOrder(2)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ImmutableDictionary<string, string>? Annotations { get; init; }
/// <summary>
/// Creates a material reference for an attestation.
/// </summary>
public static InTotoMaterial ForAttestation(string attestationDigest, string predicateType)
{
var normalizedDigest = attestationDigest.StartsWith("sha256:")
? attestationDigest.Substring(7)
: attestationDigest;
return new InTotoMaterial
{
Uri = $"attestation:sha256:{normalizedDigest}",
Digest = ImmutableDictionary.Create<string, string>()
.Add("sha256", normalizedDigest),
Annotations = ImmutableDictionary.Create<string, string>()
.Add("predicateType", predicateType)
};
}
/// <summary>
/// Creates a material reference for a container image.
/// </summary>
public static InTotoMaterial ForImage(string imageRef, string digest)
{
var normalizedDigest = digest.StartsWith("sha256:")
? digest.Substring(7)
: digest;
return new InTotoMaterial
{
Uri = $"{imageRef}@sha256:{normalizedDigest}",
Digest = ImmutableDictionary.Create<string, string>()
.Add("sha256", normalizedDigest)
};
}
/// <summary>
/// Creates a material reference for a Git commit.
/// </summary>
public static InTotoMaterial ForGitCommit(string repository, string commitSha)
{
return new InTotoMaterial
{
Uri = $"git+{repository}@{commitSha}",
Digest = ImmutableDictionary.Create<string, string>()
.Add("sha1", commitSha),
Annotations = ImmutableDictionary.Create<string, string>()
.Add("vcs", "git")
};
}
/// <summary>
/// Creates a material reference for a container layer.
/// </summary>
public static InTotoMaterial ForLayer(string imageRef, string layerDigest, int layerIndex)
{
var normalizedDigest = layerDigest.StartsWith("sha256:")
? layerDigest.Substring(7)
: layerDigest;
return new InTotoMaterial
{
Uri = $"{imageRef}#layer/{layerIndex}",
Digest = ImmutableDictionary.Create<string, string>()
.Add("sha256", normalizedDigest),
Annotations = ImmutableDictionary.Create<string, string>()
.Add("layerIndex", layerIndex.ToString())
};
}
}
/// <summary>
/// Builder for adding materials to an in-toto statement.
/// </summary>
public sealed class MaterialsBuilder
{
private readonly List<InTotoMaterial> _materials = [];
/// <summary>
/// Adds an attestation as a material reference.
/// </summary>
public MaterialsBuilder AddAttestation(string attestationDigest, string predicateType)
{
_materials.Add(InTotoMaterial.ForAttestation(attestationDigest, predicateType));
return this;
}
/// <summary>
/// Adds an image as a material reference.
/// </summary>
public MaterialsBuilder AddImage(string imageRef, string digest)
{
_materials.Add(InTotoMaterial.ForImage(imageRef, digest));
return this;
}
/// <summary>
/// Adds a Git commit as a material reference.
/// </summary>
public MaterialsBuilder AddGitCommit(string repository, string commitSha)
{
_materials.Add(InTotoMaterial.ForGitCommit(repository, commitSha));
return this;
}
/// <summary>
/// Adds a layer as a material reference.
/// </summary>
public MaterialsBuilder AddLayer(string imageRef, string layerDigest, int layerIndex)
{
_materials.Add(InTotoMaterial.ForLayer(imageRef, layerDigest, layerIndex));
return this;
}
/// <summary>
/// Adds a custom material.
/// </summary>
public MaterialsBuilder Add(InTotoMaterial material)
{
_materials.Add(material);
return this;
}
/// <summary>
/// Builds the materials list.
/// </summary>
public ImmutableArray<InTotoMaterial> Build() => [.. _materials];
}
/// <summary>
/// Constants for material annotations.
/// </summary>
public static class MaterialAnnotations
{
public const string PredicateType = "predicateType";
public const string LayerIndex = "layerIndex";
public const string Vcs = "vcs";
public const string Format = "format";
public const string MediaType = "mediaType";
}
/// <summary>
/// URI scheme prefixes for materials.
/// </summary>
public static class MaterialUriSchemes
{
public const string Attestation = "attestation:";
public const string Git = "git+";
public const string Oci = "oci://";
public const string Pkg = "pkg:";
}

View File

@@ -0,0 +1,128 @@
// -----------------------------------------------------------------------------
// ILayerAttestationService.cs
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
// Task: T015
// Description: Interface for layer-specific attestation operations.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
namespace StellaOps.Attestor.Core.Layers;
/// <summary>
/// Service for creating and managing per-layer attestations.
/// </summary>
public interface ILayerAttestationService
{
/// <summary>
/// Creates an attestation for a single layer.
/// </summary>
/// <param name="request">The layer attestation request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result of the attestation creation.</returns>
Task<LayerAttestationResult> CreateLayerAttestationAsync(
LayerAttestationRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates attestations for multiple layers in a batch (efficient signing).
/// </summary>
/// <param name="request">The batch attestation request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Results for all layer attestations.</returns>
Task<BatchLayerAttestationResult> CreateBatchLayerAttestationsAsync(
BatchLayerAttestationRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all layer attestations for an image.
/// </summary>
/// <param name="imageDigest">The image digest.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Layer attestation results ordered by layer index.</returns>
Task<ImmutableArray<LayerAttestationResult>> GetLayerAttestationsAsync(
string imageDigest,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a specific layer attestation.
/// </summary>
/// <param name="imageDigest">The image digest.</param>
/// <param name="layerOrder">The layer order (0-based).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The layer attestation result, or null if not found.</returns>
Task<LayerAttestationResult?> GetLayerAttestationAsync(
string imageDigest,
int layerOrder,
CancellationToken cancellationToken = default);
/// <summary>
/// Verifies a layer attestation.
/// </summary>
/// <param name="attestationId">The attestation ID to verify.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Verification result.</returns>
Task<LayerAttestationVerifyResult> VerifyLayerAttestationAsync(
string attestationId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of layer attestation verification.
/// </summary>
public sealed record LayerAttestationVerifyResult
{
/// <summary>
/// The attestation ID that was verified.
/// </summary>
public required string AttestationId { get; init; }
/// <summary>
/// Whether verification succeeded.
/// </summary>
public required bool IsValid { get; init; }
/// <summary>
/// Verification errors if any.
/// </summary>
public required ImmutableArray<string> Errors { get; init; }
/// <summary>
/// The signer identity if verification succeeded.
/// </summary>
public string? SignerIdentity { get; init; }
/// <summary>
/// When verification was performed.
/// </summary>
public required DateTimeOffset VerifiedAt { get; init; }
/// <summary>
/// Creates a successful verification result.
/// </summary>
public static LayerAttestationVerifyResult Success(
string attestationId,
string? signerIdentity,
DateTimeOffset verifiedAt) => new()
{
AttestationId = attestationId,
IsValid = true,
Errors = [],
SignerIdentity = signerIdentity,
VerifiedAt = verifiedAt
};
/// <summary>
/// Creates a failed verification result.
/// </summary>
public static LayerAttestationVerifyResult Failure(
string attestationId,
ImmutableArray<string> errors,
DateTimeOffset verifiedAt) => new()
{
AttestationId = attestationId,
IsValid = false,
Errors = errors,
VerifiedAt = verifiedAt
};
}

View File

@@ -0,0 +1,283 @@
// -----------------------------------------------------------------------------
// LayerAttestation.cs
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
// Task: T014
// Description: Models for per-layer attestations.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.Core.Layers;
/// <summary>
/// Request to create a layer-specific attestation.
/// </summary>
public sealed record LayerAttestationRequest
{
/// <summary>
/// The parent image digest.
/// </summary>
[JsonPropertyName("imageDigest")]
public required string ImageDigest { get; init; }
/// <summary>
/// The layer digest (sha256).
/// </summary>
[JsonPropertyName("layerDigest")]
public required string LayerDigest { get; init; }
/// <summary>
/// The layer order (0-based index).
/// </summary>
[JsonPropertyName("layerOrder")]
public required int LayerOrder { get; init; }
/// <summary>
/// The SBOM digest for this layer.
/// </summary>
[JsonPropertyName("sbomDigest")]
public required string SbomDigest { get; init; }
/// <summary>
/// The SBOM format (cyclonedx, spdx).
/// </summary>
[JsonPropertyName("sbomFormat")]
public required string SbomFormat { get; init; }
/// <summary>
/// The SBOM content bytes.
/// </summary>
[JsonIgnore]
public byte[]? SbomContent { get; init; }
/// <summary>
/// Optional tenant ID for multi-tenant environments.
/// </summary>
[JsonPropertyName("tenantId")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? TenantId { get; init; }
/// <summary>
/// Optional media type of the layer.
/// </summary>
[JsonPropertyName("mediaType")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? MediaType { get; init; }
/// <summary>
/// Optional layer size in bytes.
/// </summary>
[JsonPropertyName("size")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public long? Size { get; init; }
}
/// <summary>
/// Batch request for creating multiple layer attestations.
/// </summary>
public sealed record BatchLayerAttestationRequest
{
/// <summary>
/// The parent image digest.
/// </summary>
[JsonPropertyName("imageDigest")]
public required string ImageDigest { get; init; }
/// <summary>
/// The image reference (registry/repo:tag).
/// </summary>
[JsonPropertyName("imageRef")]
public required string ImageRef { get; init; }
/// <summary>
/// Individual layer attestation requests.
/// </summary>
[JsonPropertyName("layers")]
public required ImmutableArray<LayerAttestationRequest> Layers { get; init; }
/// <summary>
/// Optional tenant ID for multi-tenant environments.
/// </summary>
[JsonPropertyName("tenantId")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? TenantId { get; init; }
/// <summary>
/// Whether to link layer attestations to parent image attestation.
/// </summary>
[JsonPropertyName("linkToParent")]
public bool LinkToParent { get; init; } = true;
/// <summary>
/// The parent image attestation ID to link to (if LinkToParent is true).
/// </summary>
[JsonPropertyName("parentAttestationId")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ParentAttestationId { get; init; }
}
/// <summary>
/// Result of creating a layer attestation.
/// </summary>
public sealed record LayerAttestationResult
{
/// <summary>
/// The layer digest this attestation is for.
/// </summary>
[JsonPropertyName("layerDigest")]
public required string LayerDigest { get; init; }
/// <summary>
/// The layer order.
/// </summary>
[JsonPropertyName("layerOrder")]
public required int LayerOrder { get; init; }
/// <summary>
/// The generated attestation ID.
/// </summary>
[JsonPropertyName("attestationId")]
public required string AttestationId { get; init; }
/// <summary>
/// The DSSE envelope digest.
/// </summary>
[JsonPropertyName("envelopeDigest")]
public required string EnvelopeDigest { get; init; }
/// <summary>
/// Whether the attestation was created successfully.
/// </summary>
[JsonPropertyName("success")]
public required bool Success { get; init; }
/// <summary>
/// Error message if creation failed.
/// </summary>
[JsonPropertyName("error")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Error { get; init; }
/// <summary>
/// When the attestation was created.
/// </summary>
[JsonPropertyName("createdAt")]
public required DateTimeOffset CreatedAt { get; init; }
}
/// <summary>
/// Result of batch layer attestation creation.
/// </summary>
public sealed record BatchLayerAttestationResult
{
/// <summary>
/// The parent image digest.
/// </summary>
[JsonPropertyName("imageDigest")]
public required string ImageDigest { get; init; }
/// <summary>
/// Results for each layer.
/// </summary>
[JsonPropertyName("layers")]
public required ImmutableArray<LayerAttestationResult> Layers { get; init; }
/// <summary>
/// Whether all layers were attested successfully.
/// </summary>
[JsonPropertyName("allSucceeded")]
public bool AllSucceeded => Layers.All(l => l.Success);
/// <summary>
/// Number of successful attestations.
/// </summary>
[JsonPropertyName("successCount")]
public int SuccessCount => Layers.Count(l => l.Success);
/// <summary>
/// Number of failed attestations.
/// </summary>
[JsonPropertyName("failedCount")]
public int FailedCount => Layers.Count(l => !l.Success);
/// <summary>
/// Total processing time.
/// </summary>
[JsonPropertyName("processingTime")]
public required TimeSpan ProcessingTime { get; init; }
/// <summary>
/// When the batch operation completed.
/// </summary>
[JsonPropertyName("completedAt")]
public required DateTimeOffset CompletedAt { get; init; }
/// <summary>
/// Links created between layers and parent.
/// </summary>
[JsonPropertyName("linksCreated")]
public int LinksCreated { get; init; }
}
/// <summary>
/// Layer SBOM predicate for in-toto statement.
/// </summary>
public sealed record LayerSbomPredicate
{
/// <summary>
/// The predicate type URI.
/// </summary>
[JsonPropertyName("predicateType")]
public static string PredicateType => "StellaOps.LayerSBOM@1";
/// <summary>
/// The parent image digest.
/// </summary>
[JsonPropertyName("imageDigest")]
public required string ImageDigest { get; init; }
/// <summary>
/// The layer order (0-based).
/// </summary>
[JsonPropertyName("layerOrder")]
public required int LayerOrder { get; init; }
/// <summary>
/// The SBOM format.
/// </summary>
[JsonPropertyName("sbomFormat")]
public required string SbomFormat { get; init; }
/// <summary>
/// The SBOM digest.
/// </summary>
[JsonPropertyName("sbomDigest")]
public required string SbomDigest { get; init; }
/// <summary>
/// Number of components in the SBOM.
/// </summary>
[JsonPropertyName("componentCount")]
public int ComponentCount { get; init; }
/// <summary>
/// When the layer SBOM was generated.
/// </summary>
[JsonPropertyName("generatedAt")]
public required DateTimeOffset GeneratedAt { get; init; }
/// <summary>
/// Tool that generated the SBOM.
/// </summary>
[JsonPropertyName("generatorTool")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? GeneratorTool { get; init; }
/// <summary>
/// Generator tool version.
/// </summary>
[JsonPropertyName("generatorVersion")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? GeneratorVersion { get; init; }
}

View File

@@ -0,0 +1,445 @@
// -----------------------------------------------------------------------------
// LayerAttestationService.cs
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
// Task: T016
// Description: Implementation of layer-specific attestation service.
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using StellaOps.Attestor.Core.Chain;
namespace StellaOps.Attestor.Core.Layers;
/// <summary>
/// Service for creating and managing per-layer attestations.
/// </summary>
public sealed class LayerAttestationService : ILayerAttestationService
{
private readonly ILayerAttestationSigner _signer;
private readonly ILayerAttestationStore _store;
private readonly IAttestationLinkStore _linkStore;
private readonly AttestationChainBuilder _chainBuilder;
private readonly TimeProvider _timeProvider;
public LayerAttestationService(
ILayerAttestationSigner signer,
ILayerAttestationStore store,
IAttestationLinkStore linkStore,
AttestationChainBuilder chainBuilder,
TimeProvider timeProvider)
{
_signer = signer;
_store = store;
_linkStore = linkStore;
_chainBuilder = chainBuilder;
_timeProvider = timeProvider;
}
/// <inheritdoc />
public async Task<LayerAttestationResult> CreateLayerAttestationAsync(
LayerAttestationRequest request,
CancellationToken cancellationToken = default)
{
try
{
// Create the layer SBOM predicate
var predicate = new LayerSbomPredicate
{
ImageDigest = request.ImageDigest,
LayerOrder = request.LayerOrder,
SbomFormat = request.SbomFormat,
SbomDigest = request.SbomDigest,
GeneratedAt = _timeProvider.GetUtcNow()
};
// Sign the attestation
var signResult = await _signer.SignLayerAttestationAsync(
request.LayerDigest,
predicate,
cancellationToken).ConfigureAwait(false);
if (!signResult.Success)
{
return new LayerAttestationResult
{
LayerDigest = request.LayerDigest,
LayerOrder = request.LayerOrder,
AttestationId = string.Empty,
EnvelopeDigest = string.Empty,
Success = false,
Error = signResult.Error,
CreatedAt = _timeProvider.GetUtcNow()
};
}
// Store the attestation
var result = new LayerAttestationResult
{
LayerDigest = request.LayerDigest,
LayerOrder = request.LayerOrder,
AttestationId = signResult.AttestationId,
EnvelopeDigest = signResult.EnvelopeDigest,
Success = true,
CreatedAt = _timeProvider.GetUtcNow()
};
await _store.StoreAsync(request.ImageDigest, result, cancellationToken)
.ConfigureAwait(false);
return result;
}
catch (Exception ex)
{
return new LayerAttestationResult
{
LayerDigest = request.LayerDigest,
LayerOrder = request.LayerOrder,
AttestationId = string.Empty,
EnvelopeDigest = string.Empty,
Success = false,
Error = ex.Message,
CreatedAt = _timeProvider.GetUtcNow()
};
}
}
/// <inheritdoc />
public async Task<BatchLayerAttestationResult> CreateBatchLayerAttestationsAsync(
BatchLayerAttestationRequest request,
CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
var results = new List<LayerAttestationResult>();
var linksCreated = 0;
// Sort layers by order for consistent processing
var orderedLayers = request.Layers.OrderBy(l => l.LayerOrder).ToList();
// Create predicates for batch signing
var predicates = orderedLayers.Select(layer => new LayerSbomPredicate
{
ImageDigest = request.ImageDigest,
LayerOrder = layer.LayerOrder,
SbomFormat = layer.SbomFormat,
SbomDigest = layer.SbomDigest,
GeneratedAt = _timeProvider.GetUtcNow()
}).ToList();
// Batch sign all layers (T018 - efficient batch signing)
var signResults = await _signer.BatchSignLayerAttestationsAsync(
orderedLayers.Select(l => l.LayerDigest).ToList(),
predicates,
cancellationToken).ConfigureAwait(false);
// Process results
for (var i = 0; i < orderedLayers.Count; i++)
{
var layer = orderedLayers[i];
var signResult = signResults[i];
var result = new LayerAttestationResult
{
LayerDigest = layer.LayerDigest,
LayerOrder = layer.LayerOrder,
AttestationId = signResult.AttestationId,
EnvelopeDigest = signResult.EnvelopeDigest,
Success = signResult.Success,
Error = signResult.Error,
CreatedAt = _timeProvider.GetUtcNow()
};
results.Add(result);
if (result.Success)
{
// Store the attestation
await _store.StoreAsync(request.ImageDigest, result, cancellationToken)
.ConfigureAwait(false);
// Create link to parent if requested
if (request.LinkToParent && !string.IsNullOrEmpty(request.ParentAttestationId))
{
var linkResult = await _chainBuilder.CreateLinkAsync(
request.ParentAttestationId,
result.AttestationId,
AttestationLinkType.DependsOn,
new LinkMetadata
{
Reason = $"Layer {layer.LayerOrder} attestation",
Annotations = ImmutableDictionary<string, string>.Empty
.Add("layerOrder", layer.LayerOrder.ToString())
.Add("layerDigest", layer.LayerDigest)
},
cancellationToken).ConfigureAwait(false);
if (linkResult.IsSuccess)
{
linksCreated++;
}
}
}
}
stopwatch.Stop();
return new BatchLayerAttestationResult
{
ImageDigest = request.ImageDigest,
Layers = [.. results],
ProcessingTime = stopwatch.Elapsed,
CompletedAt = _timeProvider.GetUtcNow(),
LinksCreated = linksCreated
};
}
/// <inheritdoc />
public async Task<ImmutableArray<LayerAttestationResult>> GetLayerAttestationsAsync(
string imageDigest,
CancellationToken cancellationToken = default)
{
return await _store.GetByImageAsync(imageDigest, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<LayerAttestationResult?> GetLayerAttestationAsync(
string imageDigest,
int layerOrder,
CancellationToken cancellationToken = default)
{
return await _store.GetAsync(imageDigest, layerOrder, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<LayerAttestationVerifyResult> VerifyLayerAttestationAsync(
string attestationId,
CancellationToken cancellationToken = default)
{
return await _signer.VerifyAsync(attestationId, cancellationToken)
.ConfigureAwait(false);
}
}
/// <summary>
/// Interface for signing layer attestations.
/// </summary>
public interface ILayerAttestationSigner
{
/// <summary>
/// Signs a single layer attestation.
/// </summary>
Task<LayerSignResult> SignLayerAttestationAsync(
string layerDigest,
LayerSbomPredicate predicate,
CancellationToken cancellationToken = default);
/// <summary>
/// Signs multiple layer attestations in a batch.
/// </summary>
Task<IReadOnlyList<LayerSignResult>> BatchSignLayerAttestationsAsync(
IReadOnlyList<string> layerDigests,
IReadOnlyList<LayerSbomPredicate> predicates,
CancellationToken cancellationToken = default);
/// <summary>
/// Verifies a layer attestation.
/// </summary>
Task<LayerAttestationVerifyResult> VerifyAsync(
string attestationId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of signing a layer attestation.
/// </summary>
public sealed record LayerSignResult
{
public required string AttestationId { get; init; }
public required string EnvelopeDigest { get; init; }
public required bool Success { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Interface for storing layer attestations.
/// </summary>
public interface ILayerAttestationStore
{
/// <summary>
/// Stores a layer attestation result.
/// </summary>
Task StoreAsync(
string imageDigest,
LayerAttestationResult result,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all layer attestations for an image.
/// </summary>
Task<ImmutableArray<LayerAttestationResult>> GetByImageAsync(
string imageDigest,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a specific layer attestation.
/// </summary>
Task<LayerAttestationResult?> GetAsync(
string imageDigest,
int layerOrder,
CancellationToken cancellationToken = default);
}
/// <summary>
/// In-memory implementation of layer attestation store for testing.
/// </summary>
public sealed class InMemoryLayerAttestationStore : ILayerAttestationStore
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<int, LayerAttestationResult>> _store = new();
public Task StoreAsync(
string imageDigest,
LayerAttestationResult result,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var imageStore = _store.GetOrAdd(imageDigest, _ => new());
imageStore[result.LayerOrder] = result;
return Task.CompletedTask;
}
public Task<ImmutableArray<LayerAttestationResult>> GetByImageAsync(
string imageDigest,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (_store.TryGetValue(imageDigest, out var imageStore))
{
return Task.FromResult(imageStore.Values
.OrderBy(r => r.LayerOrder)
.ToImmutableArray());
}
return Task.FromResult(ImmutableArray<LayerAttestationResult>.Empty);
}
public Task<LayerAttestationResult?> GetAsync(
string imageDigest,
int layerOrder,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (_store.TryGetValue(imageDigest, out var imageStore) &&
imageStore.TryGetValue(layerOrder, out var result))
{
return Task.FromResult<LayerAttestationResult?>(result);
}
return Task.FromResult<LayerAttestationResult?>(null);
}
public void Clear() => _store.Clear();
}
/// <summary>
/// In-memory implementation of layer attestation signer for testing.
/// </summary>
public sealed class InMemoryLayerAttestationSigner : ILayerAttestationSigner
{
private readonly TimeProvider _timeProvider;
private readonly ConcurrentDictionary<string, byte[]> _signatures = new();
public InMemoryLayerAttestationSigner(TimeProvider timeProvider)
{
_timeProvider = timeProvider;
}
public Task<LayerSignResult> SignLayerAttestationAsync(
string layerDigest,
LayerSbomPredicate predicate,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var attestationId = ComputeAttestationId(layerDigest, predicate);
var envelopeDigest = ComputeEnvelopeDigest(attestationId);
// Store "signature" for verification
_signatures[attestationId] = Encoding.UTF8.GetBytes(attestationId);
return Task.FromResult(new LayerSignResult
{
AttestationId = attestationId,
EnvelopeDigest = envelopeDigest,
Success = true
});
}
public Task<IReadOnlyList<LayerSignResult>> BatchSignLayerAttestationsAsync(
IReadOnlyList<string> layerDigests,
IReadOnlyList<LayerSbomPredicate> predicates,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var results = new List<LayerSignResult>();
for (var i = 0; i < layerDigests.Count; i++)
{
var attestationId = ComputeAttestationId(layerDigests[i], predicates[i]);
var envelopeDigest = ComputeEnvelopeDigest(attestationId);
_signatures[attestationId] = Encoding.UTF8.GetBytes(attestationId);
results.Add(new LayerSignResult
{
AttestationId = attestationId,
EnvelopeDigest = envelopeDigest,
Success = true
});
}
return Task.FromResult<IReadOnlyList<LayerSignResult>>(results);
}
public Task<LayerAttestationVerifyResult> VerifyAsync(
string attestationId,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (_signatures.ContainsKey(attestationId))
{
return Task.FromResult(LayerAttestationVerifyResult.Success(
attestationId,
"test-signer",
_timeProvider.GetUtcNow()));
}
return Task.FromResult(LayerAttestationVerifyResult.Failure(
attestationId,
["Attestation not found"],
_timeProvider.GetUtcNow()));
}
private static string ComputeAttestationId(string layerDigest, LayerSbomPredicate predicate)
{
var content = $"{layerDigest}:{predicate.ImageDigest}:{predicate.LayerOrder}:{predicate.SbomDigest}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private static string ComputeEnvelopeDigest(string attestationId)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"envelope:{attestationId}"));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}

View File

@@ -10,6 +10,7 @@
<PackageReference Include="JsonSchema.Net" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Sodium.Core" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Schemas\*.json" />

View File

@@ -1,8 +1,9 @@
using System.Formats.Asn1;
using System.Security.Cryptography;
using System.Text;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using Sodium;
namespace StellaOps.Attestor.Core.Verification;
@@ -223,7 +224,7 @@ public static partial class CheckpointSignatureVerifier
return false;
}
// Note format: "<body>\n\n origin <base64sig>\n"
// Note format: "<body>\n\n- origin <base64sig>\n"
var separator = signedCheckpoint.IndexOf("\n\n", StringComparison.Ordinal);
string signatureSection;
@@ -348,18 +349,65 @@ public static partial class CheckpointSignatureVerifier
}
/// <summary>
/// Verifies an Ed25519 signature (placeholder for actual implementation).
/// Verifies an Ed25519 signature using libsodium.
/// </summary>
private static bool VerifyEd25519(byte[] data, byte[] signature, byte[] publicKey)
{
// .NET 10 may have built-in Ed25519 support
// For now, this is a placeholder that would use a library like NSec
// In production, this would call the appropriate Ed25519 verification
try
{
// Ed25519 signatures are 64 bytes
if (signature.Length != 64)
{
return false;
}
// TODO: Implement Ed25519 verification when .NET 10 supports it natively
// or use NSec.Cryptography
byte[] keyBytes = publicKey;
return false;
// Check if PEM encoded - extract DER
if (TryExtractPem(publicKey, out var der))
{
keyBytes = ExtractRawEd25519PublicKey(der);
}
else if (IsEd25519SubjectPublicKeyInfo(publicKey))
{
// Already DER encoded SPKI
keyBytes = ExtractRawEd25519PublicKey(publicKey);
}
// Raw Ed25519 public keys are 32 bytes
if (keyBytes.Length != 32)
{
return false;
}
// Use libsodium for Ed25519 verification
return PublicKeyAuth.VerifyDetached(signature, data, keyBytes);
}
catch
{
return false;
}
}
/// <summary>
/// Extracts raw Ed25519 public key bytes from SPKI DER encoding.
/// </summary>
private static byte[] ExtractRawEd25519PublicKey(byte[] spki)
{
try
{
var reader = new AsnReader(spki, AsnEncodingRules.DER);
var sequence = reader.ReadSequence();
// Skip algorithm identifier
_ = sequence.ReadSequence();
// Read BIT STRING containing the public key
var bitString = sequence.ReadBitString(out _);
return bitString;
}
catch
{
return spki; // Return original if extraction fails
}
}
private static bool IsEd25519PublicKey(ReadOnlySpan<byte> publicKey)

View File

@@ -25,6 +25,12 @@ using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Attestor.Tests;
/// <summary>
/// Integration tests for time skew validation in attestation submission and verification.
/// </summary>
[Trait("Category", TestCategories.Integration)]
[Trait("BlastRadius", TestCategories.BlastRadius.Evidence)]
[Trait("BlastRadius", TestCategories.BlastRadius.Crypto)]
public sealed class TimeSkewValidationIntegrationTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 18, 12, 0, 0, TimeSpan.Zero);

View File

@@ -0,0 +1,244 @@
// -----------------------------------------------------------------------------
// ChainController.cs
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
// Task: T020-T024
// Description: API controller for attestation chain queries.
// -----------------------------------------------------------------------------
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using StellaOps.Attestor.WebService.Models;
using StellaOps.Attestor.WebService.Services;
namespace StellaOps.Attestor.WebService.Controllers;
/// <summary>
/// API controller for attestation chain queries and visualization.
/// Enables traversal of attestation relationships and dependency graphs.
/// </summary>
[ApiController]
[Route("api/v1/chains")]
[Authorize("attestor:read")]
[EnableRateLimiting("attestor-reads")]
public sealed class ChainController : ControllerBase
{
private readonly IChainQueryService _chainQueryService;
private readonly ILogger<ChainController> _logger;
public ChainController(
IChainQueryService chainQueryService,
ILogger<ChainController> logger)
{
_chainQueryService = chainQueryService;
_logger = logger;
}
/// <summary>
/// Get upstream (parent) attestations from a starting attestation.
/// Traverses the chain following "depends on" relationships.
/// </summary>
/// <param name="attestationId">The attestation ID to start from (sha256:...)</param>
/// <param name="maxDepth">Maximum traversal depth (default: 5, max: 10)</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Chain response with upstream attestations</returns>
[HttpGet("{attestationId}/upstream")]
[ProducesResponseType(typeof(AttestationChainResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetUpstreamChainAsync(
[FromRoute] string attestationId,
[FromQuery] int? maxDepth,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(attestationId))
{
return BadRequest(new { error = "attestationId is required" });
}
var depth = Math.Clamp(maxDepth ?? 5, 1, 10);
_logger.LogDebug("Getting upstream chain for {AttestationId} with depth {Depth}",
attestationId, depth);
var result = await _chainQueryService.GetUpstreamChainAsync(attestationId, depth, cancellationToken);
if (result is null)
{
return NotFound(new { error = $"Attestation {attestationId} not found" });
}
return Ok(result);
}
/// <summary>
/// Get downstream (child) attestations from a starting attestation.
/// Traverses the chain following attestations that depend on this one.
/// </summary>
/// <param name="attestationId">The attestation ID to start from (sha256:...)</param>
/// <param name="maxDepth">Maximum traversal depth (default: 5, max: 10)</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Chain response with downstream attestations</returns>
[HttpGet("{attestationId}/downstream")]
[ProducesResponseType(typeof(AttestationChainResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetDownstreamChainAsync(
[FromRoute] string attestationId,
[FromQuery] int? maxDepth,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(attestationId))
{
return BadRequest(new { error = "attestationId is required" });
}
var depth = Math.Clamp(maxDepth ?? 5, 1, 10);
_logger.LogDebug("Getting downstream chain for {AttestationId} with depth {Depth}",
attestationId, depth);
var result = await _chainQueryService.GetDownstreamChainAsync(attestationId, depth, cancellationToken);
if (result is null)
{
return NotFound(new { error = $"Attestation {attestationId} not found" });
}
return Ok(result);
}
/// <summary>
/// Get the full attestation chain (both directions) from a starting point.
/// Returns a complete graph of all related attestations.
/// </summary>
/// <param name="attestationId">The attestation ID to start from (sha256:...)</param>
/// <param name="maxDepth">Maximum traversal depth in each direction (default: 5, max: 10)</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Chain response with full attestation graph</returns>
[HttpGet("{attestationId}")]
[ProducesResponseType(typeof(AttestationChainResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetFullChainAsync(
[FromRoute] string attestationId,
[FromQuery] int? maxDepth,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(attestationId))
{
return BadRequest(new { error = "attestationId is required" });
}
var depth = Math.Clamp(maxDepth ?? 5, 1, 10);
_logger.LogDebug("Getting full chain for {AttestationId} with depth {Depth}",
attestationId, depth);
var result = await _chainQueryService.GetFullChainAsync(attestationId, depth, cancellationToken);
if (result is null)
{
return NotFound(new { error = $"Attestation {attestationId} not found" });
}
return Ok(result);
}
/// <summary>
/// Get a graph visualization of the attestation chain.
/// Supports Mermaid, DOT (Graphviz), and JSON formats.
/// </summary>
/// <param name="attestationId">The attestation ID to start from (sha256:...)</param>
/// <param name="format">Output format: mermaid, dot, or json (default: mermaid)</param>
/// <param name="maxDepth">Maximum traversal depth (default: 5, max: 10)</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Graph visualization in requested format</returns>
[HttpGet("{attestationId}/graph")]
[ProducesResponseType(typeof(ChainGraphResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetChainGraphAsync(
[FromRoute] string attestationId,
[FromQuery] string? format,
[FromQuery] int? maxDepth,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(attestationId))
{
return BadRequest(new { error = "attestationId is required" });
}
var graphFormat = ParseGraphFormat(format);
var depth = Math.Clamp(maxDepth ?? 5, 1, 10);
_logger.LogDebug("Getting chain graph for {AttestationId} in format {Format} with depth {Depth}",
attestationId, graphFormat, depth);
var result = await _chainQueryService.GetChainGraphAsync(attestationId, graphFormat, depth, cancellationToken);
if (result is null)
{
return NotFound(new { error = $"Attestation {attestationId} not found" });
}
return Ok(result);
}
/// <summary>
/// Get all attestations for an artifact with optional chain expansion.
/// </summary>
/// <param name="artifactDigest">The artifact digest (sha256:...)</param>
/// <param name="chain">Whether to include the full chain (default: false)</param>
/// <param name="maxDepth">Maximum chain traversal depth (default: 5, max: 10)</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Attestations for the artifact with optional chain</returns>
[HttpGet("artifact/{artifactDigest}")]
[ProducesResponseType(typeof(ArtifactChainResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetAttestationsForArtifactAsync(
[FromRoute] string artifactDigest,
[FromQuery] bool? chain,
[FromQuery] int? maxDepth,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(artifactDigest))
{
return BadRequest(new { error = "artifactDigest is required" });
}
var includeChain = chain ?? false;
var depth = Math.Clamp(maxDepth ?? 5, 1, 10);
_logger.LogDebug("Getting attestations for artifact {ArtifactDigest} with chain={IncludeChain}",
artifactDigest, includeChain);
var result = await _chainQueryService.GetAttestationsForArtifactAsync(
artifactDigest, includeChain, depth, cancellationToken);
if (result is null)
{
return NotFound(new { error = $"No attestations found for artifact {artifactDigest}" });
}
return Ok(result);
}
private static GraphFormat ParseGraphFormat(string? format)
{
if (string.IsNullOrWhiteSpace(format))
{
return GraphFormat.Mermaid;
}
return format.ToLowerInvariant() switch
{
"mermaid" => GraphFormat.Mermaid,
"dot" => GraphFormat.Dot,
"graphviz" => GraphFormat.Dot,
"json" => GraphFormat.Json,
_ => GraphFormat.Mermaid
};
}
}

View File

@@ -0,0 +1,205 @@
// -----------------------------------------------------------------------------
// ChainApiModels.cs
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
// Task: T020
// Description: API response models for attestation chain queries.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json.Serialization;
using StellaOps.Attestor.Core.Chain;
namespace StellaOps.Attestor.WebService.Models;
/// <summary>
/// Response containing attestation chain traversal results.
/// </summary>
public sealed record AttestationChainResponse
{
[JsonPropertyName("attestationId")]
public required string AttestationId { get; init; }
[JsonPropertyName("direction")]
public required string Direction { get; init; } // "upstream", "downstream", "full"
[JsonPropertyName("maxDepth")]
public required int MaxDepth { get; init; }
[JsonPropertyName("queryTime")]
public required DateTimeOffset QueryTime { get; init; }
[JsonPropertyName("nodes")]
public required ImmutableArray<AttestationNodeDto> Nodes { get; init; }
[JsonPropertyName("links")]
public required ImmutableArray<AttestationLinkDto> Links { get; init; }
[JsonPropertyName("summary")]
public required AttestationChainSummaryDto Summary { get; init; }
}
/// <summary>
/// A node in the attestation chain graph.
/// </summary>
public sealed record AttestationNodeDto
{
[JsonPropertyName("attestationId")]
public required string AttestationId { get; init; }
[JsonPropertyName("predicateType")]
public required string PredicateType { get; init; }
[JsonPropertyName("subjectDigest")]
public required string SubjectDigest { get; init; }
[JsonPropertyName("createdAt")]
public required DateTimeOffset CreatedAt { get; init; }
[JsonPropertyName("depth")]
public required int Depth { get; init; }
[JsonPropertyName("isRoot")]
public required bool IsRoot { get; init; }
[JsonPropertyName("isLeaf")]
public required bool IsLeaf { get; init; }
[JsonPropertyName("metadata")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ImmutableDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// A link (edge) in the attestation chain graph.
/// </summary>
public sealed record AttestationLinkDto
{
[JsonPropertyName("sourceId")]
public required string SourceId { get; init; }
[JsonPropertyName("targetId")]
public required string TargetId { get; init; }
[JsonPropertyName("linkType")]
public required string LinkType { get; init; }
[JsonPropertyName("createdAt")]
public required DateTimeOffset CreatedAt { get; init; }
[JsonPropertyName("reason")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Reason { get; init; }
}
/// <summary>
/// Summary statistics for the chain traversal.
/// </summary>
public sealed record AttestationChainSummaryDto
{
[JsonPropertyName("totalNodes")]
public required int TotalNodes { get; init; }
[JsonPropertyName("totalLinks")]
public required int TotalLinks { get; init; }
[JsonPropertyName("maxDepthReached")]
public required int MaxDepthReached { get; init; }
[JsonPropertyName("rootCount")]
public required int RootCount { get; init; }
[JsonPropertyName("leafCount")]
public required int LeafCount { get; init; }
[JsonPropertyName("predicateTypes")]
public required ImmutableArray<string> PredicateTypes { get; init; }
[JsonPropertyName("isComplete")]
public required bool IsComplete { get; init; }
[JsonPropertyName("truncatedReason")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? TruncatedReason { get; init; }
}
/// <summary>
/// Graph visualization format options.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum GraphFormat
{
Mermaid,
Dot,
Json
}
/// <summary>
/// Response containing graph visualization.
/// </summary>
public sealed record ChainGraphResponse
{
[JsonPropertyName("attestationId")]
public required string AttestationId { get; init; }
[JsonPropertyName("format")]
public required GraphFormat Format { get; init; }
[JsonPropertyName("content")]
public required string Content { get; init; }
[JsonPropertyName("nodeCount")]
public required int NodeCount { get; init; }
[JsonPropertyName("linkCount")]
public required int LinkCount { get; init; }
[JsonPropertyName("generatedAt")]
public required DateTimeOffset GeneratedAt { get; init; }
}
/// <summary>
/// Response for artifact chain lookup.
/// </summary>
public sealed record ArtifactChainResponse
{
[JsonPropertyName("artifactDigest")]
public required string ArtifactDigest { get; init; }
[JsonPropertyName("queryTime")]
public required DateTimeOffset QueryTime { get; init; }
[JsonPropertyName("attestations")]
public required ImmutableArray<AttestationSummaryDto> Attestations { get; init; }
[JsonPropertyName("chain")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public AttestationChainResponse? Chain { get; init; }
}
/// <summary>
/// Summary of an attestation for artifact lookup.
/// </summary>
public sealed record AttestationSummaryDto
{
[JsonPropertyName("attestationId")]
public required string AttestationId { get; init; }
[JsonPropertyName("predicateType")]
public required string PredicateType { get; init; }
[JsonPropertyName("createdAt")]
public required DateTimeOffset CreatedAt { get; init; }
[JsonPropertyName("status")]
public required string Status { get; init; }
[JsonPropertyName("rekorLogIndex")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public long? RekorLogIndex { get; init; }
[JsonPropertyName("upstreamCount")]
public required int UpstreamCount { get; init; }
[JsonPropertyName("downstreamCount")]
public required int DownstreamCount { get; init; }
}

View File

@@ -0,0 +1,362 @@
// -----------------------------------------------------------------------------
// ChainQueryService.cs
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
// Task: T021-T024
// Description: Implementation of attestation chain query service.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text;
using StellaOps.Attestor.Core.Chain;
using StellaOps.Attestor.WebService.Models;
namespace StellaOps.Attestor.WebService.Services;
/// <summary>
/// Service for querying attestation chains and their relationships.
/// </summary>
public sealed class ChainQueryService : IChainQueryService
{
private readonly IAttestationLinkResolver _linkResolver;
private readonly IAttestationLinkStore _linkStore;
private readonly IAttestationNodeProvider _nodeProvider;
private readonly TimeProvider _timeProvider;
private readonly ILogger<ChainQueryService> _logger;
private const int MaxAllowedDepth = 10;
private const int MaxNodes = 500;
public ChainQueryService(
IAttestationLinkResolver linkResolver,
IAttestationLinkStore linkStore,
IAttestationNodeProvider nodeProvider,
TimeProvider timeProvider,
ILogger<ChainQueryService> logger)
{
_linkResolver = linkResolver;
_linkStore = linkStore;
_nodeProvider = nodeProvider;
_timeProvider = timeProvider;
_logger = logger;
}
/// <inheritdoc />
public async Task<AttestationChainResponse?> GetUpstreamChainAsync(
string attestationId,
int maxDepth = 5,
CancellationToken cancellationToken = default)
{
var depth = Math.Clamp(maxDepth, 1, MaxAllowedDepth);
var chain = await _linkResolver.ResolveUpstreamAsync(attestationId, depth, cancellationToken)
.ConfigureAwait(false);
if (chain is null)
{
return null;
}
return BuildChainResponse(attestationId, chain, "upstream", depth);
}
/// <inheritdoc />
public async Task<AttestationChainResponse?> GetDownstreamChainAsync(
string attestationId,
int maxDepth = 5,
CancellationToken cancellationToken = default)
{
var depth = Math.Clamp(maxDepth, 1, MaxAllowedDepth);
var chain = await _linkResolver.ResolveDownstreamAsync(attestationId, depth, cancellationToken)
.ConfigureAwait(false);
if (chain is null)
{
return null;
}
return BuildChainResponse(attestationId, chain, "downstream", depth);
}
/// <inheritdoc />
public async Task<AttestationChainResponse?> GetFullChainAsync(
string attestationId,
int maxDepth = 5,
CancellationToken cancellationToken = default)
{
var depth = Math.Clamp(maxDepth, 1, MaxAllowedDepth);
var chain = await _linkResolver.ResolveFullChainAsync(attestationId, depth, cancellationToken)
.ConfigureAwait(false);
if (chain is null)
{
return null;
}
return BuildChainResponse(attestationId, chain, "full", depth);
}
/// <inheritdoc />
public async Task<ArtifactChainResponse?> GetAttestationsForArtifactAsync(
string artifactDigest,
bool includeChain = false,
int maxDepth = 5,
CancellationToken cancellationToken = default)
{
var attestations = await _nodeProvider.GetBySubjectAsync(artifactDigest, cancellationToken)
.ConfigureAwait(false);
if (attestations.Count == 0)
{
return null;
}
var summaries = new List<AttestationSummaryDto>();
foreach (var node in attestations)
{
var upstreamLinks = await _linkStore.GetByTargetAsync(node.AttestationId, cancellationToken)
.ConfigureAwait(false);
var downstreamLinks = await _linkStore.GetBySourceAsync(node.AttestationId, cancellationToken)
.ConfigureAwait(false);
summaries.Add(new AttestationSummaryDto
{
AttestationId = node.AttestationId,
PredicateType = node.PredicateType,
CreatedAt = node.CreatedAt,
Status = "verified",
RekorLogIndex = null,
UpstreamCount = upstreamLinks.Length,
DownstreamCount = downstreamLinks.Length
});
}
AttestationChainResponse? chainResponse = null;
if (includeChain && summaries.Count > 0)
{
var depth = Math.Clamp(maxDepth, 1, MaxAllowedDepth);
var primaryAttestation = summaries.OrderByDescending(s => s.CreatedAt).First();
chainResponse = await GetFullChainAsync(primaryAttestation.AttestationId, depth, cancellationToken)
.ConfigureAwait(false);
}
return new ArtifactChainResponse
{
ArtifactDigest = artifactDigest,
QueryTime = _timeProvider.GetUtcNow(),
Attestations = [.. summaries.OrderByDescending(s => s.CreatedAt)],
Chain = chainResponse
};
}
/// <inheritdoc />
public async Task<ChainGraphResponse?> GetChainGraphAsync(
string attestationId,
GraphFormat format = GraphFormat.Mermaid,
int maxDepth = 5,
CancellationToken cancellationToken = default)
{
var depth = Math.Clamp(maxDepth, 1, MaxAllowedDepth);
var chain = await _linkResolver.ResolveFullChainAsync(attestationId, depth, cancellationToken)
.ConfigureAwait(false);
if (chain is null)
{
return null;
}
var content = format switch
{
GraphFormat.Mermaid => GenerateMermaidGraph(chain),
GraphFormat.Dot => GenerateDotGraph(chain),
GraphFormat.Json => GenerateJsonGraph(chain),
_ => GenerateMermaidGraph(chain)
};
return new ChainGraphResponse
{
AttestationId = attestationId,
Format = format,
Content = content,
NodeCount = chain.Nodes.Length,
LinkCount = chain.Links.Length,
GeneratedAt = _timeProvider.GetUtcNow()
};
}
private AttestationChainResponse BuildChainResponse(
string attestationId,
AttestationChain chain,
string direction,
int requestedDepth)
{
var nodeCount = chain.Nodes.Length;
var isTruncated = nodeCount >= MaxNodes;
var maxDepthReached = chain.Nodes.Length > 0
? chain.Nodes.Max(n => n.Depth)
: 0;
var rootNodes = chain.Nodes.Where(n => n.IsRoot).ToImmutableArray();
var leafNodes = chain.Nodes.Where(n => n.IsLeaf).ToImmutableArray();
var predicateTypes = chain.Nodes
.Select(n => n.PredicateType)
.Distinct()
.ToImmutableArray();
var nodes = chain.Nodes.Select(n => new AttestationNodeDto
{
AttestationId = n.AttestationId,
PredicateType = n.PredicateType,
SubjectDigest = n.SubjectDigest,
CreatedAt = n.CreatedAt,
Depth = n.Depth,
IsRoot = n.IsRoot,
IsLeaf = n.IsLeaf,
Metadata = n.Metadata?.Count > 0 ? n.Metadata : null
}).ToImmutableArray();
var links = chain.Links.Select(l => new AttestationLinkDto
{
SourceId = l.SourceAttestationId,
TargetId = l.TargetAttestationId,
LinkType = l.LinkType.ToString(),
CreatedAt = l.CreatedAt,
Reason = l.Metadata?.Reason
}).ToImmutableArray();
return new AttestationChainResponse
{
AttestationId = attestationId,
Direction = direction,
MaxDepth = requestedDepth,
QueryTime = _timeProvider.GetUtcNow(),
Nodes = nodes,
Links = links,
Summary = new AttestationChainSummaryDto
{
TotalNodes = nodeCount,
TotalLinks = chain.Links.Length,
MaxDepthReached = maxDepthReached,
RootCount = rootNodes.Length,
LeafCount = leafNodes.Length,
PredicateTypes = predicateTypes,
IsComplete = !isTruncated && maxDepthReached < requestedDepth,
TruncatedReason = isTruncated ? $"Result truncated at {MaxNodes} nodes" : null
}
};
}
private static string GenerateMermaidGraph(AttestationChain chain)
{
var sb = new StringBuilder();
sb.AppendLine("graph TD");
// Add node definitions with shapes based on predicate type
foreach (var node in chain.Nodes)
{
var shortId = GetShortId(node.AttestationId);
var label = $"{node.PredicateType}\\n{shortId}";
var shape = node.PredicateType.ToUpperInvariant() switch
{
"SBOM" => $" {shortId}[/{label}/]",
"VEX" => $" {shortId}[({label})]",
"VERDICT" => $" {shortId}{{{{{label}}}}}",
_ => $" {shortId}[{label}]"
};
sb.AppendLine(shape);
}
sb.AppendLine();
// Add edges with link type labels
foreach (var link in chain.Links)
{
var sourceShort = GetShortId(link.SourceAttestationId);
var targetShort = GetShortId(link.TargetAttestationId);
var linkLabel = link.LinkType.ToString().ToLowerInvariant();
sb.AppendLine($" {sourceShort} -->|{linkLabel}| {targetShort}");
}
return sb.ToString();
}
private static string GenerateDotGraph(AttestationChain chain)
{
var sb = new StringBuilder();
sb.AppendLine("digraph attestation_chain {");
sb.AppendLine(" rankdir=TB;");
sb.AppendLine(" node [fontname=\"Helvetica\"];");
sb.AppendLine();
// Add node definitions
foreach (var node in chain.Nodes)
{
var shortId = GetShortId(node.AttestationId);
var shape = node.PredicateType.ToUpperInvariant() switch
{
"SBOM" => "parallelogram",
"VEX" => "ellipse",
"VERDICT" => "diamond",
_ => "box"
};
sb.AppendLine($" \"{shortId}\" [label=\"{node.PredicateType}\\n{shortId}\", shape={shape}];");
}
sb.AppendLine();
// Add edges
foreach (var link in chain.Links)
{
var sourceShort = GetShortId(link.SourceAttestationId);
var targetShort = GetShortId(link.TargetAttestationId);
var linkLabel = link.LinkType.ToString().ToLowerInvariant();
sb.AppendLine($" \"{sourceShort}\" -> \"{targetShort}\" [label=\"{linkLabel}\"];");
}
sb.AppendLine("}");
return sb.ToString();
}
private static string GenerateJsonGraph(AttestationChain chain)
{
var graph = new
{
nodes = chain.Nodes.Select(n => new
{
id = n.AttestationId,
shortId = GetShortId(n.AttestationId),
type = n.PredicateType,
subject = n.SubjectDigest,
depth = n.Depth,
isRoot = n.IsRoot,
isLeaf = n.IsLeaf
}).ToArray(),
edges = chain.Links.Select(l => new
{
source = l.SourceAttestationId,
target = l.TargetAttestationId,
type = l.LinkType.ToString()
}).ToArray()
};
return System.Text.Json.JsonSerializer.Serialize(graph, new System.Text.Json.JsonSerializerOptions
{
WriteIndented = true
});
}
private static string GetShortId(string attestationId)
{
if (attestationId.StartsWith("sha256:", StringComparison.Ordinal) && attestationId.Length > 15)
{
return attestationId[7..15];
}
return attestationId.Length > 8 ? attestationId[..8] : attestationId;
}
}

View File

@@ -0,0 +1,80 @@
// -----------------------------------------------------------------------------
// IChainQueryService.cs
// Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking
// Task: T020
// Description: Service interface for attestation chain queries.
// -----------------------------------------------------------------------------
using StellaOps.Attestor.WebService.Models;
namespace StellaOps.Attestor.WebService.Services;
/// <summary>
/// Service for querying attestation chains and their relationships.
/// </summary>
public interface IChainQueryService
{
/// <summary>
/// Gets upstream (parent) attestations from a starting point.
/// </summary>
/// <param name="attestationId">The attestation ID to start from.</param>
/// <param name="maxDepth">Maximum traversal depth.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Chain response with upstream attestations.</returns>
Task<AttestationChainResponse?> GetUpstreamChainAsync(
string attestationId,
int maxDepth = 5,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets downstream (child) attestations from a starting point.
/// </summary>
/// <param name="attestationId">The attestation ID to start from.</param>
/// <param name="maxDepth">Maximum traversal depth.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Chain response with downstream attestations.</returns>
Task<AttestationChainResponse?> GetDownstreamChainAsync(
string attestationId,
int maxDepth = 5,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the full chain (both directions) from a starting point.
/// </summary>
/// <param name="attestationId">The attestation ID to start from.</param>
/// <param name="maxDepth">Maximum traversal depth in each direction.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Chain response with full attestation graph.</returns>
Task<AttestationChainResponse?> GetFullChainAsync(
string attestationId,
int maxDepth = 5,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all attestations for an artifact with optional chain expansion.
/// </summary>
/// <param name="artifactDigest">The artifact digest (sha256:...).</param>
/// <param name="includeChain">Whether to include the full chain.</param>
/// <param name="maxDepth">Maximum chain traversal depth.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Artifact chain response.</returns>
Task<ArtifactChainResponse?> GetAttestationsForArtifactAsync(
string artifactDigest,
bool includeChain = false,
int maxDepth = 5,
CancellationToken cancellationToken = default);
/// <summary>
/// Generates a graph visualization for a chain.
/// </summary>
/// <param name="attestationId">The attestation ID to start from.</param>
/// <param name="format">The output format (Mermaid, Dot, Json).</param>
/// <param name="maxDepth">Maximum traversal depth.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Graph visualization response.</returns>
Task<ChainGraphResponse?> GetChainGraphAsync(
string attestationId,
GraphFormat format = GraphFormat.Mermaid,
int maxDepth = 5,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,256 @@
// <copyright file="AuthorityConfigDiffTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
// Sprint: SPRINT_20260105_002_005_TEST_cross_cutting
// Task: CCUT-021
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.TestKit;
using StellaOps.Testing.ConfigDiff;
using Xunit;
namespace StellaOps.Authority.ConfigDiff.Tests;
/// <summary>
/// Config-diff tests for the Authority module.
/// Verifies that configuration changes produce only expected behavioral deltas.
/// </summary>
[Trait("Category", TestCategories.ConfigDiff)]
[Trait("Category", TestCategories.Integration)]
[Trait("BlastRadius", TestCategories.BlastRadius.Auth)]
public class AuthorityConfigDiffTests : ConfigDiffTestBase
{
/// <summary>
/// Initializes a new instance of the <see cref="AuthorityConfigDiffTests"/> class.
/// </summary>
public AuthorityConfigDiffTests()
: base(
new ConfigDiffTestConfig(StrictMode: true),
NullLogger.Instance)
{
}
/// <summary>
/// Verifies that changing token lifetime only affects token behavior.
/// </summary>
[Fact]
public async Task ChangingTokenLifetime_OnlyAffectsTokenBehavior()
{
// Arrange
var baselineConfig = new AuthorityTestConfig
{
AccessTokenLifetimeMinutes = 15,
RefreshTokenLifetimeHours = 24,
MaxConcurrentSessions = 5
};
var changedConfig = baselineConfig with
{
AccessTokenLifetimeMinutes = 30
};
// Act
var result = await TestConfigIsolationAsync(
baselineConfig,
changedConfig,
changedSetting: "AccessTokenLifetimeMinutes",
unrelatedBehaviors:
[
async config => await GetSessionBehaviorAsync(config),
async config => await GetRefreshBehaviorAsync(config),
async config => await GetAuthenticationBehaviorAsync(config)
]);
// Assert
result.IsSuccess.Should().BeTrue(
because: "changing token lifetime should not affect sessions or authentication");
}
/// <summary>
/// Verifies that changing max sessions produces expected behavioral delta.
/// </summary>
[Fact]
public async Task ChangingMaxSessions_ProducesExpectedDelta()
{
// Arrange
var baselineConfig = new AuthorityTestConfig { MaxConcurrentSessions = 3 };
var changedConfig = new AuthorityTestConfig { MaxConcurrentSessions = 10 };
var expectedDelta = new ConfigDelta(
ChangedBehaviors: ["SessionLimit", "ConcurrencyPolicy"],
BehaviorDeltas:
[
new BehaviorDelta("SessionLimit", "3", "10", null),
new BehaviorDelta("ConcurrencyPolicy", "restrictive", "permissive",
"More sessions allowed")
]);
// Act
var result = await TestConfigBehavioralDeltaAsync(
baselineConfig,
changedConfig,
getBehavior: async config => await CaptureSessionBehaviorAsync(config),
computeDelta: ComputeBehaviorSnapshotDelta,
expectedDelta: expectedDelta);
// Assert
result.IsSuccess.Should().BeTrue(
because: "session limit change should produce expected behavioral delta");
}
/// <summary>
/// Verifies that enabling DPoP only affects token binding.
/// </summary>
[Fact]
public async Task EnablingDPoP_OnlyAffectsTokenBinding()
{
// Arrange
var baselineConfig = new AuthorityTestConfig { EnableDPoP = false };
var changedConfig = new AuthorityTestConfig { EnableDPoP = true };
// Act
var result = await TestConfigIsolationAsync(
baselineConfig,
changedConfig,
changedSetting: "EnableDPoP",
unrelatedBehaviors:
[
async config => await GetSessionBehaviorAsync(config),
async config => await GetPasswordPolicyBehaviorAsync(config)
]);
// Assert
result.IsSuccess.Should().BeTrue(
because: "DPoP should not affect sessions or password policy");
}
/// <summary>
/// Verifies that changing password policy produces expected changes.
/// </summary>
[Fact]
public async Task ChangingPasswordMinLength_ProducesExpectedDelta()
{
// Arrange
var baselineConfig = new AuthorityTestConfig { MinPasswordLength = 8 };
var changedConfig = new AuthorityTestConfig { MinPasswordLength = 12 };
var expectedDelta = new ConfigDelta(
ChangedBehaviors: ["PasswordComplexity", "ValidationRejectionRate"],
BehaviorDeltas:
[
new BehaviorDelta("PasswordComplexity", "standard", "enhanced", null),
new BehaviorDelta("ValidationRejectionRate", "increase", null,
"Stricter requirements reject more passwords")
]);
// Act
var result = await TestConfigBehavioralDeltaAsync(
baselineConfig,
changedConfig,
getBehavior: async config => await CapturePasswordPolicyBehaviorAsync(config),
computeDelta: ComputeBehaviorSnapshotDelta,
expectedDelta: expectedDelta);
// Assert
result.IsSuccess.Should().BeTrue();
}
/// <summary>
/// Verifies that enabling MFA only affects authentication flow.
/// </summary>
[Fact]
public async Task EnablingMFA_OnlyAffectsAuthentication()
{
// Arrange
var baselineConfig = new AuthorityTestConfig { RequireMFA = false };
var changedConfig = new AuthorityTestConfig { RequireMFA = true };
// Act
var result = await TestConfigIsolationAsync(
baselineConfig,
changedConfig,
changedSetting: "RequireMFA",
unrelatedBehaviors:
[
async config => await GetTokenBehaviorAsync(config),
async config => await GetSessionBehaviorAsync(config)
]);
// Assert
result.IsSuccess.Should().BeTrue(
because: "MFA should not affect token issuance or session management");
}
// Helper methods
private static Task<object> GetSessionBehaviorAsync(AuthorityTestConfig config)
{
return Task.FromResult<object>(new { MaxSessions = config.MaxConcurrentSessions });
}
private static Task<object> GetRefreshBehaviorAsync(AuthorityTestConfig config)
{
return Task.FromResult<object>(new { RefreshLifetime = config.RefreshTokenLifetimeHours });
}
private static Task<object> GetAuthenticationBehaviorAsync(AuthorityTestConfig config)
{
return Task.FromResult<object>(new { MfaRequired = config.RequireMFA });
}
private static Task<object> GetPasswordPolicyBehaviorAsync(AuthorityTestConfig config)
{
return Task.FromResult<object>(new { MinLength = config.MinPasswordLength });
}
private static Task<object> GetTokenBehaviorAsync(AuthorityTestConfig config)
{
return Task.FromResult<object>(new { Lifetime = config.AccessTokenLifetimeMinutes });
}
private static Task<BehaviorSnapshot> CaptureSessionBehaviorAsync(AuthorityTestConfig config)
{
var snapshot = new BehaviorSnapshot(
ConfigurationId: $"sessions-{config.MaxConcurrentSessions}",
Behaviors:
[
new CapturedBehavior("SessionLimit", config.MaxConcurrentSessions.ToString(), DateTimeOffset.UtcNow),
new CapturedBehavior("ConcurrencyPolicy",
config.MaxConcurrentSessions > 5 ? "permissive" : "restrictive", DateTimeOffset.UtcNow)
],
CapturedAt: DateTimeOffset.UtcNow);
return Task.FromResult(snapshot);
}
private static Task<BehaviorSnapshot> CapturePasswordPolicyBehaviorAsync(AuthorityTestConfig config)
{
var snapshot = new BehaviorSnapshot(
ConfigurationId: $"password-{config.MinPasswordLength}",
Behaviors:
[
new CapturedBehavior("PasswordComplexity",
config.MinPasswordLength >= 12 ? "enhanced" : "standard", DateTimeOffset.UtcNow),
new CapturedBehavior("ValidationRejectionRate",
config.MinPasswordLength >= 12 ? "increase" : "standard", DateTimeOffset.UtcNow)
],
CapturedAt: DateTimeOffset.UtcNow);
return Task.FromResult(snapshot);
}
}
/// <summary>
/// Test configuration for Authority module.
/// </summary>
public sealed record AuthorityTestConfig
{
public int AccessTokenLifetimeMinutes { get; init; } = 15;
public int RefreshTokenLifetimeHours { get; init; } = 24;
public int MaxConcurrentSessions { get; init; } = 5;
public bool EnableDPoP { get; init; } = false;
public int MinPasswordLength { get; init; } = 8;
public bool RequireMFA { get; init; } = false;
}

View File

@@ -0,0 +1,23 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<LangVersion>preview</LangVersion>
<Description>Config-diff tests for Authority module</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Authority.Core/StellaOps.Authority.Core.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
<ProjectReference Include="../../../__Tests/__Libraries/StellaOps.Testing.ConfigDiff/StellaOps.Testing.ConfigDiff.csproj" />
</ItemGroup>
</Project>

View File

@@ -15,5 +15,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Authority.Core/StellaOps.Authority.Core.csproj" />
<ProjectReference Include="../../../__Tests/__Libraries/StellaOps.Testing.Temporal/StellaOps.Testing.Temporal.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,296 @@
// <copyright file="TemporalVerdictTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
// Sprint: SPRINT_20260105_002_001_TEST_time_skew_idempotency
// Task: TSKW-011
using FluentAssertions;
using StellaOps.Authority.Core.Verdicts;
using StellaOps.Testing.Temporal;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Authority.Core.Tests.Verdicts;
/// <summary>
/// Temporal testing for verdict manifests using the Testing.Temporal library.
/// Tests clock cutoff handling, timestamp consistency, and determinism under time skew.
/// </summary>
[Trait("Category", TestCategories.Unit)]
public sealed class TemporalVerdictTests
{
private static readonly DateTimeOffset BaseTime = new(2026, 1, 5, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void VerdictManifest_ClockCutoff_BoundaryPrecision()
{
// Arrange
var ttlProvider = new TtlBoundaryTimeProvider(BaseTime);
var ttl = TimeSpan.FromHours(24); // Typical verdict validity window
var clockCutoff = BaseTime;
// Position at various boundaries
var testCases = TtlBoundaryTimeProvider.GenerateBoundaryTestCases(clockCutoff, ttl).ToList();
// Assert - verify all boundary cases are correctly handled
foreach (var testCase in testCases)
{
var isExpired = testCase.Time >= clockCutoff.Add(ttl);
isExpired.Should().Be(
testCase.ShouldBeExpired,
$"Verdict clock cutoff case '{testCase.Name}' should be expired={testCase.ShouldBeExpired}");
}
}
[Fact]
public void VerdictManifestBuilder_IsDeterministic_UnderTimeAdvancement()
{
// Arrange
var timeProvider = new SimulatedTimeProvider(BaseTime);
var results = new List<string>();
// Act - build multiple manifests while advancing time
for (int i = 0; i < 10; i++)
{
var manifest = BuildTestManifest(BaseTime); // Use fixed clock, not advancing
results.Add(manifest.ManifestDigest);
timeProvider.Advance(TimeSpan.FromMinutes(5)); // Advance between builds
}
// Assert - all manifests should have same digest (deterministic)
results.Distinct().Should().HaveCount(1, "manifests built with same inputs should be deterministic");
}
[Fact]
public void VerdictManifestBuilder_Build_IsIdempotent()
{
// Arrange
var stateSnapshotter = () => BuildTestManifest(BaseTime).ManifestDigest;
var verifier = new IdempotencyVerifier<string>(stateSnapshotter);
// Act - verify Build is idempotent
var result = verifier.Verify(() => { /* Build is called in snapshotter */ }, repetitions: 5);
// Assert
result.IsIdempotent.Should().BeTrue("VerdictManifestBuilder.Build should be idempotent");
result.AllSucceeded.Should().BeTrue();
}
[Fact]
public void VerdictManifest_TimestampOrdering_IsMonotonic()
{
// Arrange - simulate verdict timestamps
var timeProvider = new SimulatedTimeProvider(BaseTime);
var timestamps = new List<DateTimeOffset>();
// Simulate verdict lifecycle: created, processed, signed, stored
timestamps.Add(timeProvider.GetUtcNow()); // Created
timeProvider.Advance(TimeSpan.FromMilliseconds(50));
timestamps.Add(timeProvider.GetUtcNow()); // Processed
timeProvider.Advance(TimeSpan.FromMilliseconds(100));
timestamps.Add(timeProvider.GetUtcNow()); // Signed
timeProvider.Advance(TimeSpan.FromMilliseconds(20));
timestamps.Add(timeProvider.GetUtcNow()); // Stored
// Act & Assert - timestamps should be monotonically increasing
ClockSkewAssertions.AssertMonotonicTimestamps(timestamps);
}
[Fact]
public void VerdictManifest_HandlesClockSkewForward()
{
// Arrange
var timeProvider = new SimulatedTimeProvider(BaseTime);
var clockCutoff1 = timeProvider.GetUtcNow();
// Simulate clock jump forward (NTP correction)
timeProvider.JumpTo(BaseTime.AddHours(2));
var clockCutoff2 = timeProvider.GetUtcNow();
// Act - build manifests with different clock cutoffs
var manifest1 = BuildTestManifest(clockCutoff1);
var manifest2 = BuildTestManifest(clockCutoff2);
// Assert - different clock cutoffs should produce different digests
manifest1.ManifestDigest.Should().NotBe(manifest2.ManifestDigest,
"different clock cutoffs should produce different manifest digests");
// Clock cutoff difference should be within expected range
ClockSkewAssertions.AssertTimestampsWithinTolerance(
clockCutoff1,
clockCutoff2,
tolerance: TimeSpan.FromHours(3));
}
[Fact]
public void VerdictManifest_ClockDrift_DoesNotAffectDeterminism()
{
// Arrange
var timeProvider = new SimulatedTimeProvider(BaseTime);
timeProvider.SetDrift(TimeSpan.FromMilliseconds(10)); // 10ms/second drift
var results = new List<string>();
var fixedClock = BaseTime; // Use fixed clock for manifest
// Act - build manifests while time drifts
for (int i = 0; i < 10; i++)
{
var manifest = BuildTestManifest(fixedClock);
results.Add(manifest.ManifestDigest);
timeProvider.Advance(TimeSpan.FromSeconds(10)); // Time advances with drift
}
// Assert - all should be identical (fixed clock input)
results.Distinct().Should().HaveCount(1,
"manifests with fixed clock should be deterministic regardless of system drift");
}
[Fact]
public void VerdictManifest_ClockJumpBackward_IsDetected()
{
// Arrange
var timeProvider = new SimulatedTimeProvider(BaseTime);
var timestamps = new List<DateTimeOffset>();
// Record timestamps
timestamps.Add(timeProvider.GetUtcNow());
timeProvider.Advance(TimeSpan.FromMinutes(5));
timestamps.Add(timeProvider.GetUtcNow());
// Simulate clock jump backward
timeProvider.JumpBackward(TimeSpan.FromMinutes(3));
timestamps.Add(timeProvider.GetUtcNow());
// Assert - backward jump should be detected
timeProvider.HasJumpedBackward().Should().BeTrue();
// Non-monotonic timestamps should be detected
var act = () => ClockSkewAssertions.AssertMonotonicTimestamps(timestamps);
act.Should().Throw<ClockSkewAssertionException>();
}
[Theory]
[InlineData(0.9, VexStatus.NotAffected)]
[InlineData(0.7, VexStatus.Affected)]
[InlineData(0.5, VexStatus.UnderInvestigation)]
public void VerdictManifest_ConfidenceScores_AreIdempotent(double confidence, VexStatus status)
{
// Arrange
var stateSnapshotter = () =>
{
var manifest = BuildTestManifest(BaseTime, confidence, status);
return manifest.Result.Confidence;
};
var verifier = new IdempotencyVerifier<double>(stateSnapshotter);
// Act
var result = verifier.Verify(() => { }, repetitions: 3);
// Assert
result.IsIdempotent.Should().BeTrue();
result.States.Should().AllSatisfy(c => c.Should().Be(confidence));
}
[Fact]
public void VerdictManifest_ExpiryWindow_BoundaryTests()
{
// Arrange - simulate verdict expiry window (e.g., 7 days)
var expiryWindow = TimeSpan.FromDays(7);
var createdAt = BaseTime;
// Generate boundary test cases
var testCases = TtlBoundaryTimeProvider.GenerateBoundaryTestCases(createdAt, expiryWindow);
// Assert
foreach (var testCase in testCases)
{
var isExpired = testCase.Time >= createdAt.Add(expiryWindow);
isExpired.Should().Be(testCase.ShouldBeExpired, testCase.Name);
}
}
[Theory]
[MemberData(nameof(GetVerdictExpiryBoundaryData))]
public void VerdictManifest_TheoryBoundaryTests(
string name,
DateTimeOffset testTime,
bool shouldBeExpired)
{
// Arrange
var expiryWindow = TimeSpan.FromDays(7);
var expiry = BaseTime.Add(expiryWindow);
// Act
var isExpired = testTime >= expiry;
// Assert
isExpired.Should().Be(shouldBeExpired, $"Case '{name}' should be expired={shouldBeExpired}");
}
public static IEnumerable<object[]> GetVerdictExpiryBoundaryData()
{
var expiryWindow = TimeSpan.FromDays(7);
return TtlBoundaryTimeProvider.GenerateTheoryData(BaseTime, expiryWindow);
}
[Fact]
public void VerdictManifest_LeapSecondScenario_MaintainsDeterminism()
{
// Arrange
var leapDay = new DateOnly(2016, 12, 31);
var leapProvider = new LeapSecondTimeProvider(
new DateTimeOffset(2016, 12, 31, 23, 0, 0, TimeSpan.Zero),
leapDay);
var results = new List<string>();
var fixedClock = new DateTimeOffset(2016, 12, 31, 12, 0, 0, TimeSpan.Zero);
// Act - build manifests while advancing through leap second
foreach (var moment in leapProvider.AdvanceThroughLeapSecond(leapDay))
{
var manifest = BuildTestManifest(fixedClock);
results.Add(manifest.ManifestDigest);
}
// Assert - all manifests should be identical (fixed clock)
results.Distinct().Should().HaveCount(1,
"manifests should be deterministic even during leap second transition");
}
private static VerdictManifest BuildTestManifest(
DateTimeOffset clockCutoff,
double confidence = 0.85,
VexStatus status = VexStatus.NotAffected)
{
return new VerdictManifestBuilder(() => "test-manifest-id")
.WithTenant("tenant-1")
.WithAsset("sha256:abc123", "CVE-2024-1234")
.WithInputs(
sbomDigests: new[] { "sha256:sbom1" },
vulnFeedSnapshotIds: new[] { "feed-snapshot-1" },
vexDocumentDigests: new[] { "sha256:vex1" },
clockCutoff: clockCutoff)
.WithResult(
status: status,
confidence: confidence,
explanations: new[]
{
new VerdictExplanation
{
SourceId = "vendor-a",
Reason = "Test explanation",
ProvenanceScore = 0.9,
CoverageScore = 0.8,
ReplayabilityScore = 0.7,
StrengthMultiplier = 1.0,
FreshnessMultiplier = 0.95,
ClaimScore = confidence,
AssertedStatus = status,
Accepted = true,
},
})
.WithPolicy("sha256:policy123", "1.0.0")
.WithClock(clockCutoff)
.Build();
}
}

View File

@@ -253,6 +253,24 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.FixIn
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.WebService.Tests", "__Tests\StellaOps.BinaryIndex.WebService.Tests\StellaOps.BinaryIndex.WebService.Tests.csproj", "{C12D06F8-7B69-4A24-B206-C47326778F2E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Semantic", "__Libraries\StellaOps.BinaryIndex.Semantic\StellaOps.BinaryIndex.Semantic.csproj", "{1C21DB5D-C8FF-4EF2-9847-7049515A0FE7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Disassembly.Abstractions", "__Libraries\StellaOps.BinaryIndex.Disassembly.Abstractions\StellaOps.BinaryIndex.Disassembly.Abstractions.csproj", "{3112D5DD-E993-4737-955B-D8FE20CEC88A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Semantic.Tests", "__Tests\StellaOps.BinaryIndex.Semantic.Tests\StellaOps.BinaryIndex.Semantic.Tests.csproj", "{89CCD547-09D4-4923-9644-17724AF60F1C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TestKit", "..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj", "{C064F3B6-AF8E-4C92-A2FB-3BEF9FB7CC92}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Ensemble", "__Libraries\StellaOps.BinaryIndex.Ensemble\StellaOps.BinaryIndex.Ensemble.csproj", "{7612CE73-B27A-4489-A89E-E22FF19981B7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Decompiler", "__Libraries\StellaOps.BinaryIndex.Decompiler\StellaOps.BinaryIndex.Decompiler.csproj", "{66EEF897-8006-4C53-B2AB-C55D82BDE6D7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Ghidra", "__Libraries\StellaOps.BinaryIndex.Ghidra\StellaOps.BinaryIndex.Ghidra.csproj", "{C5C87F73-6EEF-4296-A1DD-24563E4F05B4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.ML", "__Libraries\StellaOps.BinaryIndex.ML\StellaOps.BinaryIndex.ML.csproj", "{850F7C46-E98B-431A-B202-FF97FB041BAD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.BinaryIndex.Ensemble.Tests", "__Tests\StellaOps.BinaryIndex.Ensemble.Tests\StellaOps.BinaryIndex.Ensemble.Tests.csproj", "{87356481-048B-4D3F-B4D5-3B6494A1F038}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -1151,6 +1169,114 @@ Global
{C12D06F8-7B69-4A24-B206-C47326778F2E}.Release|x64.Build.0 = Release|Any CPU
{C12D06F8-7B69-4A24-B206-C47326778F2E}.Release|x86.ActiveCfg = Release|Any CPU
{C12D06F8-7B69-4A24-B206-C47326778F2E}.Release|x86.Build.0 = Release|Any CPU
{1C21DB5D-C8FF-4EF2-9847-7049515A0FE7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1C21DB5D-C8FF-4EF2-9847-7049515A0FE7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1C21DB5D-C8FF-4EF2-9847-7049515A0FE7}.Debug|x64.ActiveCfg = Debug|Any CPU
{1C21DB5D-C8FF-4EF2-9847-7049515A0FE7}.Debug|x64.Build.0 = Debug|Any CPU
{1C21DB5D-C8FF-4EF2-9847-7049515A0FE7}.Debug|x86.ActiveCfg = Debug|Any CPU
{1C21DB5D-C8FF-4EF2-9847-7049515A0FE7}.Debug|x86.Build.0 = Debug|Any CPU
{1C21DB5D-C8FF-4EF2-9847-7049515A0FE7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1C21DB5D-C8FF-4EF2-9847-7049515A0FE7}.Release|Any CPU.Build.0 = Release|Any CPU
{1C21DB5D-C8FF-4EF2-9847-7049515A0FE7}.Release|x64.ActiveCfg = Release|Any CPU
{1C21DB5D-C8FF-4EF2-9847-7049515A0FE7}.Release|x64.Build.0 = Release|Any CPU
{1C21DB5D-C8FF-4EF2-9847-7049515A0FE7}.Release|x86.ActiveCfg = Release|Any CPU
{1C21DB5D-C8FF-4EF2-9847-7049515A0FE7}.Release|x86.Build.0 = Release|Any CPU
{3112D5DD-E993-4737-955B-D8FE20CEC88A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3112D5DD-E993-4737-955B-D8FE20CEC88A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3112D5DD-E993-4737-955B-D8FE20CEC88A}.Debug|x64.ActiveCfg = Debug|Any CPU
{3112D5DD-E993-4737-955B-D8FE20CEC88A}.Debug|x64.Build.0 = Debug|Any CPU
{3112D5DD-E993-4737-955B-D8FE20CEC88A}.Debug|x86.ActiveCfg = Debug|Any CPU
{3112D5DD-E993-4737-955B-D8FE20CEC88A}.Debug|x86.Build.0 = Debug|Any CPU
{3112D5DD-E993-4737-955B-D8FE20CEC88A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3112D5DD-E993-4737-955B-D8FE20CEC88A}.Release|Any CPU.Build.0 = Release|Any CPU
{3112D5DD-E993-4737-955B-D8FE20CEC88A}.Release|x64.ActiveCfg = Release|Any CPU
{3112D5DD-E993-4737-955B-D8FE20CEC88A}.Release|x64.Build.0 = Release|Any CPU
{3112D5DD-E993-4737-955B-D8FE20CEC88A}.Release|x86.ActiveCfg = Release|Any CPU
{3112D5DD-E993-4737-955B-D8FE20CEC88A}.Release|x86.Build.0 = Release|Any CPU
{89CCD547-09D4-4923-9644-17724AF60F1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{89CCD547-09D4-4923-9644-17724AF60F1C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{89CCD547-09D4-4923-9644-17724AF60F1C}.Debug|x64.ActiveCfg = Debug|Any CPU
{89CCD547-09D4-4923-9644-17724AF60F1C}.Debug|x64.Build.0 = Debug|Any CPU
{89CCD547-09D4-4923-9644-17724AF60F1C}.Debug|x86.ActiveCfg = Debug|Any CPU
{89CCD547-09D4-4923-9644-17724AF60F1C}.Debug|x86.Build.0 = Debug|Any CPU
{89CCD547-09D4-4923-9644-17724AF60F1C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{89CCD547-09D4-4923-9644-17724AF60F1C}.Release|Any CPU.Build.0 = Release|Any CPU
{89CCD547-09D4-4923-9644-17724AF60F1C}.Release|x64.ActiveCfg = Release|Any CPU
{89CCD547-09D4-4923-9644-17724AF60F1C}.Release|x64.Build.0 = Release|Any CPU
{89CCD547-09D4-4923-9644-17724AF60F1C}.Release|x86.ActiveCfg = Release|Any CPU
{89CCD547-09D4-4923-9644-17724AF60F1C}.Release|x86.Build.0 = Release|Any CPU
{C064F3B6-AF8E-4C92-A2FB-3BEF9FB7CC92}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C064F3B6-AF8E-4C92-A2FB-3BEF9FB7CC92}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C064F3B6-AF8E-4C92-A2FB-3BEF9FB7CC92}.Debug|x64.ActiveCfg = Debug|Any CPU
{C064F3B6-AF8E-4C92-A2FB-3BEF9FB7CC92}.Debug|x64.Build.0 = Debug|Any CPU
{C064F3B6-AF8E-4C92-A2FB-3BEF9FB7CC92}.Debug|x86.ActiveCfg = Debug|Any CPU
{C064F3B6-AF8E-4C92-A2FB-3BEF9FB7CC92}.Debug|x86.Build.0 = Debug|Any CPU
{C064F3B6-AF8E-4C92-A2FB-3BEF9FB7CC92}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C064F3B6-AF8E-4C92-A2FB-3BEF9FB7CC92}.Release|Any CPU.Build.0 = Release|Any CPU
{C064F3B6-AF8E-4C92-A2FB-3BEF9FB7CC92}.Release|x64.ActiveCfg = Release|Any CPU
{C064F3B6-AF8E-4C92-A2FB-3BEF9FB7CC92}.Release|x64.Build.0 = Release|Any CPU
{C064F3B6-AF8E-4C92-A2FB-3BEF9FB7CC92}.Release|x86.ActiveCfg = Release|Any CPU
{C064F3B6-AF8E-4C92-A2FB-3BEF9FB7CC92}.Release|x86.Build.0 = Release|Any CPU
{7612CE73-B27A-4489-A89E-E22FF19981B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7612CE73-B27A-4489-A89E-E22FF19981B7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7612CE73-B27A-4489-A89E-E22FF19981B7}.Debug|x64.ActiveCfg = Debug|Any CPU
{7612CE73-B27A-4489-A89E-E22FF19981B7}.Debug|x64.Build.0 = Debug|Any CPU
{7612CE73-B27A-4489-A89E-E22FF19981B7}.Debug|x86.ActiveCfg = Debug|Any CPU
{7612CE73-B27A-4489-A89E-E22FF19981B7}.Debug|x86.Build.0 = Debug|Any CPU
{7612CE73-B27A-4489-A89E-E22FF19981B7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7612CE73-B27A-4489-A89E-E22FF19981B7}.Release|Any CPU.Build.0 = Release|Any CPU
{7612CE73-B27A-4489-A89E-E22FF19981B7}.Release|x64.ActiveCfg = Release|Any CPU
{7612CE73-B27A-4489-A89E-E22FF19981B7}.Release|x64.Build.0 = Release|Any CPU
{7612CE73-B27A-4489-A89E-E22FF19981B7}.Release|x86.ActiveCfg = Release|Any CPU
{7612CE73-B27A-4489-A89E-E22FF19981B7}.Release|x86.Build.0 = Release|Any CPU
{66EEF897-8006-4C53-B2AB-C55D82BDE6D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{66EEF897-8006-4C53-B2AB-C55D82BDE6D7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{66EEF897-8006-4C53-B2AB-C55D82BDE6D7}.Debug|x64.ActiveCfg = Debug|Any CPU
{66EEF897-8006-4C53-B2AB-C55D82BDE6D7}.Debug|x64.Build.0 = Debug|Any CPU
{66EEF897-8006-4C53-B2AB-C55D82BDE6D7}.Debug|x86.ActiveCfg = Debug|Any CPU
{66EEF897-8006-4C53-B2AB-C55D82BDE6D7}.Debug|x86.Build.0 = Debug|Any CPU
{66EEF897-8006-4C53-B2AB-C55D82BDE6D7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{66EEF897-8006-4C53-B2AB-C55D82BDE6D7}.Release|Any CPU.Build.0 = Release|Any CPU
{66EEF897-8006-4C53-B2AB-C55D82BDE6D7}.Release|x64.ActiveCfg = Release|Any CPU
{66EEF897-8006-4C53-B2AB-C55D82BDE6D7}.Release|x64.Build.0 = Release|Any CPU
{66EEF897-8006-4C53-B2AB-C55D82BDE6D7}.Release|x86.ActiveCfg = Release|Any CPU
{66EEF897-8006-4C53-B2AB-C55D82BDE6D7}.Release|x86.Build.0 = Release|Any CPU
{C5C87F73-6EEF-4296-A1DD-24563E4F05B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C5C87F73-6EEF-4296-A1DD-24563E4F05B4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C5C87F73-6EEF-4296-A1DD-24563E4F05B4}.Debug|x64.ActiveCfg = Debug|Any CPU
{C5C87F73-6EEF-4296-A1DD-24563E4F05B4}.Debug|x64.Build.0 = Debug|Any CPU
{C5C87F73-6EEF-4296-A1DD-24563E4F05B4}.Debug|x86.ActiveCfg = Debug|Any CPU
{C5C87F73-6EEF-4296-A1DD-24563E4F05B4}.Debug|x86.Build.0 = Debug|Any CPU
{C5C87F73-6EEF-4296-A1DD-24563E4F05B4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C5C87F73-6EEF-4296-A1DD-24563E4F05B4}.Release|Any CPU.Build.0 = Release|Any CPU
{C5C87F73-6EEF-4296-A1DD-24563E4F05B4}.Release|x64.ActiveCfg = Release|Any CPU
{C5C87F73-6EEF-4296-A1DD-24563E4F05B4}.Release|x64.Build.0 = Release|Any CPU
{C5C87F73-6EEF-4296-A1DD-24563E4F05B4}.Release|x86.ActiveCfg = Release|Any CPU
{C5C87F73-6EEF-4296-A1DD-24563E4F05B4}.Release|x86.Build.0 = Release|Any CPU
{850F7C46-E98B-431A-B202-FF97FB041BAD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{850F7C46-E98B-431A-B202-FF97FB041BAD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{850F7C46-E98B-431A-B202-FF97FB041BAD}.Debug|x64.ActiveCfg = Debug|Any CPU
{850F7C46-E98B-431A-B202-FF97FB041BAD}.Debug|x64.Build.0 = Debug|Any CPU
{850F7C46-E98B-431A-B202-FF97FB041BAD}.Debug|x86.ActiveCfg = Debug|Any CPU
{850F7C46-E98B-431A-B202-FF97FB041BAD}.Debug|x86.Build.0 = Debug|Any CPU
{850F7C46-E98B-431A-B202-FF97FB041BAD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{850F7C46-E98B-431A-B202-FF97FB041BAD}.Release|Any CPU.Build.0 = Release|Any CPU
{850F7C46-E98B-431A-B202-FF97FB041BAD}.Release|x64.ActiveCfg = Release|Any CPU
{850F7C46-E98B-431A-B202-FF97FB041BAD}.Release|x64.Build.0 = Release|Any CPU
{850F7C46-E98B-431A-B202-FF97FB041BAD}.Release|x86.ActiveCfg = Release|Any CPU
{850F7C46-E98B-431A-B202-FF97FB041BAD}.Release|x86.Build.0 = Release|Any CPU
{87356481-048B-4D3F-B4D5-3B6494A1F038}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{87356481-048B-4D3F-B4D5-3B6494A1F038}.Debug|Any CPU.Build.0 = Debug|Any CPU
{87356481-048B-4D3F-B4D5-3B6494A1F038}.Debug|x64.ActiveCfg = Debug|Any CPU
{87356481-048B-4D3F-B4D5-3B6494A1F038}.Debug|x64.Build.0 = Debug|Any CPU
{87356481-048B-4D3F-B4D5-3B6494A1F038}.Debug|x86.ActiveCfg = Debug|Any CPU
{87356481-048B-4D3F-B4D5-3B6494A1F038}.Debug|x86.Build.0 = Debug|Any CPU
{87356481-048B-4D3F-B4D5-3B6494A1F038}.Release|Any CPU.ActiveCfg = Release|Any CPU
{87356481-048B-4D3F-B4D5-3B6494A1F038}.Release|Any CPU.Build.0 = Release|Any CPU
{87356481-048B-4D3F-B4D5-3B6494A1F038}.Release|x64.ActiveCfg = Release|Any CPU
{87356481-048B-4D3F-B4D5-3B6494A1F038}.Release|x64.Build.0 = Release|Any CPU
{87356481-048B-4D3F-B4D5-3B6494A1F038}.Release|x86.ActiveCfg = Release|Any CPU
{87356481-048B-4D3F-B4D5-3B6494A1F038}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -1246,6 +1372,14 @@ Global
{FB127279-C17B-40DC-AC68-320B7CE85E76} = {BB76B5A5-14BA-E317-828D-110B711D71F5}
{AAE98543-46B4-4707-AD1F-CCC9142F8712} = {BB76B5A5-14BA-E317-828D-110B711D71F5}
{C12D06F8-7B69-4A24-B206-C47326778F2E} = {BB76B5A5-14BA-E317-828D-110B711D71F5}
{1C21DB5D-C8FF-4EF2-9847-7049515A0FE7} = {A5C98087-E847-D2C4-2143-20869479839D}
{3112D5DD-E993-4737-955B-D8FE20CEC88A} = {A5C98087-E847-D2C4-2143-20869479839D}
{89CCD547-09D4-4923-9644-17724AF60F1C} = {BB76B5A5-14BA-E317-828D-110B711D71F5}
{7612CE73-B27A-4489-A89E-E22FF19981B7} = {A5C98087-E847-D2C4-2143-20869479839D}
{66EEF897-8006-4C53-B2AB-C55D82BDE6D7} = {A5C98087-E847-D2C4-2143-20869479839D}
{C5C87F73-6EEF-4296-A1DD-24563E4F05B4} = {A5C98087-E847-D2C4-2143-20869479839D}
{850F7C46-E98B-431A-B202-FF97FB041BAD} = {A5C98087-E847-D2C4-2143-20869479839D}
{87356481-048B-4D3F-B4D5-3B6494A1F038} = {BB76B5A5-14BA-E317-828D-110B711D71F5}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {21B6BF22-3A64-CD15-49B3-21A490AAD068}

View File

@@ -1,3 +1,5 @@
using StellaOps.BinaryIndex.Semantic;
namespace StellaOps.BinaryIndex.Builders;
/// <summary>
@@ -109,6 +111,12 @@ public sealed record FunctionFingerprint
/// Source line number if debug info available.
/// </summary>
public int? SourceLine { get; init; }
/// <summary>
/// Semantic fingerprint for enhanced similarity comparison.
/// Uses IR-level analysis for resilience to compiler optimizations.
/// </summary>
public Semantic.SemanticFingerprint? SemanticFingerprint { get; init; }
}
/// <summary>

View File

@@ -192,25 +192,42 @@ public sealed record HashWeights
/// <summary>
/// Weight for basic block hash comparison.
/// </summary>
public decimal BasicBlockWeight { get; init; } = 0.5m;
public decimal BasicBlockWeight { get; init; } = 0.4m;
/// <summary>
/// Weight for CFG hash comparison.
/// </summary>
public decimal CfgWeight { get; init; } = 0.3m;
public decimal CfgWeight { get; init; } = 0.25m;
/// <summary>
/// Weight for string refs hash comparison.
/// </summary>
public decimal StringRefsWeight { get; init; } = 0.2m;
public decimal StringRefsWeight { get; init; } = 0.15m;
/// <summary>
/// Weight for semantic fingerprint comparison.
/// Only used when both fingerprints have semantic data.
/// </summary>
public decimal SemanticWeight { get; init; } = 0.2m;
/// <summary>
/// Default weights.
/// </summary>
public static HashWeights Default => new();
/// <summary>
/// Weights without semantic analysis (traditional mode).
/// </summary>
public static HashWeights Traditional => new()
{
BasicBlockWeight = 0.5m,
CfgWeight = 0.3m,
StringRefsWeight = 0.2m,
SemanticWeight = 0.0m
};
/// <summary>
/// Validates that weights sum to 1.0.
/// </summary>
public bool IsValid => Math.Abs(BasicBlockWeight + CfgWeight + StringRefsWeight - 1.0m) < 0.001m;
public bool IsValid => Math.Abs(BasicBlockWeight + CfgWeight + StringRefsWeight + SemanticWeight - 1.0m) < 0.001m;
}

View File

@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging;
using StellaOps.BinaryIndex.Semantic;
namespace StellaOps.BinaryIndex.Builders;
@@ -202,6 +203,16 @@ public sealed class PatchDiffEngine : IPatchDiffEngine
matchedWeight += weights.StringRefsWeight;
}
// Include semantic fingerprint similarity if available
if (weights.SemanticWeight > 0 &&
a.SemanticFingerprint is not null &&
b.SemanticFingerprint is not null)
{
totalWeight += weights.SemanticWeight;
var semanticSimilarity = ComputeSemanticSimilarity(a.SemanticFingerprint, b.SemanticFingerprint);
matchedWeight += weights.SemanticWeight * semanticSimilarity;
}
// Size similarity bonus (if sizes are within 10%, add small bonus)
if (a.Size > 0 && b.Size > 0)
{
@@ -216,6 +227,86 @@ public sealed class PatchDiffEngine : IPatchDiffEngine
return totalWeight > 0 ? matchedWeight / totalWeight : 0m;
}
private static decimal ComputeSemanticSimilarity(
Semantic.SemanticFingerprint a,
Semantic.SemanticFingerprint b)
{
// Check for exact hash match first
if (a.HashEquals(b))
{
return 1.0m;
}
// Compute weighted similarity from components
decimal graphSim = ComputeHashSimilarity(a.GraphHash, b.GraphHash);
decimal opSim = ComputeHashSimilarity(a.OperationHash, b.OperationHash);
decimal dfSim = ComputeHashSimilarity(a.DataFlowHash, b.DataFlowHash);
decimal apiSim = ComputeApiCallSimilarity(a.ApiCalls, b.ApiCalls);
// Weights: graph structure 40%, operation sequence 25%, data flow 20%, API calls 15%
return (graphSim * 0.40m) + (opSim * 0.25m) + (dfSim * 0.20m) + (apiSim * 0.15m);
}
private static decimal ComputeHashSimilarity(byte[] hashA, byte[] hashB)
{
if (hashA.Length == 0 || hashB.Length == 0)
{
return 0m;
}
if (hashA.AsSpan().SequenceEqual(hashB))
{
return 1.0m;
}
// Count matching bits (Hamming similarity)
int matchingBits = 0;
int totalBits = hashA.Length * 8;
int len = Math.Min(hashA.Length, hashB.Length);
for (int i = 0; i < len; i++)
{
byte xor = (byte)(hashA[i] ^ hashB[i]);
matchingBits += 8 - PopCount(xor);
}
return (decimal)matchingBits / totalBits;
}
private static int PopCount(byte value)
{
int count = 0;
while (value != 0)
{
count += value & 1;
value >>= 1;
}
return count;
}
private static decimal ComputeApiCallSimilarity(
System.Collections.Immutable.ImmutableArray<string> apiCallsA,
System.Collections.Immutable.ImmutableArray<string> apiCallsB)
{
if (apiCallsA.IsEmpty && apiCallsB.IsEmpty)
{
return 1.0m;
}
if (apiCallsA.IsEmpty || apiCallsB.IsEmpty)
{
return 0.0m;
}
var setA = new HashSet<string>(apiCallsA, StringComparer.Ordinal);
var setB = new HashSet<string>(apiCallsB, StringComparer.Ordinal);
var intersection = setA.Intersect(setB).Count();
var union = setA.Union(setB).Count();
return union > 0 ? (decimal)intersection / union : 0m;
}
/// <inheritdoc />
public IReadOnlyDictionary<string, string> FindFunctionMappings(
IReadOnlyList<FunctionFingerprint> vulnerable,

View File

@@ -20,5 +20,6 @@
<ItemGroup>
<ProjectReference Include="../StellaOps.BinaryIndex.Core/StellaOps.BinaryIndex.Core.csproj" />
<ProjectReference Include="../StellaOps.BinaryIndex.Fingerprints/StellaOps.BinaryIndex.Fingerprints.csproj" />
<ProjectReference Include="../StellaOps.BinaryIndex.Semantic/StellaOps.BinaryIndex.Semantic.csproj" />
</ItemGroup>
</Project>

View File

@@ -510,6 +510,27 @@ public sealed class CachedBinaryVulnerabilityService : IBinaryVulnerabilityServi
}
}
/// <inheritdoc />
public async Task<ImmutableArray<CorpusFunctionMatch>> IdentifyFunctionFromCorpusAsync(
FunctionFingerprintSet fingerprints,
CorpusLookupOptions? options = null,
CancellationToken ct = default)
{
// Delegate to inner service - corpus lookups typically don't benefit from caching
// due to high variance in fingerprint sets
return await _inner.IdentifyFunctionFromCorpusAsync(fingerprints, options, ct).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<ImmutableDictionary<string, ImmutableArray<CorpusFunctionMatch>>> IdentifyFunctionsFromCorpusBatchAsync(
IEnumerable<(string Key, FunctionFingerprintSet Fingerprints)> functions,
CorpusLookupOptions? options = null,
CancellationToken ct = default)
{
// Delegate to inner service - batch corpus lookups typically don't benefit from caching
return await _inner.IdentifyFunctionsFromCorpusBatchAsync(functions, options, ct).ConfigureAwait(false);
}
public async ValueTask DisposeAsync()
{
_connectionLock.Dispose();

View File

@@ -99,6 +99,27 @@ public interface IBinaryVulnerabilityService
string symbolName,
DeltaSigLookupOptions? options = null,
CancellationToken ct = default);
/// <summary>
/// Identify a function by its fingerprints using the corpus database.
/// Returns matching library functions with CVE associations.
/// </summary>
/// <param name="fingerprints">Function fingerprints (semantic, instruction, API call).</param>
/// <param name="options">Corpus lookup options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Identified functions with vulnerability associations.</returns>
Task<ImmutableArray<CorpusFunctionMatch>> IdentifyFunctionFromCorpusAsync(
FunctionFingerprintSet fingerprints,
CorpusLookupOptions? options = null,
CancellationToken ct = default);
/// <summary>
/// Batch identify functions from corpus for scan performance.
/// </summary>
Task<ImmutableDictionary<string, ImmutableArray<CorpusFunctionMatch>>> IdentifyFunctionsFromCorpusBatchAsync(
IEnumerable<(string Key, FunctionFingerprintSet Fingerprints)> functions,
CorpusLookupOptions? options = null,
CancellationToken ct = default);
}
/// <summary>
@@ -225,3 +246,141 @@ public sealed record FixStatusResult
/// <summary>Reference to the underlying evidence record.</summary>
public Guid? EvidenceId { get; init; }
}
/// <summary>
/// Function fingerprint set for corpus matching.
/// </summary>
public sealed record FunctionFingerprintSet
{
/// <summary>Semantic fingerprint (IR-based).</summary>
public byte[]? SemanticFingerprint { get; init; }
/// <summary>Instruction fingerprint (normalized assembly).</summary>
public byte[]? InstructionFingerprint { get; init; }
/// <summary>API call sequence fingerprint.</summary>
public byte[]? ApiCallFingerprint { get; init; }
/// <summary>Function name if available (may be stripped).</summary>
public string? FunctionName { get; init; }
/// <summary>Architecture of the binary.</summary>
public required string Architecture { get; init; }
/// <summary>Function size in bytes.</summary>
public int? FunctionSize { get; init; }
}
/// <summary>
/// Options for corpus-based function identification.
/// </summary>
public sealed record CorpusLookupOptions
{
/// <summary>Minimum similarity threshold (0.0-1.0). Default 0.85.</summary>
public decimal MinSimilarity { get; init; } = 0.85m;
/// <summary>Maximum candidates to return. Default 5.</summary>
public int MaxCandidates { get; init; } = 5;
/// <summary>Library name filter (glibc, openssl, etc.). Null means all.</summary>
public string? LibraryFilter { get; init; }
/// <summary>Whether to include CVE associations. Default true.</summary>
public bool IncludeCveAssociations { get; init; } = true;
/// <summary>Whether to check fix status for matched CVEs. Default true.</summary>
public bool CheckFixStatus { get; init; } = true;
/// <summary>Distro hint for fix status lookup.</summary>
public string? DistroHint { get; init; }
/// <summary>Release hint for fix status lookup.</summary>
public string? ReleaseHint { get; init; }
/// <summary>Prefer semantic fingerprint matching over instruction. Default true.</summary>
public bool PreferSemanticMatch { get; init; } = true;
}
/// <summary>
/// Result of corpus-based function identification.
/// </summary>
public sealed record CorpusFunctionMatch
{
/// <summary>Matched library name (glibc, openssl, etc.).</summary>
public required string LibraryName { get; init; }
/// <summary>Library version range where this function appears.</summary>
public required string VersionRange { get; init; }
/// <summary>Canonical function name.</summary>
public required string FunctionName { get; init; }
/// <summary>Overall match confidence (0.0-1.0).</summary>
public required decimal Confidence { get; init; }
/// <summary>Match method used (semantic, instruction, combined).</summary>
public required CorpusMatchMethod Method { get; init; }
/// <summary>Semantic similarity score if available.</summary>
public decimal? SemanticSimilarity { get; init; }
/// <summary>Instruction similarity score if available.</summary>
public decimal? InstructionSimilarity { get; init; }
/// <summary>CVEs affecting this function (if requested).</summary>
public ImmutableArray<CorpusCveAssociation> CveAssociations { get; init; } = [];
}
/// <summary>
/// Method used for corpus matching.
/// </summary>
public enum CorpusMatchMethod
{
/// <summary>Matched via semantic fingerprint (IR-based).</summary>
Semantic,
/// <summary>Matched via instruction fingerprint.</summary>
Instruction,
/// <summary>Matched via API call sequence.</summary>
ApiCall,
/// <summary>Combined match using multiple fingerprints.</summary>
Combined
}
/// <summary>
/// CVE association from corpus for a matched function.
/// </summary>
public sealed record CorpusCveAssociation
{
/// <summary>CVE identifier.</summary>
public required string CveId { get; init; }
/// <summary>Affected state for the matched version.</summary>
public required CorpusAffectedState AffectedState { get; init; }
/// <summary>Version where fix was applied (if fixed).</summary>
public string? FixedInVersion { get; init; }
/// <summary>Confidence in the CVE association.</summary>
public required decimal Confidence { get; init; }
/// <summary>Evidence type for the association.</summary>
public string? EvidenceType { get; init; }
}
/// <summary>
/// Affected state for corpus CVE associations.
/// </summary>
public enum CorpusAffectedState
{
/// <summary>Function is vulnerable to the CVE.</summary>
Vulnerable,
/// <summary>Function has been fixed.</summary>
Fixed,
/// <summary>Function is not affected by the CVE.</summary>
NotAffected
}

View File

@@ -0,0 +1,447 @@
using System.Collections.Immutable;
using System.Net.Http;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using StellaOps.BinaryIndex.Corpus.Models;
namespace StellaOps.BinaryIndex.Corpus.Connectors;
/// <summary>
/// Corpus connector for libcurl/curl library.
/// Fetches pre-built binaries from distribution packages or official releases.
/// </summary>
public sealed partial class CurlCorpusConnector : ILibraryCorpusConnector
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<CurlCorpusConnector> _logger;
/// <summary>
/// Base URL for curl official releases.
/// </summary>
public const string CurlReleasesUrl = "https://curl.se/download/";
/// <summary>
/// Supported architectures.
/// </summary>
private static readonly ImmutableArray<string> s_supportedArchitectures =
["x86_64", "aarch64", "armhf", "i386"];
public CurlCorpusConnector(
IHttpClientFactory httpClientFactory,
ILogger<CurlCorpusConnector> logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
}
/// <inheritdoc />
public string LibraryName => "curl";
/// <inheritdoc />
public ImmutableArray<string> SupportedArchitectures => s_supportedArchitectures;
/// <inheritdoc />
public async Task<ImmutableArray<string>> GetAvailableVersionsAsync(CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient("Curl");
var versions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Fetch releases from curl.se
try
{
_logger.LogDebug("Fetching curl versions from {Url}", CurlReleasesUrl);
var html = await client.GetStringAsync(CurlReleasesUrl, ct);
var currentVersions = ParseVersionsFromListing(html);
foreach (var v in currentVersions)
{
versions.Add(v);
}
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "Failed to fetch current curl releases");
}
// Also check archive
const string archiveUrl = "https://curl.se/download/archeology/";
try
{
_logger.LogDebug("Fetching old curl versions from {Url}", archiveUrl);
var archiveHtml = await client.GetStringAsync(archiveUrl, ct);
var archiveVersions = ParseVersionsFromListing(archiveHtml);
foreach (var v in archiveVersions)
{
versions.Add(v);
}
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "Failed to fetch curl archive releases");
}
_logger.LogInformation("Found {Count} curl versions", versions.Count);
return [.. versions.OrderByDescending(ParseVersion)];
}
/// <inheritdoc />
public async Task<LibraryBinary?> FetchBinaryAsync(
string version,
string architecture,
LibraryFetchOptions? options = null,
CancellationToken ct = default)
{
var normalizedArch = NormalizeArchitecture(architecture);
_logger.LogInformation(
"Fetching curl {Version} for {Architecture}",
version,
normalizedArch);
// Strategy 1: Try Debian/Ubuntu package (pre-built, preferred)
var debBinary = await TryFetchDebianPackageAsync(version, normalizedArch, options, ct);
if (debBinary is not null)
{
_logger.LogDebug("Found curl {Version} from Debian packages", version);
return debBinary;
}
// Strategy 2: Try Alpine APK
var alpineBinary = await TryFetchAlpinePackageAsync(version, normalizedArch, options, ct);
if (alpineBinary is not null)
{
_logger.LogDebug("Found curl {Version} from Alpine packages", version);
return alpineBinary;
}
_logger.LogWarning(
"Could not find pre-built curl {Version} for {Architecture}. Source build not implemented.",
version,
normalizedArch);
return null;
}
/// <inheritdoc />
public async IAsyncEnumerable<LibraryBinary> FetchBinariesAsync(
IEnumerable<string> versions,
string architecture,
LibraryFetchOptions? options = null,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
{
foreach (var version in versions)
{
ct.ThrowIfCancellationRequested();
var binary = await FetchBinaryAsync(version, architecture, options, ct);
if (binary is not null)
{
yield return binary;
}
}
}
#region Private Methods
private ImmutableArray<string> ParseVersionsFromListing(string html)
{
// Match patterns like curl-8.5.0.tar.gz or curl-7.88.1.tar.xz
var matches = CurlVersionRegex().Matches(html);
var versions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (Match match in matches)
{
if (match.Groups["version"].Success)
{
versions.Add(match.Groups["version"].Value);
}
}
return [.. versions];
}
private async Task<LibraryBinary?> TryFetchDebianPackageAsync(
string version,
string architecture,
LibraryFetchOptions? options,
CancellationToken ct)
{
var client = _httpClientFactory.CreateClient("DebianPackages");
var debArch = MapToDebianArchitecture(architecture);
if (debArch is null)
{
return null;
}
// curl library package names:
// libcurl4 (current), libcurl3 (older)
var packageNames = new[] { "libcurl4", "libcurl3" };
foreach (var packageName in packageNames)
{
var packageUrls = await FindDebianPackageUrlsAsync(client, packageName, version, debArch, ct);
foreach (var url in packageUrls)
{
try
{
_logger.LogDebug("Trying Debian curl package URL: {Url}", url);
var packageBytes = await client.GetByteArrayAsync(url, ct);
var binary = await ExtractLibCurlFromDebAsync(packageBytes, version, architecture, options, ct);
if (binary is not null)
{
return binary;
}
}
catch (HttpRequestException ex)
{
_logger.LogDebug(ex, "Failed to download Debian package from {Url}", url);
}
}
}
return null;
}
private async Task<LibraryBinary?> TryFetchAlpinePackageAsync(
string version,
string architecture,
LibraryFetchOptions? options,
CancellationToken ct)
{
var client = _httpClientFactory.CreateClient("AlpinePackages");
var alpineArch = MapToAlpineArchitecture(architecture);
if (alpineArch is null)
{
return null;
}
// Query Alpine package repository for libcurl
var packageUrls = await FindAlpinePackageUrlsAsync(client, "libcurl", version, alpineArch, ct);
foreach (var url in packageUrls)
{
try
{
_logger.LogDebug("Trying Alpine curl package URL: {Url}", url);
var packageBytes = await client.GetByteArrayAsync(url, ct);
var binary = await ExtractLibCurlFromApkAsync(packageBytes, version, architecture, options, ct);
if (binary is not null)
{
return binary;
}
}
catch (HttpRequestException ex)
{
_logger.LogDebug(ex, "Failed to download Alpine package from {Url}", url);
}
}
return null;
}
private async Task<ImmutableArray<string>> FindDebianPackageUrlsAsync(
HttpClient client,
string packageName,
string version,
string debianArch,
CancellationToken ct)
{
var apiUrl = $"https://snapshot.debian.org/mr/binary/{packageName}/";
try
{
var response = await client.GetStringAsync(apiUrl, ct);
var urls = ExtractPackageUrlsForVersion(response, version, debianArch);
return urls;
}
catch (HttpRequestException ex)
{
_logger.LogDebug(ex, "Debian snapshot API query failed for {Package}", packageName);
return [];
}
}
private async Task<ImmutableArray<string>> FindAlpinePackageUrlsAsync(
HttpClient client,
string packageName,
string version,
string alpineArch,
CancellationToken ct)
{
var releases = new[] { "v3.20", "v3.19", "v3.18", "v3.17" };
var urls = new List<string>();
foreach (var release in releases)
{
var baseUrl = $"https://dl-cdn.alpinelinux.org/alpine/{release}/main/{alpineArch}/";
try
{
var html = await client.GetStringAsync(baseUrl, ct);
var matches = AlpinePackageRegex().Matches(html);
foreach (Match match in matches)
{
if (match.Groups["name"].Value == packageName &&
match.Groups["version"].Value.StartsWith(version, StringComparison.OrdinalIgnoreCase))
{
urls.Add($"{baseUrl}{match.Groups["file"].Value}");
}
}
}
catch (HttpRequestException)
{
// Skip releases we can't access
}
}
return [.. urls];
}
private async Task<LibraryBinary?> ExtractLibCurlFromDebAsync(
byte[] debPackage,
string version,
string architecture,
LibraryFetchOptions? options,
CancellationToken ct)
{
// .deb extraction - placeholder
await Task.CompletedTask;
_logger.LogDebug(
"Debian package extraction not fully implemented. Package size: {Size} bytes",
debPackage.Length);
return null;
}
private async Task<LibraryBinary?> ExtractLibCurlFromApkAsync(
byte[] apkPackage,
string version,
string architecture,
LibraryFetchOptions? options,
CancellationToken ct)
{
// .apk extraction - placeholder
await Task.CompletedTask;
_logger.LogDebug(
"Alpine package extraction not fully implemented. Package size: {Size} bytes",
apkPackage.Length);
return null;
}
private static ImmutableArray<string> ExtractPackageUrlsForVersion(
string json,
string version,
string debianArch)
{
var urls = new List<string>();
try
{
using var doc = System.Text.Json.JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("result", out var results))
{
foreach (var item in results.EnumerateArray())
{
if (item.TryGetProperty("binary_version", out var binaryVersion) &&
item.TryGetProperty("architecture", out var arch))
{
var binVer = binaryVersion.GetString() ?? string.Empty;
var archStr = arch.GetString() ?? string.Empty;
if (binVer.Contains(version, StringComparison.OrdinalIgnoreCase) &&
archStr.Equals(debianArch, StringComparison.OrdinalIgnoreCase))
{
if (item.TryGetProperty("files", out var files))
{
foreach (var file in files.EnumerateArray())
{
if (file.TryGetProperty("hash", out var hashElement))
{
var hash = hashElement.GetString();
if (!string.IsNullOrEmpty(hash))
{
urls.Add($"https://snapshot.debian.org/file/{hash}");
}
}
}
}
}
}
}
}
}
catch (System.Text.Json.JsonException)
{
// Invalid JSON
}
return [.. urls];
}
private static string NormalizeArchitecture(string architecture)
{
return architecture.ToLowerInvariant() switch
{
"x86_64" or "amd64" => "x86_64",
"aarch64" or "arm64" => "aarch64",
"armhf" or "armv7" or "arm" => "armhf",
"i386" or "i686" or "x86" => "i386",
_ => architecture
};
}
private static string? MapToDebianArchitecture(string architecture)
{
return architecture.ToLowerInvariant() switch
{
"x86_64" => "amd64",
"aarch64" => "arm64",
"armhf" or "armv7" => "armhf",
"i386" or "i686" => "i386",
_ => null
};
}
private static string? MapToAlpineArchitecture(string architecture)
{
return architecture.ToLowerInvariant() switch
{
"x86_64" => "x86_64",
"aarch64" => "aarch64",
"armhf" or "armv7" => "armhf",
"i386" or "i686" => "x86",
_ => null
};
}
private static Version? ParseVersion(string versionString)
{
if (Version.TryParse(versionString, out var version))
{
return version;
}
return null;
}
#endregion
#region Generated Regexes
[GeneratedRegex(@"curl-(?<version>\d+\.\d+(?:\.\d+)?)", RegexOptions.IgnoreCase)]
private static partial Regex CurlVersionRegex();
[GeneratedRegex(@"href=""(?<file>(?<name>[a-z0-9_-]+)-(?<version>[0-9.]+(?:-r\d+)?)\.apk)""", RegexOptions.IgnoreCase)]
private static partial Regex AlpinePackageRegex();
#endregion
}

View File

@@ -0,0 +1,549 @@
using System.Collections.Immutable;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using StellaOps.BinaryIndex.Corpus.Models;
namespace StellaOps.BinaryIndex.Corpus.Connectors;
/// <summary>
/// Corpus connector for GNU C Library (glibc).
/// Fetches pre-built binaries from Debian/Ubuntu package repositories
/// or GNU FTP mirrors for source builds.
/// </summary>
public sealed partial class GlibcCorpusConnector : ILibraryCorpusConnector
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<GlibcCorpusConnector> _logger;
/// <summary>
/// Base URL for GNU FTP mirror (source tarballs).
/// </summary>
public const string GnuMirrorUrl = "https://ftp.gnu.org/gnu/glibc/";
/// <summary>
/// Base URL for Debian package archive.
/// </summary>
public const string DebianSnapshotUrl = "https://snapshot.debian.org/package/glibc/";
/// <summary>
/// Supported architectures for glibc.
/// </summary>
private static readonly ImmutableArray<string> s_supportedArchitectures =
["x86_64", "aarch64", "armhf", "i386", "arm64", "ppc64el", "s390x"];
public GlibcCorpusConnector(
IHttpClientFactory httpClientFactory,
ILogger<GlibcCorpusConnector> logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
}
/// <inheritdoc />
public string LibraryName => "glibc";
/// <inheritdoc />
public ImmutableArray<string> SupportedArchitectures => s_supportedArchitectures;
/// <inheritdoc />
public async Task<ImmutableArray<string>> GetAvailableVersionsAsync(CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient("GnuMirror");
try
{
_logger.LogDebug("Fetching glibc versions from {Url}", GnuMirrorUrl);
var html = await client.GetStringAsync(GnuMirrorUrl, ct);
// Parse directory listing for glibc-X.Y.tar.xz files
var versions = ParseVersionsFromListing(html);
_logger.LogInformation("Found {Count} glibc versions from GNU mirror", versions.Length);
return versions;
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "Failed to fetch glibc versions from GNU mirror, trying Debian snapshot");
// Fallback to Debian snapshot
return await GetVersionsFromDebianSnapshotAsync(client, ct);
}
}
/// <inheritdoc />
public async Task<LibraryBinary?> FetchBinaryAsync(
string version,
string architecture,
LibraryFetchOptions? options = null,
CancellationToken ct = default)
{
var normalizedArch = NormalizeArchitecture(architecture);
var abi = options?.PreferredAbi ?? "gnu";
_logger.LogInformation(
"Fetching glibc {Version} for {Architecture}",
version,
normalizedArch);
// Strategy 1: Try Debian package (pre-built, preferred)
var debBinary = await TryFetchDebianPackageAsync(version, normalizedArch, options, ct);
if (debBinary is not null)
{
_logger.LogDebug("Found glibc {Version} from Debian packages", version);
return debBinary;
}
// Strategy 2: Try Ubuntu package
var ubuntuBinary = await TryFetchUbuntuPackageAsync(version, normalizedArch, options, ct);
if (ubuntuBinary is not null)
{
_logger.LogDebug("Found glibc {Version} from Ubuntu packages", version);
return ubuntuBinary;
}
_logger.LogWarning(
"Could not find pre-built glibc {Version} for {Architecture}. Source build not implemented.",
version,
normalizedArch);
return null;
}
/// <inheritdoc />
public async IAsyncEnumerable<LibraryBinary> FetchBinariesAsync(
IEnumerable<string> versions,
string architecture,
LibraryFetchOptions? options = null,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
{
foreach (var version in versions)
{
ct.ThrowIfCancellationRequested();
var binary = await FetchBinaryAsync(version, architecture, options, ct);
if (binary is not null)
{
yield return binary;
}
}
}
#region Private Methods
private ImmutableArray<string> ParseVersionsFromListing(string html)
{
// Match patterns like glibc-2.31.tar.gz or glibc-2.38.tar.xz
var matches = GlibcVersionRegex().Matches(html);
var versions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (Match match in matches)
{
if (match.Groups["version"].Success)
{
versions.Add(match.Groups["version"].Value);
}
}
return [.. versions.OrderByDescending(ParseVersion)];
}
private async Task<ImmutableArray<string>> GetVersionsFromDebianSnapshotAsync(
HttpClient client,
CancellationToken ct)
{
try
{
var html = await client.GetStringAsync(DebianSnapshotUrl, ct);
// Parse Debian snapshot listing for glibc versions
var matches = DebianVersionRegex().Matches(html);
var versions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (Match match in matches)
{
if (match.Groups["version"].Success)
{
// Extract just the upstream version (before the Debian revision)
var fullVersion = match.Groups["version"].Value;
var upstreamVersion = ExtractUpstreamVersion(fullVersion);
if (!string.IsNullOrEmpty(upstreamVersion))
{
versions.Add(upstreamVersion);
}
}
}
return [.. versions.OrderByDescending(ParseVersion)];
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to fetch versions from Debian snapshot");
return [];
}
}
private async Task<LibraryBinary?> TryFetchDebianPackageAsync(
string version,
string architecture,
LibraryFetchOptions? options,
CancellationToken ct)
{
var client = _httpClientFactory.CreateClient("DebianPackages");
// Map architecture to Debian naming
var debArch = MapToDebianArchitecture(architecture);
if (debArch is null)
{
_logger.LogDebug("Architecture {Arch} not supported for Debian packages", architecture);
return null;
}
// Query Debian snapshot for matching package
var packageUrls = await FindDebianPackageUrlsAsync(client, version, debArch, ct);
foreach (var url in packageUrls)
{
try
{
_logger.LogDebug("Trying Debian package URL: {Url}", url);
var packageBytes = await client.GetByteArrayAsync(url, ct);
// Extract the libc6 shared library from the .deb package
var binary = await ExtractLibcFromDebAsync(packageBytes, version, architecture, options, ct);
if (binary is not null)
{
return binary;
}
}
catch (HttpRequestException ex)
{
_logger.LogDebug(ex, "Failed to download Debian package from {Url}", url);
}
}
return null;
}
private async Task<LibraryBinary?> TryFetchUbuntuPackageAsync(
string version,
string architecture,
LibraryFetchOptions? options,
CancellationToken ct)
{
var client = _httpClientFactory.CreateClient("UbuntuPackages");
// Map architecture to Ubuntu naming (same as Debian)
var debArch = MapToDebianArchitecture(architecture);
if (debArch is null)
{
return null;
}
// Query Launchpad for matching package
var packageUrls = await FindUbuntuPackageUrlsAsync(client, version, debArch, ct);
foreach (var url in packageUrls)
{
try
{
_logger.LogDebug("Trying Ubuntu package URL: {Url}", url);
var packageBytes = await client.GetByteArrayAsync(url, ct);
// Extract the libc6 shared library from the .deb package
var binary = await ExtractLibcFromDebAsync(packageBytes, version, architecture, options, ct);
if (binary is not null)
{
return binary;
}
}
catch (HttpRequestException ex)
{
_logger.LogDebug(ex, "Failed to download Ubuntu package from {Url}", url);
}
}
return null;
}
private async Task<ImmutableArray<string>> FindDebianPackageUrlsAsync(
HttpClient client,
string version,
string debianArch,
CancellationToken ct)
{
// Construct Debian snapshot API URL
// Format: https://snapshot.debian.org/mr/package/glibc/<version>/binfiles/libc6/<arch>
var apiUrl = $"https://snapshot.debian.org/mr/package/glibc/{version}/binfiles/libc6/{debianArch}";
try
{
var response = await client.GetStringAsync(apiUrl, ct);
// Parse JSON response to get file hashes and construct download URLs
// Simplified: extract URLs from response
var urls = ExtractPackageUrlsFromSnapshotResponse(response);
return urls;
}
catch (HttpRequestException)
{
// Try alternative: direct binary package search
return await FindDebianPackageUrlsViaSearchAsync(client, version, debianArch, ct);
}
}
private async Task<ImmutableArray<string>> FindDebianPackageUrlsViaSearchAsync(
HttpClient client,
string version,
string debianArch,
CancellationToken ct)
{
// Fallback: search packages.debian.org
var searchUrl = $"https://packages.debian.org/search?keywords=libc6&searchon=names&suite=all&section=all&arch={debianArch}";
try
{
var html = await client.GetStringAsync(searchUrl, ct);
// Parse search results to find matching version
var urls = ParseDebianSearchResults(html, version, debianArch);
return urls;
}
catch (HttpRequestException ex)
{
_logger.LogDebug(ex, "Debian package search failed");
return [];
}
}
private async Task<ImmutableArray<string>> FindUbuntuPackageUrlsAsync(
HttpClient client,
string version,
string debianArch,
CancellationToken ct)
{
// Query Launchpad for libc6 package
// Format: https://launchpad.net/ubuntu/+archive/primary/+files/libc6_<version>_<arch>.deb
var launchpadApiUrl = $"https://api.launchpad.net/1.0/ubuntu/+archive/primary?ws.op=getPublishedBinaries&binary_name=libc6&version={version}&distro_arch_series=https://api.launchpad.net/1.0/ubuntu/+distroarchseries/{debianArch}";
try
{
var response = await client.GetStringAsync(launchpadApiUrl, ct);
var urls = ExtractPackageUrlsFromLaunchpadResponse(response);
return urls;
}
catch (HttpRequestException ex)
{
_logger.LogDebug(ex, "Launchpad API query failed");
return [];
}
}
private async Task<LibraryBinary?> ExtractLibcFromDebAsync(
byte[] debPackage,
string version,
string architecture,
LibraryFetchOptions? options,
CancellationToken ct)
{
// .deb files are ar archives containing:
// - debian-binary (version string)
// - control.tar.xz (package metadata)
// - data.tar.xz (actual files)
//
// We need to extract /lib/x86_64-linux-gnu/libc.so.6 from data.tar.xz
try
{
// Use SharpCompress or similar to extract (placeholder for now)
// In production, implement proper ar + tar.xz extraction
await Task.CompletedTask; // Placeholder for async extraction
// For now, return null - full extraction requires SharpCompress/libarchive
_logger.LogDebug(
"Debian package extraction not fully implemented. Package size: {Size} bytes",
debPackage.Length);
return null;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to extract libc from .deb package");
return null;
}
}
private static string NormalizeArchitecture(string architecture)
{
return architecture.ToLowerInvariant() switch
{
"x86_64" or "amd64" => "x86_64",
"aarch64" or "arm64" => "aarch64",
"armhf" or "armv7" or "arm" => "armhf",
"i386" or "i686" or "x86" => "i386",
"ppc64le" or "ppc64el" => "ppc64el",
"s390x" => "s390x",
_ => architecture
};
}
private static string? MapToDebianArchitecture(string architecture)
{
return architecture.ToLowerInvariant() switch
{
"x86_64" => "amd64",
"aarch64" => "arm64",
"armhf" or "armv7" => "armhf",
"i386" or "i686" => "i386",
"ppc64el" => "ppc64el",
"s390x" => "s390x",
_ => null
};
}
private static string? ExtractUpstreamVersion(string debianVersion)
{
// Debian version format: [epoch:]upstream_version[-debian_revision]
// Examples:
// 2.31-13+deb11u5 -> 2.31
// 1:2.35-0ubuntu3 -> 2.35
var match = UpstreamVersionRegex().Match(debianVersion);
return match.Success ? match.Groups["upstream"].Value : null;
}
private static ImmutableArray<string> ExtractPackageUrlsFromSnapshotResponse(string json)
{
// Parse JSON response from snapshot.debian.org
// Format: {"result": [{"hash": "...", "name": "libc6_2.31-13_amd64.deb"}]}
var urls = new List<string>();
try
{
using var doc = System.Text.Json.JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("result", out var results))
{
foreach (var item in results.EnumerateArray())
{
if (item.TryGetProperty("hash", out var hashElement))
{
var hash = hashElement.GetString();
if (!string.IsNullOrEmpty(hash))
{
// Construct download URL from hash
var url = $"https://snapshot.debian.org/file/{hash}";
urls.Add(url);
}
}
}
}
}
catch (System.Text.Json.JsonException)
{
// Invalid JSON, return empty
}
return [.. urls];
}
private static ImmutableArray<string> ExtractPackageUrlsFromLaunchpadResponse(string json)
{
var urls = new List<string>();
try
{
using var doc = System.Text.Json.JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("entries", out var entries))
{
foreach (var entry in entries.EnumerateArray())
{
if (entry.TryGetProperty("binary_package_version", out var versionElement) &&
entry.TryGetProperty("self_link", out var selfLink))
{
var link = selfLink.GetString();
if (!string.IsNullOrEmpty(link))
{
// Launchpad provides download URL in separate field
urls.Add(link);
}
}
}
}
}
catch (System.Text.Json.JsonException)
{
// Invalid JSON
}
return [.. urls];
}
private static ImmutableArray<string> ParseDebianSearchResults(
string html,
string version,
string debianArch)
{
// Parse HTML search results to find package URLs
// This is a simplified implementation
var urls = new List<string>();
var matches = DebianPackageUrlRegex().Matches(html);
foreach (Match match in matches)
{
if (match.Groups["url"].Success)
{
var url = match.Groups["url"].Value;
if (url.Contains(version) && url.Contains(debianArch))
{
urls.Add(url);
}
}
}
return [.. urls];
}
private static Version? ParseVersion(string versionString)
{
// Try to parse as Version, handling various formats
// 2.31 -> 2.31.0.0
// 2.31.1 -> 2.31.1.0
if (Version.TryParse(versionString, out var version))
{
return version;
}
// Try adding .0 suffix
if (Version.TryParse(versionString + ".0", out version))
{
return version;
}
return null;
}
#endregion
#region Generated Regexes
[GeneratedRegex(@"glibc-(?<version>\d+\.\d+(?:\.\d+)?)", RegexOptions.IgnoreCase)]
private static partial Regex GlibcVersionRegex();
[GeneratedRegex(@"(?<version>\d+\.\d+(?:\.\d+)?(?:-\d+)?)", RegexOptions.IgnoreCase)]
private static partial Regex DebianVersionRegex();
[GeneratedRegex(@"(?:^|\:)?(?<upstream>\d+\.\d+(?:\.\d+)?)(?:-|$)", RegexOptions.IgnoreCase)]
private static partial Regex UpstreamVersionRegex();
[GeneratedRegex(@"href=""(?<url>https?://[^""]+\.deb)""", RegexOptions.IgnoreCase)]
private static partial Regex DebianPackageUrlRegex();
#endregion
}

View File

@@ -0,0 +1,554 @@
using System.Collections.Immutable;
using System.Net.Http;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using StellaOps.BinaryIndex.Corpus.Models;
namespace StellaOps.BinaryIndex.Corpus.Connectors;
/// <summary>
/// Corpus connector for OpenSSL libraries.
/// Fetches pre-built binaries from distribution packages or official releases.
/// </summary>
public sealed partial class OpenSslCorpusConnector : ILibraryCorpusConnector
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<OpenSslCorpusConnector> _logger;
/// <summary>
/// Base URL for OpenSSL official releases.
/// </summary>
public const string OpenSslReleasesUrl = "https://www.openssl.org/source/";
/// <summary>
/// Base URL for OpenSSL old releases.
/// </summary>
public const string OpenSslOldReleasesUrl = "https://www.openssl.org/source/old/";
/// <summary>
/// Supported architectures.
/// </summary>
private static readonly ImmutableArray<string> s_supportedArchitectures =
["x86_64", "aarch64", "armhf", "i386"];
public OpenSslCorpusConnector(
IHttpClientFactory httpClientFactory,
ILogger<OpenSslCorpusConnector> logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
}
/// <inheritdoc />
public string LibraryName => "openssl";
/// <inheritdoc />
public ImmutableArray<string> SupportedArchitectures => s_supportedArchitectures;
/// <inheritdoc />
public async Task<ImmutableArray<string>> GetAvailableVersionsAsync(CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient("OpenSsl");
var versions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Fetch current releases
try
{
_logger.LogDebug("Fetching OpenSSL versions from {Url}", OpenSslReleasesUrl);
var html = await client.GetStringAsync(OpenSslReleasesUrl, ct);
var currentVersions = ParseVersionsFromListing(html);
foreach (var v in currentVersions)
{
versions.Add(v);
}
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "Failed to fetch current OpenSSL releases");
}
// Fetch old releases index
try
{
_logger.LogDebug("Fetching old OpenSSL versions from {Url}", OpenSslOldReleasesUrl);
var oldHtml = await client.GetStringAsync(OpenSslOldReleasesUrl, ct);
var oldVersionDirs = ParseOldVersionDirectories(oldHtml);
foreach (var dir in oldVersionDirs)
{
var dirUrl = $"{OpenSslOldReleasesUrl}{dir}/";
try
{
var dirHtml = await client.GetStringAsync(dirUrl, ct);
var dirVersions = ParseVersionsFromListing(dirHtml);
foreach (var v in dirVersions)
{
versions.Add(v);
}
}
catch (HttpRequestException)
{
// Skip directories we can't access
}
}
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "Failed to fetch old OpenSSL releases");
}
_logger.LogInformation("Found {Count} OpenSSL versions", versions.Count);
return [.. versions.OrderByDescending(ParseVersion)];
}
/// <inheritdoc />
public async Task<LibraryBinary?> FetchBinaryAsync(
string version,
string architecture,
LibraryFetchOptions? options = null,
CancellationToken ct = default)
{
var normalizedArch = NormalizeArchitecture(architecture);
_logger.LogInformation(
"Fetching OpenSSL {Version} for {Architecture}",
version,
normalizedArch);
// Strategy 1: Try Debian/Ubuntu package (pre-built, preferred)
var debBinary = await TryFetchDebianPackageAsync(version, normalizedArch, options, ct);
if (debBinary is not null)
{
_logger.LogDebug("Found OpenSSL {Version} from Debian packages", version);
return debBinary;
}
// Strategy 2: Try Alpine APK
var alpineBinary = await TryFetchAlpinePackageAsync(version, normalizedArch, options, ct);
if (alpineBinary is not null)
{
_logger.LogDebug("Found OpenSSL {Version} from Alpine packages", version);
return alpineBinary;
}
_logger.LogWarning(
"Could not find pre-built OpenSSL {Version} for {Architecture}. Source build not implemented.",
version,
normalizedArch);
return null;
}
/// <inheritdoc />
public async IAsyncEnumerable<LibraryBinary> FetchBinariesAsync(
IEnumerable<string> versions,
string architecture,
LibraryFetchOptions? options = null,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
{
foreach (var version in versions)
{
ct.ThrowIfCancellationRequested();
var binary = await FetchBinaryAsync(version, architecture, options, ct);
if (binary is not null)
{
yield return binary;
}
}
}
#region Private Methods
private ImmutableArray<string> ParseVersionsFromListing(string html)
{
// Match patterns like openssl-1.1.1n.tar.gz or openssl-3.0.8.tar.gz
var matches = OpenSslVersionRegex().Matches(html);
var versions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (Match match in matches)
{
if (match.Groups["version"].Success)
{
var version = match.Groups["version"].Value;
// Normalize version: 1.1.1n -> 1.1.1n, 3.0.8 -> 3.0.8
versions.Add(version);
}
}
return [.. versions];
}
private ImmutableArray<string> ParseOldVersionDirectories(string html)
{
// Match directory names like 1.0.2/, 1.1.0/, 1.1.1/, 3.0/
var matches = VersionDirRegex().Matches(html);
var dirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (Match match in matches)
{
if (match.Groups["dir"].Success)
{
dirs.Add(match.Groups["dir"].Value);
}
}
return [.. dirs];
}
private async Task<LibraryBinary?> TryFetchDebianPackageAsync(
string version,
string architecture,
LibraryFetchOptions? options,
CancellationToken ct)
{
var client = _httpClientFactory.CreateClient("DebianPackages");
var debArch = MapToDebianArchitecture(architecture);
if (debArch is null)
{
return null;
}
// Determine package name based on version
// OpenSSL 1.x -> libssl1.1
// OpenSSL 3.x -> libssl3
var packageName = GetDebianPackageName(version);
// Query Debian snapshot for matching package
var packageUrls = await FindDebianPackageUrlsAsync(client, packageName, version, debArch, ct);
foreach (var url in packageUrls)
{
try
{
_logger.LogDebug("Trying Debian OpenSSL package URL: {Url}", url);
var packageBytes = await client.GetByteArrayAsync(url, ct);
// Extract libssl.so.X from the .deb package
var binary = await ExtractLibSslFromDebAsync(packageBytes, version, architecture, options, ct);
if (binary is not null)
{
return binary;
}
}
catch (HttpRequestException ex)
{
_logger.LogDebug(ex, "Failed to download Debian package from {Url}", url);
}
}
return null;
}
private async Task<LibraryBinary?> TryFetchAlpinePackageAsync(
string version,
string architecture,
LibraryFetchOptions? options,
CancellationToken ct)
{
var client = _httpClientFactory.CreateClient("AlpinePackages");
var alpineArch = MapToAlpineArchitecture(architecture);
if (alpineArch is null)
{
return null;
}
// Query Alpine package repository
var packageUrls = await FindAlpinePackageUrlsAsync(client, "libssl3", version, alpineArch, ct);
foreach (var url in packageUrls)
{
try
{
_logger.LogDebug("Trying Alpine OpenSSL package URL: {Url}", url);
var packageBytes = await client.GetByteArrayAsync(url, ct);
// Extract libssl.so.X from the .apk package
var binary = await ExtractLibSslFromApkAsync(packageBytes, version, architecture, options, ct);
if (binary is not null)
{
return binary;
}
}
catch (HttpRequestException ex)
{
_logger.LogDebug(ex, "Failed to download Alpine package from {Url}", url);
}
}
return null;
}
private async Task<ImmutableArray<string>> FindDebianPackageUrlsAsync(
HttpClient client,
string packageName,
string version,
string debianArch,
CancellationToken ct)
{
// Map OpenSSL version to Debian source package version
// e.g., 1.1.1n -> libssl1.1_1.1.1n-0+deb11u4
var apiUrl = $"https://snapshot.debian.org/mr/binary/{packageName}/";
try
{
var response = await client.GetStringAsync(apiUrl, ct);
// Parse JSON response to find matching versions
var urls = ExtractPackageUrlsForVersion(response, version, debianArch);
return urls;
}
catch (HttpRequestException ex)
{
_logger.LogDebug(ex, "Debian snapshot API query failed for {Package}", packageName);
return [];
}
}
private async Task<ImmutableArray<string>> FindAlpinePackageUrlsAsync(
HttpClient client,
string packageName,
string version,
string alpineArch,
CancellationToken ct)
{
// Alpine uses different repository structure
// https://dl-cdn.alpinelinux.org/alpine/v3.18/main/x86_64/libssl3-3.1.1-r1.apk
var releases = new[] { "v3.20", "v3.19", "v3.18", "v3.17" };
var urls = new List<string>();
foreach (var release in releases)
{
var baseUrl = $"https://dl-cdn.alpinelinux.org/alpine/{release}/main/{alpineArch}/";
try
{
var html = await client.GetStringAsync(baseUrl, ct);
// Find package URLs matching version
var matches = AlpinePackageRegex().Matches(html);
foreach (Match match in matches)
{
if (match.Groups["name"].Value == packageName &&
match.Groups["version"].Value.StartsWith(version, StringComparison.OrdinalIgnoreCase))
{
urls.Add($"{baseUrl}{match.Groups["file"].Value}");
}
}
}
catch (HttpRequestException)
{
// Skip releases we can't access
}
}
return [.. urls];
}
private async Task<LibraryBinary?> ExtractLibSslFromDebAsync(
byte[] debPackage,
string version,
string architecture,
LibraryFetchOptions? options,
CancellationToken ct)
{
// .deb extraction - placeholder for now
// In production, implement proper ar + tar.xz extraction
await Task.CompletedTask;
_logger.LogDebug(
"Debian package extraction not fully implemented. Package size: {Size} bytes",
debPackage.Length);
return null;
}
private async Task<LibraryBinary?> ExtractLibSslFromApkAsync(
byte[] apkPackage,
string version,
string architecture,
LibraryFetchOptions? options,
CancellationToken ct)
{
// .apk files are gzip-compressed tar archives
// In production, implement proper tar.gz extraction
await Task.CompletedTask;
_logger.LogDebug(
"Alpine package extraction not fully implemented. Package size: {Size} bytes",
apkPackage.Length);
return null;
}
private static string GetDebianPackageName(string version)
{
// OpenSSL 1.0.x -> libssl1.0.0
// OpenSSL 1.1.x -> libssl1.1
// OpenSSL 3.x -> libssl3
if (version.StartsWith("1.0", StringComparison.OrdinalIgnoreCase))
{
return "libssl1.0.0";
}
else if (version.StartsWith("1.1", StringComparison.OrdinalIgnoreCase))
{
return "libssl1.1";
}
else
{
return "libssl3";
}
}
private static ImmutableArray<string> ExtractPackageUrlsForVersion(
string json,
string version,
string debianArch)
{
var urls = new List<string>();
try
{
using var doc = System.Text.Json.JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("result", out var results))
{
foreach (var item in results.EnumerateArray())
{
if (item.TryGetProperty("binary_version", out var binaryVersion) &&
item.TryGetProperty("architecture", out var arch))
{
var binVer = binaryVersion.GetString() ?? string.Empty;
var archStr = arch.GetString() ?? string.Empty;
// Check if version matches and architecture matches
if (binVer.Contains(version, StringComparison.OrdinalIgnoreCase) &&
archStr.Equals(debianArch, StringComparison.OrdinalIgnoreCase))
{
if (item.TryGetProperty("files", out var files))
{
foreach (var file in files.EnumerateArray())
{
if (file.TryGetProperty("hash", out var hashElement))
{
var hash = hashElement.GetString();
if (!string.IsNullOrEmpty(hash))
{
urls.Add($"https://snapshot.debian.org/file/{hash}");
}
}
}
}
}
}
}
}
}
catch (System.Text.Json.JsonException)
{
// Invalid JSON
}
return [.. urls];
}
private static string NormalizeArchitecture(string architecture)
{
return architecture.ToLowerInvariant() switch
{
"x86_64" or "amd64" => "x86_64",
"aarch64" or "arm64" => "aarch64",
"armhf" or "armv7" or "arm" => "armhf",
"i386" or "i686" or "x86" => "i386",
_ => architecture
};
}
private static string? MapToDebianArchitecture(string architecture)
{
return architecture.ToLowerInvariant() switch
{
"x86_64" => "amd64",
"aarch64" => "arm64",
"armhf" or "armv7" => "armhf",
"i386" or "i686" => "i386",
_ => null
};
}
private static string? MapToAlpineArchitecture(string architecture)
{
return architecture.ToLowerInvariant() switch
{
"x86_64" => "x86_64",
"aarch64" => "aarch64",
"armhf" or "armv7" => "armhf",
"i386" or "i686" => "x86",
_ => null
};
}
private static Version? ParseVersion(string versionString)
{
// OpenSSL versions can be like 1.1.1n or 3.0.8
// Extract numeric parts only
var numericPart = ExtractNumericVersion(versionString);
if (Version.TryParse(numericPart, out var version))
{
return version;
}
return null;
}
private static string ExtractNumericVersion(string version)
{
// 1.1.1n -> 1.1.1
// 3.0.8 -> 3.0.8
var parts = new List<string>();
foreach (var ch in version)
{
if (char.IsDigit(ch) || ch == '.')
{
if (parts.Count == 0)
{
parts.Add(ch.ToString());
}
else if (ch == '.')
{
parts.Add(".");
}
else
{
parts[^1] += ch;
}
}
else if (parts.Count > 0 && parts[^1] != ".")
{
// Stop at first non-digit after version starts
break;
}
}
return string.Join("", parts).TrimEnd('.');
}
#endregion
#region Generated Regexes
[GeneratedRegex(@"openssl-(?<version>\d+\.\d+\.\d+[a-z]?)", RegexOptions.IgnoreCase)]
private static partial Regex OpenSslVersionRegex();
[GeneratedRegex(@"href=""(?<dir>\d+\.\d+(?:\.\d+)?)/""", RegexOptions.IgnoreCase)]
private static partial Regex VersionDirRegex();
[GeneratedRegex(@"href=""(?<file>(?<name>[a-z0-9_-]+)-(?<version>[0-9.]+[a-z]?-r\d+)\.apk)""", RegexOptions.IgnoreCase)]
private static partial Regex AlpinePackageRegex();
#endregion
}

View File

@@ -0,0 +1,452 @@
using System.Collections.Immutable;
using System.Net.Http;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using StellaOps.BinaryIndex.Corpus.Models;
namespace StellaOps.BinaryIndex.Corpus.Connectors;
/// <summary>
/// Corpus connector for zlib compression library.
/// Fetches pre-built binaries from distribution packages or official releases.
/// </summary>
public sealed partial class ZlibCorpusConnector : ILibraryCorpusConnector
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<ZlibCorpusConnector> _logger;
/// <summary>
/// Base URL for zlib official releases.
/// </summary>
public const string ZlibReleasesUrl = "https://www.zlib.net/";
/// <summary>
/// Base URL for zlib fossils/old releases.
/// </summary>
public const string ZlibFossilsUrl = "https://www.zlib.net/fossils/";
/// <summary>
/// Supported architectures.
/// </summary>
private static readonly ImmutableArray<string> s_supportedArchitectures =
["x86_64", "aarch64", "armhf", "i386"];
public ZlibCorpusConnector(
IHttpClientFactory httpClientFactory,
ILogger<ZlibCorpusConnector> logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
}
/// <inheritdoc />
public string LibraryName => "zlib";
/// <inheritdoc />
public ImmutableArray<string> SupportedArchitectures => s_supportedArchitectures;
/// <inheritdoc />
public async Task<ImmutableArray<string>> GetAvailableVersionsAsync(CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient("Zlib");
var versions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Fetch current release
try
{
_logger.LogDebug("Fetching zlib versions from {Url}", ZlibReleasesUrl);
var html = await client.GetStringAsync(ZlibReleasesUrl, ct);
var currentVersions = ParseVersionsFromListing(html);
foreach (var v in currentVersions)
{
versions.Add(v);
}
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "Failed to fetch current zlib releases");
}
// Fetch old releases (fossils)
try
{
_logger.LogDebug("Fetching old zlib versions from {Url}", ZlibFossilsUrl);
var fossilsHtml = await client.GetStringAsync(ZlibFossilsUrl, ct);
var fossilVersions = ParseVersionsFromListing(fossilsHtml);
foreach (var v in fossilVersions)
{
versions.Add(v);
}
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "Failed to fetch old zlib releases");
}
_logger.LogInformation("Found {Count} zlib versions", versions.Count);
return [.. versions.OrderByDescending(ParseVersion)];
}
/// <inheritdoc />
public async Task<LibraryBinary?> FetchBinaryAsync(
string version,
string architecture,
LibraryFetchOptions? options = null,
CancellationToken ct = default)
{
var normalizedArch = NormalizeArchitecture(architecture);
_logger.LogInformation(
"Fetching zlib {Version} for {Architecture}",
version,
normalizedArch);
// Strategy 1: Try Debian/Ubuntu package (pre-built, preferred)
var debBinary = await TryFetchDebianPackageAsync(version, normalizedArch, options, ct);
if (debBinary is not null)
{
_logger.LogDebug("Found zlib {Version} from Debian packages", version);
return debBinary;
}
// Strategy 2: Try Alpine APK
var alpineBinary = await TryFetchAlpinePackageAsync(version, normalizedArch, options, ct);
if (alpineBinary is not null)
{
_logger.LogDebug("Found zlib {Version} from Alpine packages", version);
return alpineBinary;
}
_logger.LogWarning(
"Could not find pre-built zlib {Version} for {Architecture}. Source build not implemented.",
version,
normalizedArch);
return null;
}
/// <inheritdoc />
public async IAsyncEnumerable<LibraryBinary> FetchBinariesAsync(
IEnumerable<string> versions,
string architecture,
LibraryFetchOptions? options = null,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
{
foreach (var version in versions)
{
ct.ThrowIfCancellationRequested();
var binary = await FetchBinaryAsync(version, architecture, options, ct);
if (binary is not null)
{
yield return binary;
}
}
}
#region Private Methods
private ImmutableArray<string> ParseVersionsFromListing(string html)
{
// Match patterns like zlib-1.2.13.tar.gz or zlib-1.3.1.tar.xz
var matches = ZlibVersionRegex().Matches(html);
var versions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (Match match in matches)
{
if (match.Groups["version"].Success)
{
versions.Add(match.Groups["version"].Value);
}
}
return [.. versions];
}
private async Task<LibraryBinary?> TryFetchDebianPackageAsync(
string version,
string architecture,
LibraryFetchOptions? options,
CancellationToken ct)
{
var client = _httpClientFactory.CreateClient("DebianPackages");
var debArch = MapToDebianArchitecture(architecture);
if (debArch is null)
{
return null;
}
// zlib package name is zlib1g
const string packageName = "zlib1g";
// Query Debian snapshot for matching package
var packageUrls = await FindDebianPackageUrlsAsync(client, packageName, version, debArch, ct);
foreach (var url in packageUrls)
{
try
{
_logger.LogDebug("Trying Debian zlib package URL: {Url}", url);
var packageBytes = await client.GetByteArrayAsync(url, ct);
// Extract libz.so.1 from the .deb package
var binary = await ExtractLibZFromDebAsync(packageBytes, version, architecture, options, ct);
if (binary is not null)
{
return binary;
}
}
catch (HttpRequestException ex)
{
_logger.LogDebug(ex, "Failed to download Debian package from {Url}", url);
}
}
return null;
}
private async Task<LibraryBinary?> TryFetchAlpinePackageAsync(
string version,
string architecture,
LibraryFetchOptions? options,
CancellationToken ct)
{
var client = _httpClientFactory.CreateClient("AlpinePackages");
var alpineArch = MapToAlpineArchitecture(architecture);
if (alpineArch is null)
{
return null;
}
// Query Alpine package repository for zlib
var packageUrls = await FindAlpinePackageUrlsAsync(client, "zlib", version, alpineArch, ct);
foreach (var url in packageUrls)
{
try
{
_logger.LogDebug("Trying Alpine zlib package URL: {Url}", url);
var packageBytes = await client.GetByteArrayAsync(url, ct);
// Extract libz.so.1 from the .apk package
var binary = await ExtractLibZFromApkAsync(packageBytes, version, architecture, options, ct);
if (binary is not null)
{
return binary;
}
}
catch (HttpRequestException ex)
{
_logger.LogDebug(ex, "Failed to download Alpine package from {Url}", url);
}
}
return null;
}
private async Task<ImmutableArray<string>> FindDebianPackageUrlsAsync(
HttpClient client,
string packageName,
string version,
string debianArch,
CancellationToken ct)
{
var apiUrl = $"https://snapshot.debian.org/mr/binary/{packageName}/";
try
{
var response = await client.GetStringAsync(apiUrl, ct);
var urls = ExtractPackageUrlsForVersion(response, version, debianArch);
return urls;
}
catch (HttpRequestException ex)
{
_logger.LogDebug(ex, "Debian snapshot API query failed for {Package}", packageName);
return [];
}
}
private async Task<ImmutableArray<string>> FindAlpinePackageUrlsAsync(
HttpClient client,
string packageName,
string version,
string alpineArch,
CancellationToken ct)
{
var releases = new[] { "v3.20", "v3.19", "v3.18", "v3.17" };
var urls = new List<string>();
foreach (var release in releases)
{
var baseUrl = $"https://dl-cdn.alpinelinux.org/alpine/{release}/main/{alpineArch}/";
try
{
var html = await client.GetStringAsync(baseUrl, ct);
// Find package URLs matching version
var matches = AlpinePackageRegex().Matches(html);
foreach (Match match in matches)
{
if (match.Groups["name"].Value == packageName &&
match.Groups["version"].Value.StartsWith(version, StringComparison.OrdinalIgnoreCase))
{
urls.Add($"{baseUrl}{match.Groups["file"].Value}");
}
}
}
catch (HttpRequestException)
{
// Skip releases we can't access
}
}
return [.. urls];
}
private async Task<LibraryBinary?> ExtractLibZFromDebAsync(
byte[] debPackage,
string version,
string architecture,
LibraryFetchOptions? options,
CancellationToken ct)
{
// .deb extraction - placeholder for now
await Task.CompletedTask;
_logger.LogDebug(
"Debian package extraction not fully implemented. Package size: {Size} bytes",
debPackage.Length);
return null;
}
private async Task<LibraryBinary?> ExtractLibZFromApkAsync(
byte[] apkPackage,
string version,
string architecture,
LibraryFetchOptions? options,
CancellationToken ct)
{
// .apk extraction - placeholder for now
await Task.CompletedTask;
_logger.LogDebug(
"Alpine package extraction not fully implemented. Package size: {Size} bytes",
apkPackage.Length);
return null;
}
private static ImmutableArray<string> ExtractPackageUrlsForVersion(
string json,
string version,
string debianArch)
{
var urls = new List<string>();
try
{
using var doc = System.Text.Json.JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("result", out var results))
{
foreach (var item in results.EnumerateArray())
{
if (item.TryGetProperty("binary_version", out var binaryVersion) &&
item.TryGetProperty("architecture", out var arch))
{
var binVer = binaryVersion.GetString() ?? string.Empty;
var archStr = arch.GetString() ?? string.Empty;
// Check if version matches and architecture matches
if (binVer.Contains(version, StringComparison.OrdinalIgnoreCase) &&
archStr.Equals(debianArch, StringComparison.OrdinalIgnoreCase))
{
if (item.TryGetProperty("files", out var files))
{
foreach (var file in files.EnumerateArray())
{
if (file.TryGetProperty("hash", out var hashElement))
{
var hash = hashElement.GetString();
if (!string.IsNullOrEmpty(hash))
{
urls.Add($"https://snapshot.debian.org/file/{hash}");
}
}
}
}
}
}
}
}
}
catch (System.Text.Json.JsonException)
{
// Invalid JSON
}
return [.. urls];
}
private static string NormalizeArchitecture(string architecture)
{
return architecture.ToLowerInvariant() switch
{
"x86_64" or "amd64" => "x86_64",
"aarch64" or "arm64" => "aarch64",
"armhf" or "armv7" or "arm" => "armhf",
"i386" or "i686" or "x86" => "i386",
_ => architecture
};
}
private static string? MapToDebianArchitecture(string architecture)
{
return architecture.ToLowerInvariant() switch
{
"x86_64" => "amd64",
"aarch64" => "arm64",
"armhf" or "armv7" => "armhf",
"i386" or "i686" => "i386",
_ => null
};
}
private static string? MapToAlpineArchitecture(string architecture)
{
return architecture.ToLowerInvariant() switch
{
"x86_64" => "x86_64",
"aarch64" => "aarch64",
"armhf" or "armv7" => "armhf",
"i386" or "i686" => "x86",
_ => null
};
}
private static Version? ParseVersion(string versionString)
{
if (Version.TryParse(versionString, out var version))
{
return version;
}
return null;
}
#endregion
#region Generated Regexes
[GeneratedRegex(@"zlib-(?<version>\d+\.\d+(?:\.\d+)?)", RegexOptions.IgnoreCase)]
private static partial Regex ZlibVersionRegex();
[GeneratedRegex(@"href=""(?<file>(?<name>[a-z0-9_-]+)-(?<version>[0-9.]+(?:-r\d+)?)\.apk)""", RegexOptions.IgnoreCase)]
private static partial Regex AlpinePackageRegex();
#endregion
}

View File

@@ -0,0 +1,135 @@
using System.Collections.Immutable;
using StellaOps.BinaryIndex.Corpus.Models;
namespace StellaOps.BinaryIndex.Corpus;
/// <summary>
/// Service for ingesting library functions into the corpus.
/// </summary>
public interface ICorpusIngestionService
{
/// <summary>
/// Ingest all functions from a library binary.
/// </summary>
/// <param name="metadata">Library metadata.</param>
/// <param name="binaryStream">Binary file stream.</param>
/// <param name="options">Ingestion options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Ingestion result with statistics.</returns>
Task<IngestionResult> IngestLibraryAsync(
LibraryIngestionMetadata metadata,
Stream binaryStream,
IngestionOptions? options = null,
CancellationToken ct = default);
/// <summary>
/// Ingest functions from a library connector.
/// </summary>
/// <param name="libraryName">Library name (e.g., "glibc").</param>
/// <param name="connector">Library corpus connector.</param>
/// <param name="options">Ingestion options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Stream of ingestion results.</returns>
IAsyncEnumerable<IngestionResult> IngestFromConnectorAsync(
string libraryName,
ILibraryCorpusConnector connector,
IngestionOptions? options = null,
CancellationToken ct = default);
/// <summary>
/// Update CVE associations for corpus functions.
/// </summary>
/// <param name="cveId">CVE identifier.</param>
/// <param name="associations">Function-CVE associations.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Number of associations updated.</returns>
Task<int> UpdateCveAssociationsAsync(
string cveId,
IReadOnlyList<FunctionCveAssociation> associations,
CancellationToken ct = default);
/// <summary>
/// Get ingestion job status.
/// </summary>
/// <param name="jobId">Job ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Job details or null if not found.</returns>
Task<IngestionJob?> GetJobStatusAsync(Guid jobId, CancellationToken ct = default);
}
/// <summary>
/// Metadata for library ingestion.
/// </summary>
public sealed record LibraryIngestionMetadata(
string Name,
string Version,
string Architecture,
string? Abi = null,
string? Compiler = null,
string? CompilerVersion = null,
string? OptimizationLevel = null,
DateOnly? ReleaseDate = null,
bool IsSecurityRelease = false,
string? SourceArchiveSha256 = null);
/// <summary>
/// Options for corpus ingestion.
/// </summary>
public sealed record IngestionOptions
{
/// <summary>
/// Minimum function size to index (bytes).
/// </summary>
public int MinFunctionSize { get; init; } = 16;
/// <summary>
/// Maximum functions per binary.
/// </summary>
public int MaxFunctionsPerBinary { get; init; } = 10_000;
/// <summary>
/// Algorithms to use for fingerprinting.
/// </summary>
public ImmutableArray<FingerprintAlgorithm> Algorithms { get; init; } =
[FingerprintAlgorithm.SemanticKsg, FingerprintAlgorithm.InstructionBb, FingerprintAlgorithm.CfgWl];
/// <summary>
/// Include exported functions only.
/// </summary>
public bool ExportedOnly { get; init; } = false;
/// <summary>
/// Generate function clusters after ingestion.
/// </summary>
public bool GenerateClusters { get; init; } = true;
/// <summary>
/// Parallel degree for function processing.
/// </summary>
public int ParallelDegree { get; init; } = 4;
}
/// <summary>
/// Result of a library ingestion.
/// </summary>
public sealed record IngestionResult(
Guid JobId,
string LibraryName,
string Version,
string Architecture,
int FunctionsIndexed,
int FingerprintsGenerated,
int ClustersCreated,
TimeSpan Duration,
ImmutableArray<string> Errors,
ImmutableArray<string> Warnings);
/// <summary>
/// Association between a function and a CVE.
/// </summary>
public sealed record FunctionCveAssociation(
Guid FunctionId,
CveAffectedState AffectedState,
string? PatchCommit,
decimal Confidence,
CveEvidenceType? EvidenceType);

View File

@@ -0,0 +1,186 @@
using System.Collections.Immutable;
using StellaOps.BinaryIndex.Corpus.Models;
namespace StellaOps.BinaryIndex.Corpus;
/// <summary>
/// Service for querying the function corpus.
/// </summary>
public interface ICorpusQueryService
{
/// <summary>
/// Identify a function by its fingerprints.
/// </summary>
/// <param name="fingerprints">Function fingerprints to match.</param>
/// <param name="options">Query options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Matching functions ordered by similarity.</returns>
Task<ImmutableArray<FunctionMatch>> IdentifyFunctionAsync(
FunctionFingerprints fingerprints,
IdentifyOptions? options = null,
CancellationToken ct = default);
/// <summary>
/// Batch identify functions.
/// </summary>
/// <param name="fingerprints">Multiple function fingerprints.</param>
/// <param name="options">Query options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Matches for each input fingerprint.</returns>
Task<ImmutableDictionary<int, ImmutableArray<FunctionMatch>>> IdentifyBatchAsync(
IReadOnlyList<FunctionFingerprints> fingerprints,
IdentifyOptions? options = null,
CancellationToken ct = default);
/// <summary>
/// Get all functions associated with a CVE.
/// </summary>
/// <param name="cveId">CVE identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Functions affected by the CVE.</returns>
Task<ImmutableArray<CorpusFunctionWithCve>> GetFunctionsForCveAsync(
string cveId,
CancellationToken ct = default);
/// <summary>
/// Get function evolution across library versions.
/// </summary>
/// <param name="libraryName">Library name.</param>
/// <param name="functionName">Function name.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Function evolution timeline.</returns>
Task<FunctionEvolution?> GetFunctionEvolutionAsync(
string libraryName,
string functionName,
CancellationToken ct = default);
/// <summary>
/// Get corpus statistics.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>Corpus statistics.</returns>
Task<CorpusStatistics> GetStatisticsAsync(CancellationToken ct = default);
/// <summary>
/// List libraries in the corpus.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>Libraries with version counts.</returns>
Task<ImmutableArray<LibrarySummary>> ListLibrariesAsync(CancellationToken ct = default);
/// <summary>
/// List versions for a library.
/// </summary>
/// <param name="libraryName">Library name.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Version information.</returns>
Task<ImmutableArray<LibraryVersionSummary>> ListVersionsAsync(
string libraryName,
CancellationToken ct = default);
}
/// <summary>
/// Fingerprints for function identification.
/// </summary>
public sealed record FunctionFingerprints(
byte[]? SemanticHash,
byte[]? InstructionHash,
byte[]? CfgHash,
ImmutableArray<string>? ApiCalls,
int? SizeBytes);
/// <summary>
/// Options for function identification.
/// </summary>
public sealed record IdentifyOptions
{
/// <summary>
/// Minimum similarity threshold (0.0-1.0).
/// </summary>
public decimal MinSimilarity { get; init; } = 0.70m;
/// <summary>
/// Maximum results to return.
/// </summary>
public int MaxResults { get; init; } = 10;
/// <summary>
/// Filter by library names.
/// </summary>
public ImmutableArray<string>? LibraryFilter { get; init; }
/// <summary>
/// Filter by architectures.
/// </summary>
public ImmutableArray<string>? ArchitectureFilter { get; init; }
/// <summary>
/// Include CVE information in results.
/// </summary>
public bool IncludeCveInfo { get; init; } = true;
/// <summary>
/// Weights for similarity computation.
/// </summary>
public SimilarityWeights Weights { get; init; } = SimilarityWeights.Default;
}
/// <summary>
/// Weights for computing overall similarity.
/// </summary>
public sealed record SimilarityWeights
{
public decimal SemanticWeight { get; init; } = 0.35m;
public decimal InstructionWeight { get; init; } = 0.25m;
public decimal CfgWeight { get; init; } = 0.25m;
public decimal ApiCallWeight { get; init; } = 0.15m;
public static SimilarityWeights Default { get; } = new();
}
/// <summary>
/// Function with CVE information.
/// </summary>
public sealed record CorpusFunctionWithCve(
CorpusFunction Function,
LibraryMetadata Library,
LibraryVersion Version,
BuildVariant Build,
FunctionCve CveInfo);
/// <summary>
/// Corpus statistics.
/// </summary>
public sealed record CorpusStatistics(
int LibraryCount,
int VersionCount,
int BuildVariantCount,
int FunctionCount,
int FingerprintCount,
int ClusterCount,
int CveAssociationCount,
DateTimeOffset? LastUpdated);
/// <summary>
/// Summary of a library in the corpus.
/// </summary>
public sealed record LibrarySummary(
Guid Id,
string Name,
string? Description,
int VersionCount,
int FunctionCount,
int CveCount,
DateTimeOffset? LatestVersionDate);
/// <summary>
/// Summary of a library version.
/// </summary>
public sealed record LibraryVersionSummary(
Guid Id,
string Version,
DateOnly? ReleaseDate,
bool IsSecurityRelease,
int BuildVariantCount,
int FunctionCount,
ImmutableArray<string> Architectures);

View File

@@ -0,0 +1,327 @@
using System.Collections.Immutable;
using StellaOps.BinaryIndex.Corpus.Models;
namespace StellaOps.BinaryIndex.Corpus;
/// <summary>
/// Repository for corpus data access.
/// </summary>
public interface ICorpusRepository
{
#region Libraries
/// <summary>
/// Get or create a library.
/// </summary>
Task<LibraryMetadata> GetOrCreateLibraryAsync(
string name,
string? description = null,
string? homepageUrl = null,
string? sourceRepo = null,
CancellationToken ct = default);
/// <summary>
/// Get a library by name.
/// </summary>
Task<LibraryMetadata?> GetLibraryAsync(string name, CancellationToken ct = default);
/// <summary>
/// Get a library by ID.
/// </summary>
Task<LibraryMetadata?> GetLibraryByIdAsync(Guid id, CancellationToken ct = default);
/// <summary>
/// List all libraries.
/// </summary>
Task<ImmutableArray<LibrarySummary>> ListLibrariesAsync(CancellationToken ct = default);
#endregion
#region Library Versions
/// <summary>
/// Get or create a library version.
/// </summary>
Task<LibraryVersion> GetOrCreateVersionAsync(
Guid libraryId,
string version,
DateOnly? releaseDate = null,
bool isSecurityRelease = false,
string? sourceArchiveSha256 = null,
CancellationToken ct = default);
/// <summary>
/// Get a library version.
/// </summary>
Task<LibraryVersion?> GetVersionAsync(
Guid libraryId,
string version,
CancellationToken ct = default);
/// <summary>
/// Get a library version by ID.
/// </summary>
Task<LibraryVersion?> GetLibraryVersionAsync(
Guid versionId,
CancellationToken ct = default);
/// <summary>
/// List versions for a library.
/// </summary>
Task<ImmutableArray<LibraryVersionSummary>> ListVersionsAsync(
string libraryName,
CancellationToken ct = default);
#endregion
#region Build Variants
/// <summary>
/// Get or create a build variant.
/// </summary>
Task<BuildVariant> GetOrCreateBuildVariantAsync(
Guid libraryVersionId,
string architecture,
string binarySha256,
string? abi = null,
string? compiler = null,
string? compilerVersion = null,
string? optimizationLevel = null,
string? buildId = null,
CancellationToken ct = default);
/// <summary>
/// Get a build variant by binary hash.
/// </summary>
Task<BuildVariant?> GetBuildVariantBySha256Async(
string binarySha256,
CancellationToken ct = default);
/// <summary>
/// Get a build variant by ID.
/// </summary>
Task<BuildVariant?> GetBuildVariantAsync(
Guid variantId,
CancellationToken ct = default);
/// <summary>
/// Get build variants for a version.
/// </summary>
Task<ImmutableArray<BuildVariant>> GetBuildVariantsAsync(
Guid libraryVersionId,
CancellationToken ct = default);
#endregion
#region Functions
/// <summary>
/// Bulk insert functions.
/// </summary>
Task<int> InsertFunctionsAsync(
IReadOnlyList<CorpusFunction> functions,
CancellationToken ct = default);
/// <summary>
/// Get a function by ID.
/// </summary>
Task<CorpusFunction?> GetFunctionAsync(Guid id, CancellationToken ct = default);
/// <summary>
/// Get functions for a build variant.
/// </summary>
Task<ImmutableArray<CorpusFunction>> GetFunctionsForVariantAsync(
Guid buildVariantId,
CancellationToken ct = default);
/// <summary>
/// Get function count for a build variant.
/// </summary>
Task<int> GetFunctionCountAsync(Guid buildVariantId, CancellationToken ct = default);
#endregion
#region Fingerprints
/// <summary>
/// Bulk insert fingerprints.
/// </summary>
Task<int> InsertFingerprintsAsync(
IReadOnlyList<CorpusFingerprint> fingerprints,
CancellationToken ct = default);
/// <summary>
/// Find functions by fingerprint hash.
/// </summary>
Task<ImmutableArray<Guid>> FindFunctionsByFingerprintAsync(
FingerprintAlgorithm algorithm,
byte[] fingerprint,
CancellationToken ct = default);
/// <summary>
/// Find similar fingerprints (for approximate matching).
/// </summary>
Task<ImmutableArray<FingerprintSearchResult>> FindSimilarFingerprintsAsync(
FingerprintAlgorithm algorithm,
byte[] fingerprint,
int maxResults = 10,
CancellationToken ct = default);
/// <summary>
/// Get fingerprints for a function.
/// </summary>
Task<ImmutableArray<CorpusFingerprint>> GetFingerprintsAsync(
Guid functionId,
CancellationToken ct = default);
/// <summary>
/// Get fingerprints for a function (alias).
/// </summary>
Task<ImmutableArray<CorpusFingerprint>> GetFingerprintsForFunctionAsync(
Guid functionId,
CancellationToken ct = default);
#endregion
#region Clusters
/// <summary>
/// Get or create a function cluster.
/// </summary>
Task<FunctionCluster> GetOrCreateClusterAsync(
Guid libraryId,
string canonicalName,
string? description = null,
CancellationToken ct = default);
/// <summary>
/// Get a cluster by ID.
/// </summary>
Task<FunctionCluster?> GetClusterAsync(
Guid clusterId,
CancellationToken ct = default);
/// <summary>
/// Get all clusters for a library.
/// </summary>
Task<ImmutableArray<FunctionCluster>> GetClustersForLibraryAsync(
Guid libraryId,
CancellationToken ct = default);
/// <summary>
/// Insert a new cluster.
/// </summary>
Task InsertClusterAsync(
FunctionCluster cluster,
CancellationToken ct = default);
/// <summary>
/// Add members to a cluster.
/// </summary>
Task<int> AddClusterMembersAsync(
Guid clusterId,
IReadOnlyList<ClusterMember> members,
CancellationToken ct = default);
/// <summary>
/// Add a single member to a cluster.
/// </summary>
Task AddClusterMemberAsync(
ClusterMember member,
CancellationToken ct = default);
/// <summary>
/// Get cluster members.
/// </summary>
Task<ImmutableArray<Guid>> GetClusterMemberIdsAsync(
Guid clusterId,
CancellationToken ct = default);
/// <summary>
/// Get cluster members with details.
/// </summary>
Task<ImmutableArray<ClusterMember>> GetClusterMembersAsync(
Guid clusterId,
CancellationToken ct = default);
/// <summary>
/// Clear all members from a cluster.
/// </summary>
Task ClearClusterMembersAsync(
Guid clusterId,
CancellationToken ct = default);
#endregion
#region CVE Associations
/// <summary>
/// Upsert CVE associations.
/// </summary>
Task<int> UpsertCveAssociationsAsync(
string cveId,
IReadOnlyList<FunctionCve> associations,
CancellationToken ct = default);
/// <summary>
/// Get functions for a CVE.
/// </summary>
Task<ImmutableArray<Guid>> GetFunctionIdsForCveAsync(
string cveId,
CancellationToken ct = default);
/// <summary>
/// Get CVEs for a function.
/// </summary>
Task<ImmutableArray<FunctionCve>> GetCvesForFunctionAsync(
Guid functionId,
CancellationToken ct = default);
#endregion
#region Ingestion Jobs
/// <summary>
/// Create an ingestion job.
/// </summary>
Task<IngestionJob> CreateIngestionJobAsync(
Guid libraryId,
IngestionJobType jobType,
CancellationToken ct = default);
/// <summary>
/// Update ingestion job status.
/// </summary>
Task UpdateIngestionJobAsync(
Guid jobId,
IngestionJobStatus status,
int? functionsIndexed = null,
int? fingerprintsGenerated = null,
int? clustersCreated = null,
ImmutableArray<string>? errors = null,
CancellationToken ct = default);
/// <summary>
/// Get ingestion job.
/// </summary>
Task<IngestionJob?> GetIngestionJobAsync(Guid jobId, CancellationToken ct = default);
#endregion
#region Statistics
/// <summary>
/// Get corpus statistics.
/// </summary>
Task<CorpusStatistics> GetStatisticsAsync(CancellationToken ct = default);
#endregion
}
/// <summary>
/// Result of a fingerprint similarity search.
/// </summary>
public sealed record FingerprintSearchResult(
Guid FunctionId,
byte[] Fingerprint,
decimal Similarity);

View File

@@ -0,0 +1,155 @@
using System.Collections.Immutable;
using StellaOps.BinaryIndex.Corpus.Models;
namespace StellaOps.BinaryIndex.Corpus;
/// <summary>
/// Connector for fetching library binaries from various sources.
/// Used to populate the function corpus.
/// </summary>
public interface ILibraryCorpusConnector
{
/// <summary>
/// Library name this connector handles (e.g., "glibc", "openssl").
/// </summary>
string LibraryName { get; }
/// <summary>
/// Supported architectures.
/// </summary>
ImmutableArray<string> SupportedArchitectures { get; }
/// <summary>
/// Get available versions of the library.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>Available versions ordered newest first.</returns>
Task<ImmutableArray<string>> GetAvailableVersionsAsync(CancellationToken ct = default);
/// <summary>
/// Fetch a library binary for a specific version and architecture.
/// </summary>
/// <param name="version">Library version.</param>
/// <param name="architecture">Target architecture.</param>
/// <param name="options">Fetch options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Library binary or null if not available.</returns>
Task<LibraryBinary?> FetchBinaryAsync(
string version,
string architecture,
LibraryFetchOptions? options = null,
CancellationToken ct = default);
/// <summary>
/// Stream binaries for multiple versions.
/// </summary>
/// <param name="versions">Versions to fetch.</param>
/// <param name="architecture">Target architecture.</param>
/// <param name="options">Fetch options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Stream of library binaries.</returns>
IAsyncEnumerable<LibraryBinary> FetchBinariesAsync(
IEnumerable<string> versions,
string architecture,
LibraryFetchOptions? options = null,
CancellationToken ct = default);
}
/// <summary>
/// A library binary fetched from a connector.
/// </summary>
public sealed record LibraryBinary(
string LibraryName,
string Version,
string Architecture,
string? Abi,
string? Compiler,
string? CompilerVersion,
string? OptimizationLevel,
Stream BinaryStream,
string Sha256,
string? BuildId,
LibraryBinarySource Source,
DateOnly? ReleaseDate) : IDisposable
{
public void Dispose()
{
BinaryStream.Dispose();
}
}
/// <summary>
/// Source of a library binary.
/// </summary>
public sealed record LibraryBinarySource(
LibrarySourceType Type,
string? PackageName,
string? DistroRelease,
string? MirrorUrl);
/// <summary>
/// Type of library source.
/// </summary>
public enum LibrarySourceType
{
/// <summary>
/// Binary from Debian/Ubuntu package.
/// </summary>
DebianPackage,
/// <summary>
/// Binary from RPM package.
/// </summary>
RpmPackage,
/// <summary>
/// Binary from Alpine APK.
/// </summary>
AlpineApk,
/// <summary>
/// Binary compiled from source.
/// </summary>
CompiledSource,
/// <summary>
/// Binary from upstream release.
/// </summary>
UpstreamRelease,
/// <summary>
/// Binary from debug symbol server.
/// </summary>
DebugSymbolServer
}
/// <summary>
/// Options for fetching library binaries.
/// </summary>
public sealed record LibraryFetchOptions
{
/// <summary>
/// Preferred ABI (e.g., "gnu", "musl").
/// </summary>
public string? PreferredAbi { get; init; }
/// <summary>
/// Preferred compiler.
/// </summary>
public string? PreferredCompiler { get; init; }
/// <summary>
/// Include debug symbols if available.
/// </summary>
public bool IncludeDebugSymbols { get; init; } = true;
/// <summary>
/// Preferred distro for pre-built packages.
/// </summary>
public string? PreferredDistro { get; init; }
/// <summary>
/// Timeout for network operations.
/// </summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromMinutes(5);
}

View File

@@ -0,0 +1,273 @@
using System.Collections.Immutable;
namespace StellaOps.BinaryIndex.Corpus.Models;
/// <summary>
/// Metadata about a known library in the corpus.
/// </summary>
public sealed record LibraryMetadata(
Guid Id,
string Name,
string? Description,
string? HomepageUrl,
string? SourceRepo,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
/// <summary>
/// A specific version of a library in the corpus.
/// </summary>
public sealed record LibraryVersion(
Guid Id,
Guid LibraryId,
string Version,
DateOnly? ReleaseDate,
bool IsSecurityRelease,
string? SourceArchiveSha256,
DateTimeOffset IndexedAt);
/// <summary>
/// A specific build variant of a library version.
/// </summary>
public sealed record BuildVariant(
Guid Id,
Guid LibraryVersionId,
string Architecture,
string? Abi,
string? Compiler,
string? CompilerVersion,
string? OptimizationLevel,
string? BuildId,
string BinarySha256,
DateTimeOffset IndexedAt);
/// <summary>
/// A function in the corpus.
/// </summary>
public sealed record CorpusFunction(
Guid Id,
Guid BuildVariantId,
string Name,
string? DemangledName,
ulong Address,
int SizeBytes,
bool IsExported,
bool IsInline,
string? SourceFile,
int? SourceLine);
/// <summary>
/// A fingerprint for a function in the corpus.
/// </summary>
public sealed record CorpusFingerprint(
Guid Id,
Guid FunctionId,
FingerprintAlgorithm Algorithm,
byte[] Fingerprint,
string FingerprintHex,
FingerprintMetadata? Metadata,
DateTimeOffset CreatedAt);
/// <summary>
/// Algorithm used to generate a fingerprint.
/// </summary>
public enum FingerprintAlgorithm
{
/// <summary>
/// Semantic key-semantics graph fingerprint (from Phase 1).
/// </summary>
SemanticKsg,
/// <summary>
/// Instruction-level basic block hash.
/// </summary>
InstructionBb,
/// <summary>
/// Control flow graph Weisfeiler-Lehman hash.
/// </summary>
CfgWl,
/// <summary>
/// API call sequence hash.
/// </summary>
ApiCalls,
/// <summary>
/// Combined multi-algorithm fingerprint.
/// </summary>
Combined
}
/// <summary>
/// Algorithm-specific metadata for a fingerprint.
/// </summary>
public sealed record FingerprintMetadata(
int? NodeCount,
int? EdgeCount,
int? CyclomaticComplexity,
ImmutableArray<string>? ApiCalls,
string? OperationHashHex,
string? DataFlowHashHex);
/// <summary>
/// A cluster of similar functions across versions.
/// </summary>
public sealed record FunctionCluster(
Guid Id,
Guid LibraryId,
string CanonicalName,
string? Description,
DateTimeOffset CreatedAt);
/// <summary>
/// Membership in a function cluster.
/// </summary>
public sealed record ClusterMember(
Guid ClusterId,
Guid FunctionId,
decimal? SimilarityToCentroid);
/// <summary>
/// CVE association for a function.
/// </summary>
public sealed record FunctionCve(
Guid FunctionId,
string CveId,
CveAffectedState AffectedState,
string? PatchCommit,
decimal Confidence,
CveEvidenceType? EvidenceType);
/// <summary>
/// CVE affected state for a function.
/// </summary>
public enum CveAffectedState
{
Vulnerable,
Fixed,
NotAffected
}
/// <summary>
/// Type of evidence linking a function to a CVE.
/// </summary>
public enum CveEvidenceType
{
Changelog,
Commit,
Advisory,
PatchHeader,
Manual
}
/// <summary>
/// Ingestion job tracking.
/// </summary>
public sealed record IngestionJob(
Guid Id,
Guid LibraryId,
IngestionJobType JobType,
IngestionJobStatus Status,
DateTimeOffset? StartedAt,
DateTimeOffset? CompletedAt,
int? FunctionsIndexed,
ImmutableArray<string>? Errors,
DateTimeOffset CreatedAt);
/// <summary>
/// Type of ingestion job.
/// </summary>
public enum IngestionJobType
{
FullIngest,
Incremental,
CveUpdate
}
/// <summary>
/// Status of an ingestion job.
/// </summary>
public enum IngestionJobStatus
{
Pending,
Running,
Completed,
Failed,
Cancelled
}
/// <summary>
/// Result of a function identification query.
/// </summary>
public sealed record FunctionMatch(
string LibraryName,
string Version,
string FunctionName,
string? DemangledName,
decimal Similarity,
MatchConfidence Confidence,
string Architecture,
string? Abi,
MatchDetails Details);
/// <summary>
/// Confidence level of a match.
/// </summary>
public enum MatchConfidence
{
/// <summary>
/// Low confidence (similarity 50-70%).
/// </summary>
Low,
/// <summary>
/// Medium confidence (similarity 70-85%).
/// </summary>
Medium,
/// <summary>
/// High confidence (similarity 85-95%).
/// </summary>
High,
/// <summary>
/// Very high confidence (similarity 95%+).
/// </summary>
VeryHigh,
/// <summary>
/// Exact match (100% or hash collision).
/// </summary>
Exact
}
/// <summary>
/// Details about a function match.
/// </summary>
public sealed record MatchDetails(
decimal SemanticSimilarity,
decimal InstructionSimilarity,
decimal CfgSimilarity,
decimal ApiCallSimilarity,
ImmutableArray<string> MatchedApiCalls,
int SizeDifferenceBytes);
/// <summary>
/// Evolution of a function across library versions.
/// </summary>
public sealed record FunctionEvolution(
string LibraryName,
string FunctionName,
ImmutableArray<FunctionVersionInfo> Versions);
/// <summary>
/// Information about a function in a specific version.
/// </summary>
public sealed record FunctionVersionInfo(
string Version,
DateOnly? ReleaseDate,
int SizeBytes,
string FingerprintHex,
decimal? SimilarityToPrevious,
ImmutableArray<string>? CveIds);

View File

@@ -0,0 +1,464 @@
using System.Collections.Immutable;
using System.Threading.Channels;
using Microsoft.Extensions.Logging;
using StellaOps.BinaryIndex.Corpus.Models;
namespace StellaOps.BinaryIndex.Corpus.Services;
/// <summary>
/// Service for batch generation of function fingerprints.
/// Uses a producer-consumer pattern for efficient parallel processing.
/// </summary>
public sealed class BatchFingerprintPipeline : IBatchFingerprintPipeline
{
private readonly ICorpusRepository _repository;
private readonly IFingerprintGeneratorFactory _generatorFactory;
private readonly ILogger<BatchFingerprintPipeline> _logger;
public BatchFingerprintPipeline(
ICorpusRepository repository,
IFingerprintGeneratorFactory generatorFactory,
ILogger<BatchFingerprintPipeline> logger)
{
_repository = repository;
_generatorFactory = generatorFactory;
_logger = logger;
}
/// <inheritdoc />
public async Task<BatchFingerprintResult> GenerateFingerprintsAsync(
Guid buildVariantId,
BatchFingerprintOptions? options = null,
CancellationToken ct = default)
{
var opts = options ?? new BatchFingerprintOptions();
_logger.LogInformation(
"Starting batch fingerprint generation for variant {VariantId}",
buildVariantId);
// Get all functions for this variant
var functions = await _repository.GetFunctionsForVariantAsync(buildVariantId, ct);
if (functions.Length == 0)
{
_logger.LogWarning("No functions found for variant {VariantId}", buildVariantId);
return new BatchFingerprintResult(
buildVariantId,
0,
0,
TimeSpan.Zero,
[],
[]);
}
return await GenerateFingerprintsForFunctionsAsync(
functions,
buildVariantId,
opts,
ct);
}
/// <inheritdoc />
public async Task<BatchFingerprintResult> GenerateFingerprintsForLibraryAsync(
string libraryName,
BatchFingerprintOptions? options = null,
CancellationToken ct = default)
{
var opts = options ?? new BatchFingerprintOptions();
_logger.LogInformation(
"Starting batch fingerprint generation for library {Library}",
libraryName);
var library = await _repository.GetLibraryAsync(libraryName, ct);
if (library is null)
{
_logger.LogWarning("Library {Library} not found", libraryName);
return new BatchFingerprintResult(
Guid.Empty,
0,
0,
TimeSpan.Zero,
["Library not found"],
[]);
}
// Get all versions
var versions = await _repository.ListVersionsAsync(libraryName, ct);
var totalFunctions = 0;
var totalFingerprints = 0;
var totalDuration = TimeSpan.Zero;
var allErrors = new List<string>();
var allWarnings = new List<string>();
foreach (var version in versions)
{
ct.ThrowIfCancellationRequested();
// Get build variants for this version
var variants = await _repository.GetBuildVariantsAsync(version.Id, ct);
foreach (var variant in variants)
{
ct.ThrowIfCancellationRequested();
var result = await GenerateFingerprintsAsync(variant.Id, opts, ct);
totalFunctions += result.FunctionsProcessed;
totalFingerprints += result.FingerprintsGenerated;
totalDuration += result.Duration;
allErrors.AddRange(result.Errors);
allWarnings.AddRange(result.Warnings);
}
}
return new BatchFingerprintResult(
library.Id,
totalFunctions,
totalFingerprints,
totalDuration,
[.. allErrors],
[.. allWarnings]);
}
/// <inheritdoc />
public async IAsyncEnumerable<FingerprintProgress> StreamProgressAsync(
Guid buildVariantId,
BatchFingerprintOptions? options = null,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
{
var opts = options ?? new BatchFingerprintOptions();
var functions = await _repository.GetFunctionsForVariantAsync(buildVariantId, ct);
var total = functions.Length;
var processed = 0;
var errors = 0;
var channel = Channel.CreateBounded<FingerprintWorkItem>(new BoundedChannelOptions(opts.BatchSize * 2)
{
FullMode = BoundedChannelFullMode.Wait
});
// Producer task: read functions and queue them
var producerTask = Task.Run(async () =>
{
try
{
foreach (var function in functions)
{
ct.ThrowIfCancellationRequested();
await channel.Writer.WriteAsync(new FingerprintWorkItem(function), ct);
}
}
finally
{
channel.Writer.Complete();
}
}, ct);
// Consumer: process batches and yield progress
var batch = new List<FingerprintWorkItem>();
await foreach (var item in channel.Reader.ReadAllAsync(ct))
{
batch.Add(item);
if (batch.Count >= opts.BatchSize)
{
var batchResult = await ProcessBatchAsync(batch, opts, ct);
processed += batchResult.Processed;
errors += batchResult.Errors;
batch.Clear();
yield return new FingerprintProgress(
processed,
total,
errors,
(double)processed / total);
}
}
// Process remaining items
if (batch.Count > 0)
{
var batchResult = await ProcessBatchAsync(batch, opts, ct);
processed += batchResult.Processed;
errors += batchResult.Errors;
yield return new FingerprintProgress(
processed,
total,
errors,
1.0);
}
await producerTask;
}
#region Private Methods
private async Task<BatchFingerprintResult> GenerateFingerprintsForFunctionsAsync(
ImmutableArray<CorpusFunction> functions,
Guid contextId,
BatchFingerprintOptions options,
CancellationToken ct)
{
var startTime = DateTime.UtcNow;
var processed = 0;
var generated = 0;
var errors = new List<string>();
var warnings = new List<string>();
// Process in batches with parallelism
var batches = functions
.Select((f, i) => new { Function = f, Index = i })
.GroupBy(x => x.Index / options.BatchSize)
.Select(g => g.Select(x => x.Function).ToList())
.ToList();
foreach (var batch in batches)
{
ct.ThrowIfCancellationRequested();
var semaphore = new SemaphoreSlim(options.ParallelDegree);
var batchFingerprints = new List<CorpusFingerprint>();
var tasks = batch.Select(async function =>
{
await semaphore.WaitAsync(ct);
try
{
var fingerprints = await GenerateFingerprintsForFunctionAsync(function, options, ct);
lock (batchFingerprints)
{
batchFingerprints.AddRange(fingerprints);
}
Interlocked.Increment(ref processed);
}
catch (Exception ex)
{
lock (errors)
{
errors.Add($"Function {function.Name}: {ex.Message}");
}
}
finally
{
semaphore.Release();
}
});
await Task.WhenAll(tasks);
// Batch insert fingerprints
if (batchFingerprints.Count > 0)
{
var insertedCount = await _repository.InsertFingerprintsAsync(batchFingerprints, ct);
generated += insertedCount;
}
}
var duration = DateTime.UtcNow - startTime;
_logger.LogInformation(
"Batch fingerprint generation completed: {Functions} functions, {Fingerprints} fingerprints in {Duration:c}",
processed,
generated,
duration);
return new BatchFingerprintResult(
contextId,
processed,
generated,
duration,
[.. errors],
[.. warnings]);
}
private async Task<ImmutableArray<CorpusFingerprint>> GenerateFingerprintsForFunctionAsync(
CorpusFunction function,
BatchFingerprintOptions options,
CancellationToken ct)
{
var fingerprints = new List<CorpusFingerprint>();
foreach (var algorithm in options.Algorithms)
{
ct.ThrowIfCancellationRequested();
var generator = _generatorFactory.GetGenerator(algorithm);
if (generator is null)
{
continue;
}
var fingerprint = await generator.GenerateAsync(function, ct);
if (fingerprint is not null)
{
fingerprints.Add(new CorpusFingerprint(
Guid.NewGuid(),
function.Id,
algorithm,
fingerprint.Hash,
Convert.ToHexStringLower(fingerprint.Hash),
fingerprint.Metadata,
DateTimeOffset.UtcNow));
}
}
return [.. fingerprints];
}
private async Task<(int Processed, int Errors)> ProcessBatchAsync(
List<FingerprintWorkItem> batch,
BatchFingerprintOptions options,
CancellationToken ct)
{
var processed = 0;
var errors = 0;
var allFingerprints = new List<CorpusFingerprint>();
var semaphore = new SemaphoreSlim(options.ParallelDegree);
var tasks = batch.Select(async item =>
{
await semaphore.WaitAsync(ct);
try
{
var fingerprints = await GenerateFingerprintsForFunctionAsync(item.Function, options, ct);
lock (allFingerprints)
{
allFingerprints.AddRange(fingerprints);
}
Interlocked.Increment(ref processed);
}
catch
{
Interlocked.Increment(ref errors);
}
finally
{
semaphore.Release();
}
});
await Task.WhenAll(tasks);
if (allFingerprints.Count > 0)
{
await _repository.InsertFingerprintsAsync(allFingerprints, ct);
}
return (processed, errors);
}
#endregion
private sealed record FingerprintWorkItem(CorpusFunction Function);
}
/// <summary>
/// Interface for batch fingerprint generation.
/// </summary>
public interface IBatchFingerprintPipeline
{
/// <summary>
/// Generate fingerprints for all functions in a build variant.
/// </summary>
Task<BatchFingerprintResult> GenerateFingerprintsAsync(
Guid buildVariantId,
BatchFingerprintOptions? options = null,
CancellationToken ct = default);
/// <summary>
/// Generate fingerprints for all functions in a library.
/// </summary>
Task<BatchFingerprintResult> GenerateFingerprintsForLibraryAsync(
string libraryName,
BatchFingerprintOptions? options = null,
CancellationToken ct = default);
/// <summary>
/// Stream progress for fingerprint generation.
/// </summary>
IAsyncEnumerable<FingerprintProgress> StreamProgressAsync(
Guid buildVariantId,
BatchFingerprintOptions? options = null,
CancellationToken ct = default);
}
/// <summary>
/// Options for batch fingerprint generation.
/// </summary>
public sealed record BatchFingerprintOptions
{
/// <summary>
/// Number of functions to process per batch.
/// </summary>
public int BatchSize { get; init; } = 100;
/// <summary>
/// Degree of parallelism for processing.
/// </summary>
public int ParallelDegree { get; init; } = 4;
/// <summary>
/// Algorithms to generate fingerprints for.
/// </summary>
public ImmutableArray<FingerprintAlgorithm> Algorithms { get; init; } =
[FingerprintAlgorithm.SemanticKsg, FingerprintAlgorithm.InstructionBb, FingerprintAlgorithm.CfgWl];
}
/// <summary>
/// Result of batch fingerprint generation.
/// </summary>
public sealed record BatchFingerprintResult(
Guid ContextId,
int FunctionsProcessed,
int FingerprintsGenerated,
TimeSpan Duration,
ImmutableArray<string> Errors,
ImmutableArray<string> Warnings);
/// <summary>
/// Progress update for fingerprint generation.
/// </summary>
public sealed record FingerprintProgress(
int Processed,
int Total,
int Errors,
double PercentComplete);
/// <summary>
/// Factory for creating fingerprint generators.
/// </summary>
public interface IFingerprintGeneratorFactory
{
/// <summary>
/// Get a fingerprint generator for the specified algorithm.
/// </summary>
ICorpusFingerprintGenerator? GetGenerator(FingerprintAlgorithm algorithm);
}
/// <summary>
/// Interface for corpus fingerprint generation.
/// </summary>
public interface ICorpusFingerprintGenerator
{
/// <summary>
/// Generate a fingerprint for a corpus function.
/// </summary>
Task<GeneratedFingerprint?> GenerateAsync(
CorpusFunction function,
CancellationToken ct = default);
}
/// <summary>
/// A generated fingerprint.
/// </summary>
public sealed record GeneratedFingerprint(
byte[] Hash,
FingerprintMetadata? Metadata);

View File

@@ -0,0 +1,466 @@
using System.Collections.Immutable;
using System.Diagnostics;
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
using StellaOps.BinaryIndex.Corpus.Models;
namespace StellaOps.BinaryIndex.Corpus.Services;
/// <summary>
/// Service for ingesting library binaries into the function corpus.
/// </summary>
public sealed class CorpusIngestionService : ICorpusIngestionService
{
private readonly ICorpusRepository _repository;
private readonly IFingerprintGenerator? _fingerprintGenerator;
private readonly IFunctionExtractor? _functionExtractor;
private readonly ILogger<CorpusIngestionService> _logger;
public CorpusIngestionService(
ICorpusRepository repository,
ILogger<CorpusIngestionService> logger,
IFingerprintGenerator? fingerprintGenerator = null,
IFunctionExtractor? functionExtractor = null)
{
_repository = repository;
_logger = logger;
_fingerprintGenerator = fingerprintGenerator;
_functionExtractor = functionExtractor;
}
/// <inheritdoc />
public async Task<IngestionResult> IngestLibraryAsync(
LibraryIngestionMetadata metadata,
Stream binaryStream,
IngestionOptions? options = null,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(metadata);
ArgumentNullException.ThrowIfNull(binaryStream);
var opts = options ?? new IngestionOptions();
var stopwatch = Stopwatch.StartNew();
var warnings = new List<string>();
var errors = new List<string>();
_logger.LogInformation(
"Starting ingestion for {Library} {Version} ({Architecture})",
metadata.Name,
metadata.Version,
metadata.Architecture);
// Compute binary hash
var binarySha256 = await ComputeSha256Async(binaryStream, ct);
binaryStream.Position = 0; // Reset for reading
// Check if we've already indexed this exact binary
var existingVariant = await _repository.GetBuildVariantBySha256Async(binarySha256, ct);
if (existingVariant is not null)
{
_logger.LogInformation(
"Binary {Sha256} already indexed as variant {VariantId}",
binarySha256[..16],
existingVariant.Id);
stopwatch.Stop();
return new IngestionResult(
Guid.Empty,
metadata.Name,
metadata.Version,
metadata.Architecture,
0,
0,
0,
stopwatch.Elapsed,
["Binary already indexed."],
[]);
}
// Create or get library record
var library = await _repository.GetOrCreateLibraryAsync(
metadata.Name,
null,
null,
null,
ct);
// Create ingestion job
var job = await _repository.CreateIngestionJobAsync(
library.Id,
IngestionJobType.FullIngest,
ct);
try
{
await _repository.UpdateIngestionJobAsync(
job.Id,
IngestionJobStatus.Running,
ct: ct);
// Create or get version record
var version = await _repository.GetOrCreateVersionAsync(
library.Id,
metadata.Version,
metadata.ReleaseDate,
metadata.IsSecurityRelease,
metadata.SourceArchiveSha256,
ct);
// Create build variant record
var variant = await _repository.GetOrCreateBuildVariantAsync(
version.Id,
metadata.Architecture,
binarySha256,
metadata.Abi,
metadata.Compiler,
metadata.CompilerVersion,
metadata.OptimizationLevel,
null,
ct);
// Extract functions from binary
var functions = await ExtractFunctionsAsync(binaryStream, variant.Id, opts, warnings, ct);
// Filter functions based on options
functions = ApplyFunctionFilters(functions, opts);
// Insert functions into database
var insertedCount = await _repository.InsertFunctionsAsync(functions, ct);
_logger.LogInformation(
"Extracted and inserted {Count} functions from {Library} {Version}",
insertedCount,
metadata.Name,
metadata.Version);
// Generate fingerprints for each function
var fingerprintsGenerated = 0;
if (_fingerprintGenerator is not null)
{
fingerprintsGenerated = await GenerateFingerprintsAsync(functions, opts, ct);
}
// Generate clusters if enabled
var clustersCreated = 0;
if (opts.GenerateClusters)
{
clustersCreated = await GenerateClustersAsync(library.Id, functions, ct);
}
// Update job with success
await _repository.UpdateIngestionJobAsync(
job.Id,
IngestionJobStatus.Completed,
functionsIndexed: insertedCount,
fingerprintsGenerated: fingerprintsGenerated,
clustersCreated: clustersCreated,
ct: ct);
stopwatch.Stop();
return new IngestionResult(
job.Id,
metadata.Name,
metadata.Version,
metadata.Architecture,
insertedCount,
fingerprintsGenerated,
clustersCreated,
stopwatch.Elapsed,
[],
[.. warnings]);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Ingestion failed for {Library} {Version}",
metadata.Name,
metadata.Version);
await _repository.UpdateIngestionJobAsync(
job.Id,
IngestionJobStatus.Failed,
errors: [ex.Message],
ct: ct);
stopwatch.Stop();
return new IngestionResult(
job.Id,
metadata.Name,
metadata.Version,
metadata.Architecture,
0,
0,
0,
stopwatch.Elapsed,
[ex.Message],
[.. warnings]);
}
}
/// <inheritdoc />
public async IAsyncEnumerable<IngestionResult> IngestFromConnectorAsync(
string libraryName,
ILibraryCorpusConnector connector,
IngestionOptions? options = null,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrEmpty(libraryName);
ArgumentNullException.ThrowIfNull(connector);
var opts = options ?? new IngestionOptions();
_logger.LogInformation(
"Starting bulk ingestion from {Connector} for library {Library}",
connector.LibraryName,
libraryName);
// Get available versions
var versions = await connector.GetAvailableVersionsAsync(ct);
_logger.LogInformation(
"Found {Count} versions for {Library}",
versions.Length,
libraryName);
var fetchOptions = new LibraryFetchOptions
{
IncludeDebugSymbols = true
};
// Process each architecture
foreach (var arch in connector.SupportedArchitectures)
{
await foreach (var binary in connector.FetchBinariesAsync(
[.. versions],
arch,
fetchOptions,
ct))
{
ct.ThrowIfCancellationRequested();
using (binary)
{
var metadata = new LibraryIngestionMetadata(
libraryName,
binary.Version,
binary.Architecture,
binary.Abi,
binary.Compiler,
binary.CompilerVersion,
binary.OptimizationLevel,
binary.ReleaseDate,
false,
null);
var result = await IngestLibraryAsync(metadata, binary.BinaryStream, opts, ct);
yield return result;
}
}
}
}
/// <inheritdoc />
public async Task<int> UpdateCveAssociationsAsync(
string cveId,
IReadOnlyList<FunctionCveAssociation> associations,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrEmpty(cveId);
ArgumentNullException.ThrowIfNull(associations);
if (associations.Count == 0)
{
return 0;
}
_logger.LogInformation(
"Updating CVE associations for {CveId} ({Count} functions)",
cveId,
associations.Count);
// Convert to FunctionCve records
var cveRecords = associations.Select(a => new FunctionCve(
a.FunctionId,
cveId,
a.AffectedState,
a.PatchCommit,
a.Confidence,
a.EvidenceType)).ToList();
return await _repository.UpsertCveAssociationsAsync(cveId, cveRecords, ct);
}
/// <inheritdoc />
public async Task<IngestionJob?> GetJobStatusAsync(Guid jobId, CancellationToken ct = default)
{
return await _repository.GetIngestionJobAsync(jobId, ct);
}
#region Private Methods
private async Task<ImmutableArray<CorpusFunction>> ExtractFunctionsAsync(
Stream binaryStream,
Guid buildVariantId,
IngestionOptions options,
List<string> warnings,
CancellationToken ct)
{
if (_functionExtractor is null)
{
warnings.Add("No function extractor configured, returning empty function list");
_logger.LogWarning("No function extractor configured");
return [];
}
var extractedFunctions = await _functionExtractor.ExtractFunctionsAsync(binaryStream, ct);
// Convert to corpus functions with IDs
var functions = extractedFunctions.Select(f => new CorpusFunction(
Guid.NewGuid(),
buildVariantId,
f.Name,
f.DemangledName,
f.Address,
f.SizeBytes,
f.IsExported,
f.IsInline,
f.SourceFile,
f.SourceLine)).ToImmutableArray();
return functions;
}
private static ImmutableArray<CorpusFunction> ApplyFunctionFilters(
ImmutableArray<CorpusFunction> functions,
IngestionOptions options)
{
var filtered = functions
.Where(f => f.SizeBytes >= options.MinFunctionSize)
.Where(f => !options.ExportedOnly || f.IsExported)
.Take(options.MaxFunctionsPerBinary);
return [.. filtered];
}
private async Task<int> GenerateFingerprintsAsync(
ImmutableArray<CorpusFunction> functions,
IngestionOptions options,
CancellationToken ct)
{
if (_fingerprintGenerator is null)
{
return 0;
}
var allFingerprints = new List<CorpusFingerprint>();
// Process in parallel with degree limit
var semaphore = new SemaphoreSlim(options.ParallelDegree);
var tasks = functions.Select(async function =>
{
await semaphore.WaitAsync(ct);
try
{
var fingerprints = await _fingerprintGenerator.GenerateFingerprintsAsync(function.Id, ct);
lock (allFingerprints)
{
allFingerprints.AddRange(fingerprints);
}
}
finally
{
semaphore.Release();
}
});
await Task.WhenAll(tasks);
if (allFingerprints.Count > 0)
{
return await _repository.InsertFingerprintsAsync(allFingerprints, ct);
}
return 0;
}
private async Task<int> GenerateClustersAsync(
Guid libraryId,
ImmutableArray<CorpusFunction> functions,
CancellationToken ct)
{
// Simple clustering: group functions by demangled name (if available) or name
var clusters = functions
.GroupBy(f => f.DemangledName ?? f.Name)
.Where(g => g.Count() > 1) // Only create clusters for functions appearing multiple times
.ToList();
var clustersCreated = 0;
foreach (var group in clusters)
{
ct.ThrowIfCancellationRequested();
var cluster = await _repository.GetOrCreateClusterAsync(
libraryId,
group.Key,
null,
ct);
var members = group.Select(f => new ClusterMember(cluster.Id, f.Id, 1.0m)).ToList();
await _repository.AddClusterMembersAsync(cluster.Id, members, ct);
clustersCreated++;
}
return clustersCreated;
}
private static async Task<string> ComputeSha256Async(Stream stream, CancellationToken ct)
{
using var sha256 = SHA256.Create();
var hash = await sha256.ComputeHashAsync(stream, ct);
return Convert.ToHexStringLower(hash);
}
#endregion
}
/// <summary>
/// Interface for extracting functions from binary files.
/// </summary>
public interface IFunctionExtractor
{
/// <summary>
/// Extract functions from a binary stream.
/// </summary>
Task<ImmutableArray<ExtractedFunction>> ExtractFunctionsAsync(
Stream binaryStream,
CancellationToken ct = default);
}
/// <summary>
/// Interface for generating function fingerprints.
/// </summary>
public interface IFingerprintGenerator
{
/// <summary>
/// Generate fingerprints for a function.
/// </summary>
Task<ImmutableArray<CorpusFingerprint>> GenerateFingerprintsAsync(
Guid functionId,
CancellationToken ct = default);
}
/// <summary>
/// A function extracted from a binary.
/// </summary>
public sealed record ExtractedFunction(
string Name,
string? DemangledName,
ulong Address,
int SizeBytes,
bool IsExported,
bool IsInline,
string? SourceFile,
int? SourceLine);

View File

@@ -0,0 +1,419 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.BinaryIndex.Corpus.Models;
namespace StellaOps.BinaryIndex.Corpus.Services;
/// <summary>
/// Service for querying the function corpus to identify functions.
/// </summary>
public sealed class CorpusQueryService : ICorpusQueryService
{
private readonly ICorpusRepository _repository;
private readonly IClusterSimilarityComputer _similarityComputer;
private readonly ILogger<CorpusQueryService> _logger;
public CorpusQueryService(
ICorpusRepository repository,
IClusterSimilarityComputer similarityComputer,
ILogger<CorpusQueryService> logger)
{
_repository = repository;
_similarityComputer = similarityComputer;
_logger = logger;
}
/// <inheritdoc />
public async Task<ImmutableArray<FunctionMatch>> IdentifyFunctionAsync(
FunctionFingerprints fingerprints,
IdentifyOptions? options = null,
CancellationToken ct = default)
{
var opts = options ?? new IdentifyOptions();
_logger.LogDebug("Identifying function with fingerprints");
var candidates = new List<FunctionCandidate>();
// Search by each available fingerprint type
if (fingerprints.SemanticHash is { Length: > 0 })
{
var matches = await SearchByFingerprintAsync(
FingerprintAlgorithm.SemanticKsg,
fingerprints.SemanticHash,
opts,
ct);
candidates.AddRange(matches);
}
if (fingerprints.InstructionHash is { Length: > 0 })
{
var matches = await SearchByFingerprintAsync(
FingerprintAlgorithm.InstructionBb,
fingerprints.InstructionHash,
opts,
ct);
candidates.AddRange(matches);
}
if (fingerprints.CfgHash is { Length: > 0 })
{
var matches = await SearchByFingerprintAsync(
FingerprintAlgorithm.CfgWl,
fingerprints.CfgHash,
opts,
ct);
candidates.AddRange(matches);
}
// Group candidates by function and compute combined similarity
var groupedCandidates = candidates
.GroupBy(c => c.FunctionId)
.Select(g => ComputeCombinedScore(g, fingerprints, opts.Weights))
.Where(c => c.Similarity >= opts.MinSimilarity)
.OrderByDescending(c => c.Similarity)
.Take(opts.MaxResults)
.ToList();
// Enrich with full function details
var results = new List<FunctionMatch>();
foreach (var candidate in groupedCandidates)
{
ct.ThrowIfCancellationRequested();
// Get the original candidates for this function
var functionCandidates = candidates.Where(c => c.FunctionId == candidate.FunctionId).ToList();
var function = await _repository.GetFunctionAsync(candidate.FunctionId, ct);
if (function is null) continue;
var variant = await _repository.GetBuildVariantAsync(function.BuildVariantId, ct);
if (variant is null) continue;
// Apply filters
if (opts.ArchitectureFilter is { Length: > 0 })
{
if (!opts.ArchitectureFilter.Value.Contains(variant.Architecture, StringComparer.OrdinalIgnoreCase))
continue;
}
var version = await _repository.GetLibraryVersionAsync(variant.LibraryVersionId, ct);
if (version is null) continue;
var library = await _repository.GetLibraryByIdAsync(version.LibraryId, ct);
if (library is null) continue;
// Apply library filter
if (opts.LibraryFilter is { Length: > 0 })
{
if (!opts.LibraryFilter.Value.Contains(library.Name, StringComparer.OrdinalIgnoreCase))
continue;
}
results.Add(new FunctionMatch(
library.Name,
version.Version,
function.Name,
function.DemangledName,
candidate.Similarity,
ComputeConfidence(candidate),
variant.Architecture,
variant.Abi,
new MatchDetails(
GetAlgorithmSimilarity(functionCandidates, FingerprintAlgorithm.SemanticKsg),
GetAlgorithmSimilarity(functionCandidates, FingerprintAlgorithm.InstructionBb),
GetAlgorithmSimilarity(functionCandidates, FingerprintAlgorithm.CfgWl),
GetAlgorithmSimilarity(functionCandidates, FingerprintAlgorithm.ApiCalls),
[],
fingerprints.SizeBytes.HasValue
? function.SizeBytes - fingerprints.SizeBytes.Value
: 0)));
}
return [.. results];
}
/// <inheritdoc />
public async Task<ImmutableDictionary<int, ImmutableArray<FunctionMatch>>> IdentifyBatchAsync(
IReadOnlyList<FunctionFingerprints> fingerprints,
IdentifyOptions? options = null,
CancellationToken ct = default)
{
var results = ImmutableDictionary.CreateBuilder<int, ImmutableArray<FunctionMatch>>();
// Process in parallel with controlled concurrency
var semaphore = new SemaphoreSlim(4);
var tasks = fingerprints.Select(async (fp, index) =>
{
await semaphore.WaitAsync(ct);
try
{
var matches = await IdentifyFunctionAsync(fp, options, ct);
return (Index: index, Matches: matches);
}
finally
{
semaphore.Release();
}
});
var completedResults = await Task.WhenAll(tasks);
foreach (var result in completedResults)
{
results.Add(result.Index, result.Matches);
}
return results.ToImmutable();
}
/// <inheritdoc />
public async Task<ImmutableArray<CorpusFunctionWithCve>> GetFunctionsForCveAsync(
string cveId,
CancellationToken ct = default)
{
_logger.LogDebug("Getting functions for CVE {CveId}", cveId);
var functionIds = await _repository.GetFunctionIdsForCveAsync(cveId, ct);
var results = new List<CorpusFunctionWithCve>();
foreach (var functionId in functionIds)
{
ct.ThrowIfCancellationRequested();
var function = await _repository.GetFunctionAsync(functionId, ct);
if (function is null) continue;
var variant = await _repository.GetBuildVariantAsync(function.BuildVariantId, ct);
if (variant is null) continue;
var version = await _repository.GetLibraryVersionAsync(variant.LibraryVersionId, ct);
if (version is null) continue;
var library = await _repository.GetLibraryByIdAsync(version.LibraryId, ct);
if (library is null) continue;
var cves = await _repository.GetCvesForFunctionAsync(functionId, ct);
var cveInfo = cves.FirstOrDefault(c => c.CveId == cveId);
if (cveInfo is null) continue;
results.Add(new CorpusFunctionWithCve(function, library, version, variant, cveInfo));
}
return [.. results];
}
/// <inheritdoc />
public async Task<FunctionEvolution?> GetFunctionEvolutionAsync(
string libraryName,
string functionName,
CancellationToken ct = default)
{
_logger.LogDebug("Getting evolution for function {Function} in {Library}", functionName, libraryName);
var library = await _repository.GetLibraryAsync(libraryName, ct);
if (library is null)
{
return null;
}
var versions = await _repository.ListVersionsAsync(libraryName, ct);
var snapshots = new List<FunctionVersionInfo>();
string? previousFingerprintHex = null;
foreach (var versionSummary in versions.OrderBy(v => v.ReleaseDate))
{
ct.ThrowIfCancellationRequested();
var version = await _repository.GetVersionAsync(library.Id, versionSummary.Version, ct);
if (version is null) continue;
var variants = await _repository.GetBuildVariantsAsync(version.Id, ct);
// Find the function in any variant
CorpusFunction? targetFunction = null;
CorpusFingerprint? fingerprint = null;
foreach (var variant in variants)
{
var functions = await _repository.GetFunctionsForVariantAsync(variant.Id, ct);
targetFunction = functions.FirstOrDefault(f =>
string.Equals(f.Name, functionName, StringComparison.Ordinal) ||
string.Equals(f.DemangledName, functionName, StringComparison.Ordinal));
if (targetFunction is not null)
{
var fps = await _repository.GetFingerprintsAsync(targetFunction.Id, ct);
fingerprint = fps.FirstOrDefault(f => f.Algorithm == FingerprintAlgorithm.SemanticKsg);
break;
}
}
if (targetFunction is null)
{
continue;
}
// Get CVE info for this version
var cves = await _repository.GetCvesForFunctionAsync(targetFunction.Id, ct);
var cveIds = cves.Select(c => c.CveId).ToImmutableArray();
// Compute similarity to previous version if available
decimal? similarityToPrevious = null;
var currentFingerprintHex = fingerprint?.FingerprintHex ?? string.Empty;
if (previousFingerprintHex is not null && currentFingerprintHex.Length > 0)
{
// Simple comparison: same hash = 1.0, different = 0.5 (would need proper similarity for better results)
similarityToPrevious = string.Equals(previousFingerprintHex, currentFingerprintHex, StringComparison.Ordinal)
? 1.0m
: 0.5m;
}
previousFingerprintHex = currentFingerprintHex;
snapshots.Add(new FunctionVersionInfo(
versionSummary.Version,
versionSummary.ReleaseDate,
targetFunction.SizeBytes,
currentFingerprintHex,
similarityToPrevious,
cveIds.Length > 0 ? cveIds : null));
}
if (snapshots.Count == 0)
{
return null;
}
return new FunctionEvolution(libraryName, functionName, [.. snapshots]);
}
/// <inheritdoc />
public async Task<CorpusStatistics> GetStatisticsAsync(CancellationToken ct = default)
{
return await _repository.GetStatisticsAsync(ct);
}
/// <inheritdoc />
public async Task<ImmutableArray<LibrarySummary>> ListLibrariesAsync(CancellationToken ct = default)
{
return await _repository.ListLibrariesAsync(ct);
}
/// <inheritdoc />
public async Task<ImmutableArray<LibraryVersionSummary>> ListVersionsAsync(
string libraryName,
CancellationToken ct = default)
{
return await _repository.ListVersionsAsync(libraryName, ct);
}
#region Private Methods
private async Task<List<FunctionCandidate>> SearchByFingerprintAsync(
FingerprintAlgorithm algorithm,
byte[] fingerprint,
IdentifyOptions options,
CancellationToken ct)
{
var candidates = new List<FunctionCandidate>();
// First try exact match
var exactMatches = await _repository.FindFunctionsByFingerprintAsync(algorithm, fingerprint, ct);
foreach (var functionId in exactMatches)
{
candidates.Add(new FunctionCandidate(functionId, algorithm, 1.0m, fingerprint));
}
// Then try approximate matching
var similarResults = await _repository.FindSimilarFingerprintsAsync(
algorithm,
fingerprint,
options.MaxResults * 2, // Get more to account for filtering
ct);
foreach (var result in similarResults)
{
if (!candidates.Any(c => c.FunctionId == result.FunctionId))
{
candidates.Add(new FunctionCandidate(
result.FunctionId,
algorithm,
result.Similarity,
result.Fingerprint));
}
}
return candidates;
}
private static CombinedCandidate ComputeCombinedScore(
IGrouping<Guid, FunctionCandidate> group,
FunctionFingerprints query,
SimilarityWeights weights)
{
var candidates = group.ToList();
decimal totalScore = 0;
decimal totalWeight = 0;
var algorithms = new List<FingerprintAlgorithm>();
foreach (var candidate in candidates)
{
var weight = candidate.Algorithm switch
{
FingerprintAlgorithm.SemanticKsg => weights.SemanticWeight,
FingerprintAlgorithm.InstructionBb => weights.InstructionWeight,
FingerprintAlgorithm.CfgWl => weights.CfgWeight,
FingerprintAlgorithm.ApiCalls => weights.ApiCallWeight,
_ => 0.1m
};
totalScore += candidate.Similarity * weight;
totalWeight += weight;
algorithms.Add(candidate.Algorithm);
}
var combinedSimilarity = totalWeight > 0 ? totalScore / totalWeight : 0;
return new CombinedCandidate(group.Key, combinedSimilarity, [.. algorithms]);
}
private static MatchConfidence ComputeConfidence(CombinedCandidate candidate)
{
// Higher confidence with more matching algorithms and higher similarity
var algorithmCount = candidate.MatchingAlgorithms.Length;
var similarity = candidate.Similarity;
if (algorithmCount >= 3 && similarity >= 0.95m)
return MatchConfidence.Exact;
if (algorithmCount >= 3 && similarity >= 0.85m)
return MatchConfidence.VeryHigh;
if (algorithmCount >= 2 && similarity >= 0.85m)
return MatchConfidence.High;
if (algorithmCount >= 1 && similarity >= 0.70m)
return MatchConfidence.Medium;
return MatchConfidence.Low;
}
private static decimal GetAlgorithmSimilarity(
List<FunctionCandidate> candidates,
FingerprintAlgorithm algorithm)
{
var match = candidates.FirstOrDefault(c => c.Algorithm == algorithm);
return match?.Similarity ?? 0m;
}
#endregion
private sealed record FunctionCandidate(
Guid FunctionId,
FingerprintAlgorithm Algorithm,
decimal Similarity,
byte[] Fingerprint);
private sealed record CombinedCandidate(
Guid FunctionId,
decimal Similarity,
ImmutableArray<FingerprintAlgorithm> MatchingAlgorithms);
}

View File

@@ -0,0 +1,423 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.BinaryIndex.Corpus.Models;
namespace StellaOps.BinaryIndex.Corpus.Services;
/// <summary>
/// Service for updating CVE-to-function mappings in the corpus.
/// </summary>
public sealed class CveFunctionMappingUpdater : ICveFunctionMappingUpdater
{
private readonly ICorpusRepository _repository;
private readonly ICveDataProvider _cveDataProvider;
private readonly ILogger<CveFunctionMappingUpdater> _logger;
public CveFunctionMappingUpdater(
ICorpusRepository repository,
ICveDataProvider cveDataProvider,
ILogger<CveFunctionMappingUpdater> logger)
{
_repository = repository;
_cveDataProvider = cveDataProvider;
_logger = logger;
}
/// <inheritdoc />
public async Task<CveMappingUpdateResult> UpdateMappingsForCveAsync(
string cveId,
CancellationToken ct = default)
{
_logger.LogInformation("Updating function mappings for CVE {CveId}", cveId);
var startTime = DateTime.UtcNow;
var errors = new List<string>();
var functionsUpdated = 0;
try
{
// Get CVE details from provider
var cveDetails = await _cveDataProvider.GetCveDetailsAsync(cveId, ct);
if (cveDetails is null)
{
return new CveMappingUpdateResult(
cveId,
0,
DateTime.UtcNow - startTime,
[$"CVE {cveId} not found in data provider"]);
}
// Get affected library
var library = await _repository.GetLibraryAsync(cveDetails.AffectedLibrary, ct);
if (library is null)
{
return new CveMappingUpdateResult(
cveId,
0,
DateTime.UtcNow - startTime,
[$"Library {cveDetails.AffectedLibrary} not found in corpus"]);
}
// Process affected versions
var associations = new List<FunctionCve>();
foreach (var affectedVersion in cveDetails.AffectedVersions)
{
ct.ThrowIfCancellationRequested();
// Find matching version in corpus
var version = await FindMatchingVersionAsync(library.Id, affectedVersion, ct);
if (version is null)
{
_logger.LogDebug("Version {Version} not found in corpus", affectedVersion);
continue;
}
// Get all build variants for this version
var variants = await _repository.GetBuildVariantsAsync(version.Id, ct);
foreach (var variant in variants)
{
// Get functions in this variant
var functions = await _repository.GetFunctionsForVariantAsync(variant.Id, ct);
// If we have specific function names, only map those
if (cveDetails.AffectedFunctions.Length > 0)
{
var matchedFunctions = functions.Where(f =>
cveDetails.AffectedFunctions.Contains(f.Name, StringComparer.Ordinal) ||
(f.DemangledName is not null &&
cveDetails.AffectedFunctions.Contains(f.DemangledName, StringComparer.Ordinal)));
foreach (var function in matchedFunctions)
{
associations.Add(CreateAssociation(function.Id, cveId, cveDetails, affectedVersion));
functionsUpdated++;
}
}
else
{
// Map all functions in affected variant as potentially affected
foreach (var function in functions.Take(100)) // Limit to avoid huge updates
{
associations.Add(CreateAssociation(function.Id, cveId, cveDetails, affectedVersion));
functionsUpdated++;
}
}
}
}
// Upsert all associations
if (associations.Count > 0)
{
await _repository.UpsertCveAssociationsAsync(cveId, associations, ct);
}
var duration = DateTime.UtcNow - startTime;
_logger.LogInformation(
"Updated {Count} function mappings for CVE {CveId} in {Duration:c}",
functionsUpdated, cveId, duration);
return new CveMappingUpdateResult(cveId, functionsUpdated, duration, [.. errors]);
}
catch (Exception ex)
{
errors.Add(ex.Message);
_logger.LogError(ex, "Error updating mappings for CVE {CveId}", cveId);
return new CveMappingUpdateResult(cveId, functionsUpdated, DateTime.UtcNow - startTime, [.. errors]);
}
}
/// <inheritdoc />
public async Task<CveBatchMappingResult> UpdateMappingsForLibraryAsync(
string libraryName,
CancellationToken ct = default)
{
_logger.LogInformation("Updating all CVE mappings for library {Library}", libraryName);
var startTime = DateTime.UtcNow;
var results = new List<CveMappingUpdateResult>();
// Get all CVEs for this library
var cves = await _cveDataProvider.GetCvesForLibraryAsync(libraryName, ct);
foreach (var cveId in cves)
{
ct.ThrowIfCancellationRequested();
var result = await UpdateMappingsForCveAsync(cveId, ct);
results.Add(result);
}
var totalDuration = DateTime.UtcNow - startTime;
return new CveBatchMappingResult(
libraryName,
results.Count,
results.Sum(r => r.FunctionsUpdated),
totalDuration,
[.. results.Where(r => r.Errors.Length > 0).SelectMany(r => r.Errors)]);
}
/// <inheritdoc />
public async Task<CveMappingUpdateResult> MarkFunctionFixedAsync(
string cveId,
string libraryName,
string version,
string? functionName,
string? patchCommit,
CancellationToken ct = default)
{
_logger.LogInformation(
"Marking functions as fixed for CVE {CveId} in {Library} {Version}",
cveId, libraryName, version);
var startTime = DateTime.UtcNow;
var functionsUpdated = 0;
var library = await _repository.GetLibraryAsync(libraryName, ct);
if (library is null)
{
return new CveMappingUpdateResult(
cveId, 0, DateTime.UtcNow - startTime,
[$"Library {libraryName} not found"]);
}
var libVersion = await _repository.GetVersionAsync(library.Id, version, ct);
if (libVersion is null)
{
return new CveMappingUpdateResult(
cveId, 0, DateTime.UtcNow - startTime,
[$"Version {version} not found"]);
}
var variants = await _repository.GetBuildVariantsAsync(libVersion.Id, ct);
var associations = new List<FunctionCve>();
foreach (var variant in variants)
{
var functions = await _repository.GetFunctionsForVariantAsync(variant.Id, ct);
IEnumerable<CorpusFunction> targetFunctions = functionName is null
? functions
: functions.Where(f =>
string.Equals(f.Name, functionName, StringComparison.Ordinal) ||
string.Equals(f.DemangledName, functionName, StringComparison.Ordinal));
foreach (var function in targetFunctions)
{
associations.Add(new FunctionCve(
function.Id,
cveId,
CveAffectedState.Fixed,
patchCommit,
0.9m, // High confidence for explicit marking
CveEvidenceType.Commit));
functionsUpdated++;
}
}
if (associations.Count > 0)
{
await _repository.UpsertCveAssociationsAsync(cveId, associations, ct);
}
return new CveMappingUpdateResult(
cveId, functionsUpdated, DateTime.UtcNow - startTime, []);
}
/// <inheritdoc />
public async Task<ImmutableArray<string>> GetUnmappedCvesAsync(
string libraryName,
CancellationToken ct = default)
{
// Get all known CVEs for this library
var allCves = await _cveDataProvider.GetCvesForLibraryAsync(libraryName, ct);
// Get CVEs that have function mappings
var unmapped = new List<string>();
foreach (var cveId in allCves)
{
ct.ThrowIfCancellationRequested();
var functionIds = await _repository.GetFunctionIdsForCveAsync(cveId, ct);
if (functionIds.Length == 0)
{
unmapped.Add(cveId);
}
}
return [.. unmapped];
}
#region Private Methods
private async Task<LibraryVersion?> FindMatchingVersionAsync(
Guid libraryId,
string versionString,
CancellationToken ct)
{
// Try exact match first
var exactMatch = await _repository.GetVersionAsync(libraryId, versionString, ct);
if (exactMatch is not null)
{
return exactMatch;
}
// Try with common prefixes/suffixes removed
var normalizedVersion = NormalizeVersion(versionString);
if (normalizedVersion != versionString)
{
return await _repository.GetVersionAsync(libraryId, normalizedVersion, ct);
}
return null;
}
private static string NormalizeVersion(string version)
{
// Remove common prefixes
if (version.StartsWith("v", StringComparison.OrdinalIgnoreCase))
{
version = version[1..];
}
// Remove release suffixes
var suffixIndex = version.IndexOfAny(['-', '+', '_']);
if (suffixIndex > 0)
{
version = version[..suffixIndex];
}
return version;
}
private static FunctionCve CreateAssociation(
Guid functionId,
string cveId,
CveDetails cveDetails,
string version)
{
var isFixed = cveDetails.FixedVersions.Contains(version, StringComparer.OrdinalIgnoreCase);
return new FunctionCve(
functionId,
cveId,
isFixed ? CveAffectedState.Fixed : CveAffectedState.Vulnerable,
cveDetails.PatchCommit,
ComputeConfidence(cveDetails),
cveDetails.EvidenceType);
}
private static decimal ComputeConfidence(CveDetails details)
{
// Higher confidence for specific function names and commit evidence
var baseConfidence = 0.5m;
if (details.AffectedFunctions.Length > 0)
{
baseConfidence += 0.2m;
}
if (!string.IsNullOrEmpty(details.PatchCommit))
{
baseConfidence += 0.2m;
}
return details.EvidenceType switch
{
CveEvidenceType.Commit => baseConfidence + 0.1m,
CveEvidenceType.Advisory => baseConfidence + 0.05m,
CveEvidenceType.Changelog => baseConfidence + 0.05m,
_ => baseConfidence
};
}
#endregion
}
/// <summary>
/// Interface for CVE-to-function mapping updates.
/// </summary>
public interface ICveFunctionMappingUpdater
{
/// <summary>
/// Update function mappings for a specific CVE.
/// </summary>
Task<CveMappingUpdateResult> UpdateMappingsForCveAsync(
string cveId,
CancellationToken ct = default);
/// <summary>
/// Update all CVE mappings for a library.
/// </summary>
Task<CveBatchMappingResult> UpdateMappingsForLibraryAsync(
string libraryName,
CancellationToken ct = default);
/// <summary>
/// Mark functions as fixed for a CVE.
/// </summary>
Task<CveMappingUpdateResult> MarkFunctionFixedAsync(
string cveId,
string libraryName,
string version,
string? functionName,
string? patchCommit,
CancellationToken ct = default);
/// <summary>
/// Get CVEs that have no function mappings.
/// </summary>
Task<ImmutableArray<string>> GetUnmappedCvesAsync(
string libraryName,
CancellationToken ct = default);
}
/// <summary>
/// Provider for CVE data.
/// </summary>
public interface ICveDataProvider
{
/// <summary>
/// Get details for a CVE.
/// </summary>
Task<CveDetails?> GetCveDetailsAsync(string cveId, CancellationToken ct = default);
/// <summary>
/// Get all CVEs affecting a library.
/// </summary>
Task<ImmutableArray<string>> GetCvesForLibraryAsync(string libraryName, CancellationToken ct = default);
}
/// <summary>
/// CVE details from a data provider.
/// </summary>
public sealed record CveDetails(
string CveId,
string AffectedLibrary,
ImmutableArray<string> AffectedVersions,
ImmutableArray<string> FixedVersions,
ImmutableArray<string> AffectedFunctions,
string? PatchCommit,
CveEvidenceType EvidenceType);
/// <summary>
/// Result of a CVE mapping update.
/// </summary>
public sealed record CveMappingUpdateResult(
string CveId,
int FunctionsUpdated,
TimeSpan Duration,
ImmutableArray<string> Errors);
/// <summary>
/// Result of batch CVE mapping update.
/// </summary>
public sealed record CveBatchMappingResult(
string LibraryName,
int CvesProcessed,
int TotalFunctionsUpdated,
TimeSpan Duration,
ImmutableArray<string> Errors);

View File

@@ -0,0 +1,531 @@
using System.Collections.Immutable;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using StellaOps.BinaryIndex.Corpus.Models;
namespace StellaOps.BinaryIndex.Corpus.Services;
/// <summary>
/// Service for clustering semantically similar functions across library versions.
/// Groups functions by their canonical name and computes similarity to cluster centroid.
/// </summary>
public sealed partial class FunctionClusteringService : IFunctionClusteringService
{
private readonly ICorpusRepository _repository;
private readonly IClusterSimilarityComputer _similarityComputer;
private readonly ILogger<FunctionClusteringService> _logger;
public FunctionClusteringService(
ICorpusRepository repository,
IClusterSimilarityComputer similarityComputer,
ILogger<FunctionClusteringService> logger)
{
_repository = repository;
_similarityComputer = similarityComputer;
_logger = logger;
}
/// <inheritdoc />
public async Task<ClusteringResult> ClusterFunctionsAsync(
Guid libraryId,
ClusteringOptions? options = null,
CancellationToken ct = default)
{
var opts = options ?? new ClusteringOptions();
var startTime = DateTime.UtcNow;
_logger.LogInformation(
"Starting function clustering for library {LibraryId}",
libraryId);
// Get all functions with fingerprints for this library
var functionsWithFingerprints = await GetFunctionsWithFingerprintsAsync(libraryId, ct);
if (functionsWithFingerprints.Count == 0)
{
_logger.LogWarning("No functions with fingerprints found for library {LibraryId}", libraryId);
return new ClusteringResult(
libraryId,
0,
0,
TimeSpan.Zero,
[],
[]);
}
_logger.LogInformation(
"Found {Count} functions with fingerprints",
functionsWithFingerprints.Count);
// Group functions by canonical name
var groupedByName = functionsWithFingerprints
.GroupBy(f => NormalizeCanonicalName(f.Function.DemangledName ?? f.Function.Name))
.Where(g => !string.IsNullOrWhiteSpace(g.Key))
.ToList();
_logger.LogInformation(
"Grouped into {Count} canonical function names",
groupedByName.Count);
var clustersCreated = 0;
var membersAssigned = 0;
var errors = new List<string>();
var warnings = new List<string>();
foreach (var group in groupedByName)
{
ct.ThrowIfCancellationRequested();
try
{
var result = await ProcessFunctionGroupAsync(
libraryId,
group.Key,
group.ToList(),
opts,
ct);
clustersCreated++;
membersAssigned += result.MembersAdded;
if (result.Warnings.Length > 0)
{
warnings.AddRange(result.Warnings);
}
}
catch (Exception ex)
{
errors.Add($"Failed to cluster '{group.Key}': {ex.Message}");
_logger.LogError(ex, "Error clustering function group {Name}", group.Key);
}
}
var duration = DateTime.UtcNow - startTime;
_logger.LogInformation(
"Clustering completed: {Clusters} clusters, {Members} members in {Duration:c}",
clustersCreated,
membersAssigned,
duration);
return new ClusteringResult(
libraryId,
clustersCreated,
membersAssigned,
duration,
[.. errors],
[.. warnings]);
}
/// <inheritdoc />
public async Task<ClusteringResult> ReclusterAsync(
Guid clusterId,
ClusteringOptions? options = null,
CancellationToken ct = default)
{
var opts = options ?? new ClusteringOptions();
var startTime = DateTime.UtcNow;
// Get existing cluster
var cluster = await _repository.GetClusterAsync(clusterId, ct);
if (cluster is null)
{
return new ClusteringResult(
Guid.Empty,
0,
0,
TimeSpan.Zero,
["Cluster not found"],
[]);
}
// Get current members
var members = await _repository.GetClusterMembersAsync(clusterId, ct);
if (members.Length == 0)
{
return new ClusteringResult(
cluster.LibraryId,
0,
0,
TimeSpan.Zero,
[],
["Cluster has no members"]);
}
// Get functions with fingerprints
var functionsWithFingerprints = new List<FunctionWithFingerprint>();
foreach (var member in members)
{
var function = await _repository.GetFunctionAsync(member.FunctionId, ct);
if (function is null)
{
continue;
}
var fingerprints = await _repository.GetFingerprintsForFunctionAsync(function.Id, ct);
var semanticFp = fingerprints.FirstOrDefault(f => f.Algorithm == FingerprintAlgorithm.SemanticKsg);
if (semanticFp is not null)
{
functionsWithFingerprints.Add(new FunctionWithFingerprint(function, semanticFp));
}
}
// Clear existing members
await _repository.ClearClusterMembersAsync(clusterId, ct);
// Recompute similarities
var centroid = ComputeCentroid(functionsWithFingerprints, opts);
var membersAdded = 0;
foreach (var fwf in functionsWithFingerprints)
{
var similarity = await _similarityComputer.ComputeSimilarityAsync(
fwf.Fingerprint.Fingerprint,
centroid,
ct);
if (similarity >= opts.MinimumSimilarity)
{
await _repository.AddClusterMemberAsync(
new ClusterMember(clusterId, fwf.Function.Id, similarity),
ct);
membersAdded++;
}
}
var duration = DateTime.UtcNow - startTime;
return new ClusteringResult(
cluster.LibraryId,
1,
membersAdded,
duration,
[],
[]);
}
/// <inheritdoc />
public async Task<ImmutableArray<FunctionCluster>> GetClustersForLibraryAsync(
Guid libraryId,
CancellationToken ct = default)
{
return await _repository.GetClustersForLibraryAsync(libraryId, ct);
}
/// <inheritdoc />
public async Task<ClusterDetails?> GetClusterDetailsAsync(
Guid clusterId,
CancellationToken ct = default)
{
var cluster = await _repository.GetClusterAsync(clusterId, ct);
if (cluster is null)
{
return null;
}
var members = await _repository.GetClusterMembersAsync(clusterId, ct);
var functionDetails = new List<ClusterMemberDetails>();
foreach (var member in members)
{
var function = await _repository.GetFunctionAsync(member.FunctionId, ct);
if (function is null)
{
continue;
}
var variant = await _repository.GetBuildVariantAsync(function.BuildVariantId, ct);
LibraryVersion? version = null;
if (variant is not null)
{
version = await _repository.GetLibraryVersionAsync(variant.LibraryVersionId, ct);
}
functionDetails.Add(new ClusterMemberDetails(
member.FunctionId,
function.Name,
function.DemangledName,
version?.Version ?? "unknown",
variant?.Architecture ?? "unknown",
member.SimilarityToCentroid ?? 0m));
}
return new ClusterDetails(
cluster.Id,
cluster.LibraryId,
cluster.CanonicalName,
cluster.Description,
[.. functionDetails]);
}
#region Private Methods
private async Task<List<FunctionWithFingerprint>> GetFunctionsWithFingerprintsAsync(
Guid libraryId,
CancellationToken ct)
{
var result = new List<FunctionWithFingerprint>();
// Get all versions for the library
var library = await _repository.GetLibraryByIdAsync(libraryId, ct);
if (library is null)
{
return result;
}
var versions = await _repository.ListVersionsAsync(library.Name, ct);
foreach (var version in versions)
{
var variants = await _repository.GetBuildVariantsAsync(version.Id, ct);
foreach (var variant in variants)
{
var functions = await _repository.GetFunctionsForVariantAsync(variant.Id, ct);
foreach (var function in functions)
{
var fingerprints = await _repository.GetFingerprintsForFunctionAsync(function.Id, ct);
var semanticFp = fingerprints.FirstOrDefault(f => f.Algorithm == FingerprintAlgorithm.SemanticKsg);
if (semanticFp is not null)
{
result.Add(new FunctionWithFingerprint(function, semanticFp));
}
}
}
}
return result;
}
private async Task<GroupClusteringResult> ProcessFunctionGroupAsync(
Guid libraryId,
string canonicalName,
List<FunctionWithFingerprint> functions,
ClusteringOptions options,
CancellationToken ct)
{
// Ensure cluster exists
var existingClusters = await _repository.GetClustersForLibraryAsync(libraryId, ct);
var cluster = existingClusters.FirstOrDefault(c =>
string.Equals(c.CanonicalName, canonicalName, StringComparison.OrdinalIgnoreCase));
Guid clusterId;
if (cluster is null)
{
// Create new cluster
var newCluster = new FunctionCluster(
Guid.NewGuid(),
libraryId,
canonicalName,
$"Cluster for function '{canonicalName}'",
DateTimeOffset.UtcNow);
await _repository.InsertClusterAsync(newCluster, ct);
clusterId = newCluster.Id;
}
else
{
clusterId = cluster.Id;
// Clear existing members for recomputation
await _repository.ClearClusterMembersAsync(clusterId, ct);
}
// Compute centroid fingerprint
var centroid = ComputeCentroid(functions, options);
var membersAdded = 0;
var warnings = new List<string>();
foreach (var fwf in functions)
{
var similarity = await _similarityComputer.ComputeSimilarityAsync(
fwf.Fingerprint.Fingerprint,
centroid,
ct);
if (similarity >= options.MinimumSimilarity)
{
await _repository.AddClusterMemberAsync(
new ClusterMember(clusterId, fwf.Function.Id, similarity),
ct);
membersAdded++;
}
else
{
warnings.Add($"Function {fwf.Function.Name} excluded: similarity {similarity:F4} < threshold {options.MinimumSimilarity:F4}");
}
}
return new GroupClusteringResult(membersAdded, [.. warnings]);
}
private static byte[] ComputeCentroid(
List<FunctionWithFingerprint> functions,
ClusteringOptions options)
{
if (functions.Count == 0)
{
return [];
}
if (functions.Count == 1)
{
return functions[0].Fingerprint.Fingerprint;
}
// Use most common fingerprint as centroid (mode-based approach)
// This is more robust than averaging for discrete hash-based fingerprints
var fingerprintCounts = functions
.GroupBy(f => Convert.ToHexStringLower(f.Fingerprint.Fingerprint))
.OrderByDescending(g => g.Count())
.ToList();
var mostCommon = fingerprintCounts.First();
return functions
.First(f => Convert.ToHexStringLower(f.Fingerprint.Fingerprint) == mostCommon.Key)
.Fingerprint.Fingerprint;
}
/// <summary>
/// Normalizes a function name to its canonical form for clustering.
/// </summary>
private static string NormalizeCanonicalName(string name)
{
if (string.IsNullOrWhiteSpace(name))
{
return string.Empty;
}
// Remove GLIBC version annotations (e.g., memcpy@GLIBC_2.14 -> memcpy)
var normalized = GlibcVersionPattern().Replace(name, "");
// Remove trailing @@ symbols
normalized = normalized.TrimEnd('@');
// Remove common symbol prefixes
if (normalized.StartsWith("__"))
{
normalized = normalized[2..];
}
// Remove _internal suffixes
normalized = InternalSuffixPattern().Replace(normalized, "");
// Trim whitespace
normalized = normalized.Trim();
return normalized;
}
[GeneratedRegex(@"@GLIBC_[\d.]+", RegexOptions.Compiled)]
private static partial Regex GlibcVersionPattern();
[GeneratedRegex(@"_internal$", RegexOptions.Compiled | RegexOptions.IgnoreCase)]
private static partial Regex InternalSuffixPattern();
#endregion
private sealed record FunctionWithFingerprint(CorpusFunction Function, CorpusFingerprint Fingerprint);
private sealed record GroupClusteringResult(int MembersAdded, ImmutableArray<string> Warnings);
}
/// <summary>
/// Interface for function clustering.
/// </summary>
public interface IFunctionClusteringService
{
/// <summary>
/// Cluster all functions for a library.
/// </summary>
Task<ClusteringResult> ClusterFunctionsAsync(
Guid libraryId,
ClusteringOptions? options = null,
CancellationToken ct = default);
/// <summary>
/// Recompute a specific cluster.
/// </summary>
Task<ClusteringResult> ReclusterAsync(
Guid clusterId,
ClusteringOptions? options = null,
CancellationToken ct = default);
/// <summary>
/// Get all clusters for a library.
/// </summary>
Task<ImmutableArray<FunctionCluster>> GetClustersForLibraryAsync(
Guid libraryId,
CancellationToken ct = default);
/// <summary>
/// Get detailed information about a cluster.
/// </summary>
Task<ClusterDetails?> GetClusterDetailsAsync(
Guid clusterId,
CancellationToken ct = default);
}
/// <summary>
/// Options for function clustering.
/// </summary>
public sealed record ClusteringOptions
{
/// <summary>
/// Minimum similarity threshold to include a function in a cluster.
/// </summary>
public decimal MinimumSimilarity { get; init; } = 0.7m;
/// <summary>
/// Algorithm to use for clustering.
/// </summary>
public FingerprintAlgorithm Algorithm { get; init; } = FingerprintAlgorithm.SemanticKsg;
}
/// <summary>
/// Result of clustering operation.
/// </summary>
public sealed record ClusteringResult(
Guid LibraryId,
int ClustersCreated,
int MembersAssigned,
TimeSpan Duration,
ImmutableArray<string> Errors,
ImmutableArray<string> Warnings);
/// <summary>
/// Detailed cluster information.
/// </summary>
public sealed record ClusterDetails(
Guid ClusterId,
Guid LibraryId,
string CanonicalName,
string? Description,
ImmutableArray<ClusterMemberDetails> Members);
/// <summary>
/// Details about a cluster member.
/// </summary>
public sealed record ClusterMemberDetails(
Guid FunctionId,
string FunctionName,
string? DemangledName,
string Version,
string Architecture,
decimal SimilarityToCentroid);
/// <summary>
/// Interface for computing similarity between fingerprints.
/// </summary>
public interface IClusterSimilarityComputer
{
/// <summary>
/// Compute similarity between two fingerprints.
/// </summary>
Task<decimal> ComputeSimilarityAsync(
byte[] fingerprint1,
byte[] fingerprint2,
CancellationToken ct = default);
}

View File

@@ -10,6 +10,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Http" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,392 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
using System.Collections.Immutable;
namespace StellaOps.BinaryIndex.Decompiler;
/// <summary>
/// Engine for comparing AST structures using tree edit distance and semantic analysis.
/// </summary>
public sealed class AstComparisonEngine : IAstComparisonEngine
{
/// <inheritdoc />
public decimal ComputeStructuralSimilarity(DecompiledAst a, DecompiledAst b)
{
ArgumentNullException.ThrowIfNull(a);
ArgumentNullException.ThrowIfNull(b);
// Use normalized tree edit distance
var editDistance = ComputeEditDistance(a, b);
return 1.0m - editDistance.NormalizedDistance;
}
/// <inheritdoc />
public AstEditDistance ComputeEditDistance(DecompiledAst a, DecompiledAst b)
{
ArgumentNullException.ThrowIfNull(a);
ArgumentNullException.ThrowIfNull(b);
// Simplified Zhang-Shasha tree edit distance
var operations = ComputeTreeEditOperations(a.Root, b.Root);
var totalNodes = Math.Max(a.NodeCount, b.NodeCount);
var normalized = totalNodes > 0
? (decimal)operations.TotalOperations / totalNodes
: 0m;
return new AstEditDistance(
operations.Insertions,
operations.Deletions,
operations.Modifications,
operations.TotalOperations,
Math.Clamp(normalized, 0m, 1m));
}
/// <inheritdoc />
public ImmutableArray<SemanticEquivalence> FindEquivalences(DecompiledAst a, DecompiledAst b)
{
ArgumentNullException.ThrowIfNull(a);
ArgumentNullException.ThrowIfNull(b);
var equivalences = new List<SemanticEquivalence>();
// Find equivalent subtrees
var nodesA = CollectNodes(a.Root).ToList();
var nodesB = CollectNodes(b.Root).ToList();
foreach (var nodeA in nodesA)
{
foreach (var nodeB in nodesB)
{
var equivalence = CheckEquivalence(nodeA, nodeB);
if (equivalence is not null)
{
equivalences.Add(equivalence);
}
}
}
// Remove redundant equivalences (child nodes when parent is equivalent)
return [.. FilterRedundantEquivalences(equivalences)];
}
/// <inheritdoc />
public ImmutableArray<CodeDifference> FindDifferences(DecompiledAst a, DecompiledAst b)
{
ArgumentNullException.ThrowIfNull(a);
ArgumentNullException.ThrowIfNull(b);
var differences = new List<CodeDifference>();
// Compare root structures
CompareNodes(a.Root, b.Root, differences);
return [.. differences];
}
private static EditOperations ComputeTreeEditOperations(AstNode a, AstNode b)
{
// Simplified tree comparison
if (a.Type != b.Type)
{
return new EditOperations(0, 0, 1, 1);
}
var childrenA = a.Children;
var childrenB = b.Children;
var insertions = 0;
var deletions = 0;
var modifications = 0;
// Compare children using LCS-like approach
var maxLen = Math.Max(childrenA.Length, childrenB.Length);
var minLen = Math.Min(childrenA.Length, childrenB.Length);
insertions = childrenB.Length - minLen;
deletions = childrenA.Length - minLen;
for (var i = 0; i < minLen; i++)
{
var childOps = ComputeTreeEditOperations(childrenA[i], childrenB[i]);
insertions += childOps.Insertions;
deletions += childOps.Deletions;
modifications += childOps.Modifications;
}
return new EditOperations(insertions, deletions, modifications, insertions + deletions + modifications);
}
private static SemanticEquivalence? CheckEquivalence(AstNode a, AstNode b)
{
// Same type - potential equivalence
if (a.Type != b.Type)
{
return null;
}
// Check for identical
if (AreNodesIdentical(a, b))
{
return new SemanticEquivalence(a, b, EquivalenceType.Identical, 1.0m, "Identical nodes");
}
// Check for renamed (same structure, different names)
if (AreNodesRenamed(a, b))
{
return new SemanticEquivalence(a, b, EquivalenceType.Renamed, 0.95m, "Same structure with renamed identifiers");
}
// Check for optimization variants
if (AreOptimizationVariants(a, b))
{
return new SemanticEquivalence(a, b, EquivalenceType.Optimized, 0.85m, "Optimization variant");
}
return null;
}
private static bool AreNodesIdentical(AstNode a, AstNode b)
{
if (a.Type != b.Type || a.Children.Length != b.Children.Length)
{
return false;
}
// Check node-specific equality
if (a is ConstantNode constA && b is ConstantNode constB)
{
return constA.Value?.ToString() == constB.Value?.ToString();
}
if (a is VariableNode varA && b is VariableNode varB)
{
return varA.Name == varB.Name;
}
if (a is BinaryOpNode binA && b is BinaryOpNode binB)
{
if (binA.Operator != binB.Operator)
{
return false;
}
}
if (a is CallNode callA && b is CallNode callB)
{
if (callA.FunctionName != callB.FunctionName)
{
return false;
}
}
// Check children recursively
for (var i = 0; i < a.Children.Length; i++)
{
if (!AreNodesIdentical(a.Children[i], b.Children[i]))
{
return false;
}
}
return true;
}
private static bool AreNodesRenamed(AstNode a, AstNode b)
{
if (a.Type != b.Type || a.Children.Length != b.Children.Length)
{
return false;
}
// Same structure but variable/parameter names differ
if (a is VariableNode && b is VariableNode)
{
return true; // Different name but same position = renamed
}
// Check children have same structure
for (var i = 0; i < a.Children.Length; i++)
{
if (!AreNodesRenamed(a.Children[i], b.Children[i]) &&
!AreNodesIdentical(a.Children[i], b.Children[i]))
{
return false;
}
}
return true;
}
private static bool AreOptimizationVariants(AstNode a, AstNode b)
{
// Detect common optimization patterns
// Loop unrolling: for loop vs repeated statements
if (a.Type == AstNodeType.For && b.Type == AstNodeType.Block)
{
return true; // Might be unrolled
}
// Strength reduction: multiplication vs addition
if (a is BinaryOpNode binA && b is BinaryOpNode binB)
{
if ((binA.Operator == "*" && binB.Operator == "<<") ||
(binA.Operator == "/" && binB.Operator == ">>"))
{
return true;
}
}
// Inline expansion
if (a.Type == AstNodeType.Call && b.Type == AstNodeType.Block)
{
return true; // Might be inlined
}
return false;
}
private static void CompareNodes(AstNode a, AstNode b, List<CodeDifference> differences)
{
if (a.Type != b.Type)
{
differences.Add(new CodeDifference(
DifferenceType.Modified,
a,
b,
$"Node type changed: {a.Type} -> {b.Type}"));
return;
}
// Compare specific node types
switch (a)
{
case VariableNode varA when b is VariableNode varB:
if (varA.Name != varB.Name)
{
differences.Add(new CodeDifference(
DifferenceType.Modified,
a,
b,
$"Variable renamed: {varA.Name} -> {varB.Name}"));
}
break;
case ConstantNode constA when b is ConstantNode constB:
if (constA.Value?.ToString() != constB.Value?.ToString())
{
differences.Add(new CodeDifference(
DifferenceType.Modified,
a,
b,
$"Constant changed: {constA.Value} -> {constB.Value}"));
}
break;
case BinaryOpNode binA when b is BinaryOpNode binB:
if (binA.Operator != binB.Operator)
{
differences.Add(new CodeDifference(
DifferenceType.Modified,
a,
b,
$"Operator changed: {binA.Operator} -> {binB.Operator}"));
}
break;
case CallNode callA when b is CallNode callB:
if (callA.FunctionName != callB.FunctionName)
{
differences.Add(new CodeDifference(
DifferenceType.Modified,
a,
b,
$"Function call changed: {callA.FunctionName} -> {callB.FunctionName}"));
}
break;
}
// Compare children
var minChildren = Math.Min(a.Children.Length, b.Children.Length);
for (var i = 0; i < minChildren; i++)
{
CompareNodes(a.Children[i], b.Children[i], differences);
}
// Handle added/removed children
for (var i = minChildren; i < a.Children.Length; i++)
{
differences.Add(new CodeDifference(
DifferenceType.Removed,
a.Children[i],
null,
$"Node removed: {a.Children[i].Type}"));
}
for (var i = minChildren; i < b.Children.Length; i++)
{
differences.Add(new CodeDifference(
DifferenceType.Added,
null,
b.Children[i],
$"Node added: {b.Children[i].Type}"));
}
}
private static IEnumerable<AstNode> CollectNodes(AstNode root)
{
yield return root;
foreach (var child in root.Children)
{
foreach (var node in CollectNodes(child))
{
yield return node;
}
}
}
private static IEnumerable<SemanticEquivalence> FilterRedundantEquivalences(
List<SemanticEquivalence> equivalences)
{
// Keep only top-level equivalences
var result = new List<SemanticEquivalence>();
foreach (var eq in equivalences)
{
var isRedundant = equivalences.Any(other =>
other != eq &&
IsAncestor(other.NodeA, eq.NodeA) &&
IsAncestor(other.NodeB, eq.NodeB));
if (!isRedundant)
{
result.Add(eq);
}
}
return result;
}
private static bool IsAncestor(AstNode potential, AstNode node)
{
if (potential == node)
{
return false;
}
foreach (var child in potential.Children)
{
if (child == node || IsAncestor(child, node))
{
return true;
}
}
return false;
}
private readonly record struct EditOperations(int Insertions, int Deletions, int Modifications, int TotalOperations);
}

Some files were not shown because too many files have changed in this diff Show More