using System.CommandLine; using System.CommandLine.Parsing; using System.Text.Json; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; namespace ZB.MOM.WW.ScadaBridge.CLI.Commands; /// /// Transport (#24) bundle export / preview / import. The bundle bytes travel /// through the management endpoint as base64 inside the standard JSON envelope /// so no transport plumbing diverges from the other commands; the CLI handles /// file I/O at the edges. /// public static class BundleCommands { private static readonly TimeSpan BundleCommandTimeout = TimeSpan.FromMinutes(5); /// Builds the bundle command group with export, preview, and import sub-commands. /// Shared management URL option. /// Shared output format option. /// Shared username option. /// Shared password option. /// The configured for the bundle group. public static Command Build( Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var command = new Command("bundle") { Description = "Export, preview, and import Transport bundles", }; command.Add(BuildExport(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildPreview(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildImport(urlOption, formatOption, usernameOption, passwordOption)); return command; } // ==================================================================== // bundle export // ==================================================================== private static Command BuildExport( Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var outputOption = new Option("--output") { Description = "Output file path (.scadabundle)", Required = true, }; var passphraseOption = new Option("--passphrase") { Description = "Encryption passphrase. Omit to produce an unencrypted bundle.", }; var allOption = new Option("--all") { Description = "Export every entity of every supported type (ignores per-type name flags).", }; var templatesOption = NameListOption("--templates", "Comma-separated template names"); var sharedScriptsOption = NameListOption("--shared-scripts", "Comma-separated shared-script names"); var externalSystemsOption = NameListOption("--external-systems", "Comma-separated external-system names"); var dbConnectionsOption = NameListOption("--db-connections", "Comma-separated database-connection names"); var notificationListsOption = NameListOption("--notification-lists", "Comma-separated notification-list names"); var smtpConfigsOption = NameListOption("--smtp-configs", "Comma-separated SMTP host names"); // Inbound API keys are not transported between environments (re-arch C4) — no // --api-keys option. Re-create keys and re-grant their method scopes on the // destination via the admin UI/CLI. var apiMethodsOption = NameListOption("--api-methods", "Comma-separated API-method names"); var includeDepsOption = new Option("--include-dependencies") { Description = "Pull transitive dependencies (referenced shared scripts, parents, composed members) into the bundle.", }; var sourceEnvOption = new Option("--source-environment") { Description = "SourceEnvironment value stamped into the bundle manifest. Defaults to 'cli'.", }; var cmd = new Command("export") { Description = "Export a bundle to a file", }; cmd.Add(outputOption); cmd.Add(passphraseOption); cmd.Add(allOption); cmd.Add(templatesOption); cmd.Add(sharedScriptsOption); cmd.Add(externalSystemsOption); cmd.Add(dbConnectionsOption); cmd.Add(notificationListsOption); cmd.Add(smtpConfigsOption); cmd.Add(apiMethodsOption); cmd.Add(includeDepsOption); cmd.Add(sourceEnvOption); cmd.SetAction(async (ParseResult result) => { var output = result.GetValue(outputOption)!; var passphrase = result.GetValue(passphraseOption); var all = result.GetValue(allOption); var includeDeps = result.GetValue(includeDepsOption); var sourceEnv = result.GetValue(sourceEnvOption) ?? "cli"; var payload = new ExportBundleCommand( All: all, TemplateNames: result.GetValue(templatesOption), SharedScriptNames: result.GetValue(sharedScriptsOption), ExternalSystemNames: result.GetValue(externalSystemsOption), DatabaseConnectionNames: result.GetValue(dbConnectionsOption), NotificationListNames: result.GetValue(notificationListsOption), SmtpConfigurationNames: result.GetValue(smtpConfigsOption), ApiMethodNames: result.GetValue(apiMethodsOption), IncludeDependencies: includeDeps, Passphrase: passphrase, SourceEnvironment: sourceEnv); return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, payload, timeout: BundleCommandTimeout, onSuccess: jsonOk => { // CLI-020: previously the JSON envelope parse + property extraction + // base64 decode all ran unguarded — a server-side bug that omits one of // the two expected properties, returns a null base64 value, sends invalid // base64, or returns a malformed JSON envelope would surface as one of // KeyNotFoundException / InvalidOperationException / FormatException / // JsonException, i.e. an unhandled stack trace rather than the // documented "exit 1 with a clean INVALID_RESPONSE error". Wrap the // envelope parse and the streamed write in a single try/catch matching // the graceful-degradation theme established by CLI-002 / CLI-003 / CLI-005. string base64; int byteCount; try { using var doc = JsonDocument.Parse(jsonOk); base64 = doc.RootElement.GetProperty("base64Bundle").GetString()!; byteCount = doc.RootElement.GetProperty("byteCount").GetInt32(); } catch (Exception ex) when (ex is JsonException or KeyNotFoundException or InvalidOperationException) { OutputFormatter.WriteError( $"Server returned a malformed bundle-export response: {ex.Message}", "INVALID_RESPONSE"); return 1; } // CLI-019: stream the base64 → file write so a 100 MB bundle // doesn't double-buffer through Convert.FromBase64String's // ~100 MB byte[] on the LOH plus a synchronous File.WriteAllBytes. // The management envelope's body is still buffered into the // jsonOk string (wire-format limit), but the decode + write // are now chunked, so peak working-set drops from // ~base64+byte[]+envelope to ~base64+small-chunk. long written; try { written = StreamBase64ToFile(base64, output); } catch (FormatException ex) { OutputFormatter.WriteError( $"Server returned invalid base64 in the bundle response: {ex.Message}", "INVALID_RESPONSE"); return 1; } Console.WriteLine($"Wrote {written:N0} bytes to {output} (server reported {byteCount:N0})."); return 0; }); }); return cmd; } // ==================================================================== // bundle preview // ==================================================================== private static Command BuildPreview( Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var inputOption = new Option("--input") { Description = "Bundle file path (.scadabundle)", Required = true, }; var passphraseOption = new Option("--passphrase") { Description = "Passphrase for encrypted bundles.", }; var cmd = new Command("preview") { Description = "Load a bundle and print the diff preview without applying", }; cmd.Add(inputOption); cmd.Add(passphraseOption); cmd.SetAction(async (ParseResult result) => { var input = result.GetValue(inputOption)!; if (!File.Exists(input)) { OutputFormatter.WriteError($"Bundle file not found: {input}", "FILE_NOT_FOUND"); return 1; } var bytes = await File.ReadAllBytesAsync(input); var payload = new PreviewBundleCommand( Base64Bundle: Convert.ToBase64String(bytes), Passphrase: result.GetValue(passphraseOption)); return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, payload, timeout: BundleCommandTimeout, onSuccess: jsonOk => { Console.WriteLine(jsonOk); return 0; }); }); return cmd; } // ==================================================================== // bundle import // ==================================================================== private static Command BuildImport( Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var inputOption = new Option("--input") { Description = "Bundle file path (.scadabundle)", Required = true, }; var passphraseOption = new Option("--passphrase") { Description = "Passphrase for encrypted bundles.", }; var onConflictOption = new Option("--on-conflict") { Description = "Resolution policy applied to every Modified row: skip, overwrite, or rename. Default: overwrite.", DefaultValueFactory = _ => "overwrite", }; var cmd = new Command("import") { Description = "Load + apply a bundle with a single global conflict policy", }; cmd.Add(inputOption); cmd.Add(passphraseOption); cmd.Add(onConflictOption); cmd.SetAction(async (ParseResult result) => { var input = result.GetValue(inputOption)!; if (!File.Exists(input)) { OutputFormatter.WriteError($"Bundle file not found: {input}", "FILE_NOT_FOUND"); return 1; } var bytes = await File.ReadAllBytesAsync(input); var payload = new ImportBundleCommand( Base64Bundle: Convert.ToBase64String(bytes), Passphrase: result.GetValue(passphraseOption), DefaultConflictPolicy: result.GetValue(onConflictOption)!); return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, payload, timeout: BundleCommandTimeout, onSuccess: jsonOk => { Console.WriteLine(jsonOk); return 0; }); }); return cmd; } // ==================================================================== // Shared HTTP plumbing // ==================================================================== // // CLI-017: bundle commands previously routed through a private // RunBundleCommandAsync that re-implemented URL/credential resolution and // skipped the IsAuthorizationFailure(...) check that ExecuteCommandAsync // enforces — a server that signalled FORBIDDEN/UNAUTHORIZED via the error // code on a non-403 status would exit 1 instead of the documented exit 2. // The bundle path now delegates to CommandHelpers.ExecuteCommandAsync with // the longer BundleCommandTimeout and a per-command success handler, so the // exit-code contract is unified across every command group. // CLI-019: chunked base64 → file streaming. The management envelope's // success body is a single buffered JSON string (the wire format does not // currently support response-body streaming), so we cannot remove the // ~base64-string + ~envelope-string allocation. What we CAN — and do — // remove is the intermediate ~bytecount-sized byte[] that // Convert.FromBase64String allocates plus the synchronous File.WriteAllBytes: // we slice the base64 string into 4-byte-multiple chunks (4 base64 chars // decode into exactly 3 bytes, so any multiple of 4 is a clean boundary) // and decode each chunk into a small rented buffer that we copy into the // output FileStream. The chunk size is a tradeoff — large enough that the // per-chunk loop overhead is negligible, small enough that we never put // anything on the LOH (1 MB is below the 85 KB LOH threshold's larger // cousin for buffers we don't keep). Returns the total decoded byte count // for the post-write summary line. internal const int Base64StreamChunkChars = 1024 * 1024; // 1 MB of base64 chars ≈ 768 KB decoded internal static long StreamBase64ToFile(string base64, string outputPath) { if (base64 is null) throw new ArgumentNullException(nameof(base64)); if (string.IsNullOrEmpty(outputPath)) throw new ArgumentException("Output path required.", nameof(outputPath)); // Skip any leading whitespace and trailing padding noise. Convert.TryFromBase64Chars // tolerates internal whitespace, but slicing on arbitrary positions would split a // run of base64 chars mid-quad — round the chunk to a multiple of 4 so each slice // is independently decodable. var chunkChars = Base64StreamChunkChars - (Base64StreamChunkChars % 4); var totalChars = base64.Length; var totalWritten = 0L; using var fileStream = new FileStream( outputPath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 81920, useAsync: false); // 4 base64 chars = 3 bytes, so the decoded buffer is sized accordingly. var byteBuffer = new byte[(chunkChars / 4) * 3]; for (var offset = 0; offset < totalChars; offset += chunkChars) { var take = Math.Min(chunkChars, totalChars - offset); var slice = base64.AsSpan(offset, take); // The final slice may be shorter than chunkChars and may carry // trailing '=' padding; TryFromBase64Chars handles that. if (!Convert.TryFromBase64Chars(slice, byteBuffer, out var written)) { throw new FormatException( $"Bundle response contained invalid base64 at character offset {offset}."); } fileStream.Write(byteBuffer, 0, written); totalWritten += written; } return totalWritten; } private static Option?> NameListOption(string name, string description) { var opt = new Option?>(name) { Description = description, CustomParser = arg => { var token = arg.Tokens.Count == 0 ? null : arg.Tokens[0].Value; if (string.IsNullOrWhiteSpace(token)) return null; return token .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .ToArray(); }, }; return opt; } }