using System.Globalization; using Spectre.Console; namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows; /// /// Exports the session's store to a ciphertext-only on disk. The operator is /// offered a default filename (secrets-bundle-<target>-<yyyyMMdd>.json) they can accept /// or override, and a confirmation for whether tombstoned rows are included. The bundle carries no /// plaintext — only the encrypted rows and the source KEK id — so it is safe at rest and useless without /// the source key. Requires a KEK-capable session (the shell upgrades a degraded session before dispatch). /// public sealed class BundleExportFlow : IInteractiveFlow { private readonly TimeProvider _timeProvider; /// Creates the flow. /// /// Clock used for the default filename's date stamp and the bundle's /// timestamp; defaults to . /// public BundleExportFlow(TimeProvider? timeProvider = null) => _timeProvider = timeProvider ?? TimeProvider.System; /// public string Title => "Export bundle (ciphertext-only)"; /// public bool RequiresKek => true; /// public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct) { ArgumentNullException.ThrowIfNull(console); ArgumentNullException.ThrowIfNull(session); string defaultPath = DefaultBundleFileName(session.Target.Name, _timeProvider.GetUtcNow()); string path = console.Prompt( new TextPrompt("Bundle [green]path[/]:").DefaultValue(defaultPath)); bool includeDeleted = console.Prompt( new ConfirmationPrompt("Include tombstoned (deleted) rows?") { DefaultValue = false }); int count; try { count = await new BundleService(_timeProvider) .ExportAsync(session, path, includeDeleted, ct).ConfigureAwait(false); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { console.MarkupLineInterpolated($"[red]Could not write bundle to '{path}': {ex.Message}[/]"); return; } console.MarkupLineInterpolated($"[green]Exported {count} row(s) to '{path}'.[/]"); } // Builds a filesystem-safe default name: secrets-bundle--.json, with any character // illegal in a file name collapsed to '-' so the offered default is always a valid path segment. private static string DefaultBundleFileName(string targetName, DateTimeOffset now) { string sanitized = Sanitize(targetName); string date = now.ToString("yyyyMMdd", CultureInfo.InvariantCulture); return $"secrets-bundle-{sanitized}-{date}.json"; } private static string Sanitize(string name) { if (string.IsNullOrWhiteSpace(name)) { return "store"; } char[] invalid = Path.GetInvalidFileNameChars(); char[] chars = name.Trim().Select(c => invalid.Contains(c) ? '-' : c).ToArray(); return new string(chars); } }