sprints and audit work

This commit is contained in:
StellaOps Bot
2026-01-07 09:36:16 +02:00
parent 05833e0af2
commit ab364c6032
377 changed files with 64534 additions and 1627 deletions

View File

@@ -7,7 +7,7 @@ namespace StellaOps.Facet;
/// <summary>
/// Standard implementation of <see cref="IFacet"/> for defining facets.
/// </summary>
internal sealed class FacetDefinition : IFacet
public sealed class FacetDefinition : IFacet
{
/// <inheritdoc/>
public string FacetId { get; }

View File

@@ -0,0 +1,349 @@
// <copyright file="FacetDriftVexEmitter.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
// Sprint: SPRINT_20260105_002_003_FACET (QTA-016)
using System.Collections.Immutable;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Facet;
/// <summary>
/// Emits VEX drafts for facet drift that requires authorization.
/// When drift exceeds quota and action is RequireVex, this emitter
/// generates a draft VEX document for human review.
/// </summary>
public sealed class FacetDriftVexEmitter
{
private readonly FacetDriftVexEmitterOptions _options;
private readonly TimeProvider _timeProvider;
/// <summary>
/// Initializes a new instance of the <see cref="FacetDriftVexEmitter"/> class.
/// </summary>
public FacetDriftVexEmitter(
FacetDriftVexEmitterOptions? options = null,
TimeProvider? timeProvider = null)
{
_options = options ?? FacetDriftVexEmitterOptions.Default;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Evaluates facet drift and emits VEX drafts for facets that exceed quotas.
/// </summary>
public FacetDriftVexEmissionResult EmitDrafts(FacetDriftVexEmissionContext context)
{
ArgumentNullException.ThrowIfNull(context);
var drafts = new List<FacetDriftVexDraft>();
foreach (var facetDrift in context.DriftReport.FacetDrifts)
{
// Only emit drafts for facets that require VEX authorization
if (facetDrift.QuotaVerdict != QuotaVerdict.RequiresVex)
continue;
var draft = CreateVexDraft(facetDrift, context);
drafts.Add(draft);
if (drafts.Count >= _options.MaxDraftsPerBatch)
break;
}
return new FacetDriftVexEmissionResult(
ImageDigest: context.DriftReport.ImageDigest,
BaselineSealId: context.DriftReport.BaselineSealId,
DraftsEmitted: drafts.Count,
Drafts: [.. drafts],
GeneratedAt: _timeProvider.GetUtcNow());
}
/// <summary>
/// Creates a VEX draft for a single facet that exceeded its quota.
/// </summary>
private FacetDriftVexDraft CreateVexDraft(
FacetDrift drift,
FacetDriftVexEmissionContext context)
{
var draftId = GenerateDraftId(drift, context);
var now = _timeProvider.GetUtcNow();
// Build evidence links
var evidenceLinks = new List<FacetDriftEvidenceLink>
{
new(
Type: "facet_drift_analysis",
Uri: $"facet://{context.DriftReport.ImageDigest}/{drift.FacetId}",
Description: $"Facet drift analysis for {drift.FacetId}"),
new(
Type: "baseline_seal",
Uri: $"seal://{context.DriftReport.BaselineSealId}",
Description: "Baseline seal used for comparison")
};
// Add links for significant changes
if (drift.Added.Length > 0)
{
evidenceLinks.Add(new FacetDriftEvidenceLink(
Type: "added_files",
Uri: $"facet://{context.DriftReport.ImageDigest}/{drift.FacetId}/added",
Description: $"{drift.Added.Length} files added"));
}
if (drift.Removed.Length > 0)
{
evidenceLinks.Add(new FacetDriftEvidenceLink(
Type: "removed_files",
Uri: $"facet://{context.DriftReport.ImageDigest}/{drift.FacetId}/removed",
Description: $"{drift.Removed.Length} files removed"));
}
if (drift.Modified.Length > 0)
{
evidenceLinks.Add(new FacetDriftEvidenceLink(
Type: "modified_files",
Uri: $"facet://{context.DriftReport.ImageDigest}/{drift.FacetId}/modified",
Description: $"{drift.Modified.Length} files modified"));
}
return new FacetDriftVexDraft(
DraftId: draftId,
FacetId: drift.FacetId,
ImageDigest: context.DriftReport.ImageDigest,
BaselineSealId: context.DriftReport.BaselineSealId,
SuggestedStatus: FacetDriftVexStatus.Accepted,
Justification: FacetDriftVexJustification.IntentionalChange,
Rationale: GenerateRationale(drift, context),
DriftSummary: CreateDriftSummary(drift),
EvidenceLinks: [.. evidenceLinks],
GeneratedAt: now,
ExpiresAt: now.Add(_options.DraftTtl),
ReviewDeadline: now.AddDays(_options.ReviewSlaDays),
RequiresReview: true,
ReviewerNotes: GenerateReviewerNotes(drift));
}
/// <summary>
/// Generates a human-readable rationale for the VEX draft.
/// </summary>
private string GenerateRationale(FacetDrift drift, FacetDriftVexEmissionContext context)
{
var sb = new StringBuilder();
sb.Append(CultureInfo.InvariantCulture, $"Facet '{drift.FacetId}' drift exceeds configured quota. ");
sb.Append(CultureInfo.InvariantCulture, $"Churn: {drift.ChurnPercent:F1}% ({drift.TotalChanges} of {drift.BaselineFileCount} files changed). ");
if (drift.Added.Length > 0)
{
sb.Append($"{drift.Added.Length} file(s) added. ");
}
if (drift.Removed.Length > 0)
{
sb.Append($"{drift.Removed.Length} file(s) removed. ");
}
if (drift.Modified.Length > 0)
{
sb.Append($"{drift.Modified.Length} file(s) modified. ");
}
sb.Append("VEX authorization required to proceed with deployment.");
return sb.ToString();
}
/// <summary>
/// Creates a summary of the drift for the VEX draft.
/// </summary>
private static FacetDriftSummary CreateDriftSummary(FacetDrift drift)
{
return new FacetDriftSummary(
TotalChanges: drift.TotalChanges,
AddedCount: drift.Added.Length,
RemovedCount: drift.Removed.Length,
ModifiedCount: drift.Modified.Length,
ChurnPercent: drift.ChurnPercent,
DriftScore: drift.DriftScore,
BaselineFileCount: drift.BaselineFileCount);
}
/// <summary>
/// Generates notes for the reviewer.
/// </summary>
private string GenerateReviewerNotes(FacetDrift drift)
{
var sb = new StringBuilder();
sb.AppendLine("## Review Checklist");
sb.AppendLine();
sb.AppendLine("- [ ] Verify the drift is intentional and authorized");
sb.AppendLine("- [ ] Confirm no security-sensitive files were unexpectedly modified");
sb.AppendLine("- [ ] Check if the changes align with the current release scope");
if (drift.ChurnPercent > _options.HighChurnThreshold)
{
sb.AppendLine();
sb.AppendLine(CultureInfo.InvariantCulture, $"**WARNING**: High churn detected ({drift.ChurnPercent:F1}%). Consider additional scrutiny.");
}
if (drift.Removed.Length > 0)
{
sb.AppendLine();
sb.AppendLine("**NOTE**: Files were removed. Verify these removals are intentional.");
}
return sb.ToString();
}
/// <summary>
/// Generates a deterministic draft ID.
/// </summary>
private string GenerateDraftId(FacetDrift drift, FacetDriftVexEmissionContext context)
{
var input = $"{context.DriftReport.ImageDigest}:{drift.FacetId}:{context.DriftReport.BaselineSealId}:{context.DriftReport.AnalyzedAt.Ticks}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return $"vexfd-{Convert.ToHexString(hash).ToLowerInvariant()[..16]}";
}
}
/// <summary>
/// Options for facet drift VEX emission.
/// </summary>
public sealed record FacetDriftVexEmitterOptions
{
/// <summary>
/// Maximum drafts to emit per batch.
/// </summary>
public int MaxDraftsPerBatch { get; init; } = 50;
/// <summary>
/// Time-to-live for drafts before they expire.
/// </summary>
public TimeSpan DraftTtl { get; init; } = TimeSpan.FromDays(30);
/// <summary>
/// SLA in days for human review.
/// </summary>
public int ReviewSlaDays { get; init; } = 7;
/// <summary>
/// Churn percentage that triggers high-churn warning.
/// </summary>
public decimal HighChurnThreshold { get; init; } = 30m;
/// <summary>
/// Default options.
/// </summary>
public static FacetDriftVexEmitterOptions Default { get; } = new();
}
/// <summary>
/// Context for facet drift VEX emission.
/// </summary>
public sealed record FacetDriftVexEmissionContext(
FacetDriftReport DriftReport,
string? TenantId = null,
string? RequestedBy = null);
/// <summary>
/// Result of facet drift VEX emission.
/// </summary>
public sealed record FacetDriftVexEmissionResult(
string ImageDigest,
string BaselineSealId,
int DraftsEmitted,
ImmutableArray<FacetDriftVexDraft> Drafts,
DateTimeOffset GeneratedAt);
/// <summary>
/// A VEX draft generated from facet drift analysis.
/// </summary>
public sealed record FacetDriftVexDraft(
string DraftId,
string FacetId,
string ImageDigest,
string BaselineSealId,
FacetDriftVexStatus SuggestedStatus,
FacetDriftVexJustification Justification,
string Rationale,
FacetDriftSummary DriftSummary,
ImmutableArray<FacetDriftEvidenceLink> EvidenceLinks,
DateTimeOffset GeneratedAt,
DateTimeOffset ExpiresAt,
DateTimeOffset ReviewDeadline,
bool RequiresReview,
string? ReviewerNotes = null);
/// <summary>
/// Summary of drift for a VEX draft.
/// </summary>
public sealed record FacetDriftSummary(
int TotalChanges,
int AddedCount,
int RemovedCount,
int ModifiedCount,
decimal ChurnPercent,
decimal DriftScore,
int BaselineFileCount);
/// <summary>
/// VEX status for facet drift drafts.
/// </summary>
public enum FacetDriftVexStatus
{
/// <summary>
/// Drift is accepted and authorized.
/// </summary>
Accepted,
/// <summary>
/// Drift is rejected - requires remediation.
/// </summary>
Rejected,
/// <summary>
/// Under investigation - awaiting review.
/// </summary>
UnderReview
}
/// <summary>
/// VEX justification for facet drift drafts.
/// </summary>
public enum FacetDriftVexJustification
{
/// <summary>
/// Drift is an intentional change (upgrade, refactor, etc.).
/// </summary>
IntentionalChange,
/// <summary>
/// Security fix applied.
/// </summary>
SecurityFix,
/// <summary>
/// Dependency update.
/// </summary>
DependencyUpdate,
/// <summary>
/// Configuration change.
/// </summary>
ConfigurationChange,
/// <summary>
/// Other reason (requires explanation).
/// </summary>
Other
}
/// <summary>
/// Evidence link for facet drift VEX drafts.
/// </summary>
public sealed record FacetDriftEvidenceLink(
string Type,
string Uri,
string? Description = null);

View File

@@ -0,0 +1,73 @@
// <copyright file="FacetDriftVexServiceCollectionExtensions.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
// Sprint: SPRINT_20260105_002_003_FACET (QTA-019)
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Facet;
/// <summary>
/// Extension methods for registering facet drift VEX services.
/// </summary>
public static class FacetDriftVexServiceCollectionExtensions
{
/// <summary>
/// Adds facet drift VEX emitter and workflow services.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configureOptions">Optional options configuration.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFacetDriftVexServices(
this IServiceCollection services,
Action<FacetDriftVexEmitterOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(services);
// Register options
var options = FacetDriftVexEmitterOptions.Default;
if (configureOptions is not null)
{
configureOptions(options);
}
services.TryAddSingleton(options);
// Register emitter
services.TryAddSingleton<FacetDriftVexEmitter>();
// Register workflow
services.TryAddScoped<FacetDriftVexWorkflow>();
return services;
}
/// <summary>
/// Adds the in-memory draft store for testing.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddInMemoryFacetDriftVexDraftStore(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.TryAddSingleton<IFacetDriftVexDraftStore, InMemoryFacetDriftVexDraftStore>();
return services;
}
/// <summary>
/// Adds facet drift VEX services with in-memory store (for testing).
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configureOptions">Optional options configuration.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFacetDriftVexServicesWithInMemoryStore(
this IServiceCollection services,
Action<FacetDriftVexEmitterOptions>? configureOptions = null)
{
return services
.AddFacetDriftVexServices(configureOptions)
.AddInMemoryFacetDriftVexDraftStore();
}
}

