sprints work
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
|
||||
// Sprint: SPRINT_20260110_012_004_BINDEX
|
||||
// Task: GSD-007 - IDiffResultStore Interface
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.BinaryIndex.Diff;
|
||||
|
||||
/// <summary>
|
||||
/// Storage interface for patch diff results.
|
||||
/// Provides persistence and caching for verification results.
|
||||
/// </summary>
|
||||
public interface IDiffResultStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores a patch diff result.
|
||||
/// </summary>
|
||||
/// <param name="result">The diff result to store.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Unique ID of the stored result.</returns>
|
||||
Task<Guid> StoreAsync(PatchDiffResult result, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a diff result by ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The result ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The diff result, or null if not found.</returns>
|
||||
Task<PatchDiffResult?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Finds diff results for a specific binary pair.
|
||||
/// </summary>
|
||||
/// <param name="preBinaryDigest">Pre-patch binary digest.</param>
|
||||
/// <param name="postBinaryDigest">Post-patch binary digest.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of matching diff results.</returns>
|
||||
Task<ImmutableArray<StoredDiffResult>> FindByBinariesAsync(
|
||||
string preBinaryDigest,
|
||||
string postBinaryDigest,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Finds diff results for a specific golden set.
|
||||
/// </summary>
|
||||
/// <param name="goldenSetId">Golden set ID.</param>
|
||||
/// <param name="limit">Maximum results to return.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of diff results for the golden set.</returns>
|
||||
Task<ImmutableArray<StoredDiffResult>> FindByGoldenSetAsync(
|
||||
string goldenSetId,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a cached single binary check result.
|
||||
/// </summary>
|
||||
/// <param name="binaryDigest">Binary digest.</param>
|
||||
/// <param name="vulnerabilityId">Vulnerability ID (golden set ID).</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Cached result, or null if not cached.</returns>
|
||||
Task<SingleBinaryCheckResult?> GetCachedCheckAsync(
|
||||
string binaryDigest,
|
||||
string vulnerabilityId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Caches a single binary check result.
|
||||
/// </summary>
|
||||
/// <param name="binaryDigest">Binary digest.</param>
|
||||
/// <param name="vulnerabilityId">Vulnerability ID (golden set ID).</param>
|
||||
/// <param name="result">Check result to cache.</param>
|
||||
/// <param name="ttl">Time-to-live for the cache entry.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task CacheCheckAsync(
|
||||
string binaryDigest,
|
||||
string vulnerabilityId,
|
||||
SingleBinaryCheckResult result,
|
||||
TimeSpan? ttl = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries stored diff results.
|
||||
/// </summary>
|
||||
/// <param name="query">Query parameters.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Matching diff results.</returns>
|
||||
Task<DiffResultQueryResponse> QueryAsync(
|
||||
DiffResultQuery query,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets statistics about stored diff results.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Storage statistics.</returns>
|
||||
Task<DiffResultStoreStats> GetStatsAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stored diff result with metadata.
|
||||
/// </summary>
|
||||
public sealed record StoredDiffResult
|
||||
{
|
||||
/// <summary>Unique ID.</summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>The diff result.</summary>
|
||||
public required PatchDiffResult Result { get; init; }
|
||||
|
||||
/// <summary>When the result was stored.</summary>
|
||||
public required DateTimeOffset StoredAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for searching diff results.
|
||||
/// </summary>
|
||||
public sealed record DiffResultQuery
|
||||
{
|
||||
/// <summary>Filter by golden set ID.</summary>
|
||||
public string? GoldenSetId { get; init; }
|
||||
|
||||
/// <summary>Filter by verdict.</summary>
|
||||
public PatchVerdict? Verdict { get; init; }
|
||||
|
||||
/// <summary>Filter by minimum confidence.</summary>
|
||||
public decimal? MinConfidence { get; init; }
|
||||
|
||||
/// <summary>Filter by pre-binary digest.</summary>
|
||||
public string? PreBinaryDigest { get; init; }
|
||||
|
||||
/// <summary>Filter by post-binary digest.</summary>
|
||||
public string? PostBinaryDigest { get; init; }
|
||||
|
||||
/// <summary>Filter by comparison date (after).</summary>
|
||||
public DateTimeOffset? ComparedAfter { get; init; }
|
||||
|
||||
/// <summary>Filter by comparison date (before).</summary>
|
||||
public DateTimeOffset? ComparedBefore { get; init; }
|
||||
|
||||
/// <summary>Maximum results to return.</summary>
|
||||
public int Limit { get; init; } = 100;
|
||||
|
||||
/// <summary>Pagination offset.</summary>
|
||||
public int Offset { get; init; } = 0;
|
||||
|
||||
/// <summary>Order by field.</summary>
|
||||
public DiffResultOrderBy OrderBy { get; init; } = DiffResultOrderBy.ComparedAtDesc;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from a diff result query.
|
||||
/// </summary>
|
||||
public sealed record DiffResultQueryResponse
|
||||
{
|
||||
/// <summary>Matching results.</summary>
|
||||
public required ImmutableArray<StoredDiffResult> Results { get; init; }
|
||||
|
||||
/// <summary>Total count of matching results (for pagination).</summary>
|
||||
public required int TotalCount { get; init; }
|
||||
|
||||
/// <summary>Query offset.</summary>
|
||||
public required int Offset { get; init; }
|
||||
|
||||
/// <summary>Query limit.</summary>
|
||||
public required int Limit { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ordering options for diff result queries.
|
||||
/// </summary>
|
||||
public enum DiffResultOrderBy
|
||||
{
|
||||
/// <summary>Order by comparison date ascending.</summary>
|
||||
ComparedAtAsc,
|
||||
|
||||
/// <summary>Order by comparison date descending.</summary>
|
||||
ComparedAtDesc,
|
||||
|
||||
/// <summary>Order by confidence ascending.</summary>
|
||||
ConfidenceAsc,
|
||||
|
||||
/// <summary>Order by confidence descending.</summary>
|
||||
ConfidenceDesc,
|
||||
|
||||
/// <summary>Order by golden set ID ascending.</summary>
|
||||
GoldenSetIdAsc,
|
||||
|
||||
/// <summary>Order by golden set ID descending.</summary>
|
||||
GoldenSetIdDesc
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics about the diff result store.
|
||||
/// </summary>
|
||||
public sealed record DiffResultStoreStats
|
||||
{
|
||||
/// <summary>Total number of stored results.</summary>
|
||||
public required long TotalResults { get; init; }
|
||||
|
||||
/// <summary>Results by verdict.</summary>
|
||||
public required ImmutableDictionary<PatchVerdict, long> ResultsByVerdict { get; init; }
|
||||
|
||||
/// <summary>Number of unique golden sets.</summary>
|
||||
public required int UniqueGoldenSets { get; init; }
|
||||
|
||||
/// <summary>Number of unique binary pairs.</summary>
|
||||
public required long UniqueBinaryPairs { get; init; }
|
||||
|
||||
/// <summary>Number of cached check results.</summary>
|
||||
public required long CachedChecks { get; init; }
|
||||
|
||||
/// <summary>Oldest result timestamp.</summary>
|
||||
public DateTimeOffset? OldestResult { get; init; }
|
||||
|
||||
/// <summary>Newest result timestamp.</summary>
|
||||
public DateTimeOffset? NewestResult { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2026 StellaOps Contributors.
|
||||
// Sprint: SPRINT_20260110_012_004_BINDEX
|
||||
// Task: GSD-007 - IDiffResultStore Interface - InMemory Implementation
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.BinaryIndex.Diff;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IDiffResultStore"/> for testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryDiffResultStore : IDiffResultStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, StoredDiffResult> _results = new();
|
||||
private readonly ConcurrentDictionary<string, SingleBinaryCheckResult> _checkCache = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryDiffResultStore(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Guid> StoreAsync(PatchDiffResult result, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var id = Guid.NewGuid();
|
||||
var stored = new StoredDiffResult
|
||||
{
|
||||
Id = id,
|
||||
Result = result,
|
||||
StoredAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
_results[id] = stored;
|
||||
return Task.FromResult(id);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PatchDiffResult?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(_results.TryGetValue(id, out var stored) ? stored.Result : null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<StoredDiffResult>> FindByBinariesAsync(
|
||||
string preBinaryDigest,
|
||||
string postBinaryDigest,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(preBinaryDigest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(postBinaryDigest);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var matches = _results.Values
|
||||
.Where(s => string.Equals(s.Result.PreBinaryDigest, preBinaryDigest, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(s.Result.PostBinaryDigest, postBinaryDigest, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(s => s.StoredAt)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(matches);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<StoredDiffResult>> FindByGoldenSetAsync(
|
||||
string goldenSetId,
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(goldenSetId);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var matches = _results.Values
|
||||
.Where(s => string.Equals(s.Result.GoldenSetId, goldenSetId, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(s => s.StoredAt)
|
||||
.Take(limit)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(matches);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SingleBinaryCheckResult?> GetCachedCheckAsync(
|
||||
string binaryDigest,
|
||||
string vulnerabilityId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(binaryDigest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var key = GetCacheKey(binaryDigest, vulnerabilityId);
|
||||
return Task.FromResult(_checkCache.TryGetValue(key, out var result) ? result : null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task CacheCheckAsync(
|
||||
string binaryDigest,
|
||||
string vulnerabilityId,
|
||||
SingleBinaryCheckResult result,
|
||||
TimeSpan? ttl = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(binaryDigest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var key = GetCacheKey(binaryDigest, vulnerabilityId);
|
||||
_checkCache[key] = result;
|
||||
|
||||
// Note: TTL not implemented for in-memory store (testing only)
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DiffResultQueryResponse> QueryAsync(
|
||||
DiffResultQuery query,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
IEnumerable<StoredDiffResult> results = _results.Values;
|
||||
|
||||
// Apply filters
|
||||
if (!string.IsNullOrWhiteSpace(query.GoldenSetId))
|
||||
{
|
||||
results = results.Where(r =>
|
||||
string.Equals(r.Result.GoldenSetId, query.GoldenSetId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (query.Verdict.HasValue)
|
||||
{
|
||||
results = results.Where(r => r.Result.Verdict == query.Verdict.Value);
|
||||
}
|
||||
|
||||
if (query.MinConfidence.HasValue)
|
||||
{
|
||||
results = results.Where(r => r.Result.Confidence >= query.MinConfidence.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.PreBinaryDigest))
|
||||
{
|
||||
results = results.Where(r =>
|
||||
string.Equals(r.Result.PreBinaryDigest, query.PreBinaryDigest, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.PostBinaryDigest))
|
||||
{
|
||||
results = results.Where(r =>
|
||||
string.Equals(r.Result.PostBinaryDigest, query.PostBinaryDigest, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (query.ComparedAfter.HasValue)
|
||||
{
|
||||
results = results.Where(r => r.Result.Metadata.ComparedAt >= query.ComparedAfter.Value);
|
||||
}
|
||||
|
||||
if (query.ComparedBefore.HasValue)
|
||||
{
|
||||
results = results.Where(r => r.Result.Metadata.ComparedAt <= query.ComparedBefore.Value);
|
||||
}
|
||||
|
||||
// Apply ordering
|
||||
results = query.OrderBy switch
|
||||
{
|
||||
DiffResultOrderBy.ComparedAtAsc => results.OrderBy(r => r.Result.Metadata.ComparedAt),
|
||||
DiffResultOrderBy.ComparedAtDesc => results.OrderByDescending(r => r.Result.Metadata.ComparedAt),
|
||||
DiffResultOrderBy.ConfidenceAsc => results.OrderBy(r => r.Result.Confidence),
|
||||
DiffResultOrderBy.ConfidenceDesc => results.OrderByDescending(r => r.Result.Confidence),
|
||||
DiffResultOrderBy.GoldenSetIdAsc => results.OrderBy(r => r.Result.GoldenSetId, StringComparer.OrdinalIgnoreCase),
|
||||
DiffResultOrderBy.GoldenSetIdDesc => results.OrderByDescending(r => r.Result.GoldenSetId, StringComparer.OrdinalIgnoreCase),
|
||||
_ => results.OrderByDescending(r => r.StoredAt)
|
||||
};
|
||||
|
||||
var allResults = results.ToList();
|
||||
var totalCount = allResults.Count;
|
||||
|
||||
var pagedResults = allResults
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(new DiffResultQueryResponse
|
||||
{
|
||||
Results = pagedResults,
|
||||
TotalCount = totalCount,
|
||||
Offset = query.Offset,
|
||||
Limit = query.Limit
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DiffResultStoreStats> GetStatsAsync(CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var results = _results.Values.ToList();
|
||||
|
||||
var resultsByVerdict = results
|
||||
.GroupBy(r => r.Result.Verdict)
|
||||
.ToImmutableDictionary(g => g.Key, g => (long)g.Count());
|
||||
|
||||
var uniqueGoldenSets = results
|
||||
.Select(r => r.Result.GoldenSetId)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Count();
|
||||
|
||||
var uniqueBinaryPairs = results
|
||||
.Select(r => $"{r.Result.PreBinaryDigest}:{r.Result.PostBinaryDigest}")
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Count();
|
||||
|
||||
var oldestResult = results.MinBy(r => r.StoredAt)?.StoredAt;
|
||||
var newestResult = results.MaxBy(r => r.StoredAt)?.StoredAt;
|
||||
|
||||
return Task.FromResult(new DiffResultStoreStats
|
||||
{
|
||||
TotalResults = results.Count,
|
||||
ResultsByVerdict = resultsByVerdict,
|
||||
UniqueGoldenSets = uniqueGoldenSets,
|
||||
UniqueBinaryPairs = uniqueBinaryPairs,
|
||||
CachedChecks = _checkCache.Count,
|
||||
OldestResult = oldestResult,
|
||||
NewestResult = newestResult
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all stored results and cache.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_results.Clear();
|
||||
_checkCache.Clear();
|
||||
}
|
||||
|
||||
private static string GetCacheKey(string binaryDigest, string vulnerabilityId)
|
||||
=> $"{binaryDigest}:{vulnerabilityId}";
|
||||
}
|
||||
Reference in New Issue
Block a user