diff --git a/Cargo.lock b/Cargo.lock index f804fed..766934a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -869,6 +869,7 @@ dependencies = [ "clap_mangen", "dns-lookup", "libc", + "nix", "phf", "phf_codegen", "pretty_assertions", @@ -884,6 +885,7 @@ dependencies = [ "uu_lsmem", "uu_mountpoint", "uu_rev", + "uu_setsid", "uucore", "xattr", ] @@ -942,6 +944,15 @@ dependencies = [ "uucore", ] +[[package]] +name = "uu_setsid" +version = "0.0.1" +dependencies = [ + "clap", + "libc", + "uucore", +] + [[package]] name = "uucore" version = "0.0.27" diff --git a/Cargo.toml b/Cargo.toml index 4d7050e..d91e4aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ feat_common_core = [ "lsmem", "ctrlaltdel", "rev", + "setsid", "last" ] @@ -46,6 +47,7 @@ phf = "0.11.2" phf_codegen = "0.11.2" textwrap = { version = "0.16.0", features = ["terminal_size"] } xattr = "1.3.1" +nix = { version = "0.28", default-features = false } tempfile = "3.9.0" rand = { version = "0.8", features = ["small_rng"] } serde = { version = "1.0", features = ["derive"] } @@ -68,6 +70,7 @@ lsmem = { optional = true, version = "0.0.1", package = "uu_lsmem", path = "src/ mountpoint = { optional = true, version = "0.0.1", package = "uu_mountpoint", path = "src/uu/mountpoint" } ctrlaltdel = { optional = true, version = "0.0.1", package = "uu_ctrlaltdel", path = "src/uu/ctrlaltdel" } rev = { optional = true, version = "0.0.1", package = "uu_rev", path = "src/uu/rev" } +setsid = { optional = true, version = "0.0.1", package = "uu_setsid", path ="src/uu/setsid" } last = { optional = true, version = "0.0.1", package = "uu_last", path = "src/uu/last" } [dev-dependencies] @@ -80,6 +83,7 @@ uucore = { workspace = true, features = ["entries", "process", "signals"] } [target.'cfg(unix)'.dev-dependencies] xattr = { workspace = true } +nix = { workspace = true, features = ["term"] } [target.'cfg(any(target_os = "linux", target_os = "android"))'.dev-dependencies] procfs = { version = "0.17", default-features = false } diff --git a/src/uu/setsid/Cargo.toml b/src/uu/setsid/Cargo.toml new file mode 100644 index 0000000..9889d5c --- /dev/null +++ b/src/uu/setsid/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "uu_setsid" +version = "0.0.1" +edition = "2021" + +[dependencies] +uucore = { workspace = true } +libc = { workspace = true } +clap = { workspace = true } + +[lib] +path = "src/setsid.rs" + +[[bin]] +name = "setsid" +path = "src/main.rs" diff --git a/src/uu/setsid/setsid.md b/src/uu/setsid/setsid.md new file mode 100644 index 0000000..dbcb937 --- /dev/null +++ b/src/uu/setsid/setsid.md @@ -0,0 +1,7 @@ +# setsid + +``` +setsid [options] <program> [argument ...] +``` + +Run a program in a new session. diff --git a/src/uu/setsid/src/main.rs b/src/uu/setsid/src/main.rs new file mode 100644 index 0000000..916937a --- /dev/null +++ b/src/uu/setsid/src/main.rs @@ -0,0 +1 @@ +uucore::bin!(uu_setsid); diff --git a/src/uu/setsid/src/setsid.rs b/src/uu/setsid/src/setsid.rs new file mode 100644 index 0000000..a4d99e5 --- /dev/null +++ b/src/uu/setsid/src/setsid.rs @@ -0,0 +1,199 @@ +// This file is part of the uutils util-linux package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use clap::builder::ValueParser; +use clap::{crate_version, Command}; +use clap::{Arg, ArgAction}; +use uucore::{error::UResult, format_usage, help_about, help_usage}; + +const ABOUT: &str = help_about!("setsid.md"); +const USAGE: &str = help_usage!("setsid.md"); + +#[cfg(target_family = "unix")] +mod unix { + pub use std::ffi::{OsStr, OsString}; + pub use std::os::unix::process::CommandExt; + pub use std::{io, process}; + pub use uucore::error::{FromIo, UIoError, UResult}; + + // The promise made by setsid(1) is that it forks if the process + // is already a group leader, not session leader. + pub fn already_group_leader() -> bool { + let leader = unsafe { libc::getpgrp() }; + leader == process::id() as i32 + } + + pub fn report_failure_to_exec(error: io::Error, executable: &OsStr, set_error: bool) { + let kind = error.kind(); + + // FIXME: POSIX wants certain exit statuses for specific errors, should + // these be handled by uucore::error? We should be able to just return + // the UError here. + uucore::show_error!( + "failed to execute {}: {}", + executable.to_string_lossy(), + UIoError::from(error) + ); + + if set_error { + if kind == io::ErrorKind::NotFound { + uucore::error::set_exit_code(127); + } else if kind == io::ErrorKind::PermissionDenied { + uucore::error::set_exit_code(126); + } + } + } + + // This function will be potentially called after a fork(), so what it can do + // is quite restricted. This is the meat of the program. + pub fn prepare_child(take_controlling_tty: bool) -> io::Result<()> { + // SAFETY: this is effectively a wrapper to the setsid syscall. + let pid = unsafe { libc::setsid() }; + + // We fork if we are already a group leader, so an error + // here should be impossible. + assert_eq!(pid, process::id() as i32); + + // On some platforms (i.e. aarch64 Linux) TIOCSCTTY is the same type as the second argument, + // but on some it is u64, while the expected type is u32. + // SAFETY: the ioctl should not make any changes to memory, basically a wrapper + // to the syscall. + #[allow(clippy::useless_conversion)] + if take_controlling_tty && unsafe { libc::ioctl(0, libc::TIOCSCTTY.into(), 1) } < 0 { + // This is unfortunate, but we are bound by the Result type pre_exec requires, + // as well as the limitations imposed by this being executed post-fork(). + // Ideally we would return an io::Error of the Other kind so that we could handle + // everything at the same place, but that would require an allocation. + uucore::show_error!( + "failed to set the controlling terminal: {}", + UIoError::from(io::Error::last_os_error()) + ); + + // SAFETY: this is actually safer than calling process::exit(), as that may + // allocate, which is not safe post-fork. + unsafe { libc::_exit(1) }; + } + Ok(()) + } + + pub fn spawn_command( + mut to_run: process::Command, + executable: &OsStr, + wait_child: bool, + ) -> UResult<()> { + let mut child = match to_run.spawn() { + Ok(child) => child, + Err(error) => { + report_failure_to_exec(error, executable, wait_child); + return Ok(()); + } + }; + + if !wait_child { + return Ok(()); + } + + match child.wait() { + Ok(status) => { + uucore::error::set_exit_code(status.code().unwrap()); + Ok(()) + } + Err(error) => { + Err(error.map_err_context(|| format!("failed to wait on PID {}", child.id()))) + } + } + } +} + +#[cfg(target_family = "unix")] +use unix::*; + +#[cfg(target_family = "unix")] +#[uucore::main] +pub fn uumain(args: impl uucore::Args) -> UResult<()> { + let matches: clap::ArgMatches = uu_app().try_get_matches_from(args)?; + + let force_fork = matches.get_flag("fork"); + let wait_child = matches.get_flag("wait"); + let take_controlling_tty = matches.get_flag("ctty"); + + let command: Vec<_> = match matches.get_many::<OsString>("command") { + Some(v) => v.collect(), + None => return Err(uucore::error::USimpleError::new(1, "no command specified")), + }; + + // We know we have at least one item, as none was + // handled as an error on the match above. + let executable = command[0]; + let arguments = command.get(1..).unwrap_or(&[]); + + let mut to_run = process::Command::new(executable); + to_run.args(arguments.iter()); + + // SAFETY: pre_exec() happens post-fork, so the process can potentially + // be in a broken state; allocations are not safe, and we should exit + // as soon as possible if we cannot go ahead. + unsafe { + to_run.pre_exec(move || prepare_child(take_controlling_tty)); + }; + + if force_fork || already_group_leader() { + spawn_command(to_run, executable, wait_child)?; + } else { + let error = to_run.exec(); + report_failure_to_exec(error, executable, true); + } + + Ok(()) +} + +#[cfg(not(target_family = "unix"))] +#[uucore::main] +pub fn uumain(args: impl uucore::Args) -> UResult<()> { + let _matches: clap::ArgMatches = uu_app().try_get_matches_from(args)?; + + Err(uucore::error::USimpleError::new( + 1, + "`setsid` is unavailable on non-UNIX-like platforms.", + )) +} + +pub fn uu_app() -> Command { + Command::new(uucore::util_name()) + .version(crate_version!()) + .about(ABOUT) + .override_usage(format_usage(USAGE)) + .infer_long_args(true) + .arg( + Arg::new("ctty") + .short('c') + .action(ArgAction::SetTrue) + .value_parser(ValueParser::bool()) + .help("Take the current controlling terminal"), + ) + .arg( + Arg::new("fork") + .short('f') + .action(ArgAction::SetTrue) + .value_parser(ValueParser::bool()) + .long_help("Always create a new process. By default this is only done if we are already a process group lead."), + ) + .arg( + Arg::new("wait") + .short('w') + .action(ArgAction::SetTrue) + .value_parser(ValueParser::bool()) + .help("Wait for the command to finish and exit with its exit code."), + ) + .arg( + Arg::new("command") + .help("Program to be executed, followed by its arguments") + .index(1) + .action(ArgAction::Set) + .trailing_var_arg(true) + .value_parser(ValueParser::os_string()) + .num_args(1..), + ) +} diff --git a/tests/by-util/test_setsid.rs b/tests/by-util/test_setsid.rs new file mode 100644 index 0000000..cf3d159 --- /dev/null +++ b/tests/by-util/test_setsid.rs @@ -0,0 +1,174 @@ +// This file is part of the uutils util-linux package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +#[cfg(target_family = "unix")] +mod unix { + use crate::common::util::{TestScenario, UCommand, TESTS_BINARY}; + + #[test] + fn test_invalid_arg() { + new_ucmd!().arg("--definitely-invalid").fails().code_is(1); + + new_ucmd!() + .arg("-f") + .fails() + .code_is(1) + .stderr_is("setsid: no command specified\n"); + } + + #[test] + fn fork_isolates_child_exit_code() { + new_ucmd!() + .arg("-f") + .arg("/usr/bin/false") + .succeeds() + .no_output(); + } + + #[test] + fn non_fork_returns_child_exit_code() { + new_ucmd!() + .arg("/usr/bin/false") + .fails() + .code_is(1) + .no_output(); + } + + #[test] + fn fork_wait_returns_child_exit_code() { + new_ucmd!() + .arg("-f") + .arg("-w") + .arg("/usr/bin/false") + .fails() + .code_is(1) + .no_output(); + } + + #[test] + fn non_fork_returns_not_found_error() { + new_ucmd!() + .arg("/usr/bin/this-tool-does-not-exist-hopefully") + .fails() + .code_is(127) + .stderr_is("setsid: failed to execute /usr/bin/this-tool-does-not-exist-hopefully: No such file or directory\n"); + } + + #[test] + fn non_fork_on_non_executable_returns_permission_denied_error() { + new_ucmd!() + .arg("/etc/passwd") + .fails() + .code_is(126) + .stderr_is("setsid: failed to execute /etc/passwd: Permission denied\n"); + } + + #[test] + fn fork_isolates_not_found_error() { + new_ucmd!() + .arg("-f") + .arg("/usr/bin/this-tool-does-not-exist-hopefully") + .succeeds(); + // no test for output, as it's a race whether the not found error gets printed + // quickly enough, potential flakyness + } + + #[test] + fn unprivileged_user_cannot_steal_controlling_tty() { + let shell_cmd = + format!("{TESTS_BINARY} setsid -w -c {TESTS_BINARY} setsid -w -c /b/usrin/true"); + UCommand::new() + .terminal_simulation(true) + .arg(&shell_cmd) + .fails() + .code_is(1) + .no_stdout() + .stderr_is("setsid: failed to set the controlling terminal: Permission denied\r\n"); + } + + #[cfg(target_os = "linux")] + #[test] + fn unprivileged_user_can_take_new_controlling_tty() { + let shell_cmd = format!( + "/usr/bin/cat /proc/self/stat; {TESTS_BINARY} setsid -w -c /usr/bin/cat /proc/self/stat" + ); + + let cmd_result = UCommand::new() + .terminal_simulation(true) + .arg(&shell_cmd) + .succeeds(); + + let output = cmd_result.code_is(0).no_stderr().stdout_str(); + + // /proc/self/stat format has controlling terminal as the 7th + // space-separated item; if we managed to change the controlling + // terminal, we should see a difference there + let (before, after) = output + .split_once('\n') + .expect("expected 2 lines of output at least"); + let before = before + .split_whitespace() + .nth(6) + .expect("unexpected stat format"); + let after = after + .split_whitespace() + .nth(6) + .expect("unexpected stat format"); + + assert_ne!(before, after); + } + + #[cfg(target_os = "linux")] + #[test] + fn setsid_takes_session_leadership() { + let shell_cmd = format!( + "/usr/bin/cat /proc/self/stat; {TESTS_BINARY} setsid /usr/bin/cat /proc/self/stat" + ); + + let cmd_result = UCommand::new() + .terminal_simulation(true) + .arg(&shell_cmd) + .run(); + + let output = cmd_result.code_is(0).no_stderr().stdout_str(); + + // /proc/self/stat format has sessiion ID as the 6th space-separated + // item; if we managed to get session leadership, we should see a + // difference there... + let (before, after) = output + .split_once('\n') + .expect("expected 2 lines of output at least"); + let before = before + .split_whitespace() + .nth(5) + .expect("unexpected stat format"); + let after = after + .split_whitespace() + .nth(5) + .expect("unexpected stat format"); + + assert_ne!(before, after); + + // ...and it should actually be the PID of our child! We take the child + // PID here to avoid differences in handling by different shells or + // distributions. + let pid = after.split_whitespace().next().unwrap(); + assert_eq!(after, pid); + } +} + +#[cfg(not(target_family = "unix"))] +mod non_unix { + use crate::common::util::{TestScenario, UCommand}; + + #[test] + fn unsupported_platforms() { + new_ucmd!() + .arg("/usr/bin/true") + .fails() + .code_is(1) + .stderr_is("setsid: `setsid` is unavailable on non-UNIX-like platforms.\n"); + } +} diff --git a/tests/common/util.rs b/tests/common/util.rs index 4b66b85..0063454 100644 --- a/tests/common/util.rs +++ b/tests/common/util.rs @@ -3,10 +3,12 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -//spell-checker: ignore (linux) rlimit prlimit coreutil ggroups uchild uncaptured scmd SHLVL canonicalized +//spell-checker: ignore (linux) rlimit prlimit coreutil ggroups uchild uncaptured scmd SHLVL canonicalized openpty winsize xpixel ypixel #![allow(dead_code, unexpected_cfgs)] +#[cfg(unix)] +use nix::pty::OpenptyResult; use pretty_assertions::assert_eq; #[cfg(any(target_os = "linux", target_os = "android"))] use rlimit::prlimit; @@ -19,6 +21,8 @@ use std::ffi::{OsStr, OsString}; use std::fs::{self, hard_link, remove_file, File, OpenOptions}; use std::io::{self, BufWriter, Read, Result, Write}; #[cfg(unix)] +use std::os::fd::OwnedFd; +#[cfg(unix)] use std::os::unix::fs::{symlink as symlink_dir, symlink as symlink_file, PermissionsExt}; #[cfg(unix)] use std::os::unix::process::ExitStatusExt; @@ -32,7 +36,7 @@ use std::rc::Rc; use std::sync::mpsc::{self, RecvTimeoutError}; use std::thread::{sleep, JoinHandle}; use std::time::{Duration, Instant}; -use std::{env, hint, thread}; +use std::{env, hint, mem, thread}; use tempfile::{Builder, TempDir}; static TESTS_DIR: &str = "tests"; @@ -44,6 +48,7 @@ static ALREADY_RUN: &str = " you have already run this UCommand, if you want to static MULTIPLE_STDIN_MEANINGLESS: &str = "Ucommand is designed around a typical use case of: provide args and input stream -> spawn process -> block until completion -> return output streams. For verifying that a particular section of the input stream is what causes a particular behavior, use the Command type directly."; static NO_STDIN_MEANINGLESS: &str = "Setting this flag has no effect if there is no stdin"; +static END_OF_TRANSMISSION_SEQUENCE: &[u8] = &[b'\n', 0x04]; pub const TESTS_BINARY: &str = env!("CARGO_BIN_EXE_util-linux"); pub const PATH: &str = env!("PATH"); @@ -385,7 +390,10 @@ impl CmdResult { pub fn success(&self) -> &Self { assert!( self.succeeded(), - "Command was expected to succeed.\nstdout = {}\n stderr = {}", + "Command was expected to succeed. Exit code: {}.\nstdout = {}\n stderr = {}", + self.exit_status() + .code() + .map_or("n/a".to_string(), |code| code.to_string()), self.stdout_str(), self.stderr_str() ); @@ -1218,6 +1226,10 @@ pub struct UCommand { limits: Vec<(rlimit::Resource, u64, u64)>, stderr_to_stdout: bool, timeout: Option<Duration>, + #[cfg(unix)] + terminal_simulation: bool, + #[cfg(unix)] + terminal_size: Option<libc::winsize>, tmpd: Option<Rc<TempDir>>, // drop last } @@ -1396,6 +1408,68 @@ impl UCommand { self } + /// Set if process should be run in a simulated terminal + /// + /// This is useful to test behavior that is only active if [`stdout.is_terminal()`] is [`true`]. + /// (unix: pty, windows: ConPTY[not yet supported]) + #[cfg(unix)] + pub fn terminal_simulation(&mut self, enable: bool) -> &mut Self { + self.terminal_simulation = enable; + self + } + + /// Set if process should be run in a simulated terminal with specific size + /// + /// This is useful to test behavior that is only active if [`stdout.is_terminal()`] is [`true`]. + /// And the size of the terminal matters additionally. + #[cfg(unix)] + pub fn terminal_size(&mut self, win_size: libc::winsize) -> &mut Self { + self.terminal_simulation(true); + self.terminal_size = Some(win_size); + self + } + + #[cfg(unix)] + fn read_from_pty(pty_fd: std::os::fd::OwnedFd, out: File) { + let read_file = std::fs::File::from(pty_fd); + let mut reader = std::io::BufReader::new(read_file); + let mut writer = std::io::BufWriter::new(out); + let result = std::io::copy(&mut reader, &mut writer); + match result { + Ok(_) => {} + // Input/output error (os error 5) is returned due to pipe closes. Buffer gets content anyway. + Err(e) if e.raw_os_error().unwrap_or_default() == 5 => {} + Err(e) => { + eprintln!("Unexpected error: {:?}", e); + panic!("error forwarding output of pty"); + } + } + } + + #[cfg(unix)] + fn spawn_reader_thread( + &self, + captured_output: Option<CapturedOutput>, + pty_fd_master: OwnedFd, + name: String, + ) -> Option<CapturedOutput> { + if let Some(mut captured_output_i) = captured_output { + let fd = captured_output_i.try_clone().unwrap(); + + let handle = std::thread::Builder::new() + .name(name) + .spawn(move || { + Self::read_from_pty(pty_fd_master, fd); + }) + .unwrap(); + + captured_output_i.reader_thread_handle = Some(handle); + Some(captured_output_i) + } else { + None + } + } + /// Build the `std::process::Command` and apply the defaults on fields which were not specified /// by the user. /// @@ -1415,7 +1489,14 @@ impl UCommand { /// * `stderr_to_stdout`: `false` /// * `bytes_into_stdin`: `None` /// * `limits`: `None`. - fn build(&mut self) -> (Command, Option<CapturedOutput>, Option<CapturedOutput>) { + fn build( + &mut self, + ) -> ( + Command, + Option<CapturedOutput>, + Option<CapturedOutput>, + Option<File>, + ) { if self.bin_path.is_some() { if let Some(util_name) = &self.util_name { self.args.push_front(util_name.into()); @@ -1494,6 +1575,10 @@ impl UCommand { let mut captured_stdout = None; let mut captured_stderr = None; + #[cfg(unix)] + let mut stdin_pty: Option<File> = None; + #[cfg(not(unix))] + let stdin_pty: Option<File> = None; if self.stderr_to_stdout { let mut output = CapturedOutput::default(); @@ -1527,7 +1612,39 @@ impl UCommand { .stderr(stderr); }; - (command, captured_stdout, captured_stderr) + #[cfg(unix)] + if self.terminal_simulation { + let terminal_size = self.terminal_size.unwrap_or(libc::winsize { + ws_col: 80, + ws_row: 30, + ws_xpixel: 80 * 8, + ws_ypixel: 30 * 10, + }); + + let OpenptyResult { + slave: pi_slave, + master: pi_master, + } = nix::pty::openpty(&terminal_size, None).unwrap(); + let OpenptyResult { + slave: po_slave, + master: po_master, + } = nix::pty::openpty(&terminal_size, None).unwrap(); + let OpenptyResult { + slave: pe_slave, + master: pe_master, + } = nix::pty::openpty(&terminal_size, None).unwrap(); + + stdin_pty = Some(File::from(pi_master)); + + captured_stdout = + self.spawn_reader_thread(captured_stdout, po_master, "stdout_reader".to_string()); + captured_stderr = + self.spawn_reader_thread(captured_stderr, pe_master, "stderr_reader".to_string()); + + command.stdin(pi_slave).stdout(po_slave).stderr(pe_slave); + } + + (command, captured_stdout, captured_stderr, stdin_pty) } /// Spawns the command, feeds the stdin if any, and returns the @@ -1536,7 +1653,7 @@ impl UCommand { assert!(!self.has_run, "{}", ALREADY_RUN); self.has_run = true; - let (mut command, captured_stdout, captured_stderr) = self.build(); + let (mut command, captured_stdout, captured_stderr, stdin_pty) = self.build(); log_info("run", self.to_string()); let child = command.spawn().unwrap(); @@ -1552,7 +1669,7 @@ impl UCommand { .unwrap(); } - let mut child = UChild::from(self, child, captured_stdout, captured_stderr); + let mut child = UChild::from(self, child, captured_stdout, captured_stderr, stdin_pty); if let Some(input) = self.bytes_into_stdin.take() { child.pipe_in(input); @@ -1617,6 +1734,7 @@ impl std::fmt::Display for UCommand { struct CapturedOutput { current_file: File, output: tempfile::NamedTempFile, // drop last + reader_thread_handle: Option<thread::JoinHandle<()>>, } impl CapturedOutput { @@ -1625,6 +1743,7 @@ impl CapturedOutput { Self { current_file: output.reopen().unwrap(), output, + reader_thread_handle: None, } } @@ -1701,6 +1820,7 @@ impl Default for CapturedOutput { Self { current_file: file.reopen().unwrap(), output: file, + reader_thread_handle: None, } } } @@ -1834,6 +1954,7 @@ pub struct UChild { util_name: Option<String>, captured_stdout: Option<CapturedOutput>, captured_stderr: Option<CapturedOutput>, + stdin_pty: Option<File>, ignore_stdin_write_error: bool, stderr_to_stdout: bool, join_handle: Option<JoinHandle<io::Result<()>>>, @@ -1847,6 +1968,7 @@ impl UChild { child: Child, captured_stdout: Option<CapturedOutput>, captured_stderr: Option<CapturedOutput>, + stdin_pty: Option<File>, ) -> Self { Self { raw: child, @@ -1854,6 +1976,7 @@ impl UChild { util_name: ucommand.util_name.clone(), captured_stdout, captured_stderr, + stdin_pty, ignore_stdin_write_error: ucommand.ignore_stdin_write_error, stderr_to_stdout: ucommand.stderr_to_stdout, join_handle: None, @@ -1994,11 +2117,19 @@ impl UChild { /// error. #[deprecated = "Please use wait() -> io::Result<CmdResult> instead."] pub fn wait_with_output(mut self) -> io::Result<Output> { + // some apps do not stop execution until their stdin gets closed. + // to prevent a endless waiting here, we close the stdin. + self.join(); // ensure that all pending async input is piped in + self.close_stdin(); + let output = if let Some(timeout) = self.timeout { let child = self.raw; let (sender, receiver) = mpsc::channel(); - let handle = thread::spawn(move || sender.send(child.wait_with_output())); + let handle = thread::Builder::new() + .name("wait_with_output".to_string()) + .spawn(move || sender.send(child.wait_with_output())) + .unwrap(); match receiver.recv_timeout(timeout) { Ok(result) => { @@ -2030,9 +2161,15 @@ impl UChild { }; if let Some(stdout) = self.captured_stdout.as_mut() { + if let Some(handle) = stdout.reader_thread_handle.take() { + handle.join().unwrap(); + } output.stdout = stdout.output_bytes(); } if let Some(stderr) = self.captured_stderr.as_mut() { + if let Some(handle) = stderr.reader_thread_handle.take() { + handle.join().unwrap(); + } output.stderr = stderr.output_bytes(); } @@ -2194,6 +2331,29 @@ impl UChild { } } + fn access_stdin_as_writer<'a>(&'a mut self) -> Box<dyn Write + Send + 'a> { + if let Some(stdin_fd) = &self.stdin_pty { + Box::new(BufWriter::new(stdin_fd.try_clone().unwrap())) + } else { + let stdin: &mut std::process::ChildStdin = self.raw.stdin.as_mut().unwrap(); + Box::new(BufWriter::new(stdin)) + } + } + + fn take_stdin_as_writer(&mut self) -> Box<dyn Write + Send> { + if let Some(stdin_fd) = mem::take(&mut self.stdin_pty) { + Box::new(BufWriter::new(stdin_fd)) + } else { + let stdin = self + .raw + .stdin + .take() + .expect("Could not pipe into child process. Was it set to Stdio::null()?"); + + Box::new(BufWriter::new(stdin)) + } + } + /// Pipe data into [`Child`] stdin in a separate thread to avoid deadlocks. /// /// In contrast to [`UChild::write_in`], this method is designed to simulate a pipe on the @@ -2215,24 +2375,24 @@ impl UChild { /// [`JoinHandle`]: std::thread::JoinHandle pub fn pipe_in<T: Into<Vec<u8>>>(&mut self, content: T) -> &mut Self { let ignore_stdin_write_error = self.ignore_stdin_write_error; - let content = content.into(); - let stdin = self - .raw - .stdin - .take() - .expect("Could not pipe into child process. Was it set to Stdio::null()?"); + let mut content: Vec<u8> = content.into(); + if self.stdin_pty.is_some() { + content.append(&mut END_OF_TRANSMISSION_SEQUENCE.to_vec()); + } + let mut writer = self.take_stdin_as_writer(); - let join_handle = thread::spawn(move || { - let mut writer = BufWriter::new(stdin); - - match writer.write_all(&content).and_then(|()| writer.flush()) { - Err(error) if !ignore_stdin_write_error => Err(io::Error::new( - io::ErrorKind::Other, - format!("failed to write to stdin of child: {error}"), - )), - Ok(()) | Err(_) => Ok(()), - } - }); + let join_handle = std::thread::Builder::new() + .name("pipe_in".to_string()) + .spawn( + move || match writer.write_all(&content).and_then(|()| writer.flush()) { + Err(error) if !ignore_stdin_write_error => Err(io::Error::new( + io::ErrorKind::Other, + format!("failed to write to stdin of child: {error}"), + )), + Ok(()) | Err(_) => Ok(()), + }, + ) + .unwrap(); self.join_handle = Some(join_handle); self @@ -2275,10 +2435,11 @@ impl UChild { /// # Errors /// If [`ChildStdin::write_all`] or [`ChildStdin::flush`] returned an error pub fn try_write_in<T: Into<Vec<u8>>>(&mut self, data: T) -> io::Result<()> { - let stdin = self.raw.stdin.as_mut().unwrap(); + let ignore_stdin_write_error = self.ignore_stdin_write_error; + let mut writer = self.access_stdin_as_writer(); - match stdin.write_all(&data.into()).and_then(|()| stdin.flush()) { - Err(error) if !self.ignore_stdin_write_error => Err(io::Error::new( + match writer.write_all(&data.into()).and_then(|()| writer.flush()) { + Err(error) if !ignore_stdin_write_error => Err(io::Error::new( io::ErrorKind::Other, format!("failed to write to stdin of child: {error}"), )), @@ -2315,6 +2476,11 @@ impl UChild { /// Note, this does not have any effect if using the [`UChild::pipe_in`] method. pub fn close_stdin(&mut self) -> &mut Self { self.raw.stdin.take(); + if self.stdin_pty.is_some() { + // a pty can not be closed. We need to send a EOT: + let _ = self.try_write_in(END_OF_TRANSMISSION_SEQUENCE); + self.stdin_pty.take(); + } self } } @@ -3413,4 +3579,123 @@ mod tests { xattr::set(&file_path2, test_attr, test_value).unwrap(); assert!(compare_xattrs(&file_path1, &file_path2)); } + + #[cfg(unix)] + #[test] + fn test_simulation_of_terminal_false() { + let scene = TestScenario::new("util"); + + let out = scene.cmd("env").arg("sh").arg("is_atty.sh").succeeds(); + std::assert_eq!( + String::from_utf8_lossy(out.stdout()), + "stdin is not atty\nstdout is not atty\nstderr is not atty\n" + ); + std::assert_eq!( + String::from_utf8_lossy(out.stderr()), + "This is an error message.\n" + ); + } + + #[cfg(unix)] + #[test] + fn test_simulation_of_terminal_true() { + let scene = TestScenario::new("util"); + + let out = scene + .cmd("env") + .arg("sh") + .arg("is_atty.sh") + .terminal_simulation(true) + .succeeds(); + std::assert_eq!( + String::from_utf8_lossy(out.stdout()), + "stdin is atty\r\nstdout is atty\r\nstderr is atty\r\nterminal size: 30 80\r\n" + ); + std::assert_eq!( + String::from_utf8_lossy(out.stderr()), + "This is an error message.\r\n" + ); + } + + #[cfg(unix)] + #[test] + fn test_simulation_of_terminal_size_information() { + let scene = TestScenario::new("util"); + + let out = scene + .cmd("env") + .arg("sh") + .arg("is_atty.sh") + .terminal_size(libc::winsize { + ws_col: 40, + ws_row: 10, + ws_xpixel: 40 * 8, + ws_ypixel: 10 * 10, + }) + .succeeds(); + std::assert_eq!( + String::from_utf8_lossy(out.stdout()), + "stdin is atty\r\nstdout is atty\r\nstderr is atty\r\nterminal size: 10 40\r\n" + ); + std::assert_eq!( + String::from_utf8_lossy(out.stderr()), + "This is an error message.\r\n" + ); + } + + #[cfg(unix)] + #[test] + fn test_simulation_of_terminal_pty_sends_eot_automatically() { + let scene = TestScenario::new("util"); + + let mut cmd = scene.cmd("env"); + cmd.timeout(std::time::Duration::from_secs(10)); + cmd.args(&["cat", "-"]); + cmd.terminal_simulation(true); + let child = cmd.run_no_wait(); + let out = child.wait().unwrap(); // cat would block if there is no eot + + std::assert_eq!(String::from_utf8_lossy(out.stderr()), ""); + std::assert_eq!(String::from_utf8_lossy(out.stdout()), "\r\n"); + } + + #[cfg(unix)] + #[test] + fn test_simulation_of_terminal_pty_pipes_into_data_and_sends_eot_automatically() { + let scene = TestScenario::new("util"); + + let message = "Hello stdin forwarding!"; + + let mut cmd = scene.cmd("env"); + cmd.args(&["cat", "-"]); + cmd.terminal_simulation(true); + cmd.pipe_in(message); + let child = cmd.run_no_wait(); + let out = child.wait().unwrap(); + + std::assert_eq!( + String::from_utf8_lossy(out.stdout()), + format!("{}\r\n", message) + ); + std::assert_eq!(String::from_utf8_lossy(out.stderr()), ""); + } + + #[cfg(unix)] + #[test] + fn test_simulation_of_terminal_pty_write_in_data_and_sends_eot_automatically() { + let scene = TestScenario::new("util"); + + let mut cmd = scene.cmd("env"); + cmd.args(&["cat", "-"]); + cmd.terminal_simulation(true); + let mut child = cmd.run_no_wait(); + child.write_in("Hello stdin forwarding via write_in!"); + let out = child.wait().unwrap(); + + std::assert_eq!( + String::from_utf8_lossy(out.stdout()), + "Hello stdin forwarding via write_in!\r\n" + ); + std::assert_eq!(String::from_utf8_lossy(out.stderr()), ""); + } } diff --git a/tests/fixtures/util/is_atty.sh b/tests/fixtures/util/is_atty.sh new file mode 100644 index 0000000..30f8caf --- /dev/null +++ b/tests/fixtures/util/is_atty.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +if [ -t 0 ] ; then + echo "stdin is atty" +else + echo "stdin is not atty" +fi + +if [ -t 1 ] ; then + echo "stdout is atty" +else + echo "stdout is not atty" +fi + +if [ -t 2 ] ; then + echo "stderr is atty" + echo "terminal size: $(stty size)" +else + echo "stderr is not atty" +fi + +>&2 echo "This is an error message." + +true diff --git a/tests/tests.rs b/tests/tests.rs index 57e9513..64cfc84 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -25,6 +25,10 @@ mod test_ctrlaltdel; #[path = "by-util/test_rev.rs"] mod test_rev; +#[cfg(feature = "setsid")] +#[path = "by-util/test_setsid.rs"] +mod test_setsid; + #[cfg(feature = "last")] #[path = "by-util/test_last.rs"] mod test_last;