using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Globalization; using System.IO; using System.Linq; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Npgsql; using StellaOps.EvidenceLocker.Core.Builders; using StellaOps.EvidenceLocker.Core.Configuration; using StellaOps.EvidenceLocker.Core.Domain; using StellaOps.EvidenceLocker.Core.Repositories; using StellaOps.EvidenceLocker.Core.Signing; using StellaOps.EvidenceLocker.Core.Incident; using StellaOps.EvidenceLocker.Core.Timeline; using StellaOps.EvidenceLocker.Core.Storage; using StellaOps.Determinism; namespace StellaOps.EvidenceLocker.Infrastructure.Services; public sealed class EvidenceSnapshotService { private static readonly string EmptyRoot = new('0', 64); private static readonly JsonSerializerOptions IncidentSerializerOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; private readonly IEvidenceBundleRepository _repository; private readonly IEvidenceBundleBuilder _bundleBuilder; private readonly IEvidenceSignatureService _signatureService; private readonly IEvidenceTimelinePublisher _timelinePublisher; private readonly IIncidentModeState _incidentMode; private readonly IEvidenceObjectStore _objectStore; private readonly TimeProvider _timeProvider; private readonly IGuidProvider _guidProvider; private readonly ILogger _logger; private readonly QuotaOptions _quotas; public EvidenceSnapshotService( IEvidenceBundleRepository repository, IEvidenceBundleBuilder bundleBuilder, IEvidenceSignatureService signatureService, IEvidenceTimelinePublisher timelinePublisher, IIncidentModeState incidentMode, IEvidenceObjectStore objectStore, TimeProvider timeProvider, IOptions options, ILogger logger, IGuidProvider? guidProvider = null) { _repository = repository ?? throw new ArgumentNullException(nameof(repository)); _bundleBuilder = bundleBuilder ?? throw new ArgumentNullException(nameof(bundleBuilder)); _signatureService = signatureService ?? throw new ArgumentNullException(nameof(signatureService)); _timelinePublisher = timelinePublisher ?? throw new ArgumentNullException(nameof(timelinePublisher)); _incidentMode = incidentMode ?? throw new ArgumentNullException(nameof(incidentMode)); _objectStore = objectStore ?? throw new ArgumentNullException(nameof(objectStore)); _timeProvider = timeProvider ?? TimeProvider.System; ArgumentNullException.ThrowIfNull(options); _quotas = options.Value.Quotas ?? throw new InvalidOperationException("Quota options are required."); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _guidProvider = guidProvider ?? SystemGuidProvider.Instance; } public async Task CreateSnapshotAsync( TenantId tenantId, EvidenceSnapshotRequest request, CancellationToken cancellationToken) { if (tenantId == default) { throw new ArgumentException("Tenant identifier is required.", nameof(tenantId)); } ArgumentNullException.ThrowIfNull(request); ValidateRequest(request); var bundleId = EvidenceBundleId.FromGuid(_guidProvider.NewGuid()); var createdAt = _timeProvider.GetUtcNow(); var storageKey = $"tenants/{tenantId.Value:N}/bundles/{bundleId.Value:N}/bundle.tgz"; var incidentSnapshot = _incidentMode.Current; DateTimeOffset? expiresAt = null; if (incidentSnapshot.IsActive && incidentSnapshot.RetentionExtensionDays > 0) { expiresAt = createdAt.AddDays(incidentSnapshot.RetentionExtensionDays); } var metadataBuffer = new Dictionary( request.Metadata ?? new Dictionary(), StringComparer.Ordinal); if (incidentSnapshot.IsActive) { metadataBuffer["incident.mode"] = "enabled"; metadataBuffer["incident.changedAt"] = incidentSnapshot.ChangedAt.ToString("O", CultureInfo.InvariantCulture); metadataBuffer["incident.retentionExtensionDays"] = incidentSnapshot.RetentionExtensionDays.ToString(CultureInfo.InvariantCulture); } var normalizedMetadata = NormalizeMetadata(metadataBuffer); var bundle = new EvidenceBundle( bundleId, tenantId, request.Kind, EvidenceBundleStatus.Pending, EmptyRoot, storageKey, createdAt, createdAt, request.Description, null, expiresAt); await _repository.CreateBundleAsync(bundle, cancellationToken).ConfigureAwait(false); var normalizedMaterials = request.Materials .Select(material => new EvidenceBundleMaterial( material.Section ?? string.Empty, material.Path ?? string.Empty, material.Sha256, material.SizeBytes, material.MediaType ?? "application/octet-stream", NormalizeAttributes(material.Attributes))) .ToList(); if (incidentSnapshot.IsActive && incidentSnapshot.CaptureRequestSnapshot && normalizedMaterials.Count < _quotas.MaxMaterialCount) { var incidentMaterial = await TryCaptureIncidentSnapshotAsync( tenantId, bundleId, incidentSnapshot, request, normalizedMetadata, createdAt, cancellationToken).ConfigureAwait(false); if (incidentMaterial is not null) { normalizedMaterials.Add(incidentMaterial); } } var buildRequest = new EvidenceBundleBuildRequest( bundleId, tenantId, request.Kind, createdAt, normalizedMetadata, normalizedMaterials); var buildResult = await _bundleBuilder.BuildAsync(buildRequest, cancellationToken).ConfigureAwait(false); await _repository.SetBundleAssemblyAsync( bundleId, tenantId, EvidenceBundleStatus.Assembling, buildResult.RootHash, createdAt, cancellationToken).ConfigureAwait(false); var signature = await _signatureService.SignManifestAsync( bundleId, tenantId, buildResult.Manifest, cancellationToken).ConfigureAwait(false); if (signature is not null) { await _repository.UpsertSignatureAsync(signature, cancellationToken).ConfigureAwait(false); await _timelinePublisher.PublishBundleSealedAsync(signature, buildResult.Manifest, buildResult.RootHash, cancellationToken) .ConfigureAwait(false); } var sealedAt = signature?.TimestampedAt ?? signature?.SignedAt ?? _timeProvider.GetUtcNow(); await _repository.MarkBundleSealedAsync( bundleId, tenantId, EvidenceBundleStatus.Sealed, sealedAt, cancellationToken).ConfigureAwait(false); return new EvidenceSnapshotResult(bundleId.Value, buildResult.RootHash, buildResult.Manifest, signature); } public Task GetBundleAsync( TenantId tenantId, EvidenceBundleId bundleId, CancellationToken cancellationToken) { if (tenantId == default) { throw new ArgumentException("Tenant identifier is required.", nameof(tenantId)); } if (bundleId == default) { throw new ArgumentException("Bundle identifier is required.", nameof(bundleId)); } return _repository.GetBundleAsync(bundleId, tenantId, cancellationToken); } public async Task VerifyAsync( TenantId tenantId, EvidenceBundleId bundleId, string expectedRootHash, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(expectedRootHash)) { throw new ArgumentException("Expected root hash must be provided.", nameof(expectedRootHash)); } var details = await _repository.GetBundleAsync(bundleId, tenantId, cancellationToken).ConfigureAwait(false); return details is not null && string.Equals(details.Bundle.RootHash, expectedRootHash, StringComparison.OrdinalIgnoreCase); } public async Task CreateHoldAsync( TenantId tenantId, string caseId, EvidenceHoldRequest request, CancellationToken cancellationToken) { if (tenantId == default) { throw new ArgumentException("Tenant identifier is required.", nameof(tenantId)); } ArgumentException.ThrowIfNullOrWhiteSpace(caseId); ArgumentNullException.ThrowIfNull(request); ArgumentException.ThrowIfNullOrWhiteSpace(request.Reason); EvidenceBundleId? bundleId = null; if (request.BundleId.HasValue) { bundleId = EvidenceBundleId.FromGuid(request.BundleId.Value); var exists = await _repository.ExistsAsync(bundleId.Value, tenantId, cancellationToken).ConfigureAwait(false); if (!exists) { throw new InvalidOperationException($"Referenced bundle '{bundleId.Value.Value:D}' does not exist for tenant '{tenantId.Value:D}'."); } } var holdId = EvidenceHoldId.FromGuid(_guidProvider.NewGuid()); var createdAt = _timeProvider.GetUtcNow(); var hold = new EvidenceHold( holdId, tenantId, bundleId, caseId, request.Reason, createdAt, request.ExpiresAt, null, request.Notes); EvidenceHold persisted; try { persisted = await _repository.CreateHoldAsync(hold, cancellationToken).ConfigureAwait(false); } catch (PostgresException ex) when (string.Equals(ex.SqlState, PostgresErrorCodes.UniqueViolation, StringComparison.Ordinal)) { throw new InvalidOperationException($"A hold already exists for case '{caseId}' in tenant '{tenantId.Value:D}'.", ex); } if (bundleId.HasValue) { await _repository.ExtendBundleRetentionAsync( bundleId.Value, tenantId, request.ExpiresAt, createdAt, cancellationToken).ConfigureAwait(false); } await _timelinePublisher.PublishHoldCreatedAsync(persisted, cancellationToken).ConfigureAwait(false); return persisted; } private void ValidateRequest(EvidenceSnapshotRequest request) { if (!Enum.IsDefined(typeof(EvidenceBundleKind), request.Kind)) { throw new InvalidOperationException($"Unsupported evidence bundle kind '{request.Kind}'."); } var metadataCount = request.Metadata?.Count ?? 0; if (metadataCount > _quotas.MaxMetadataEntries) { throw new InvalidOperationException($"Metadata entry count {metadataCount} exceeds limit of {_quotas.MaxMetadataEntries}."); } if (request.Materials is null || request.Materials.Count == 0) { throw new InvalidOperationException("At least one material must be supplied for an evidence snapshot."); } if (request.Materials.Count > _quotas.MaxMaterialCount) { throw new InvalidOperationException($"Material count {request.Materials.Count} exceeds limit of {_quotas.MaxMaterialCount}."); } long totalSizeBytes = 0; foreach (var entry in request.Metadata ?? new Dictionary()) { ValidateMetadata(entry.Key, entry.Value); } foreach (var material in request.Materials) { ValidateMaterial(material); totalSizeBytes = checked(totalSizeBytes + material.SizeBytes); if (totalSizeBytes > _quotas.MaxTotalMaterialSizeBytes) { throw new InvalidOperationException($"Material size total {totalSizeBytes} exceeds limit of {_quotas.MaxTotalMaterialSizeBytes} bytes."); } } } private void ValidateMetadata(string key, string value) { if (string.IsNullOrWhiteSpace(key)) { throw new InvalidOperationException("Metadata keys must be non-empty."); } if (key.Length > _quotas.MaxMetadataKeyLength) { throw new InvalidOperationException($"Metadata key '{key}' exceeds length limit of {_quotas.MaxMetadataKeyLength} characters."); } if (value is null) { throw new InvalidOperationException($"Metadata value for key '{key}' must not be null."); } if (value.Length > _quotas.MaxMetadataValueLength) { throw new InvalidOperationException($"Metadata value for key '{key}' exceeds length limit of {_quotas.MaxMetadataValueLength} characters."); } } private void ValidateMaterial(EvidenceSnapshotMaterial material) { if (string.IsNullOrWhiteSpace(material.Sha256)) { throw new InvalidOperationException("Material SHA-256 digest must be provided."); } if (material.Sha256.Length != 64 || !IsHex(material.Sha256)) { throw new InvalidOperationException($"Material SHA-256 digest '{material.Sha256}' must be 64 hex characters."); } if (material.SizeBytes < 0) { throw new InvalidOperationException("Material size bytes cannot be negative."); } foreach (var attribute in material.Attributes ?? new Dictionary()) { ValidateMetadata(attribute.Key, attribute.Value); } } private static IReadOnlyDictionary NormalizeMetadata(IDictionary? metadata) { if (metadata is null || metadata.Count == 0) { return new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); } return new ReadOnlyDictionary(new Dictionary(metadata, StringComparer.Ordinal)); } private static IReadOnlyDictionary NormalizeAttributes(IDictionary? attributes) { if (attributes is null || attributes.Count == 0) { return new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); } return new ReadOnlyDictionary(new Dictionary(attributes, StringComparer.Ordinal)); } private static bool IsHex(string value) { for (var i = 0; i < value.Length; i++) { var ch = value[i]; var isHex = ch is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F'; if (!isHex) { return false; } } return true; } private async Task TryCaptureIncidentSnapshotAsync( TenantId tenantId, EvidenceBundleId bundleId, IncidentModeSnapshot incidentSnapshot, EvidenceSnapshotRequest request, IReadOnlyDictionary normalizedMetadata, DateTimeOffset capturedAt, CancellationToken cancellationToken) { try { var payload = new { capturedAt = capturedAt, incident = new { state = incidentSnapshot.IsActive ? "enabled" : "disabled", retentionExtensionDays = incidentSnapshot.RetentionExtensionDays }, request = new { kind = request.Kind, metadata = normalizedMetadata, materials = request.Materials.Select(material => new { section = material.Section, path = material.Path, sha256 = material.Sha256, sizeBytes = material.SizeBytes, mediaType = material.MediaType, attributes = material.Attributes }) } }; var bytes = JsonSerializer.SerializeToUtf8Bytes(payload, IncidentSerializerOptions); var artifactFileName = $"request-{capturedAt:yyyyMMddHHmmssfff}.json"; var artifactName = $"incident/{artifactFileName}"; await using var stream = new MemoryStream(bytes); var metadata = await _objectStore.StoreAsync( stream, new EvidenceObjectWriteOptions( tenantId, bundleId, artifactName, "application/json"), cancellationToken) .ConfigureAwait(false); var attributes = new Dictionary(StringComparer.Ordinal) { ["storageKey"] = metadata.StorageKey }; return new EvidenceBundleMaterial( "incident", artifactFileName, metadata.Sha256, metadata.SizeBytes, metadata.ContentType, attributes); } catch (OperationCanceledException) { throw; } catch (Exception ex) { _logger.LogWarning( ex, "Failed to capture incident snapshot for bundle {BundleId}: {Message}", bundleId.Value, ex.Message); return null; } } }