using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.Secrets.Cli.Interactive; /// /// Tally of an run. /// /// Rows written (new inserts plus conflict winners). /// Rows a last-writer-wins (or override) decision rejected as not newer. /// /// Rows wrapped under a KEK other than the target's, with no matching source KEK supplied to re-wrap them. /// /// Rows whose name already existed in the target store (won or lost). public sealed record BundleImportReport(int Imported, int SkippedOlder, int SkippedForeignKek, int Conflicts); /// /// Exports and imports documents — ciphertext-only movement of secret rows /// between deployment stores. Import reconciles per row with the shared last-writer-wins ordering (an /// optional per-row override can force a decision) and, when a row is wrapped under a foreign KEK, /// re-wraps it under the target's KEK given the source KEK — otherwise it is skipped and reported. /// Both operations require a full (non-degraded) session: import needs the target cipher to /// re-wrap, and export is a data-movement operation that must not run half-configured. /// public sealed class BundleService { private readonly TimeProvider _timeProvider; /// Creates the service. /// Clock used to stamp ; defaults to . public BundleService(TimeProvider? timeProvider = null) => _timeProvider = timeProvider ?? TimeProvider.System; /// /// Exports every secret in 's store to a ciphertext-only bundle at /// , written atomically (temp file then move). Tombstones are excluded unless /// is set. /// /// The full session to export from. /// Destination bundle path. /// When , tombstoned rows are exported too. /// A token to cancel the operation. /// The number of rows written to the bundle. /// The session is degraded (no KEK). public async Task ExportAsync(SecretsSession session, string path, bool includeDeleted, CancellationToken ct) { ArgumentNullException.ThrowIfNull(session); ArgumentException.ThrowIfNullOrWhiteSpace(path); IMasterKeyProvider masterKey = session.MasterKey ?? throw new InvalidOperationException( "Cannot export a bundle from a degraded session (no KEK available)."); IReadOnlyList metadata = await session.Store.ListAsync(includeDeleted, ct).ConfigureAwait(false); var entries = new List(metadata.Count); foreach (SecretMetadata meta in metadata) { StoredSecret? row = await session.Store.GetAsync(meta.Name, ct).ConfigureAwait(false); if (row is null) { continue; } entries.Add(SecretBundleCodec.FromRow(row)); } var bundle = new SecretBundle { ExportedUtc = _timeProvider.GetUtcNow(), SourceKekId = masterKey.KekId, Entries = entries, }; await WriteAtomicAsync(path, SecretBundleCodec.Serialize(bundle), ct).ConfigureAwait(false); return entries.Count; } /// /// Imports the bundle at into 's store. /// /// /// /// Per row: a row wrapped under a foreign KEK is re-wrapped under the target KEK when /// matches its , otherwise it is /// skipped (). A row whose name does not yet /// exist is imported. A row whose name already exists is a conflict resolved by /// when supplied, else by ; /// the winner is written and the loser skipped (). /// No plaintext is ever handled — the bundle is ciphertext only. /// /// /// Writes go through the store's so the row lands /// verbatim — its own revision, timestamps, and tombstone flag preserved — under a /// serializable transaction with the shared last-writer-wins gate built in. This keeps LWW metadata /// intact across further hops (a second import, or cluster anti-entropy) and means an imported /// tombstone stays a tombstone. The one exception is a that /// forces the incoming row against the LWW ordering (operator adopts an older row): a /// verbatim replicate would be silently rejected by the LWW gate, so that single case is written /// through instead — a local-write that restamps the row as /// current, which is exactly what makes the operator's forced choice durable rather than flipped /// back on the next reconciliation. /// /// /// The full session to import into. /// Source bundle path. /// The KEK the bundle was exported under, to re-wrap foreign-KEK rows; may be . /// /// Optional per-row decision for an existing row: given (existing, incoming), return /// to take the incoming row. When , last-writer-wins decides. /// /// A token to cancel the operation. /// A tally of the import. /// The session is degraded (no KEK/cipher). public async Task ImportAsync( SecretsSession session, string path, IMasterKeyProvider? sourceKek, Func? conflictOverride, CancellationToken ct) { ArgumentNullException.ThrowIfNull(session); ArgumentException.ThrowIfNullOrWhiteSpace(path); IMasterKeyProvider targetKek = session.MasterKey ?? throw new InvalidOperationException( "Cannot import a bundle into a degraded session (no KEK available)."); ISecretCipher cipher = session.Cipher ?? throw new InvalidOperationException( "Cannot import a bundle into a degraded session (no cipher available)."); string json = await File.ReadAllTextAsync(path, ct).ConfigureAwait(false); SecretBundle bundle = SecretBundleCodec.Deserialize(json); if (bundle.FormatVersion != SecretBundle.CurrentFormatVersion) { throw new InvalidOperationException( $"Bundle '{path}' has unsupported format version {bundle.FormatVersion}; " + $"this build supports version {SecretBundle.CurrentFormatVersion}."); } int imported = 0, skippedOlder = 0, skippedForeignKek = 0, conflicts = 0; for (int i = 0; i < bundle.Entries.Count; i++) { BundleEntry entry = bundle.Entries[i]; // Rehydrate the row; a malformed entry (bad base64, unknown content type, illegal name) // surfaces as an entry-aware failure rather than a bare FormatException/ArgumentException. StoredSecret row; try { row = SecretBundleCodec.ToRow(entry); } catch (Exception ex) when (ex is FormatException or ArgumentException) { throw new InvalidOperationException( $"Bundle '{path}' entry #{i} ('{entry.Name}') is malformed: {ex.Message}", ex); } // Foreign KEK: re-wrap under the target KEK if the source KEK is available, else skip. if (!string.Equals(row.KekId, targetKek.KekId, StringComparison.Ordinal)) { if (sourceKek is not null && string.Equals(sourceKek.KekId, row.KekId, StringComparison.Ordinal)) { row = cipher.Rewrap(row, sourceKek, targetKek); } else { skippedForeignKek++; continue; } } StoredSecret? existing = await session.Store.GetAsync(row.Name, ct).ConfigureAwait(false); if (existing is null) { // No local row: ApplyReplicatedAsync inserts it verbatim (its LWW read finds nothing). await session.Store.ApplyReplicatedAsync(row, ct).ConfigureAwait(false); imported++; continue; } conflicts++; bool lwwWin = SecretLastWriterWins.IsNewer( row.UpdatedUtc, row.Revision, existing.UpdatedUtc, existing.Revision); bool takeIncoming = conflictOverride is not null ? conflictOverride(existing, row) : lwwWin; if (!takeIncoming) { skippedOlder++; continue; } if (lwwWin) { // Natural (or override-agreed) winner: apply verbatim, keeping LWW metadata intact. await session.Store.ApplyReplicatedAsync(row, ct).ConfigureAwait(false); } else { // Override forced an older row against LWW. A verbatim replicate would be rejected by // the LWW gate, so adopt it as a local write — restamped current so the choice sticks. await session.Store.UpsertAsync(row, ct).ConfigureAwait(false); } imported++; } return new BundleImportReport(imported, skippedOlder, skippedForeignKek, conflicts); } // Writes content to a sibling temp file then moves it over the target, so a reader never observes // a half-written bundle and a crash mid-write cannot corrupt an existing bundle. private static async Task WriteAtomicAsync(string path, string content, CancellationToken ct) { string directory = Path.GetDirectoryName(Path.GetFullPath(path)) ?? "."; Directory.CreateDirectory(directory); string temp = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp"); try { await File.WriteAllTextAsync(temp, content, ct).ConfigureAwait(false); File.Move(temp, path, overwrite: true); } finally { if (File.Exists(temp)) { try { File.Delete(temp); } catch (IOException) { /* best-effort cleanup of the temp file */ } } } } }