diff --git a/src/cmds/cloud/aws_cmd.rs b/src/cmds/cloud/aws_cmd.rs index 64c18cf7c..1759a777e 100644 --- a/src/cmds/cloud/aws_cmd.rs +++ b/src/cmds/cloud/aws_cmd.rs @@ -52,6 +52,14 @@ pub fn run(subcommand: &str, args: &[String], verbose: u8) -> Result { format!("{} {}", subcommand, args.join(" ")) }; + // When the user explicitly requests a structured, parseable output format + // (--output json/yaml), honor it losslessly. rtk's summarizers and generic + // schema compression would otherwise drop field values and emit non-parseable + // output, breaking the --output contract (issue #2139). + if explicit_lossless_format(args) { + return run_passthrough(subcommand, args, verbose, &full_sub); + } + // Route to specialized handlers match subcommand { "sts" if !args.is_empty() && args[0] == "get-caller-identity" => run_aws_filtered( @@ -215,6 +223,66 @@ fn is_structured_operation(args: &[String]) -> bool { || op == "receive-message" } +/// Returns true when the user explicitly requested a structured, parseable +/// output format (`--output json` / `--output yaml`). In that case rtk must not +/// summarize or schema-compress — the output has to stay byte-faithful so that +/// downstream `jq` / `json.load` parsing keeps working. +fn explicit_lossless_format(args: &[String]) -> bool { + // Scan all args (last --output wins, matching aws CLI semantics for repeats). + let mut last: Option = None; + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + if arg == "--output" { + // A dangling --output (no value) is malformed and aws will reject it; + // don't silently apply lossy compression to it. + last = Some(match iter.next() { + Some(fmt) => matches!(fmt.as_str(), "json" | "yaml" | "yaml-stream"), + None => true, + }); + } else if let Some(fmt) = arg.strip_prefix("--output=") { + last = Some(matches!(fmt, "json" | "yaml" | "yaml-stream")); + } + } + last.unwrap_or(false) +} + +/// Execute an aws command without filtering, preserving output byte-for-byte. +/// Used when the user explicitly requested a lossless output format. +fn run_passthrough(subcommand: &str, args: &[String], verbose: u8, full_sub: &str) -> Result { + let timer = tracking::TimedExecution::start(); + + let mut cmd = resolved_command("aws"); + cmd.arg(subcommand); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: aws {}", full_sub); + } + + let output = cmd.output().context("Failed to run aws CLI")?; + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let cmd_label = format!("aws {}", full_sub); + let rtk_label = format!("rtk aws {}", full_sub); + + if !output.status.success() { + eprintln!("{}", stderr.trim()); + timer.track(&cmd_label, &rtk_label, &stderr, &stderr); + return Ok(exit_code_from_output(&output, "aws")); + } + + // No tee hint: output is already byte-faithful, so there is no truncated + // data to recover — teeing would just duplicate stdout. + print!("{}", stdout); + if !stderr.is_empty() { + eprint!("{}", stderr); + } + timer.track(&cmd_label, &rtk_label, &stdout, &stdout); + Ok(0) +} + /// Generic strategy: force --output json for structured ops, compress via json_cmd schema fn run_generic(subcommand: &str, args: &[String], verbose: u8, full_sub: &str) -> Result { let timer = tracking::TimedExecution::start(); @@ -224,7 +292,7 @@ fn run_generic(subcommand: &str, args: &[String], verbose: u8, full_sub: &str) - let mut has_output_flag = false; for arg in args { - if arg == "--output" { + if arg == "--output" || arg.starts_with("--output=") { has_output_flag = true; } cmd.arg(arg); @@ -1546,6 +1614,34 @@ mod tests { use super::*; use crate::core::utils::count_tokens; + #[test] + fn test_explicit_lossless_format() { + let s = |a: &[&str]| a.iter().map(|x| x.to_string()).collect::>(); + // Explicit json/yaml → lossless passthrough + assert!(explicit_lossless_format(&s(&[ + "get-function", + "--function-name", + "fn", + "--output", + "json" + ]))); + assert!(explicit_lossless_format(&s(&["get-function", "--output=json"]))); + assert!(explicit_lossless_format(&s(&["describe-stacks", "--output", "yaml"]))); + // Lossy/human formats and absent flag → keep normal filtering + assert!(!explicit_lossless_format(&s(&["get-function", "--output", "table"]))); + assert!(!explicit_lossless_format(&s(&["get-function", "--output", "text"]))); + assert!(!explicit_lossless_format(&s(&["list-functions"]))); + // Repeated --output: last wins (matches aws CLI) + assert!(explicit_lossless_format(&s(&[ + "--output", "table", "--output", "json" + ]))); + assert!(!explicit_lossless_format(&s(&[ + "--output", "json", "--output", "table" + ]))); + // Dangling --output (malformed) → don't apply lossy compression + assert!(explicit_lossless_format(&s(&["get-function", "--output"]))); + } + #[test] fn test_snapshot_sts_identity() { let json = r#"{