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 ReindexAsync( ReindexOptions options, IProgress 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(); 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(); 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 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 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(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? Metadata, ManifestEntryDocument[]? Entries); private sealed record ManifestEntryDocument( string Section, string CanonicalPath, string Sha256, long SizeBytes, string? MediaType, IDictionary? Attributes); private async Task GenerateCrossReferenceInternalAsync( TenantId tenantId, DateTimeOffset since, CancellationToken ct) { var entries = new List(); 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 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(); return _merkleTreeCalculator.CalculateRootHash( entries.Select(entry => $"{entry.CanonicalPath}|{entry.Sha256.ToLowerInvariant()}") ); } // In-memory checkpoint storage (production would use persistent storage) private readonly Dictionary _checkpoints = new(); public async Task 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(); 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 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(); 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> 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>(checkpoints); } }