Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 +
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
295
src/Attestor/StellaOps.Attestation.Tests/DsseVerifierTests.cs
Normal file
295
src/Attestor/StellaOps.Attestation.Tests/DsseVerifierTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
301
src/Attestor/StellaOps.Attestation/DsseVerifier.cs
Normal file
301
src/Attestor/StellaOps.Attestation/DsseVerifier.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
151
src/Attestor/StellaOps.Attestation/IDsseVerifier.cs
Normal file
151
src/Attestor/StellaOps.Attestation/IDsseVerifier.cs
Normal 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}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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:";
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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()}";
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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§ion=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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user