using System.Globalization; using System.Text.Json.Nodes; using Microsoft.Extensions.Logging; using StellaOps.Findings.Ledger.Domain; using StellaOps.Findings.Ledger.Infrastructure; using StellaOps.Findings.Ledger.Infrastructure.AirGap; using StellaOps.Findings.Ledger.Observability; namespace StellaOps.Findings.Ledger.Services; public sealed record AirgapImportInput( string TenantId, string BundleId, string? MirrorGeneration, string MerkleRoot, DateTimeOffset TimeAnchor, string? Publisher, string? HashAlgorithm, IReadOnlyList Contents, string? ImportOperator); public sealed record AirgapImportResult( bool Success, Guid ChainId, long? SequenceNumber, Guid? LedgerEventId, string? Error); public sealed class AirgapImportService { private readonly ILedgerEventRepository _ledgerEventRepository; private readonly ILedgerEventWriteService _writeService; private readonly IAirgapImportRepository _repository; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; public AirgapImportService( ILedgerEventRepository ledgerEventRepository, ILedgerEventWriteService writeService, IAirgapImportRepository repository, TimeProvider timeProvider, ILogger logger) { _ledgerEventRepository = ledgerEventRepository ?? throw new ArgumentNullException(nameof(ledgerEventRepository)); _writeService = writeService ?? throw new ArgumentNullException(nameof(writeService)); _repository = repository ?? throw new ArgumentNullException(nameof(repository)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task RecordAsync(AirgapImportInput input, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(input); var chainId = LedgerChainIdGenerator.FromTenantSubject(input.TenantId, $"airgap::{input.BundleId}"); var chainHead = await _ledgerEventRepository.GetChainHeadAsync(input.TenantId, chainId, cancellationToken).ConfigureAwait(false); var sequence = (chainHead?.SequenceNumber ?? 0) + 1; var previousHash = chainHead?.EventHash ?? LedgerEventConstants.EmptyHash; var eventId = Guid.NewGuid(); var recordedAt = _timeProvider.GetUtcNow(); var payload = new JsonObject { ["airgap"] = new JsonObject { ["bundleId"] = input.BundleId, ["mirrorGeneration"] = input.MirrorGeneration, ["merkleRoot"] = input.MerkleRoot, ["timeAnchor"] = input.TimeAnchor.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture), ["publisher"] = input.Publisher, ["hashAlgorithm"] = input.HashAlgorithm, ["contents"] = new JsonArray(input.Contents.Select(c => (JsonNode)c).ToArray()) } }; var envelope = new JsonObject { ["event"] = new JsonObject { ["id"] = eventId.ToString(), ["type"] = LedgerEventConstants.EventAirgapBundleImported, ["tenant"] = input.TenantId, ["chainId"] = chainId.ToString(), ["sequence"] = sequence, ["policyVersion"] = input.MirrorGeneration ?? "airgap-bundle", ["artifactId"] = input.BundleId, ["finding"] = new JsonObject { ["id"] = input.BundleId, ["artifactId"] = input.BundleId, ["vulnId"] = "airgap-import" }, ["actor"] = new JsonObject { ["id"] = input.ImportOperator ?? "airgap-operator", ["type"] = "operator" }, ["occurredAt"] = FormatTimestamp(input.TimeAnchor), ["recordedAt"] = FormatTimestamp(recordedAt), ["payload"] = payload.DeepClone() } }; var draft = new LedgerEventDraft( input.TenantId, chainId, sequence, eventId, LedgerEventConstants.EventAirgapBundleImported, input.MirrorGeneration ?? "airgap-bundle", input.BundleId, input.BundleId, SourceRunId: null, ActorId: input.ImportOperator ?? "airgap-operator", ActorType: "operator", OccurredAt: input.TimeAnchor.ToUniversalTime(), RecordedAt: recordedAt, Payload: payload, CanonicalEnvelope: envelope, ProvidedPreviousHash: previousHash); var writeResult = await _writeService.AppendAsync(draft, cancellationToken).ConfigureAwait(false); if (writeResult.Status is not (LedgerWriteStatus.Success or LedgerWriteStatus.Idempotent)) { var error = string.Join(";", writeResult.Errors); return new AirgapImportResult(false, chainId, sequence, writeResult.Record?.EventId, error); } var ledgerEventId = writeResult.Record?.EventId; var record = new AirgapImportRecord( input.TenantId, input.BundleId, input.MirrorGeneration, input.MerkleRoot, input.TimeAnchor.ToUniversalTime(), input.Publisher, input.HashAlgorithm, new JsonArray(input.Contents.Select(c => (JsonNode)c).ToArray()), recordedAt, input.ImportOperator, ledgerEventId); await _repository.InsertAsync(record, cancellationToken).ConfigureAwait(false); LedgerTimeline.EmitAirgapImport(_logger, input.TenantId, input.BundleId, input.MerkleRoot, ledgerEventId); return new AirgapImportResult(true, chainId, sequence, ledgerEventId, null); } private static string FormatTimestamp(DateTimeOffset value) => value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'"); }