diff --git a/src/core/args_utils.rs b/src/core/args_utils.rs index e431783fb2..ebfd724238 100644 --- a/src/core/args_utils.rs +++ b/src/core/args_utils.rs @@ -7,7 +7,10 @@ /// than `parsed_args` (nothing was consumed). Otherwise restores all consumed `--` at /// their original positions by returning the user-args suffix of `raw_args` verbatim. pub fn restore_double_dash(parsed_args: &[String]) -> Vec { - let raw_args: Vec = std::env::args().collect(); + // args_os(): env::args() aborts when argv holds non-UTF-8 bytes (see #3025). + let raw_args: Vec = std::env::args_os() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); restore_double_dash_with_raw(parsed_args, &raw_args) } diff --git a/src/main.rs b/src/main.rs index d1e0269f5a..17225deb20 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1281,7 +1281,13 @@ enum SbtCommands { } fn run_fallback(parse_error: clap::Error) -> Result { - let args: Vec = std::env::args().skip(1).collect(); + // argv can hold non-UTF-8 bytes (env::args() aborts on those). Keep the raw + // OsStrings for execution and derive lossy text only for display/lookup. + let raw_args: Vec = std::env::args_os().skip(1).collect(); + let args: Vec = raw_args + .iter() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); // No args → show Clap's error (user ran just "rtk" with bad syntax) if args.is_empty() { @@ -1323,14 +1329,14 @@ fn run_fallback(parse_error: clap::Error) -> Result { let result = if filter.filter_stderr { // Merge stderr into stdout so the filter can strip banners emitted by tools like liquibase core::utils::resolved_command(&args[0]) - .args(&args[1..]) + .args(&raw_args[1..]) .stdin(std::process::Stdio::inherit()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) // captured for merging .output() } else { core::utils::resolved_command(&args[0]) - .args(&args[1..]) + .args(&raw_args[1..]) .stdin(std::process::Stdio::inherit()) .stdout(std::process::Stdio::piped()) // capture .stderr(std::process::Stdio::inherit()) // stderr always direct @@ -1399,7 +1405,7 @@ fn run_fallback(parse_error: clap::Error) -> Result { } else { // No TOML match: original passthrough behaviour (Stdio::inherit, streaming) let status = core::utils::resolved_command(&args[0]) - .args(&args[1..]) + .args(&raw_args[1..]) .stdin(std::process::Stdio::inherit()) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()) diff --git a/tests/non_utf8_argv_test.rs b/tests/non_utf8_argv_test.rs new file mode 100644 index 0000000000..b30960150d --- /dev/null +++ b/tests/non_utf8_argv_test.rs @@ -0,0 +1,53 @@ +//! Regression tests for #3025: non-UTF-8 bytes in argv must not abort the process. +//! +//! Unix-only: argv is arbitrary bytes here, whereas Windows argv is UTF-16. + +#![cfg(unix)] + +use std::ffi::OsString; +use std::os::unix::ffi::OsStringExt; +use std::process::Command; + +/// Leading bytes of a double-encoded UTF-8 sequence — invalid UTF-8 on their own. +fn invalid_utf8_arg() -> OsString { + OsString::from_vec(vec![0xc3, 0xa2, 0xe2, 0x80]) +} + +#[test] +fn non_utf8_arg_does_not_abort() { + let out = Command::new(env!("CARGO_BIN_EXE_rtk")) + .arg("grep") + .arg("-c") + .arg(invalid_utf8_arg()) + .arg("/dev/null") + .output() + .expect("rtk should spawn"); + + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !stderr.contains("panicked"), + "rtk panicked on non-UTF-8 argv: {stderr}" + ); + assert!( + out.status.code().is_some(), + "rtk was killed by a signal (abort) on non-UTF-8 argv" + ); +} + +/// The fallback path executes the wrapped command, so argv bytes must reach it +/// verbatim — a lossy String conversion would silently replace them with U+FFFD. +#[test] +fn non_utf8_arg_reaches_the_wrapped_command_verbatim() { + let out = Command::new(env!("CARGO_BIN_EXE_rtk")) + .arg("printf") + .arg("%s") + .arg(invalid_utf8_arg()) + .output() + .expect("rtk should spawn"); + + assert_eq!( + out.stdout, + invalid_utf8_arg().into_vec(), + "argv bytes were altered before reaching the wrapped command" + ); +}