View File

@@ -0,0 +1,266 @@
// <copyright file="FacetDriftVexWorkflow.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
// Sprint: SPRINT_20260105_002_003_FACET (QTA-019)
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace StellaOps.Facet;
/// <summary>
/// Result of a facet drift VEX workflow execution.
/// </summary>
public sealed record FacetDriftVexWorkflowResult
{
/// <summary>
/// Emission result from the emitter.
/// </summary>
public required FacetDriftVexEmissionResult EmissionResult { get; init; }
/// <summary>
/// Number of drafts that were newly created.
/// </summary>
public int NewDraftsCreated { get; init; }
/// <summary>
/// Number of drafts that already existed (skipped).
/// </summary>
public int ExistingDraftsSkipped { get; init; }
/// <summary>
/// IDs of newly created drafts.
/// </summary>
public ImmutableArray<string> CreatedDraftIds { get; init; } = [];
/// <summary>
/// Any errors that occurred during storage.
/// </summary>
public ImmutableArray<string> Errors { get; init; } = [];
/// <summary>
/// Whether all operations completed successfully.
/// </summary>
public bool Success => Errors.Length == 0;
}
/// <summary>
/// Orchestrates the facet drift VEX workflow: emit drafts + store.
/// This integrates with the Excititor VEX workflow by providing
/// drafts that can be picked up for human review.
/// </summary>
public sealed class FacetDriftVexWorkflow
{
private readonly FacetDriftVexEmitter _emitter;
private readonly IFacetDriftVexDraftStore _draftStore;
private readonly ILogger<FacetDriftVexWorkflow> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="FacetDriftVexWorkflow"/> class.
/// </summary>
public FacetDriftVexWorkflow(
FacetDriftVexEmitter emitter,
IFacetDriftVexDraftStore draftStore,
ILogger<FacetDriftVexWorkflow>? logger = null)
{
_emitter = emitter ?? throw new ArgumentNullException(nameof(emitter));
_draftStore = draftStore ?? throw new ArgumentNullException(nameof(draftStore));
_logger = logger ?? NullLogger<FacetDriftVexWorkflow>.Instance;
}
/// <summary>
/// Executes the full workflow: emit drafts from drift report and store them.
/// </summary>
/// <param name="driftReport">The drift report to process.</param>
/// <param name="skipExisting">If true, skip creating drafts that already exist.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Workflow result with draft IDs and status.</returns>
public async Task<FacetDriftVexWorkflowResult> ExecuteAsync(
FacetDriftReport driftReport,
bool skipExisting = true,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(driftReport);
// Emit drafts from drift report
var context = new FacetDriftVexEmissionContext(driftReport);
var emissionResult = _emitter.EmitDrafts(context);
if (emissionResult.DraftsEmitted == 0)
{
_logger.LogDebug("No drafts to emit for image {ImageDigest}", driftReport.ImageDigest);
return new FacetDriftVexWorkflowResult
{
EmissionResult = emissionResult,
NewDraftsCreated = 0,
ExistingDraftsSkipped = 0
};
}
// Store drafts
var createdIds = new List<string>();
var skippedCount = 0;
var errors = new List<string>();
foreach (var draft in emissionResult.Drafts)
{
ct.ThrowIfCancellationRequested();
try
{
if (skipExisting)
{
var exists = await _draftStore.ExistsAsync(
draft.ImageDigest,
draft.FacetId,
ct).ConfigureAwait(false);
if (exists)
{
_logger.LogDebug(
"Skipping existing draft for {ImageDigest}/{FacetId}",
draft.ImageDigest,
draft.FacetId);
skippedCount++;
continue;
}
}
await _draftStore.SaveAsync(draft, ct).ConfigureAwait(false);
createdIds.Add(draft.DraftId);
_logger.LogInformation(
"Created VEX draft {DraftId} for {ImageDigest}/{FacetId} with churn {ChurnPercent:F1}%",
draft.DraftId,
draft.ImageDigest,
draft.FacetId,
draft.DriftSummary.ChurnPercent);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(
ex,
"Failed to store draft for {ImageDigest}/{FacetId}",
draft.ImageDigest,
draft.FacetId);
errors.Add($"Failed to store draft for {draft.FacetId}: {ex.Message}");
}
}
return new FacetDriftVexWorkflowResult
{
EmissionResult = emissionResult,
NewDraftsCreated = createdIds.Count,
ExistingDraftsSkipped = skippedCount,
CreatedDraftIds = [.. createdIds],
Errors = [.. errors]
};
}
/// <summary>
/// Approves a draft and converts it to a VEX statement.
/// </summary>
/// <param name="draftId">ID of the draft to approve.</param>
/// <param name="reviewedBy">Who approved the draft.</param>
/// <param name="notes">Optional review notes.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if approval succeeded.</returns>
public async Task<bool> ApproveAsync(
string draftId,
string reviewedBy,
string? notes = null,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(draftId);
ArgumentException.ThrowIfNullOrWhiteSpace(reviewedBy);
try
{
await _draftStore.UpdateReviewStatusAsync(
draftId,
FacetDriftVexReviewStatus.Approved,
reviewedBy,
notes,
ct).ConfigureAwait(false);
_logger.LogInformation(
"Draft {DraftId} approved by {ReviewedBy}",
draftId,
reviewedBy);
return true;
}
catch (KeyNotFoundException)
{
_logger.LogWarning("Draft {DraftId} not found for approval", draftId);
return false;
}
}
/// <summary>
/// Rejects a draft.
/// </summary>
/// <param name="draftId">ID of the draft to reject.</param>
/// <param name="reviewedBy">Who rejected the draft.</param>
/// <param name="reason">Reason for rejection.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if rejection succeeded.</returns>
public async Task<bool> RejectAsync(
string draftId,
string reviewedBy,
string reason,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(draftId);
ArgumentException.ThrowIfNullOrWhiteSpace(reviewedBy);
ArgumentException.ThrowIfNullOrWhiteSpace(reason);
try
{
await _draftStore.UpdateReviewStatusAsync(
draftId,
FacetDriftVexReviewStatus.Rejected,
reviewedBy,
reason,
ct).ConfigureAwait(false);
_logger.LogInformation(
"Draft {DraftId} rejected by {ReviewedBy}: {Reason}",
draftId,
reviewedBy,
reason);
return true;
}
catch (KeyNotFoundException)
{
_logger.LogWarning("Draft {DraftId} not found for rejection", draftId);
return false;
}
}
/// <summary>
/// Gets drafts pending review.
/// </summary>
public Task<ImmutableArray<FacetDriftVexDraft>> GetPendingDraftsAsync(
string? imageDigest = null,
CancellationToken ct = default)
{
var query = new FacetDriftVexDraftQuery
{
ImageDigest = imageDigest,
ReviewStatus = FacetDriftVexReviewStatus.Pending
};
return _draftStore.QueryAsync(query, ct);
}
/// <summary>
/// Gets drafts that have exceeded their review deadline.
/// </summary>
public Task<ImmutableArray<FacetDriftVexDraft>> GetOverdueDraftsAsync(CancellationToken ct = default)
{
return _draftStore.GetOverdueAsync(DateTimeOffset.UtcNow, ct);
}
}

