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 */ }
}
}
}
}