290 lines
9.3 KiB
C#
290 lines
9.3 KiB
C#
using System.Text.Json;
|
|
using Microsoft.Extensions.Logging;
|
|
using StellaOps.Cryptography;
|
|
|
|
namespace StellaOps.Policy.Snapshots;
|
|
|
|
/// <summary>
|
|
/// Service for managing knowledge snapshots.
|
|
/// </summary>
|
|
public sealed class SnapshotService : ISnapshotService
|
|
{
|
|
private readonly ISnapshotIdGenerator _idGenerator;
|
|
private readonly ICryptoSigner? _signer;
|
|
private readonly ISnapshotStore _store;
|
|
private readonly ILogger<SnapshotService> _logger;
|
|
|
|
public SnapshotService(
|
|
ISnapshotIdGenerator idGenerator,
|
|
ISnapshotStore store,
|
|
ILogger<SnapshotService> logger,
|
|
ICryptoSigner? signer = null)
|
|
{
|
|
_idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
|
|
_store = store ?? throw new ArgumentNullException(nameof(store));
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
_signer = signer;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates and persists a new snapshot.
|
|
/// </summary>
|
|
public async Task<KnowledgeSnapshotManifest> CreateSnapshotAsync(
|
|
SnapshotBuilder builder,
|
|
CancellationToken ct = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
|
|
var manifest = builder.Build();
|
|
|
|
// Validate ID before storing
|
|
if (!_idGenerator.ValidateId(manifest))
|
|
throw new InvalidOperationException("Snapshot ID validation failed");
|
|
|
|
await _store.SaveAsync(manifest, ct).ConfigureAwait(false);
|
|
|
|
_logger.LogInformation("Created snapshot {SnapshotId}", manifest.SnapshotId);
|
|
|
|
return manifest;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Seals a snapshot with a DSSE signature.
|
|
/// </summary>
|
|
public async Task<KnowledgeSnapshotManifest> SealSnapshotAsync(
|
|
KnowledgeSnapshotManifest manifest,
|
|
CancellationToken ct = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(manifest);
|
|
|
|
if (_signer is null)
|
|
throw new InvalidOperationException("No signer configured for sealing snapshots");
|
|
|
|
var payload = JsonSerializer.SerializeToUtf8Bytes(manifest with { Signature = null },
|
|
SnapshotSerializerOptions.Canonical);
|
|
var signatureBytes = await _signer.SignAsync(payload, ct).ConfigureAwait(false);
|
|
var signature = Convert.ToBase64String(signatureBytes);
|
|
|
|
var sealedManifest = manifest with { Signature = signature };
|
|
|
|
await _store.SaveAsync(sealedManifest, ct).ConfigureAwait(false);
|
|
|
|
_logger.LogInformation("Sealed snapshot {SnapshotId}", manifest.SnapshotId);
|
|
|
|
return sealedManifest;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies a snapshot's integrity and signature.
|
|
/// </summary>
|
|
public async Task<SnapshotVerificationResult> VerifySnapshotAsync(
|
|
KnowledgeSnapshotManifest manifest,
|
|
CancellationToken ct = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(manifest);
|
|
|
|
// Verify content-addressed ID
|
|
if (!_idGenerator.ValidateId(manifest))
|
|
{
|
|
return SnapshotVerificationResult.Fail("Snapshot ID does not match content");
|
|
}
|
|
|
|
// Verify signature if present
|
|
if (manifest.Signature is not null)
|
|
{
|
|
if (_signer is null)
|
|
{
|
|
return SnapshotVerificationResult.Fail("No signer configured for signature verification");
|
|
}
|
|
|
|
var payload = JsonSerializer.SerializeToUtf8Bytes(manifest with { Signature = null },
|
|
SnapshotSerializerOptions.Canonical);
|
|
var signatureBytes = Convert.FromBase64String(manifest.Signature);
|
|
var sigValid = await _signer.VerifyAsync(payload, signatureBytes, ct).ConfigureAwait(false);
|
|
|
|
if (!sigValid)
|
|
{
|
|
return SnapshotVerificationResult.Fail("Signature verification failed");
|
|
}
|
|
}
|
|
|
|
return SnapshotVerificationResult.Success();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves a snapshot by ID.
|
|
/// </summary>
|
|
public async Task<KnowledgeSnapshotManifest?> GetSnapshotAsync(
|
|
string snapshotId,
|
|
CancellationToken ct = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(snapshotId))
|
|
return null;
|
|
|
|
return await _store.GetAsync(snapshotId, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lists all snapshots in the store.
|
|
/// </summary>
|
|
public async Task<IReadOnlyList<KnowledgeSnapshotManifest>> ListSnapshotsAsync(
|
|
int skip = 0,
|
|
int take = 100,
|
|
CancellationToken ct = default)
|
|
{
|
|
return await _store.ListAsync(skip, take, ct).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of snapshot verification.
|
|
/// </summary>
|
|
public sealed record SnapshotVerificationResult(bool IsValid, string? Error)
|
|
{
|
|
public static SnapshotVerificationResult Success() => new(true, null);
|
|
public static SnapshotVerificationResult Fail(string error) => new(false, error);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Interface for snapshot management operations.
|
|
/// </summary>
|
|
public interface ISnapshotService
|
|
{
|
|
/// <summary>
|
|
/// Creates and persists a new snapshot.
|
|
/// </summary>
|
|
Task<KnowledgeSnapshotManifest> CreateSnapshotAsync(SnapshotBuilder builder, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Seals a snapshot with a DSSE signature.
|
|
/// </summary>
|
|
Task<KnowledgeSnapshotManifest> SealSnapshotAsync(KnowledgeSnapshotManifest manifest, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Verifies a snapshot's integrity and signature.
|
|
/// </summary>
|
|
Task<SnapshotVerificationResult> VerifySnapshotAsync(KnowledgeSnapshotManifest manifest, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Retrieves a snapshot by ID.
|
|
/// </summary>
|
|
Task<KnowledgeSnapshotManifest?> GetSnapshotAsync(string snapshotId, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Lists all snapshots in the store.
|
|
/// </summary>
|
|
Task<IReadOnlyList<KnowledgeSnapshotManifest>> ListSnapshotsAsync(int skip = 0, int take = 100, CancellationToken ct = default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Interface for snapshot persistence.
|
|
/// </summary>
|
|
public interface ISnapshotStore
|
|
{
|
|
/// <summary>
|
|
/// Saves a snapshot manifest.
|
|
/// </summary>
|
|
Task SaveAsync(KnowledgeSnapshotManifest manifest, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Retrieves a snapshot manifest by ID.
|
|
/// </summary>
|
|
Task<KnowledgeSnapshotManifest?> GetAsync(string snapshotId, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Lists snapshot manifests.
|
|
/// </summary>
|
|
Task<IReadOnlyList<KnowledgeSnapshotManifest>> ListAsync(int skip = 0, int take = 100, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Deletes a snapshot manifest by ID.
|
|
/// </summary>
|
|
Task<bool> DeleteAsync(string snapshotId, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Gets bundled content by path.
|
|
/// </summary>
|
|
Task<byte[]?> GetBundledContentAsync(string bundlePath, CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Gets content by digest.
|
|
/// </summary>
|
|
Task<byte[]?> GetByDigestAsync(string digest, CancellationToken ct = default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// In-memory implementation of <see cref="ISnapshotStore"/> for testing.
|
|
/// </summary>
|
|
public sealed class InMemorySnapshotStore : ISnapshotStore
|
|
{
|
|
private readonly Dictionary<string, KnowledgeSnapshotManifest> _snapshots = new();
|
|
private readonly object _lock = new();
|
|
|
|
public Task SaveAsync(KnowledgeSnapshotManifest manifest, CancellationToken ct = default)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
lock (_lock)
|
|
{
|
|
_snapshots[manifest.SnapshotId] = manifest;
|
|
}
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<KnowledgeSnapshotManifest?> GetAsync(string snapshotId, CancellationToken ct = default)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
lock (_lock)
|
|
{
|
|
return Task.FromResult(_snapshots.TryGetValue(snapshotId, out var manifest) ? manifest : null);
|
|
}
|
|
}
|
|
|
|
public Task<IReadOnlyList<KnowledgeSnapshotManifest>> ListAsync(int skip = 0, int take = 100, CancellationToken ct = default)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
lock (_lock)
|
|
{
|
|
var result = _snapshots.Values
|
|
.OrderByDescending(s => s.CreatedAt)
|
|
.Skip(skip)
|
|
.Take(take)
|
|
.ToList();
|
|
return Task.FromResult<IReadOnlyList<KnowledgeSnapshotManifest>>(result);
|
|
}
|
|
}
|
|
|
|
public Task<bool> DeleteAsync(string snapshotId, CancellationToken ct = default)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
lock (_lock)
|
|
{
|
|
return Task.FromResult(_snapshots.Remove(snapshotId));
|
|
}
|
|
}
|
|
|
|
public Task<byte[]?> GetBundledContentAsync(string bundlePath, CancellationToken ct = default)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
// In-memory implementation doesn't support bundled content
|
|
return Task.FromResult<byte[]?>(null);
|
|
}
|
|
|
|
public Task<byte[]?> GetByDigestAsync(string digest, CancellationToken ct = default)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
// In-memory implementation doesn't support digest-based lookup
|
|
return Task.FromResult<byte[]?>(null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears all snapshots from the store (for testing only).
|
|
/// </summary>
|
|
public void Clear()
|
|
{
|
|
lock (_lock)
|
|
{
|
|
_snapshots.Clear();
|
|
}
|
|
}
|
|
}
|