From 2ede78e477f56eb2f2d15df84b67e215f5bf59d5 Mon Sep 17 00:00:00 2001 From: John Costa Date: Thu, 17 Sep 2026 09:49:44 -0700 Subject: [PATCH 1/2] nohup: make the substitute stdin unreadable like GNU When stdin is a terminal, nohup replaces it with /dev/null. It was opened read-only, so a command that went on to read stdin got a silent EOF and exited 0 (`nohup cat`, `nohup date --file -`). GNU opens /dev/null write-only on purpose: "Make the substitute file descriptor unreadable, so that commands that mistakenly attempt to read from standard input can report an error." Do the same, so such a read fails with EBADF and the command exits 1 as it does under GNU nohup. Fixes #14556 --- src/uu/nohup/src/platform/unix.rs | 9 +++++++-- tests/by-util/test_nohup.rs | 32 +++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/uu/nohup/src/platform/unix.rs b/src/uu/nohup/src/platform/unix.rs index daefc83ae86..2f6f890a638 100644 --- a/src/uu/nohup/src/platform/unix.rs +++ b/src/uu/nohup/src/platform/unix.rs @@ -5,7 +5,7 @@ // spell-checker:ignore (ToDO) SIGHUP cproc vprocmgr homeout -use std::fs::{File, OpenOptions}; +use std::fs::OpenOptions; use std::io::{Error, IsTerminal as _}; use std::os::unix::{fs::OpenOptionsExt as _, process::CommandExt as _}; use std::process::Command; @@ -69,7 +69,12 @@ pub(crate) fn set_output_file_mode(opt: &mut OpenOptions) { fn replace_fds() -> UResult<()> { use rustix::stdio::{dup2_stderr, dup2_stdin, dup2_stdout, stdout}; if std::io::stdin().is_terminal() { - let new_stdin = File::open(std::path::Path::new("/dev/null")) + // Open /dev/null write-only so the substitute stdin is unreadable, as + // GNU does: a command that mistakenly reads from it gets an error + // instead of a silent EOF. + let new_stdin = OpenOptions::new() + .write(true) + .open("/dev/null") .map_err(|e| PlatformError::CannotReplace("STDIN", e))?; dup2_stdin(&new_stdin).map_err(|e| PlatformError::CannotReplace("STDIN", e.into()))?; } diff --git a/tests/by-util/test_nohup.rs b/tests/by-util/test_nohup.rs index cf6e57649b8..fc9bb38fead 100644 --- a/tests/by-util/test_nohup.rs +++ b/tests/by-util/test_nohup.rs @@ -82,6 +82,38 @@ fn test_nohup_with_pseudo_terminal_emulation_on_stdin_stdout_stderr_get_replaced // When stdin is not a TTY (e.g., a pipe), nohup preserves it. // This behavior is already tested indirectly through other tests. +// When stdin is a terminal, the replacement must be unreadable so that a +// command which mistakenly reads from it gets an error instead of a silent +// EOF (GNU opens /dev/null write-only). Since nohup execs the command, the +// exit status is the command's own. +#[test] +#[cfg(any( + target_vendor = "apple", + target_os = "linux", + target_os = "android", + target_os = "freebsd", + target_os = "openbsd" +))] +fn test_nohup_replaced_stdin_is_not_readable() { + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; + + ts.ucmd() + .terminal_simulation(true) + .arg("cat") + .fails_with_code(1) + .stderr_contains("nohup: ignoring input and appending output to 'nohup.out'"); + + sleep(std::time::Duration::from_millis(10)); + + // cat's error message goes to stderr, which nohup redirected into nohup.out + let content = std::fs::read_to_string(at.plus_as_string("nohup.out")).unwrap(); + assert!( + content.contains("Bad file descriptor"), + "expected a read error from cat in nohup.out, got: {content:?}" + ); +} + // Test that nohup creates nohup.out in current directory #[test] #[cfg(any( From 9a486e21d51017a0c6b60c1df2a0a9dd7b38a876 Mon Sep 17 00:00:00 2001 From: John Costa Date: Fri, 18 Sep 2026 10:15:12 -0700 Subject: [PATCH 2/2] nohup: gate the unreadable-stdin test on cfg(unix) --- tests/by-util/test_nohup.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/tests/by-util/test_nohup.rs b/tests/by-util/test_nohup.rs index fc9bb38fead..e475e861307 100644 --- a/tests/by-util/test_nohup.rs +++ b/tests/by-util/test_nohup.rs @@ -87,13 +87,7 @@ fn test_nohup_with_pseudo_terminal_emulation_on_stdin_stdout_stderr_get_replaced // EOF (GNU opens /dev/null write-only). Since nohup execs the command, the // exit status is the command's own. #[test] -#[cfg(any( - target_vendor = "apple", - target_os = "linux", - target_os = "android", - target_os = "freebsd", - target_os = "openbsd" -))] +#[cfg(unix)] fn test_nohup_replaced_stdin_is_not_readable() { let ts = TestScenario::new(util_name!()); let at = &ts.fixtures;