View File

@@ -49,6 +49,15 @@ public static class FacetServiceCollectionExtensions
return new FacetDriftDetector(timeProvider);
});
// Register facet extractor
services.TryAddSingleton<IFacetExtractor>(sp =>
{
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
var crypto = sp.GetService<ICryptoHash>() ?? DefaultCryptoHash.Instance;
var logger = sp.GetService<Microsoft.Extensions.Logging.ILogger<GlobFacetExtractor>>();
return new GlobFacetExtractor(timeProvider, crypto, logger);
});
return services;
}
@@ -111,6 +120,15 @@ public static class FacetServiceCollectionExtensions
return new FacetDriftDetector(timeProvider);
});
// Register facet extractor
services.TryAddSingleton<IFacetExtractor>(sp =>
{
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
var crypto = sp.GetService<ICryptoHash>() ?? DefaultCryptoHash.Instance;
var logger = sp.GetService<Microsoft.Extensions.Logging.ILogger<GlobFacetExtractor>>();
return new GlobFacetExtractor(timeProvider, crypto, logger);
});
return services;
}
}

View File

@@ -0,0 +1,379 @@
// <copyright file="GlobFacetExtractor.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using System.Diagnostics;
using System.Formats.Tar;
using System.IO.Compression;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace StellaOps.Facet;
/// <summary>
/// Extracts facets from container images using glob pattern matching.
/// </summary>
public sealed class GlobFacetExtractor : IFacetExtractor
{
private readonly FacetSealer _sealer;
private readonly ICryptoHash _cryptoHash;
private readonly ILogger<GlobFacetExtractor> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="GlobFacetExtractor"/> class.
/// </summary>
/// <param name="timeProvider">Time provider for timestamps.</param>
/// <param name="cryptoHash">Hash implementation.</param>
/// <param name="logger">Logger instance.</param>
public GlobFacetExtractor(
TimeProvider? timeProvider = null,
ICryptoHash? cryptoHash = null,
ILogger<GlobFacetExtractor>? logger = null)
{
_cryptoHash = cryptoHash ?? new DefaultCryptoHash();
_sealer = new FacetSealer(timeProvider, cryptoHash);
_logger = logger ?? NullLogger<GlobFacetExtractor>.Instance;
}
/// <inheritdoc/>
public async Task<FacetExtractionResult> ExtractFromDirectoryAsync(
string rootPath,
FacetExtractionOptions? options = null,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
if (!Directory.Exists(rootPath))
{
throw new DirectoryNotFoundException($"Directory not found: {rootPath}");
}
options ??= FacetExtractionOptions.Default;
var sw = Stopwatch.StartNew();
var facets = options.Facets.IsDefault || options.Facets.IsEmpty
? BuiltInFacets.All.ToList()
: options.Facets.ToList();
var matchers = facets.ToDictionary(f => f.FacetId, GlobMatcher.ForFacet);
var excludeMatcher = options.ExcludePatterns.Length > 0
? new GlobMatcher(options.ExcludePatterns)
: null;
var facetFiles = facets.ToDictionary(f => f.FacetId, _ => new List<FacetFileEntry>());
var unmatchedFiles = new List<FacetFileEntry>();
var skippedFiles = new List<SkippedFile>();
var warnings = new List<string>();
int totalFilesProcessed = 0;
long totalBytes = 0;
foreach (var filePath in Directory.EnumerateFiles(rootPath, "*", SearchOption.AllDirectories))
{
ct.ThrowIfCancellationRequested();
var relativePath = GetRelativePath(rootPath, filePath);
// Check exclusion patterns
if (excludeMatcher?.IsMatch(relativePath) == true)
{
skippedFiles.Add(new SkippedFile(relativePath, "Matched exclusion pattern"));
continue;
}
try
{
var fileInfo = new FileInfo(filePath);
// Skip symlinks if not following
if (!options.FollowSymlinks && fileInfo.LinkTarget is not null)
{
skippedFiles.Add(new SkippedFile(relativePath, "Symlink"));
continue;
}
// Skip files too large
if (fileInfo.Length > options.MaxFileSizeBytes)
{
skippedFiles.Add(new SkippedFile(relativePath, $"Exceeds max size ({fileInfo.Length} > {options.MaxFileSizeBytes})"));
continue;
}
totalFilesProcessed++;
totalBytes += fileInfo.Length;
var entry = await CreateFileEntryAsync(filePath, relativePath, fileInfo, options.HashAlgorithm, ct)
.ConfigureAwait(false);
bool matched = false;
foreach (var facet in facets)
{
if (matchers[facet.FacetId].IsMatch(relativePath))
{
facetFiles[facet.FacetId].Add(entry);
matched = true;
// Don't break - a file can match multiple facets
}
}
if (!matched)
{
unmatchedFiles.Add(entry);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger.LogWarning(ex, "Failed to process file: {Path}", relativePath);
skippedFiles.Add(new SkippedFile(relativePath, ex.Message));
}
}
sw.Stop();
return BuildResult(facets, facetFiles, unmatchedFiles, skippedFiles, warnings, totalFilesProcessed, totalBytes, sw.Elapsed, options);
}
/// <inheritdoc/>
public async Task<FacetExtractionResult> ExtractFromTarAsync(
Stream tarStream,
FacetExtractionOptions? options = null,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(tarStream);
options ??= FacetExtractionOptions.Default;
var sw = Stopwatch.StartNew();
var facets = options.Facets.IsDefault || options.Facets.IsEmpty
? BuiltInFacets.All.ToList()
: options.Facets.ToList();
var matchers = facets.ToDictionary(f => f.FacetId, GlobMatcher.ForFacet);
var excludeMatcher = options.ExcludePatterns.Length > 0
? new GlobMatcher(options.ExcludePatterns)
: null;
var facetFiles = facets.ToDictionary(f => f.FacetId, _ => new List<FacetFileEntry>());
var unmatchedFiles = new List<FacetFileEntry>();
var skippedFiles = new List<SkippedFile>();
var warnings = new List<string>();
int totalFilesProcessed = 0;
long totalBytes = 0;
using var tarReader = new TarReader(tarStream, leaveOpen: true);
while (await tarReader.GetNextEntryAsync(copyData: false, ct).ConfigureAwait(false) is { } tarEntry)
{
ct.ThrowIfCancellationRequested();
// Skip non-regular files
if (tarEntry.EntryType != TarEntryType.RegularFile &&
tarEntry.EntryType != TarEntryType.V7RegularFile)
{
continue;
}
var path = NormalizeTarPath(tarEntry.Name);
if (excludeMatcher?.IsMatch(path) == true)
{
skippedFiles.Add(new SkippedFile(path, "Matched exclusion pattern"));
continue;
}
if (tarEntry.Length > options.MaxFileSizeBytes)
{
skippedFiles.Add(new SkippedFile(path, $"Exceeds max size ({tarEntry.Length} > {options.MaxFileSizeBytes})"));
continue;
}
// Skip symlinks if not following
if (!options.FollowSymlinks && tarEntry.EntryType == TarEntryType.SymbolicLink)
{
skippedFiles.Add(new SkippedFile(path, "Symlink"));
continue;
}
try
{
totalFilesProcessed++;
totalBytes += tarEntry.Length;
var entry = await CreateFileEntryFromTarAsync(tarEntry, path, options.HashAlgorithm, ct)
.ConfigureAwait(false);
bool matched = false;
foreach (var facet in facets)
{
if (matchers[facet.FacetId].IsMatch(path))
{
facetFiles[facet.FacetId].Add(entry);
matched = true;
}
}
if (!matched)
{
unmatchedFiles.Add(entry);
}
}
catch (Exception ex) when (ex is IOException or InvalidDataException)
{
_logger.LogWarning(ex, "Failed to process tar entry: {Path}", path);
skippedFiles.Add(new SkippedFile(path, ex.Message));
}
}
sw.Stop();
return BuildResult(facets, facetFiles, unmatchedFiles, skippedFiles, warnings, totalFilesProcessed, totalBytes, sw.Elapsed, options);
}
/// <inheritdoc/>
public async Task<FacetExtractionResult> ExtractFromOciLayerAsync(
Stream layerStream,
FacetExtractionOptions? options = null,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(layerStream);
// OCI layers are gzipped tars - decompress then delegate
await using var gzipStream = new GZipStream(layerStream, CompressionMode.Decompress, leaveOpen: true);
return await ExtractFromTarAsync(gzipStream, options, ct).ConfigureAwait(false);
}
private async Task<FacetFileEntry> CreateFileEntryAsync(
string fullPath,
string relativePath,
FileInfo fileInfo,
string algorithm,
CancellationToken ct)
{
await using var stream = File.OpenRead(fullPath);
var hashBytes = await _cryptoHash.ComputeHashAsync(stream, algorithm, ct).ConfigureAwait(false);
var digest = FormatDigest(hashBytes, algorithm);
return new FacetFileEntry(
relativePath,
digest,
fileInfo.Length,
fileInfo.LastWriteTimeUtc);
}
private async Task<FacetFileEntry> CreateFileEntryFromTarAsync(
TarEntry entry,
string path,
string algorithm,
CancellationToken ct)
{
var dataStream = entry.DataStream;
if (dataStream is null)
{
// Empty file
var emptyHashBytes = await _cryptoHash.ComputeHashAsync(Stream.Null, algorithm, ct).ConfigureAwait(false);
var emptyDigest = FormatDigest(emptyHashBytes, algorithm);
return new FacetFileEntry(path, emptyDigest, 0, entry.ModificationTime);
}
var hashBytes = await _cryptoHash.ComputeHashAsync(dataStream, algorithm, ct).ConfigureAwait(false);
var digest = FormatDigest(hashBytes, algorithm);
return new FacetFileEntry(
path,
digest,
entry.Length,
entry.ModificationTime);
}
private static string FormatDigest(byte[] hashBytes, string algorithm)
{
var hex = Convert.ToHexString(hashBytes).ToLowerInvariant();
return $"{algorithm.ToLowerInvariant()}:{hex}";
}
private FacetExtractionResult BuildResult(
List<IFacet> facets,
Dictionary<string, List<FacetFileEntry>> facetFiles,
List<FacetFileEntry> unmatchedFiles,
List<SkippedFile> skippedFiles,
List<string> warnings,
int totalFilesProcessed,
long totalBytes,
TimeSpan duration,
FacetExtractionOptions options)
{
var facetEntries = new List<FacetEntry>();
int filesMatched = 0;
foreach (var facet in facets)
{
var files = facetFiles[facet.FacetId];
if (files.Count == 0)
{
continue;
}
filesMatched += files.Count;
// Sort files deterministically for consistent Merkle root
var sortedFiles = files.OrderBy(f => f.Path, StringComparer.Ordinal).ToList();
var entry = _sealer.CreateFacetEntry(facet, sortedFiles, options.IncludeFileDetails);
facetEntries.Add(entry);
}
// Sort facet entries deterministically
var sortedFacets = facetEntries.OrderBy(f => f.FacetId, StringComparer.Ordinal).ToImmutableArray();
var merkleTree = new FacetMerkleTree(_cryptoHash);
var combinedRoot = merkleTree.ComputeCombinedRoot(sortedFacets);
var stats = new FacetExtractionStats
{
TotalFilesProcessed = totalFilesProcessed,
TotalBytes = totalBytes,
FilesMatched = filesMatched,
FilesUnmatched = unmatchedFiles.Count,
FilesSkipped = skippedFiles.Count,
Duration = duration
};
return new FacetExtractionResult
{
Facets = sortedFacets,
UnmatchedFiles = options.IncludeFileDetails
? [.. unmatchedFiles.OrderBy(f => f.Path, StringComparer.Ordinal)]
: [],
SkippedFiles = [.. skippedFiles],
CombinedMerkleRoot = combinedRoot,
Stats = stats,
Warnings = [.. warnings]
};
}
private static string GetRelativePath(string rootPath, string fullPath)
{
var relative = Path.GetRelativePath(rootPath, fullPath);
// Normalize to Unix-style path with leading slash
return "/" + relative.Replace('\\', '/');
}
private static string NormalizeTarPath(string path)
{
// Remove leading ./ if present
if (path.StartsWith("./", StringComparison.Ordinal))
{
path = path[2..];
}
// Ensure leading slash
if (!path.StartsWith('/'))
{
path = "/" + path;
}
return path;
}
}

View File

@@ -0,0 +1,329 @@
// <copyright file="IFacetDriftVexDraftStore.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
// Sprint: SPRINT_20260105_002_003_FACET (QTA-018)
using System.Collections.Concurrent;
using System.Collections.Immutable;
namespace StellaOps.Facet;
/// <summary>
/// Query parameters for listing VEX drafts.
/// </summary>
public sealed record FacetDriftVexDraftQuery
{
/// <summary>
/// Filter by image digest.
/// </summary>
public string? ImageDigest { get; init; }
/// <summary>
/// Filter by facet ID.
/// </summary>
public string? FacetId { get; init; }
/// <summary>
/// Filter by review status.
/// </summary>
public FacetDriftVexReviewStatus? ReviewStatus { get; init; }
/// <summary>
/// Include only drafts created since this time.
/// </summary>
public DateTimeOffset? Since { get; init; }
/// <summary>
/// Include only drafts created until this time.
/// </summary>
public DateTimeOffset? Until { get; init; }
/// <summary>
/// Maximum number of results to return.
/// </summary>
public int Limit { get; init; } = 100;
/// <summary>
/// Offset for pagination.
/// </summary>
public int Offset { get; init; } = 0;
}
/// <summary>
/// Review status for facet drift VEX drafts.
/// </summary>
public enum FacetDriftVexReviewStatus
{
/// <summary>
/// Draft is pending review.
/// </summary>
Pending,
/// <summary>
/// Draft has been approved.
/// </summary>
Approved,
/// <summary>
/// Draft has been rejected.
/// </summary>
Rejected,
/// <summary>
/// Draft has expired without review.
/// </summary>
Expired
}
/// <summary>
/// Storage abstraction for facet drift VEX drafts.
/// </summary>
public interface IFacetDriftVexDraftStore
{
/// <summary>
/// Saves a new draft. Throws if a draft with the same ID already exists.
/// </summary>
Task SaveAsync(FacetDriftVexDraft draft, CancellationToken ct = default);
/// <summary>
/// Saves multiple drafts atomically.
/// </summary>
Task SaveBatchAsync(IEnumerable<FacetDriftVexDraft> drafts, CancellationToken ct = default);
/// <summary>
/// Finds a draft by its unique ID.
/// </summary>
Task<FacetDriftVexDraft?> FindByIdAsync(string draftId, CancellationToken ct = default);
/// <summary>
/// Finds drafts matching the query parameters.
/// </summary>
Task<ImmutableArray<FacetDriftVexDraft>> QueryAsync(FacetDriftVexDraftQuery query, CancellationToken ct = default);
/// <summary>
/// Updates a draft's review status.
/// </summary>
Task UpdateReviewStatusAsync(
string draftId,
FacetDriftVexReviewStatus status,
string? reviewedBy = null,
string? reviewNotes = null,
CancellationToken ct = default);
/// <summary>
/// Gets pending drafts that have passed their review deadline.
/// </summary>
Task<ImmutableArray<FacetDriftVexDraft>> GetOverdueAsync(DateTimeOffset asOf, CancellationToken ct = default);
/// <summary>
/// Deletes expired drafts older than the retention period.
/// </summary>
Task<int> PurgeExpiredAsync(DateTimeOffset asOf, CancellationToken ct = default);
/// <summary>
/// Checks if a draft exists for the given image/facet combination.
/// </summary>
Task<bool> ExistsAsync(string imageDigest, string facetId, CancellationToken ct = default);
}
/// <summary>
/// Extended draft record with review tracking.
/// </summary>
public sealed record FacetDriftVexDraftWithReview
{
/// <summary>
/// The original draft.
/// </summary>
public required FacetDriftVexDraft Draft { get; init; }
/// <summary>
/// Current review status.
/// </summary>
public FacetDriftVexReviewStatus ReviewStatus { get; init; } = FacetDriftVexReviewStatus.Pending;
/// <summary>
/// Who reviewed the draft.
/// </summary>
public string? ReviewedBy { get; init; }
/// <summary>
/// When the draft was reviewed.
/// </summary>
public DateTimeOffset? ReviewedAt { get; init; }
/// <summary>
/// Notes from the reviewer.
/// </summary>
public string? ReviewNotes { get; init; }
}
/// <summary>
/// In-memory implementation of <see cref="IFacetDriftVexDraftStore"/> for testing.
/// </summary>
public sealed class InMemoryFacetDriftVexDraftStore : IFacetDriftVexDraftStore
{
private readonly ConcurrentDictionary<string, FacetDriftVexDraftWithReview> _drafts = new(StringComparer.Ordinal);
private readonly TimeProvider _timeProvider;
/// <summary>
/// Initializes a new instance of the <see cref="InMemoryFacetDriftVexDraftStore"/> class.
/// </summary>
public InMemoryFacetDriftVexDraftStore(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public Task SaveAsync(FacetDriftVexDraft draft, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(draft);
var wrapper = new FacetDriftVexDraftWithReview { Draft = draft };
if (!_drafts.TryAdd(draft.DraftId, wrapper))
{
throw new InvalidOperationException($"Draft with ID '{draft.DraftId}' already exists.");
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SaveBatchAsync(IEnumerable<FacetDriftVexDraft> drafts, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(drafts);
foreach (var draft in drafts)
{
var wrapper = new FacetDriftVexDraftWithReview { Draft = draft };
if (!_drafts.TryAdd(draft.DraftId, wrapper))
{
throw new InvalidOperationException($"Draft with ID '{draft.DraftId}' already exists.");
}
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<FacetDriftVexDraft?> FindByIdAsync(string draftId, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(draftId);
_drafts.TryGetValue(draftId, out var wrapper);
return Task.FromResult<FacetDriftVexDraft?>(wrapper?.Draft);
}
/// <inheritdoc />
public Task<ImmutableArray<FacetDriftVexDraft>> QueryAsync(FacetDriftVexDraftQuery query, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(query);
var results = _drafts.Values.AsEnumerable();
if (!string.IsNullOrEmpty(query.ImageDigest))
{
results = results.Where(w => w.Draft.ImageDigest == query.ImageDigest);
}
if (!string.IsNullOrEmpty(query.FacetId))
{
results = results.Where(w => w.Draft.FacetId == query.FacetId);
}
if (query.ReviewStatus.HasValue)
{
results = results.Where(w => w.ReviewStatus == query.ReviewStatus.Value);
}
if (query.Since.HasValue)
{
results = results.Where(w => w.Draft.GeneratedAt >= query.Since.Value);
}
if (query.Until.HasValue)
{
results = results.Where(w => w.Draft.GeneratedAt <= query.Until.Value);
}
var paged = results
.OrderByDescending(w => w.Draft.GeneratedAt)
.Skip(query.Offset)
.Take(query.Limit)
.Select(w => w.Draft)
.ToImmutableArray();
return Task.FromResult(paged);
}
/// <inheritdoc />
public Task UpdateReviewStatusAsync(
string draftId,
FacetDriftVexReviewStatus status,
string? reviewedBy = null,
string? reviewNotes = null,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(draftId);
if (!_drafts.TryGetValue(draftId, out var wrapper))
{
throw new KeyNotFoundException($"Draft with ID '{draftId}' not found.");
}
var updated = wrapper with
{
ReviewStatus = status,
ReviewedBy = reviewedBy,
ReviewedAt = _timeProvider.GetUtcNow(),
ReviewNotes = reviewNotes
};
_drafts[draftId] = updated;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<ImmutableArray<FacetDriftVexDraft>> GetOverdueAsync(DateTimeOffset asOf, CancellationToken ct = default)
{
var overdue = _drafts.Values
.Where(w => w.ReviewStatus == FacetDriftVexReviewStatus.Pending)
.Where(w => w.Draft.ReviewDeadline < asOf)
.Select(w => w.Draft)
.ToImmutableArray();
return Task.FromResult(overdue);
}
/// <inheritdoc />
public Task<int> PurgeExpiredAsync(DateTimeOffset asOf, CancellationToken ct = default)
{
var expiredIds = _drafts
.Where(kvp => kvp.Value.Draft.ExpiresAt < asOf)
.Select(kvp => kvp.Key)
.ToList();
foreach (var id in expiredIds)
{
_drafts.TryRemove(id, out _);
}
return Task.FromResult(expiredIds.Count);
}
/// <inheritdoc />
public Task<bool> ExistsAsync(string imageDigest, string facetId, CancellationToken ct = default)
{
var exists = _drafts.Values.Any(w =>
w.Draft.ImageDigest == imageDigest &&
w.Draft.FacetId == facetId &&
w.ReviewStatus == FacetDriftVexReviewStatus.Pending);
return Task.FromResult(exists);
}
/// <summary>
/// Gets all drafts for testing purposes.
/// </summary>
public IReadOnlyCollection<FacetDriftVexDraftWithReview> GetAllForTesting()
=> _drafts.Values.ToList();
}

View File

@@ -0,0 +1,109 @@
// <copyright file="IFacetSealStore.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
namespace StellaOps.Facet;
/// <summary>
/// Persistent store for <see cref="FacetSeal"/> instances.
/// </summary>
/// <remarks>
/// <para>
/// Implementations provide storage and retrieval of facet seals for drift detection
/// and quota enforcement. Seals are indexed by image digest and creation time.
/// </para>
/// <para>
/// Sprint: SPRINT_20260105_002_003_FACET (QTA-012)
/// </para>
/// </remarks>
public interface IFacetSealStore
{
/// <summary>
/// Get the most recent seal for an image digest.
/// </summary>
/// <param name="imageDigest">The image digest (e.g., "sha256:{hex}").</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The latest seal, or null if no seal exists for this image.</returns>
Task<FacetSeal?> GetLatestSealAsync(string imageDigest, CancellationToken ct = default);
/// <summary>
/// Get a seal by its combined Merkle root (unique identifier).
/// </summary>
/// <param name="combinedMerkleRoot">The seal's combined Merkle root.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The seal, or null if not found.</returns>
Task<FacetSeal?> GetByCombinedRootAsync(string combinedMerkleRoot, CancellationToken ct = default);
/// <summary>
/// Get seal history for an image digest.
/// </summary>
/// <param name="imageDigest">The image digest.</param>
/// <param name="limit">Maximum number of seals to return.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Seals in descending order by creation time (most recent first).</returns>
Task<ImmutableArray<FacetSeal>> GetHistoryAsync(
string imageDigest,
int limit = 10,
CancellationToken ct = default);
/// <summary>
/// Save a seal to the store.
/// </summary>
/// <param name="seal">The seal to save.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task representing the async operation.</returns>
/// <exception cref="ArgumentNullException">If seal is null.</exception>
/// <exception cref="SealAlreadyExistsException">If a seal with the same combined root exists.</exception>
Task SaveAsync(FacetSeal seal, CancellationToken ct = default);
/// <summary>
/// Check if a seal exists for an image digest.
/// </summary>
/// <param name="imageDigest">The image digest.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if at least one seal exists.</returns>
Task<bool> ExistsAsync(string imageDigest, CancellationToken ct = default);
/// <summary>
/// Delete all seals for an image digest.
/// </summary>
/// <param name="imageDigest">The image digest.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Number of seals deleted.</returns>
Task<int> DeleteByImageAsync(string imageDigest, CancellationToken ct = default);
/// <summary>
/// Purge seals older than the specified retention period.
/// </summary>
/// <param name="retentionPeriod">Retention period from creation time.</param>
/// <param name="keepAtLeast">Minimum seals to keep per image digest.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Number of seals purged.</returns>
Task<int> PurgeOldSealsAsync(
TimeSpan retentionPeriod,
int keepAtLeast = 1,
CancellationToken ct = default);
}
/// <summary>
/// Exception thrown when attempting to save a duplicate seal.
/// </summary>
public sealed class SealAlreadyExistsException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="SealAlreadyExistsException"/> class.
/// </summary>
/// <param name="combinedMerkleRoot">The duplicate seal's combined root.</param>
public SealAlreadyExistsException(string combinedMerkleRoot)
: base($"A seal with combined Merkle root '{combinedMerkleRoot}' already exists.")
{
CombinedMerkleRoot = combinedMerkleRoot;
}
/// <summary>
/// Gets the duplicate seal's combined Merkle root.
/// </summary>
public string CombinedMerkleRoot { get; }
}

View File

@@ -0,0 +1,228 @@
// <copyright file="InMemoryFacetSealStore.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Collections.Concurrent;
using System.Collections.Immutable;
namespace StellaOps.Facet;
/// <summary>
/// In-memory implementation of <see cref="IFacetSealStore"/> for testing.
/// </summary>
/// <remarks>
/// <para>
/// Thread-safe but not persistent. Useful for unit tests and local development.
/// </para>
/// <para>
/// Sprint: SPRINT_20260105_002_003_FACET (QTA-012)
/// </para>
/// </remarks>
public sealed class InMemoryFacetSealStore : IFacetSealStore
{
private readonly ConcurrentDictionary<string, FacetSeal> _sealsByRoot = new();
private readonly ConcurrentDictionary<string, SortedSet<string>> _rootsByImage = new();
private readonly object _lock = new();
/// <inheritdoc/>
public Task<FacetSeal?> GetLatestSealAsync(string imageDigest, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest);
if (!_rootsByImage.TryGetValue(imageDigest, out var roots) || roots.Count == 0)
{
return Task.FromResult<FacetSeal?>(null);
}
lock (_lock)
{
// Get the most recent seal (highest creation time)
FacetSeal? latest = null;
foreach (var root in roots)
{
if (_sealsByRoot.TryGetValue(root, out var seal))
{
if (latest is null || seal.CreatedAt > latest.CreatedAt)
{
latest = seal;
}
}
}
return Task.FromResult(latest);
}
}
/// <inheritdoc/>
public Task<FacetSeal?> GetByCombinedRootAsync(string combinedMerkleRoot, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
ArgumentException.ThrowIfNullOrWhiteSpace(combinedMerkleRoot);
_sealsByRoot.TryGetValue(combinedMerkleRoot, out var seal);
return Task.FromResult(seal);
}
/// <inheritdoc/>
public Task<ImmutableArray<FacetSeal>> GetHistoryAsync(
string imageDigest,
int limit = 10,
CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(limit);
if (!_rootsByImage.TryGetValue(imageDigest, out var roots) || roots.Count == 0)
{
return Task.FromResult(ImmutableArray<FacetSeal>.Empty);
}
lock (_lock)
{
var seals = roots
.Select(r => _sealsByRoot.TryGetValue(r, out var s) ? s : null)
.Where(s => s is not null)
.Cast<FacetSeal>()
.OrderByDescending(s => s.CreatedAt)
.Take(limit)
.ToImmutableArray();
return Task.FromResult(seals);
}
}
/// <inheritdoc/>
public Task SaveAsync(FacetSeal seal, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
ArgumentNullException.ThrowIfNull(seal);
lock (_lock)
{
if (_sealsByRoot.ContainsKey(seal.CombinedMerkleRoot))
{
throw new SealAlreadyExistsException(seal.CombinedMerkleRoot);
}
_sealsByRoot[seal.CombinedMerkleRoot] = seal;
var roots = _rootsByImage.GetOrAdd(seal.ImageDigest, _ => new SortedSet<string>());
lock (roots)
{
roots.Add(seal.CombinedMerkleRoot);
}
}
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task<bool> ExistsAsync(string imageDigest, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest);
if (_rootsByImage.TryGetValue(imageDigest, out var roots))
{
lock (roots)
{
return Task.FromResult(roots.Count > 0);
}
}
return Task.FromResult(false);
}
/// <inheritdoc/>
public Task<int> DeleteByImageAsync(string imageDigest, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest);
lock (_lock)
{
if (!_rootsByImage.TryRemove(imageDigest, out var roots))
{
return Task.FromResult(0);
}
int deleted = 0;
foreach (var root in roots)
{
if (_sealsByRoot.TryRemove(root, out _))
{
deleted++;
}
}
return Task.FromResult(deleted);
}
}
/// <inheritdoc/>
public Task<int> PurgeOldSealsAsync(
TimeSpan retentionPeriod,
int keepAtLeast = 1,
CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(keepAtLeast);
var cutoff = DateTimeOffset.UtcNow - retentionPeriod;
int purged = 0;
lock (_lock)
{
foreach (var (imageDigest, roots) in _rootsByImage)
{
// Get seals for this image, sorted by creation time descending
var seals = roots
.Select(r => _sealsByRoot.TryGetValue(r, out var s) ? s : null)
.Where(s => s is not null)
.Cast<FacetSeal>()
.OrderByDescending(s => s.CreatedAt)
.ToList();
// Skip keepAtLeast, then purge old ones
var toPurge = seals
.Skip(keepAtLeast)
.Where(s => s.CreatedAt < cutoff)
.ToList();
foreach (var seal in toPurge)
{
if (_sealsByRoot.TryRemove(seal.CombinedMerkleRoot, out _))
{
lock (roots)
{
roots.Remove(seal.CombinedMerkleRoot);
}
purged++;
}
}
}
}
return Task.FromResult(purged);
}
/// <summary>
/// Clear all seals from the store.
/// </summary>
public void Clear()
{
lock (_lock)
{
_sealsByRoot.Clear();
_rootsByImage.Clear();
}
}
/// <summary>
/// Get the total number of seals in the store.
/// </summary>
public int Count => _sealsByRoot.Count;
}