504 lines
17 KiB
C#
504 lines
17 KiB
C#
|
|
|
|
using StellaOps.EvidenceLocker.Core.Builders;
|
|
using StellaOps.EvidenceLocker.Core.Domain;
|
|
using StellaOps.EvidenceLocker.Core.Reindexing;
|
|
using StellaOps.EvidenceLocker.Core.Repositories;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Text.Json;
|
|
|
|
namespace StellaOps.EvidenceLocker.Infrastructure.Reindexing;
|
|
|
|
public sealed class EvidenceReindexService : IEvidenceReindexService
|
|
{
|
|
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
|
|
|
private readonly IEvidenceBundleRepository _repository;
|
|
private readonly IMerkleTreeCalculator _merkleTreeCalculator;
|
|
private readonly TimeProvider _timeProvider;
|
|
|
|
public EvidenceReindexService(
|
|
IEvidenceBundleRepository repository,
|
|
IMerkleTreeCalculator merkleTreeCalculator,
|
|
TimeProvider timeProvider)
|
|
{
|
|
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
|
_merkleTreeCalculator = merkleTreeCalculator ?? throw new ArgumentNullException(nameof(merkleTreeCalculator));
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
}
|
|
|
|
public async Task<ReindexResult> ReindexAsync(
|
|
ReindexOptions options,
|
|
IProgress<ReindexProgress> progress,
|
|
CancellationToken ct)
|
|
{
|
|
if (options.TenantId == default)
|
|
{
|
|
throw new ArgumentException("TenantId is required for reindex operations.", nameof(options));
|
|
}
|
|
|
|
if (options.BatchSize <= 0)
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(options.BatchSize), "BatchSize must be positive.");
|
|
}
|
|
|
|
var startedAt = _timeProvider.GetUtcNow();
|
|
var errors = new List<string>();
|
|
var processed = 0;
|
|
var reindexed = 0;
|
|
var failed = 0;
|
|
DateTimeOffset? cursorUpdatedAt = options.Since;
|
|
EvidenceBundleId? cursorBundleId = null;
|
|
|
|
while (true)
|
|
{
|
|
var batch = await _repository.GetBundlesForReindexAsync(
|
|
options.TenantId,
|
|
options.Since,
|
|
cursorUpdatedAt,
|
|
cursorBundleId,
|
|
options.BatchSize,
|
|
ct).ConfigureAwait(false);
|
|
|
|
if (batch.Count == 0)
|
|
{
|
|
break;
|
|
}
|
|
|
|
foreach (var details in batch)
|
|
{
|
|
processed++;
|
|
try
|
|
{
|
|
if (details.Signature is null)
|
|
{
|
|
throw new InvalidOperationException($"Missing signature for bundle {details.Bundle.Id.Value:D}.");
|
|
}
|
|
|
|
var manifest = DecodeManifest(details.Signature.Payload);
|
|
var entries = manifest.Entries ?? Array.Empty<ManifestEntryDocument>();
|
|
|
|
var rootHash = _merkleTreeCalculator.CalculateRootHash(
|
|
entries.Select(entry => $"{entry.CanonicalPath}|{entry.Sha256.ToLowerInvariant()}"));
|
|
|
|
if (!string.Equals(rootHash, details.Bundle.RootHash, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
reindexed++;
|
|
if (!options.DryRun)
|
|
{
|
|
await _repository.SetBundleAssemblyAsync(
|
|
details.Bundle.Id,
|
|
details.Bundle.TenantId,
|
|
details.Bundle.Status,
|
|
rootHash,
|
|
_timeProvider.GetUtcNow(),
|
|
ct).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
progress?.Report(new ReindexProgress
|
|
{
|
|
TotalBundles = processed,
|
|
BundlesProcessed = processed,
|
|
CurrentBundleId = details.Bundle.Id.Value.ToString("D"),
|
|
Message = options.DryRun ? "assessed" : "reindexed"
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
failed++;
|
|
errors.Add(ex.Message);
|
|
}
|
|
|
|
cursorUpdatedAt = details.Bundle.UpdatedAt;
|
|
cursorBundleId = details.Bundle.Id;
|
|
}
|
|
}
|
|
|
|
var completedAt = _timeProvider.GetUtcNow();
|
|
return new ReindexResult
|
|
{
|
|
TotalBundles = processed,
|
|
ReindexedBundles = reindexed,
|
|
FailedBundles = failed,
|
|
StartedAt = startedAt,
|
|
CompletedAt = completedAt,
|
|
Errors = errors
|
|
};
|
|
}
|
|
|
|
public Task<ContinuityVerificationResult> VerifyContinuityAsync(
|
|
TenantId tenantId,
|
|
string oldRoot,
|
|
string newRoot,
|
|
CancellationToken ct)
|
|
{
|
|
if (tenantId == default)
|
|
{
|
|
throw new ArgumentException("TenantId is required for continuity verification.", nameof(tenantId));
|
|
}
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(oldRoot);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(newRoot);
|
|
|
|
return VerifyContinuityInternalAsync(tenantId, oldRoot, newRoot, ct);
|
|
}
|
|
|
|
public Task<RootCrossReferenceMap> GenerateCrossReferenceAsync(
|
|
TenantId tenantId,
|
|
DateTimeOffset since,
|
|
CancellationToken ct)
|
|
{
|
|
if (tenantId == default)
|
|
{
|
|
throw new ArgumentException("TenantId is required for cross-reference generation.", nameof(tenantId));
|
|
}
|
|
|
|
return GenerateCrossReferenceInternalAsync(tenantId, since, ct);
|
|
}
|
|
|
|
private static ManifestDocument DecodeManifest(string payload)
|
|
{
|
|
byte[] bytes;
|
|
try
|
|
{
|
|
bytes = Convert.FromBase64String(payload);
|
|
}
|
|
catch (FormatException ex)
|
|
{
|
|
throw new InvalidOperationException("Manifest payload is not valid base64.", ex);
|
|
}
|
|
|
|
try
|
|
{
|
|
return JsonSerializer.Deserialize<ManifestDocument>(bytes, SerializerOptions)
|
|
?? throw new InvalidOperationException("Manifest payload is empty.");
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
throw new InvalidOperationException("Manifest payload is not valid JSON.", ex);
|
|
}
|
|
}
|
|
|
|
private sealed record ManifestDocument(
|
|
Guid BundleId,
|
|
Guid TenantId,
|
|
int Kind,
|
|
DateTimeOffset CreatedAt,
|
|
IDictionary<string, string>? Metadata,
|
|
ManifestEntryDocument[]? Entries);
|
|
|
|
private sealed record ManifestEntryDocument(
|
|
string Section,
|
|
string CanonicalPath,
|
|
string Sha256,
|
|
long SizeBytes,
|
|
string? MediaType,
|
|
IDictionary<string, string>? Attributes);
|
|
|
|
private async Task<RootCrossReferenceMap> GenerateCrossReferenceInternalAsync(
|
|
TenantId tenantId,
|
|
DateTimeOffset since,
|
|
CancellationToken ct)
|
|
{
|
|
var entries = new List<RootCrossReferenceEntry>();
|
|
var failed = 0;
|
|
DateTimeOffset? cursorUpdatedAt = since;
|
|
EvidenceBundleId? cursorBundleId = null;
|
|
|
|
while (true)
|
|
{
|
|
var batch = await _repository.GetBundlesForReindexAsync(
|
|
tenantId,
|
|
since,
|
|
cursorUpdatedAt,
|
|
cursorBundleId,
|
|
250,
|
|
ct).ConfigureAwait(false);
|
|
|
|
if (batch.Count == 0)
|
|
{
|
|
break;
|
|
}
|
|
|
|
foreach (var details in batch)
|
|
{
|
|
var bundleId = details.Bundle.Id.Value.ToString("D");
|
|
var oldRoot = details.Bundle.RootHash;
|
|
var evidenceCount = 0;
|
|
var verified = false;
|
|
var digestsPreserved = false;
|
|
var newRoot = string.Empty;
|
|
|
|
try
|
|
{
|
|
if (details.Signature is null)
|
|
{
|
|
throw new InvalidOperationException($"Missing signature for bundle {bundleId}.");
|
|
}
|
|
|
|
var manifest = DecodeManifest(details.Signature.Payload);
|
|
evidenceCount = manifest.Entries?.Length ?? 0;
|
|
newRoot = ComputeRootHash(manifest);
|
|
verified = true;
|
|
digestsPreserved = string.Equals(oldRoot, newRoot, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
catch
|
|
{
|
|
failed++;
|
|
}
|
|
|
|
if (verified)
|
|
{
|
|
entries.Add(new RootCrossReferenceEntry
|
|
{
|
|
BundleId = bundleId,
|
|
OldRoot = oldRoot,
|
|
NewRoot = newRoot,
|
|
EvidenceCount = evidenceCount,
|
|
Verified = verified,
|
|
DigestsPreserved = digestsPreserved
|
|
});
|
|
}
|
|
|
|
cursorUpdatedAt = details.Bundle.UpdatedAt;
|
|
cursorBundleId = details.Bundle.Id;
|
|
}
|
|
}
|
|
|
|
return new RootCrossReferenceMap
|
|
{
|
|
SchemaVersion = "1.0.0",
|
|
GeneratedAt = _timeProvider.GetUtcNow(),
|
|
Entries = entries,
|
|
Summary = new RootCrossReferenceSummary
|
|
{
|
|
TotalBundles = entries.Count + failed,
|
|
SuccessfulMigrations = entries.Count,
|
|
FailedMigrations = failed,
|
|
DigestsPreserved = entries.Count
|
|
}
|
|
};
|
|
}
|
|
|
|
private async Task<ContinuityVerificationResult> VerifyContinuityInternalAsync(
|
|
TenantId tenantId,
|
|
string oldRoot,
|
|
string newRoot,
|
|
CancellationToken ct)
|
|
{
|
|
var foundOldRoot = false;
|
|
var crossReferenceValid = false;
|
|
var recomputedMatchesOld = false;
|
|
DateTimeOffset? cursorUpdatedAt = null;
|
|
EvidenceBundleId? cursorBundleId = null;
|
|
|
|
while (true)
|
|
{
|
|
var batch = await _repository.GetBundlesForReindexAsync(
|
|
tenantId,
|
|
null,
|
|
cursorUpdatedAt,
|
|
cursorBundleId,
|
|
250,
|
|
ct).ConfigureAwait(false);
|
|
|
|
if (batch.Count == 0)
|
|
{
|
|
break;
|
|
}
|
|
|
|
foreach (var details in batch)
|
|
{
|
|
if (!string.Equals(details.Bundle.RootHash, oldRoot, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
cursorUpdatedAt = details.Bundle.UpdatedAt;
|
|
cursorBundleId = details.Bundle.Id;
|
|
continue;
|
|
}
|
|
|
|
foundOldRoot = true;
|
|
if (details.Signature is not null)
|
|
{
|
|
var manifest = DecodeManifest(details.Signature.Payload);
|
|
var recomputed = ComputeRootHash(manifest);
|
|
recomputedMatchesOld = string.Equals(recomputed, oldRoot, StringComparison.OrdinalIgnoreCase);
|
|
if (string.Equals(recomputed, newRoot, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
crossReferenceValid = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
cursorUpdatedAt = details.Bundle.UpdatedAt;
|
|
cursorBundleId = details.Bundle.Id;
|
|
}
|
|
|
|
if (crossReferenceValid)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
var notes = !foundOldRoot
|
|
? "Old root not found in evidence bundles."
|
|
: crossReferenceValid
|
|
? null
|
|
: recomputedMatchesOld
|
|
? "Old root recomputed successfully but does not match the provided new root."
|
|
: "Old root found but manifest recomputation did not match the stored root.";
|
|
|
|
return new ContinuityVerificationResult
|
|
{
|
|
OldRootValid = foundOldRoot,
|
|
NewRootValid = crossReferenceValid,
|
|
AllEvidencePreserved = crossReferenceValid,
|
|
CrossReferenceValid = crossReferenceValid,
|
|
OldProofsStillValid = foundOldRoot && recomputedMatchesOld,
|
|
Notes = notes
|
|
};
|
|
}
|
|
|
|
private string ComputeRootHash(ManifestDocument manifest)
|
|
{
|
|
var entries = manifest.Entries ?? Array.Empty<ManifestEntryDocument>();
|
|
return _merkleTreeCalculator.CalculateRootHash(
|
|
entries.Select(entry => $"{entry.CanonicalPath}|{entry.Sha256.ToLowerInvariant()}")
|
|
);
|
|
}
|
|
|
|
// In-memory checkpoint storage (production would use persistent storage)
|
|
private readonly Dictionary<string, ReindexCheckpoint> _checkpoints = new();
|
|
|
|
public async Task<ReindexCheckpoint> CreateCheckpointAsync(
|
|
TenantId tenantId,
|
|
string checkpointName,
|
|
CancellationToken ct)
|
|
{
|
|
if (tenantId == default)
|
|
{
|
|
throw new ArgumentException("TenantId is required for checkpoint creation.", nameof(tenantId));
|
|
}
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(checkpointName);
|
|
|
|
var snapshots = new List<CheckpointBundleSnapshot>();
|
|
DateTimeOffset? cursorUpdatedAt = null;
|
|
EvidenceBundleId? cursorBundleId = null;
|
|
|
|
// Capture current state of all bundles
|
|
while (true)
|
|
{
|
|
var batch = await _repository.GetBundlesForReindexAsync(
|
|
tenantId,
|
|
null,
|
|
cursorUpdatedAt,
|
|
cursorBundleId,
|
|
250,
|
|
ct).ConfigureAwait(false);
|
|
|
|
if (batch.Count == 0)
|
|
{
|
|
break;
|
|
}
|
|
|
|
foreach (var details in batch)
|
|
{
|
|
snapshots.Add(new CheckpointBundleSnapshot
|
|
{
|
|
BundleId = details.Bundle.Id.Value.ToString("D"),
|
|
RootHash = details.Bundle.RootHash,
|
|
CapturedAt = _timeProvider.GetUtcNow()
|
|
});
|
|
|
|
cursorUpdatedAt = details.Bundle.UpdatedAt;
|
|
cursorBundleId = details.Bundle.Id;
|
|
}
|
|
}
|
|
|
|
var checkpointId = $"ckpt-{Guid.NewGuid():N}";
|
|
var checkpoint = new ReindexCheckpoint
|
|
{
|
|
CheckpointId = checkpointId,
|
|
Name = checkpointName,
|
|
CreatedAt = _timeProvider.GetUtcNow(),
|
|
BundleCount = snapshots.Count,
|
|
SchemaVersion = "1.0.0",
|
|
Snapshots = snapshots
|
|
};
|
|
|
|
_checkpoints[checkpointId] = checkpoint;
|
|
return checkpoint;
|
|
}
|
|
|
|
public async Task<RollbackResult> RollbackToCheckpointAsync(
|
|
TenantId tenantId,
|
|
string checkpointId,
|
|
CancellationToken ct)
|
|
{
|
|
if (tenantId == default)
|
|
{
|
|
throw new ArgumentException("TenantId is required for rollback.", nameof(tenantId));
|
|
}
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(checkpointId);
|
|
|
|
if (!_checkpoints.TryGetValue(checkpointId, out var checkpoint))
|
|
{
|
|
throw new InvalidOperationException($"Checkpoint '{checkpointId}' not found.");
|
|
}
|
|
|
|
var startedAt = _timeProvider.GetUtcNow();
|
|
var restored = 0;
|
|
var failed = 0;
|
|
var errors = new List<string>();
|
|
|
|
foreach (var snapshot in checkpoint.Snapshots)
|
|
{
|
|
try
|
|
{
|
|
var bundleId = EvidenceBundleId.FromGuid(Guid.Parse(snapshot.BundleId));
|
|
await _repository.SetBundleAssemblyAsync(
|
|
bundleId,
|
|
tenantId,
|
|
EvidenceBundleStatus.Sealed,
|
|
snapshot.RootHash,
|
|
_timeProvider.GetUtcNow(),
|
|
ct).ConfigureAwait(false);
|
|
|
|
restored++;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
failed++;
|
|
errors.Add($"Failed to restore bundle {snapshot.BundleId}: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
return new RollbackResult
|
|
{
|
|
Success = failed == 0,
|
|
BundlesRestored = restored,
|
|
BundlesFailed = failed,
|
|
StartedAt = startedAt,
|
|
CompletedAt = _timeProvider.GetUtcNow(),
|
|
Errors = errors
|
|
};
|
|
}
|
|
|
|
public Task<IReadOnlyList<ReindexCheckpoint>> ListCheckpointsAsync(
|
|
TenantId tenantId,
|
|
CancellationToken ct)
|
|
{
|
|
if (tenantId == default)
|
|
{
|
|
throw new ArgumentException("TenantId is required for listing checkpoints.", nameof(tenantId));
|
|
}
|
|
|
|
// Return checkpoints ordered by creation time (newest first)
|
|
var checkpoints = _checkpoints.Values
|
|
.OrderByDescending(c => c.CreatedAt)
|
|
.ToList();
|
|
|
|
return Task.FromResult<IReadOnlyList<ReindexCheckpoint>>(checkpoints);
|
|
}
|
|
}
|