From 3aeffe08e345283bd4b4d13a46ba69ad889ffaff Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sun, 8 Mar 2026 22:28:12 +0100 Subject: [PATCH 1/6] gnu test: get the version for our tests --- util/gnu-patches/series | 1 + util/gnu-patches/tests_help_help-version.patch | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 util/gnu-patches/tests_help_help-version.patch diff --git a/util/gnu-patches/series b/util/gnu-patches/series index 96e46b7eb97..c5798c1bbb3 100644 --- a/util/gnu-patches/series +++ b/util/gnu-patches/series @@ -14,3 +14,4 @@ tests_numfmt.patch tests_pwd-long.patch tests_getopt_vs_usage.patch tests_usage_vs_refs.patch +tests_help_help-version.patch diff --git a/util/gnu-patches/tests_help_help-version.patch b/util/gnu-patches/tests_help_help-version.patch new file mode 100644 index 00000000000..235ccb5d663 --- /dev/null +++ b/util/gnu-patches/tests_help_help-version.patch @@ -0,0 +1,17 @@ +Index: gnu/tests/help/help-version.sh +=================================================================== +--- gnu.orig/tests/help/help-version.sh ++++ gnu/tests/help/help-version.sh +@@ -56,9 +56,9 @@ expected_failure_status_fgrep=2 + test "$built_programs" \ + || fail_ "built_programs not specified!?!" + +-test "$VERSION" \ +- || fail_ "set envvar VERSION; it is required for a PATH sanity-check" +- ++# Extract VERSION dynamically from first program's output for uutils ++for i in $built_programs; do ++ VERSION=$(env $i --version | sed -n '1s/.* //p;q'); break; done + # Extract version from --version output of the first program + for i in $built_programs; do + v=$(env $i --version | sed -n '1s/.* //p;q') From 578df99f2591c9fef1fce89922c0bd01fd30c6e1 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Mon, 9 Mar 2026 22:29:43 +0100 Subject: [PATCH 2/6] uucore: return utility-specific exit code when --help/--version write fails --- src/uucore/src/lib/mods/clap_localization.rs | 39 +++++++++++++++++++- src/uucore/src/lib/mods/error.rs | 26 ++++++++++--- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/src/uucore/src/lib/mods/clap_localization.rs b/src/uucore/src/lib/mods/clap_localization.rs index 1048d11d27c..9c01c853c96 100644 --- a/src/uucore/src/lib/mods/clap_localization.rs +++ b/src/uucore/src/lib/mods/clap_localization.rs @@ -12,7 +12,7 @@ //! instead of parsing error strings, providing a more robust solution. //! -use crate::error::{UResult, USimpleError}; +use crate::error::{UClapError, UResult, USimpleError}; use crate::locale::translate; use clap::error::{ContextKind, ErrorKind}; @@ -475,7 +475,10 @@ where { cmd.try_get_matches_from(itr).map_err(|e| { if e.exit_code() == 0 { - e.into() // Preserve help/version + // For help/version display, use exit_code as the write failure code so that + // if stdout is full (e.g., /dev/full), the program exits with the utility's + // expected error code rather than the default 1. + e.with_exit_code(exit_code).into() } else { let formatter = ErrorFormatter::new(crate::util_name()); let code = formatter.print_error(&e, exit_code); @@ -484,6 +487,38 @@ where }) } +/// Like [`handle_clap_result_with_exit_code`], but allows specifying separate exit codes for +/// argument parse errors and for write failures when printing help/version output. +/// +/// This is useful for utilities that use different exit codes for I/O errors vs. argument +/// parse errors (e.g., `tty` exits 3 on write errors but 2 on argument parse errors). +pub fn handle_clap_result_with_exit_codes( + cmd: Command, + itr: I, + parse_error_code: i32, + write_failure_code: i32, +) -> UResult +where + I: IntoIterator, + T: Into + Clone, +{ + cmd.try_get_matches_from(itr).map_err(|e| { + if e.exit_code() == 0 { + // For DisplayHelp/DisplayVersion, ClapErrorWrapper::code() ignores the `code` field + // and returns either 0 (success) or `write_failure_code` (on stdout write failure). + // We pass `parse_error_code` to `with_exit_code` only to satisfy the constructor; + // the actual success/failure distinction is driven by `write_failure_code`. + e.with_exit_code(parse_error_code) + .with_write_failure_code(write_failure_code) + .into() + } else { + let formatter = ErrorFormatter::new(crate::util_name()); + let code = formatter.print_error(&e, parse_error_code); + USimpleError::new(code, "") + } + }) +} + /// Handles a clap error directly with a custom exit code. /// /// This function processes a clap error and exits the program with the specified diff --git a/src/uucore/src/lib/mods/error.rs b/src/uucore/src/lib/mods/error.rs index 1f513efcc1a..d3dcc5329ae 100644 --- a/src/uucore/src/lib/mods/error.rs +++ b/src/uucore/src/lib/mods/error.rs @@ -765,10 +765,22 @@ impl From for Box { #[derive(Debug)] pub struct ClapErrorWrapper { code: i32, + write_failure_code: i32, error: clap::Error, print_failed: Cell, } +impl ClapErrorWrapper { + /// Override the exit code to use when writing help/version output fails (e.g., /dev/full). + /// + /// By default this matches `code`, but some utilities use different exit codes for I/O errors + /// vs. argument parse errors (e.g., `tty` exits 3 on write errors, 2 on parse errors). + pub fn with_write_failure_code(mut self, code: i32) -> Self { + self.write_failure_code = code; + self + } +} + /// Extension trait for `clap::Error` to adjust the exit code. pub trait UClapError { /// Set the exit code for the program if `uumain` returns `Ok(())`. @@ -779,6 +791,7 @@ impl From for Box { fn from(e: clap::Error) -> Self { Box::new(ClapErrorWrapper { code: 1, + write_failure_code: 1, error: e, print_failed: Cell::new(false), }) @@ -789,6 +802,7 @@ impl UClapError for clap::Error { fn with_exit_code(self, code: i32) -> ClapErrorWrapper { ClapErrorWrapper { code, + write_failure_code: code, error: self, print_failed: Cell::new(false), } @@ -806,11 +820,16 @@ impl UClapError> impl UError for ClapErrorWrapper { fn code(&self) -> i32 { // If the error is a DisplayHelp or DisplayVersion variant, - // check if printing failed. If it did, return 1, otherwise 0. + // check if printing failed. If it did, return the utility-specific write failure code, + // otherwise 0 (success). if let clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion = self.error.kind() { - i32::from(self.print_failed.get()) + if self.print_failed.get() { + self.write_failure_code + } else { + 0 + } } else { self.code } @@ -831,9 +850,6 @@ impl Display for ClapErrorWrapper { // Try to display this error to stderr, but ignore if that fails too // since we're already in an error state. let _ = writeln!(std::io::stderr(), "{}: {print_fail}", crate::util_name()); - // Mirror GNU behavior: when failing to print help or version, exit with error code. - // This avoids silent failures when stdout is full or closed. - set_exit_code(1); } // Always return Ok(()) to satisfy Display's contract and prevent panic Ok(()) From 641a0989b3c7e96019d356fb5201651101b0da37 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Mon, 9 Mar 2026 22:29:49 +0100 Subject: [PATCH 3/6] tty: exit 3 on write failure, exit 2 on parse error --- src/uu/tty/src/tty.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/uu/tty/src/tty.rs b/src/uu/tty/src/tty.rs index 21ff1eef961..80c01cd71c8 100644 --- a/src/uu/tty/src/tty.rs +++ b/src/uu/tty/src/tty.rs @@ -18,7 +18,10 @@ mod options { #[uucore::main(no_signals)] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - let matches = uucore::clap_localization::handle_clap_result_with_exit_code(uu_app(), args, 2)?; + // tty exits 2 on argument parse errors but 3 on write errors (consistent with its + // normal write-error behavior on line output failures). + let matches = + uucore::clap_localization::handle_clap_result_with_exit_codes(uu_app(), args, 2, 3)?; // Disable SIGPIPE so we can handle broken pipe errors gracefully // and exit with code 3 instead of being killed by the signal. From ad89c50f17edb94e2e94bdf49f8870ac33e173a1 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Mon, 9 Mar 2026 22:29:53 +0100 Subject: [PATCH 4/6] expr: exit 3 on write failure for --help/--version --- src/uu/expr/src/expr.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/uu/expr/src/expr.rs b/src/uu/expr/src/expr.rs index d84eab9a525..3b31114ff6e 100644 --- a/src/uu/expr/src/expr.rs +++ b/src/uu/expr/src/expr.rs @@ -131,8 +131,18 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let mut args = &args[..]; match args { - [a] if a == b"--help" => uu_app().print_help()?, - [a] if a == b"--version" => writeln!(stdout(), "expr {}", uucore::crate_version!())?, + [a] if a == b"--help" || a == b"--version" => { + let res = if a == b"--help" { + uu_app().print_help() + } else { + writeln!(stdout(), "expr {}", uucore::crate_version!()) + }; + // expr uses exit status 3 for write errors + if let Err(e) = res { + show_error!("{}", strip_errno(&e)); + return Err(3.into()); + } + } _ => { // ignore -- as the 1st argument if let [a, rest @ ..] = args From bfb37a070312f7a26a46e59788b98a4cb738d379 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Mon, 9 Mar 2026 22:29:57 +0100 Subject: [PATCH 5/6] env, test: use correct exit codes on --help/--version write failure (125, 2) --- src/uu/env/src/env.rs | 8 ++++++-- src/uu/test/src/test.rs | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/uu/env/src/env.rs b/src/uu/env/src/env.rs index 02505edbed5..4288ad695cb 100644 --- a/src/uu/env/src/env.rs +++ b/src/uu/env/src/env.rs @@ -43,7 +43,9 @@ use std::mem::zeroed; use std::os::unix::ffi::OsStrExt; use uucore::display::{Quotable, print_all_env_vars}; -use uucore::error::{ExitCode, UError, UResult, USimpleError, UUsageError, strip_errno}; +use uucore::error::{ + ExitCode, UClapError, UError, UResult, USimpleError, UUsageError, strip_errno, +}; use uucore::line_ending::LineEnding; #[cfg(all(unix, not(target_os = "fuchsia")))] use uucore::signals::{ @@ -736,7 +738,9 @@ impl EnvAppData { Err(e) => { match e.kind() { clap::error::ErrorKind::DisplayHelp - | clap::error::ErrorKind::DisplayVersion => return Err(e.into()), + | clap::error::ErrorKind::DisplayVersion => { + return Err(e.with_exit_code(125).into()); + } _ => { // Use ErrorFormatter directly to handle error with shebang message callback let formatter = uucore::clap_localization::ErrorFormatter::new("env"); diff --git a/src/uu/test/src/test.rs b/src/uu/test/src/test.rs index 01f477ed62c..4f4b562f94f 100644 --- a/src/uu/test/src/test.rs +++ b/src/uu/test/src/test.rs @@ -58,9 +58,10 @@ pub fn uumain(mut args: impl uucore::Args) -> UResult<()> { if binary_name.ends_with('[') { // If invoked as [ we should recognize --help and --version (but not -h or -v) if args.len() == 1 && (args[0] == "--help" || args[0] == "--version") { - uucore::clap_localization::handle_clap_result( + uucore::clap_localization::handle_clap_result_with_exit_code( uu_app(), std::iter::once(program).chain(args.into_iter()), + 2, )?; return Ok(()); } From b974c94850fa6265940fc66bd21e4929fbf7c1ce Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Mon, 9 Mar 2026 22:38:01 +0100 Subject: [PATCH 6/6] tests: verify --help/--version exit codes on /dev/full --- tests/by-util/test_env.rs | 13 +++++++++++++ tests/by-util/test_expr.rs | 10 ++++++++++ tests/by-util/test_ls.rs | 10 ++++++++++ tests/by-util/test_sort.rs | 10 ++++++++++ tests/by-util/test_test.rs | 12 ++++++++++++ tests/by-util/test_tty.rs | 10 ++++++++++ 6 files changed, 65 insertions(+) diff --git a/tests/by-util/test_env.rs b/tests/by-util/test_env.rs index e550450aa5f..707964ddc4f 100644 --- a/tests/by-util/test_env.rs +++ b/tests/by-util/test_env.rs @@ -2186,3 +2186,16 @@ env: no terminating quote in -S string at position 18 for quote ''' .stderr_is("env: no terminating quote in -S string at position 18 for quote '''\n"); } } + +#[test] +#[cfg(target_os = "linux")] +fn test_help_version_dev_full_exit_code() { + use std::fs::OpenOptions; + for arg in ["--help", "--version"] { + let dev_full = OpenOptions::new().write(true).open("/dev/full").unwrap(); + new_ucmd!() + .arg(arg) + .set_stdout(dev_full) + .fails_with_code(125); + } +} diff --git a/tests/by-util/test_expr.rs b/tests/by-util/test_expr.rs index 9cb2a118d9c..595f885861a 100644 --- a/tests/by-util/test_expr.rs +++ b/tests/by-util/test_expr.rs @@ -2251,3 +2251,13 @@ fn test_exit_with_3_write_error() { .fails_with_code(3) .stderr_is("expr: No space left on device\n"); } + +#[test] +#[cfg(target_os = "linux")] +fn test_help_version_dev_full_exit_code() { + use std::fs::OpenOptions; + for arg in ["--help", "--version"] { + let dev_full = OpenOptions::new().write(true).open("/dev/full").unwrap(); + new_ucmd!().arg(arg).set_stdout(dev_full).fails_with_code(3); + } +} diff --git a/tests/by-util/test_ls.rs b/tests/by-util/test_ls.rs index 73df1d27e35..519af14e09e 100644 --- a/tests/by-util/test_ls.rs +++ b/tests/by-util/test_ls.rs @@ -8325,3 +8325,13 @@ fn test_time_style_ambiguous_and_invalid_prefixes() { .stderr_contains("invalid --time-style argument"); } } + +#[test] +#[cfg(target_os = "linux")] +fn test_help_version_dev_full_exit_code() { + use std::fs::OpenOptions; + for arg in ["--help", "--version"] { + let dev_full = OpenOptions::new().write(true).open("/dev/full").unwrap(); + new_ucmd!().arg(arg).set_stdout(dev_full).fails_with_code(2); + } +} diff --git a/tests/by-util/test_sort.rs b/tests/by-util/test_sort.rs index e381080f838..006092124b4 100644 --- a/tests/by-util/test_sort.rs +++ b/tests/by-util/test_sort.rs @@ -3886,3 +3886,13 @@ sort: invalid suffix in --buffer-size argument '8zz' } // spell-checker:enable + +#[test] +#[cfg(target_os = "linux")] +fn test_help_version_dev_full_exit_code() { + use std::fs::OpenOptions; + for arg in ["--help", "--version"] { + let dev_full = OpenOptions::new().write(true).open("/dev/full").unwrap(); + new_ucmd!().arg(arg).set_stdout(dev_full).fails_with_code(2); + } +} diff --git a/tests/by-util/test_test.rs b/tests/by-util/test_test.rs index 08675961896..79118a73735 100644 --- a/tests/by-util/test_test.rs +++ b/tests/by-util/test_test.rs @@ -1553,3 +1553,15 @@ test: invalid integer 'stdout' ); } } + +#[test] +#[cfg(target_os = "linux")] +fn test_lbracket_help_dev_full_exit_code() { + use std::fs::OpenOptions; + let dev_full = OpenOptions::new().write(true).open("/dev/full").unwrap(); + TestScenario::new("[") + .ucmd() + .arg("--help") + .set_stdout(dev_full) + .fails_with_code(2); +} diff --git a/tests/by-util/test_tty.rs b/tests/by-util/test_tty.rs index 5103a60efac..e3a6d953e0d 100644 --- a/tests/by-util/test_tty.rs +++ b/tests/by-util/test_tty.rs @@ -115,3 +115,13 @@ fn test_write_error() { .fails() .stderr_is("tty: No space left on device\n"); } + +#[test] +#[cfg(target_os = "linux")] +fn test_help_version_dev_full_exit_code() { + use std::fs::OpenOptions; + for arg in ["--help", "--version"] { + let dev_full = OpenOptions::new().write(true).open("/dev/full").unwrap(); + new_ucmd!().arg(arg).set_stdout(dev_full).fails_with_code(3); + } +}