diff --git a/Cargo.toml b/Cargo.toml index fa2382ab69f..3abf715414d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -298,7 +298,7 @@ feat_os_unix_musl = [ "feat_require_unix_utmpx", ] # "feat_os_windows" == set of utilities which can be built/run on modern windows platforms -feat_os_windows = ["feat_Tier1", "stdbuf", "timeout"] +feat_os_windows = ["feat_Tier1", "kill", "stdbuf", "timeout"] ## (secondary platforms) feature sets # "feat_os_unix_gnueabihf" == set of utilities which can be built/run on the "arm-unknown-linux-gnueabihf" target (ARMv6 Linux [hardfloat]) feat_os_unix_gnueabihf = [ diff --git a/src/uu/kill/Cargo.toml b/src/uu/kill/Cargo.toml index 45072046e42..55badd8997f 100644 --- a/src/uu/kill/Cargo.toml +++ b/src/uu/kill/Cargo.toml @@ -21,7 +21,7 @@ doctest = false [dependencies] clap = { workspace = true } thiserror = { workspace = true } -uucore = { workspace = true, features = ["signals"] } +uucore = { workspace = true, features = ["process", "signals"] } fluent = { workspace = true } [target.'cfg(unix)'.dependencies] diff --git a/src/uu/kill/locales/en-US.ftl b/src/uu/kill/locales/en-US.ftl index 61b8a4739a3..ff9491b0827 100644 --- a/src/uu/kill/locales/en-US.ftl +++ b/src/uu/kill/locales/en-US.ftl @@ -1,5 +1,23 @@ kill-about = Send signal to processes or list information about signals. kill-usage = kill [OPTIONS]... PID... +kill-after-help-windows = Windows notes: + Signalled processes are force-terminated (Windows has no signal delivery); + their exit status is 128 plus the signal number. Negative PIDs (another + process's group) and STOP are not supported. Permissions come from your + current token, with SeDebugPrivilege enabled when it is held, so run + elevated to reach processes a standard token cannot signal. Protected + (anti-malware) processes cannot be terminated at all. + + PID 0 targets the Job object kill runs in, the closest Windows analog of a + process group. Every process in that job and in its child jobs is signalled, + kill itself last, so kill dies with the group. Outside a job, PID 0 signals + only kill itself. + + Beware: a Job object is usually not yours. Terminals, IDEs, Docker, CI + agents and Windows' own Program Compatibility Assistant all run what they + launch inside a job, and a job captures every descendant from creation + onward. Under a CI agent, kill 0 signals the agent and every sibling step. + The blast radius can be far wider than a POSIX process group. # Help messages kill-help-list = Lists signals @@ -13,3 +31,5 @@ kill-error-invalid-signal = { $signal }: invalid signal kill-error-parse-argument = failed to parse argument { $argument }: { $error } kill-error-sending-signal = sending signal to { $pid } failed kill-error-write = write error: { $error } +kill-error-unsupported-signal = unsupported signal on Windows +kill-error-negative-pid-unsupported = a negative PID (another process's group) is not supported on Windows diff --git a/src/uu/kill/locales/fr-FR.ftl b/src/uu/kill/locales/fr-FR.ftl index 0e82c13da4d..729899b6806 100644 --- a/src/uu/kill/locales/fr-FR.ftl +++ b/src/uu/kill/locales/fr-FR.ftl @@ -1,5 +1,26 @@ kill-about = Envoyer un signal aux processus ou lister les informations sur les signaux. kill-usage = kill [OPTIONS]... PID... +kill-after-help-windows = Notes pour Windows : + Les processus signalés sont terminés de force (Windows ne délivre pas de + signaux) ; leur code de sortie est 128 plus le numéro du signal. Les PID + négatifs (le groupe d'un autre processus) et STOP ne sont pas pris en charge. + Les permissions proviennent de votre jeton actuel, avec SeDebugPrivilege + activé lorsqu'il est détenu ; exécutez kill en tant qu'administrateur pour + atteindre les processus qu'un jeton standard ne peut pas signaler. Les + processus protégés (anti-programmes malveillants) ne peuvent jamais être + terminés. + + Le PID 0 cible l'objet Job dans lequel kill s'exécute, l'équivalent Windows + le plus proche d'un groupe de processus. Tous les processus de ce Job et de + ses Jobs enfants sont signalés, kill lui-même en dernier, de sorte que kill + meurt avec le groupe. Hors d'un Job, le PID 0 ne signale que kill lui-même. + + Attention : un objet Job ne vous appartient généralement pas. Les terminaux, + les IDE, Docker, les agents d'intégration continue et l'Assistant de + compatibilité des programmes de Windows exécutent tous dans un Job ce qu'ils + lancent, et un Job capture chaque descendant dès sa création. Sous un agent + d'intégration continue, kill 0 signale l'agent et toutes les étapes voisines. + La portée peut être bien plus large que celle d'un groupe de processus POSIX. # Messages d'aide kill-help-list = Liste les signaux @@ -13,3 +34,5 @@ kill-error-invalid-signal = { $signal } : signal invalide kill-error-parse-argument = échec de l'analyse de l'argument { $argument } : { $error } kill-error-sending-signal = échec de l'envoi du signal au processus { $pid } kill-error-write = erreur d'écriture : { $error } +kill-error-unsupported-signal = signal non pris en charge sur Windows +kill-error-negative-pid-unsupported = un PID négatif (le groupe d'un autre processus) n'est pas pris en charge sur Windows diff --git a/src/uu/kill/src/kill.rs b/src/uu/kill/src/kill.rs index 7bfae0cd040..ebf0b49c51b 100644 --- a/src/uu/kill/src/kill.rs +++ b/src/uu/kill/src/kill.rs @@ -6,11 +6,6 @@ // spell-checker:ignore (ToDO) signalname pids killpg NOPESIG use clap::{Arg, ArgAction, Command}; -use rustix::process::{ - Pid, Signal, kill_current_process_group, kill_process, kill_process_group, - test_kill_current_process_group, test_kill_process, test_kill_process_group, -}; -use std::cmp::Ordering; use std::io::{self, BufWriter, Write}; use thiserror::Error; use uucore::display::Quotable; @@ -23,6 +18,8 @@ use uucore::signals::{ }; use uucore::{format_usage, show}; +mod platform; + // When the -l option is selected, the program displays the type of signal related to a certain // value or string. In case of a value, the program should control the lower 8 bits, but there is // a particular case in which if the value is in range [128, 159], it is translated to a signal @@ -95,7 +92,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } pub fn uu_app() -> Command { - Command::new("kill") + let cmd = Command::new("kill") .version(uucore::crate_version!()) .help_template(uucore::localized_help_template("kill")) .about(translate!("kill-about")) @@ -131,7 +128,10 @@ pub fn uu_app() -> Command { Arg::new(options::PIDS_OR_SIGNALS) .hide(true) .action(ArgAction::Append), - ) + ); + #[cfg(windows)] + let cmd = cmd.after_help(translate!("kill-after-help-windows")); + cmd } fn handle_obsolete(args: &mut Vec) -> UResult> { @@ -244,18 +244,6 @@ fn list(signals: &Vec) -> UResult<()> { Ok(()) } -// rustix's `Signal` rejects libc-reserved realtime signals, so fall back to a -// raw `libc::kill` for any value its safe constructor doesn't recognize. -fn raw_kill(pid: i32, sig: usize) -> io::Result<()> { - let sig = i32::try_from(sig).map_err(|_| io::Error::from_raw_os_error(libc::EINVAL))?; - // SAFETY: plain FFI call; `kill` has no memory-safety preconditions. - if unsafe { libc::kill(pid as libc::pid_t, sig) } == 0 { - Ok(()) - } else { - Err(io::Error::last_os_error()) - } -} - fn parse_signal_value(signal_name: &str) -> UResult { let optional_signal_value = signal_by_name_or_value(signal_name); match optional_signal_value { @@ -281,44 +269,8 @@ fn parse_pids(pids: &[String]) -> UResult> { } fn kill(sig: usize, pids: &[i32]) { - // Standard named signals use rustix's typed API; anything its safe - // constructor doesn't recognize (realtime/reserved) falls back to libc. - let named = (sig != 0) - .then(|| i32::try_from(sig).ok().and_then(Signal::from_named_raw)) - .flatten(); for &pid in pids { - let result = match pid.cmp(&0) { - Ordering::Equal => match named { - _ if sig == 0 => test_kill_current_process_group().map_err(io::Error::from), - Some(s) => kill_current_process_group(s).map_err(io::Error::from), - None => raw_kill(0, sig), - }, - Ordering::Greater => { - let pid = Pid::from_raw(pid).expect("pid > 0 guaranteed by Ordering::Greater"); - match named { - _ if sig == 0 => test_kill_process(pid).map_err(io::Error::from), - Some(s) => kill_process(pid, s).map_err(io::Error::from), - None => raw_kill(pid.as_raw_nonzero().get(), sig), - } - } - Ordering::Less => { - let Some(abs_pid) = pid.checked_neg() else { - show!(USimpleError::new( - 1, - translate!("kill-error-sending-signal", "pid" => pid), - )); - continue; - }; - let pid = - Pid::from_raw(abs_pid).expect("abs_pid > 0 since pid < 0 and pid != i32::MIN"); - match named { - _ if sig == 0 => test_kill_process_group(pid).map_err(io::Error::from), - Some(s) => kill_process_group(pid, s).map_err(io::Error::from), - None => raw_kill(-pid.as_raw_nonzero().get(), sig), - } - } - }; - if let Err(e) = result { + if let Err(e) = platform::send_signal(pid, sig) { show!(e.map_err_context(|| translate!("kill-error-sending-signal", "pid" => pid))); } } diff --git a/src/uu/kill/src/platform/mod.rs b/src/uu/kill/src/platform/mod.rs new file mode 100644 index 00000000000..716d768d082 --- /dev/null +++ b/src/uu/kill/src/platform/mod.rs @@ -0,0 +1,18 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! Platform-specific piece of `kill`: delivering one signal to one pid via +//! the [`send_signal`] facade, provided by both submodules with an identical +//! signature. The shared control flow stays in `kill.rs`. + +#[cfg(unix)] +mod unix; +#[cfg(unix)] +pub(crate) use unix::*; + +#[cfg(windows)] +mod windows; +#[cfg(windows)] +pub(crate) use windows::*; diff --git a/src/uu/kill/src/platform/unix.rs b/src/uu/kill/src/platform/unix.rs new file mode 100644 index 00000000000..f3c4a5f18a2 --- /dev/null +++ b/src/uu/kill/src/platform/unix.rs @@ -0,0 +1,64 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore pids ESRCH + +use std::cmp::Ordering; +use std::io; + +use rustix::process::{ + Pid, Signal, kill_current_process_group, kill_process, kill_process_group, + test_kill_current_process_group, test_kill_process, test_kill_process_group, +}; + +// rustix's `Signal` rejects libc-reserved realtime signals, so fall back to a +// raw `libc::kill` for any value its safe constructor doesn't recognize. +fn raw_kill(pid: i32, sig: usize) -> io::Result<()> { + let sig = i32::try_from(sig).map_err(|_| io::Error::from_raw_os_error(libc::EINVAL))?; + // SAFETY: plain FFI call; `kill` has no memory-safety preconditions. + if unsafe { libc::kill(pid as libc::pid_t, sig) } == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + +/// Deliver `sig` to `pid` with kill(2) semantics: positive pids target one +/// process, 0 the current process group, negative pids the group `-pid`. +pub(crate) fn send_signal(pid: i32, sig: usize) -> io::Result<()> { + // Standard named signals use rustix's typed API; anything its safe + // constructor doesn't recognize (realtime/reserved) falls back to libc. + let named = (sig != 0) + .then(|| i32::try_from(sig).ok().and_then(Signal::from_named_raw)) + .flatten(); + match pid.cmp(&0) { + Ordering::Equal => match named { + _ if sig == 0 => test_kill_current_process_group().map_err(io::Error::from), + Some(s) => kill_current_process_group(s).map_err(io::Error::from), + None => raw_kill(0, sig), + }, + Ordering::Greater => { + let pid = Pid::from_raw(pid).expect("pid > 0 guaranteed by Ordering::Greater"); + match named { + _ if sig == 0 => test_kill_process(pid).map_err(io::Error::from), + Some(s) => kill_process(pid, s).map_err(io::Error::from), + None => raw_kill(pid.as_raw_nonzero().get(), sig), + } + } + Ordering::Less => { + // i32::MIN cannot be negated, so no such process group can exist. + let Some(abs_pid) = pid.checked_neg() else { + return Err(io::Error::from_raw_os_error(libc::ESRCH)); + }; + let pid = + Pid::from_raw(abs_pid).expect("abs_pid > 0 since pid < 0 and pid != i32::MIN"); + match named { + _ if sig == 0 => test_kill_process_group(pid).map_err(io::Error::from), + Some(s) => kill_process_group(pid, s).map_err(io::Error::from), + None => raw_kill(-pid.as_raw_nonzero().get(), sig), + } + } + } +} diff --git a/src/uu/kill/src/platform/windows.rs b/src/uu/kill/src/platform/windows.rs new file mode 100644 index 00000000000..3b96bd23d7a --- /dev/null +++ b/src/uu/kill/src/platform/windows.rs @@ -0,0 +1,67 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore pids + +//! Windows implementation of `kill`'s platform facade, built on the signal +//! emulation in [`uucore::process`]. PID 0 targets the Job object `kill` runs +//! in, the closest Windows analog of a process group. STOP (no process-suspend +//! API) and negative pids (no way to name another process's group) are +//! rejected. + +use std::io; + +use uucore::process::{enable_debug_privilege, send_signal_to_own_group, send_signal_to_pid}; +use uucore::translate; + +const SIGNAL_STOP: usize = 19; + +fn unsupported(message: String) -> io::Error { + io::Error::new(io::ErrorKind::Unsupported, message) +} + +pub(crate) fn send_signal(pid: i32, sig: usize) -> io::Result<()> { + if sig == SIGNAL_STOP { + return Err(unsupported(translate!("kill-error-unsupported-signal"))); + } + match u32::try_from(pid) { + // Fails exactly for pid < 0. + Err(_) => Err(unsupported(translate!( + "kill-error-negative-pid-unsupported" + ))), + Ok(pid) => { + // Group members need the same rights as a single target. + enable_debug_privilege(); + if pid == 0 { + send_signal_to_own_group(sig) + } else { + send_signal_to_pid(pid, sig) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::send_signal; + + #[test] + fn negative_pids_are_rejected_without_touching_any_process() { + for pid in [-1, -2, i32::MIN] { + assert_eq!( + send_signal(pid, 9).unwrap_err().kind(), + std::io::ErrorKind::Unsupported + ); + } + } + + #[test] + fn stop_is_rejected_for_every_pid_including_zero() { + assert_eq!( + send_signal(0, 19).unwrap_err().kind(), + std::io::ErrorKind::Unsupported + ); + } +} diff --git a/src/uucore/locales/en-US.ftl b/src/uucore/locales/en-US.ftl index f408b4ee69e..964c8f2ba6b 100644 --- a/src/uucore/locales/en-US.ftl +++ b/src/uucore/locales/en-US.ftl @@ -29,6 +29,7 @@ help-flag-version = Print version information error-io = I/O error error-permission-denied = Permission denied error-file-not-found = No such file or directory +error-no-such-process = No such process error-invalid-argument = Invalid argument error-is-a-directory = { $file }: Is a directory diff --git a/src/uucore/locales/fr-FR.ftl b/src/uucore/locales/fr-FR.ftl index 507a536cc6e..f471689009d 100644 --- a/src/uucore/locales/fr-FR.ftl +++ b/src/uucore/locales/fr-FR.ftl @@ -29,6 +29,7 @@ help-flag-version = Afficher les informations de version error-io = Erreur E/S error-permission-denied = Permission refusée error-file-not-found = Aucun fichier ou répertoire de ce type +error-no-such-process = Aucun processus de ce type error-invalid-argument = Argument invalide error-is-a-directory = { $file }: Est un répertoire diff --git a/src/uucore/src/lib/features/process/windows.rs b/src/uucore/src/lib/features/process/windows.rs index da340e9dbb0..793d715cd72 100644 --- a/src/uucore/src/lib/features/process/windows.rs +++ b/src/uucore/src/lib/features/process/windows.rs @@ -3,21 +3,24 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (win-api) WAITABLE Waitable PHANDLER unsignaled +// spell-checker:ignore (win-api) WAITABLE Waitable PHANDLER unsignaled JOBOBJECT // spell-checker:ignore (signals) CHLD TSTP TTIN TTOU WINCH ESRCH -// spell-checker:ignore catchable targetable wakeup +// spell-checker:ignore catchable targetable wakeup unreaped pids LUID luid -//! Windows emulation of POSIX signal delivery for child processes. +//! Windows emulation of POSIX signal delivery, for child processes, arbitrary +//! pids ([`send_signal_to_pid`]) and the caller's own process group +//! ([`send_signal_to_own_group`]). //! //! Windows has no signals, so this module emulates the POSIX default //! dispositions with native primitives: signal numbers follow the Linux //! layout (matching `uucore::signals::ALL_SIGNALS`), "terminate" signals //! force-exit with exit code `128 + n`, and `INT`/`QUIT` map to a //! `CTRL_BREAK_EVENT` on a console process group. [`Job`] gives process-tree -//! termination, and [`enable_ctrl_forwarding`]/[`take_last_ctrl_signal`] -//! surface console control events (Ctrl-C, Ctrl-Break, close) as POSIX signal -//! numbers for forwarding. All raw Win32 calls live in the safe [`sys`] -//! wrappers. +//! termination, [`send_signal_to_own_group`] uses the job the caller runs in +//! as the stand-in for a process group, and +//! [`enable_ctrl_forwarding`]/[`take_last_ctrl_signal`] surface console +//! control events (Ctrl-C, Ctrl-Break, close) as POSIX signal numbers for +//! forwarding. All raw Win32 calls live in the safe [`sys`] wrappers. use std::io; use std::os::windows::io::{AsHandle, BorrowedHandle, OwnedHandle}; @@ -26,32 +29,46 @@ use std::sync::OnceLock; use std::sync::atomic::{AtomicI32, Ordering}; use std::time::Duration; +use windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED; use windows_sys::Win32::System::Console::{CTRL_BREAK_EVENT, CTRL_C_EVENT, CTRL_CLOSE_EVENT}; -use windows_sys::Win32::System::Threading::{CREATE_NEW_PROCESS_GROUP, INFINITE}; +use windows_sys::Win32::System::Threading::{ + CREATE_NEW_PROCESS_GROUP, INFINITE, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE, +}; use windows_sys::core::BOOL; use super::{ChildExt, TimeoutRet}; +use crate::translate; /// Safe wrappers around the raw Win32 calls used for process control: each /// validates results into [`io::Error`] and takes /// [`BorrowedHandle`]/[`OwnedHandle`] so callers never touch raw `HANDLE`s. pub mod sys { use std::io; - use std::os::windows::io::{AsRawHandle, BorrowedHandle, HandleOrNull, OwnedHandle}; + use std::mem::offset_of; + use std::os::windows::io::{ + AsRawHandle, BorrowedHandle, FromRawHandle, HandleOrNull, OwnedHandle, + }; use windows_sys::Win32::Foundation::{ - FALSE, HANDLE, TRUE, WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT, + ERROR_INVALID_PARAMETER, ERROR_MORE_DATA, FALSE, HANDLE, LUID, TRUE, WAIT_FAILED, + WAIT_OBJECT_0, WAIT_TIMEOUT, + }; + use windows_sys::Win32::Security::{ + AdjustTokenPrivileges, LUID_AND_ATTRIBUTES, LookupPrivilegeValueW, SE_DEBUG_NAME, + SE_PRIVILEGE_ENABLED, TOKEN_ADJUST_PRIVILEGES, TOKEN_PRIVILEGES, }; use windows_sys::Win32::System::Console::{ GenerateConsoleCtrlEvent, PHANDLER_ROUTINE, SetConsoleCtrlHandler, }; use windows_sys::Win32::System::JobObjects::{ - AssignProcessToJobObject, CreateJobObjectW, TerminateJobObject, + AssignProcessToJobObject, CreateJobObjectW, IsProcessInJob, + JOBOBJECT_BASIC_PROCESS_ID_LIST, JobObjectBasicProcessIdList, QueryInformationJobObject, + TerminateJobObject, }; use windows_sys::Win32::System::Threading::{ - CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, CreateEventW, CreateWaitableTimerExW, ResetEvent, - SetEvent, SetWaitableTimer, TIMER_ALL_ACCESS, TerminateProcess, WaitForMultipleObjects, - WaitForSingleObject, + CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, CreateEventW, CreateWaitableTimerExW, + GetCurrentProcess, OpenProcess, OpenProcessToken, ResetEvent, SetEvent, SetWaitableTimer, + TIMER_ALL_ACCESS, TerminateProcess, WaitForMultipleObjects, WaitForSingleObject, }; use windows_sys::core::BOOL; @@ -64,6 +81,13 @@ pub mod sys { TimedOut, } + /// The outcome of listing the processes assigned to a job. + #[derive(Debug)] + pub enum JobProcessIds { + Complete(Vec), + Truncated, + } + /// Convert a Win32 `BOOL` result into an [`io::Result`]: zero (`FALSE`) /// becomes the error from `GetLastError`. fn cvt(result: BOOL) -> io::Result<()> { @@ -110,6 +134,97 @@ pub mod sys { cvt(unsafe { TerminateJobObject(job.as_raw_handle() as HANDLE, exit_code) }) } + /// One slot on 64-bit, two on 32-bit. + const PID_LIST_HEADER_SLOTS: usize = + offset_of!(JOBOBJECT_BASIC_PROCESS_ID_LIST, ProcessIdList) / size_of::(); + + // `ProcessIdList` is a variable-length trailing array, so its buffers are + // allocated as `usize` slices: aligned for the struct by construction, and + // readable with bounds-checked indexing rather than pointer arithmetic. + // A target where that stops holding is a compile error, not run-time UB. + const _: () = assert!( + align_of::() == align_of::() + && offset_of!(JOBOBJECT_BASIC_PROCESS_ID_LIST, ProcessIdList) % size_of::() == 0 + ); + + /// Whether the calling process runs inside any job object. + /// + /// A null job handle asks "in *a* job", not "in *this* job". Needed + /// because the code a job-less caller gets from + /// [`query_job_process_ids`] is undocumented. + pub fn is_process_in_job() -> io::Result { + let mut in_job: BOOL = FALSE; + // SAFETY: the current-process pseudo-handle is always valid, a null + // job handle is documented as "any job", and `in_job` is an out-param + // valid for the duration of the call. + cvt(unsafe { IsProcessInJob(GetCurrentProcess(), std::ptr::null_mut(), &raw mut in_job) })?; + Ok(in_job != FALSE) + } + + /// List the processes assigned to `job`, with room for `capacity` of them; + /// `None` means the job the calling process itself belongs to. + /// + /// `None` is the only way to reach one's own job — a process cannot open a + /// handle to it — and it addresses the immediate job when jobs are nested, + /// whose list still covers that job's child jobs. It fails when the caller + /// is in no job at all, so check [`is_process_in_job`] first. Truncation + /// reports nothing but the fact: the counts and returned length are all + /// zero on that path on some Windows 10 builds, so only the caller can + /// pick a retry size. + pub fn query_job_process_ids( + job: Option, + capacity: usize, + ) -> io::Result { + // `max(1)` keeps the buffer at least one whole struct wide. + let mut buffer = vec![0usize; PID_LIST_HEADER_SLOTS + capacity.max(1)]; + let length = u32::try_from(buffer.len() * size_of::()) + .map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?; + let job = job.map_or(std::ptr::null_mut(), |job| job.as_raw_handle()); + // SAFETY: `job` is null or a valid handle for the duration of the + // call; `buffer` is a live `usize` allocation of exactly `length` + // bytes, so it is aligned for the struct and as large as we claim; a + // null return-length pointer is documented as valid. + let result = unsafe { + QueryInformationJobObject( + job, + JobObjectBasicProcessIdList, + buffer.as_mut_ptr().cast(), + length, + std::ptr::null_mut(), + ) + }; + match cvt(result) { + Ok(()) => Ok(JobProcessIds::Complete(process_ids(&buffer))), + // How this info class reports STATUS_BUFFER_OVERFLOW. + Err(error) if error.raw_os_error() == Some(ERROR_MORE_DATA as i32) => { + Ok(JobProcessIds::Truncated) + } + Err(error) => Err(error), + } + } + + fn process_ids(buffer: &[usize]) -> Vec { + debug_assert!(buffer.len() > PID_LIST_HEADER_SLOTS); + // SAFETY: `buffer` is a `usize` allocation at least one whole + // `JOBOBJECT_BASIC_PROCESS_ID_LIST` long (its two counts plus one pid + // slot), so the read is aligned and in bounds, and every field is an + // integer, so any bit pattern read back is a valid value. + let header = unsafe { + buffer + .as_ptr() + .cast::() + .read() + }; + buffer[PID_LIST_HEADER_SLOTS..] + .iter() + .take(header.NumberOfProcessIdsInList as usize) + // Pids are `ULONG_PTR`-wide but always fit a `DWORD`, and zero + // names no process. + .filter_map(|&pid| u32::try_from(pid).ok()) + .filter(|&pid| pid != 0) + .collect() + } + /// Terminate the process behind `process` with the given exit code. pub fn terminate_process(process: BorrowedHandle, exit_code: u32) -> io::Result<()> { // SAFETY: the process handle is valid for the duration of the call; @@ -117,6 +232,62 @@ pub mod sys { cvt(unsafe { TerminateProcess(process.as_raw_handle() as HANDLE, exit_code) }) } + /// Open `pid` with exactly `desired_access`; the handle is non-inheritable. + /// + /// Dead, never-allocated and idle (0) pids report `ERROR_INVALID_PARAMETER`, + /// reported here as the POSIX ESRCH analog. `ERROR_ACCESS_DENIED` stays raw: + /// its kind already renders "Permission denied", like unix EPERM. + pub fn open_process(pid: u32, desired_access: u32) -> io::Result { + // SAFETY: OpenProcess returns null on failure, otherwise a fresh + // handle owned by us — the `cvt_created_handle` contract. + unsafe { cvt_created_handle(OpenProcess(desired_access, FALSE, pid)) }.map_err(|error| { + if error.raw_os_error() == Some(ERROR_INVALID_PARAMETER as i32) { + super::no_such_process() + } else { + error + } + }) + } + + /// Request `SeDebugPrivilege` in this process's token. + /// + /// `Ok` does not mean the privilege is now enabled: `AdjustTokenPrivileges` + /// succeeds for a token that does not hold it too, reporting that only + /// through `GetLastError`. + pub fn enable_debug_privilege() -> io::Result<()> { + let mut token: HANDLE = std::ptr::null_mut(); + // SAFETY: the pseudo-handle is always valid; `token` is an out-param. + cvt(unsafe { + OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES, &raw mut token) + })?; + // SAFETY: OpenProcessToken succeeded, so `token` is a fresh owned handle. + let token = unsafe { OwnedHandle::from_raw_handle(token) }; + + let mut luid = LUID::default(); + // SAFETY: a null system name means the local system; `luid` is an out-param. + cvt(unsafe { LookupPrivilegeValueW(std::ptr::null(), SE_DEBUG_NAME, &raw mut luid) })?; + + let privileges = TOKEN_PRIVILEGES { + PrivilegeCount: 1, + Privileges: [LUID_AND_ATTRIBUTES { + Luid: luid, + Attributes: SE_PRIVILEGE_ENABLED, + }], + }; + // SAFETY: `privileges` outlives the call; a null previous-state pointer + // is documented as valid. + cvt(unsafe { + AdjustTokenPrivileges( + token.as_raw_handle(), + FALSE, + &raw const privileges, + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }) + } + /// Create an unnamed manual-reset event, initially unsignaled. pub fn create_manual_reset_event() -> io::Result { // SAFETY: null attributes and name are documented as valid; the @@ -301,6 +472,147 @@ pub fn send_signal_to_process(child: &Child, signal: usize) -> io::Result<()> { } } +const PROBE_ACCESS: u32 = PROCESS_SYNCHRONIZE; +// SYNCHRONIZE tells "already exited" apart from a real denial after a failed +// TerminateProcess (both report ERROR_ACCESS_DENIED). +const TERMINATE_ACCESS: u32 = PROCESS_TERMINATE | PROCESS_SYNCHRONIZE; + +fn no_such_process() -> io::Error { + io::Error::new(io::ErrorKind::NotFound, translate!("error-no-such-process")) +} + +/// A process handle becomes signaled when the process exits. +fn has_exited(handle: BorrowedHandle) -> io::Result { + Ok(matches!( + sys::wait_for_one(handle, 0)?, + sys::WaitOutcome::Object(_) + )) +} + +/// Request `SeDebugPrivilege` once per process; with it, opening a process +/// bypasses the target's security descriptor. +/// +/// Silent and best-effort: only an elevated administrator's token holds the +/// privilege, and tokens without it are left untouched. +pub fn enable_debug_privilege() { + static REQUESTED: OnceLock<()> = OnceLock::new(); + REQUESTED.get_or_init(|| { + let _ = sys::enable_debug_privilege(); + }); +} + +/// Deliver `signal` (POSIX numbering) to the arbitrary process `pid`, +/// emulating `kill(2)` for `pid > 0`. +pub fn send_signal_to_pid(pid: u32, signal: usize) -> io::Result<()> { + match disposition(signal)? { + Disposition::Probe | Disposition::Ignore => probe_pid(pid), + Disposition::Interrupt | Disposition::Terminate => terminate_pid(pid, signal), + } +} + +/// Ok while `pid` runs, "No such process" once it has exited, even if open +/// handles still pin its pid. +fn probe_pid(pid: u32) -> io::Result<()> { + let handle = sys::open_process(pid, PROBE_ACCESS)?; + if has_exited(handle.as_handle())? { + return Err(no_such_process()); + } + Ok(()) +} + +fn terminate_pid(pid: u32, signal: usize) -> io::Result<()> { + let handle = sys::open_process(pid, TERMINATE_ACCESS)?; + match terminate_with_signal(handle.as_handle(), signal) { + // An already-exited target also reports ERROR_ACCESS_DENIED; it + // counts as delivered, like unix kill on an unreaped process. + Err(e) + if e.raw_os_error() == Some(ERROR_ACCESS_DENIED as i32) + && has_exited(handle.as_handle()).unwrap_or(false) => + { + Ok(()) + } + result => result, + } +} + +/// Pids in the first attempt at listing a job, doubling up to [`JOB_PID_MAX`]. +/// +/// A job whose list does not fit can report a zero size to retry with +/// (Windows 10, notably under WOW64), so the growth cannot be driven by the +/// kernel's numbers. +const JOB_PID_CAPACITY: usize = 64; +const JOB_PID_MAX: usize = 128 * 1024; + +fn job_process_ids(job: Option) -> io::Result> { + let mut capacity = JOB_PID_CAPACITY; + while capacity <= JOB_PID_MAX { + match sys::query_job_process_ids(job, capacity)? { + sys::JobProcessIds::Complete(pids) => return Ok(pids), + sys::JobProcessIds::Truncated => capacity *= 2, + } + } + Err(io::Error::other(format!( + "job process list did not fit in {JOB_PID_MAX} entries" + ))) +} + +/// `members` with `own_pid` present exactly once, at the end, so a terminating +/// signal reaches every other member before it kills this process. +/// +/// Split out because an empty `members` *is* the job-less case, which no test +/// can reproduce by spawning (a child created with `CREATE_BREAKAWAY_FROM_JOB` +/// leaves only its immediate job; an ancestor job still claims it). +fn with_self_last(mut members: Vec, own_pid: u32) -> Vec { + members.retain(|&pid| pid != own_pid); + members.push(own_pid); + members +} + +/// Deliver `signal` to `pids` in order, succeeding if any one delivery did. +/// +/// Reports the first failure rather than the last, since later ones can be +/// consequences of earlier deliveries. +fn send_signal_to_each(pids: &[u32], signal: usize) -> io::Result<()> { + let mut delivered = false; + let mut first_error = None; + for &pid in pids { + match send_signal_to_pid(pid, signal) { + Ok(()) => delivered = true, + Err(error) => { + first_error.get_or_insert(error); + } + } + } + if delivered { + Ok(()) + } else { + Err(first_error.unwrap_or_else(no_such_process)) + } +} + +/// Deliver `signal` (POSIX numbering) to every process in the caller's own +/// process group, emulating `kill(2)` for `pid == 0`. +/// +/// The group is the caller's job object, which is broader than a POSIX process +/// group: a terminal, IDE or CI agent that runs this process inside a job puts +/// everything else it manages in the same group. A process in no job is a +/// group of one. This process is signalled last, so a terminating signal ends +/// it with exit code `128 + signal` instead of returning. +/// +/// Like `kill(0, sig)`, succeeds as soon as one member was signalled: members +/// this process may not touch, and members that exit between enumeration and +/// delivery, do not fail the call. +pub fn send_signal_to_own_group(signal: usize) -> io::Result<()> { + // Fail fast, before enumerating anything. + disposition(signal)?; + let members = if sys::is_process_in_job()? { + job_process_ids(None)? + } else { + Vec::new() + }; + send_signal_to_each(&with_self_last(members, std::process::id()), signal) +} + /// Deliver `signal` (POSIX numbering) to the console process group led by /// `pid` (the process must have been created with `CREATE_NEW_PROCESS_GROUP`, /// e.g. via [`configure_process_group`]). @@ -550,6 +862,14 @@ mod tests { ) } + fn spawn_child() -> Child { + Command::new("ping") + .args(["-n", "10", "127.0.0.1"]) + .stdout(Stdio::null()) + .spawn() + .unwrap() + } + /// `LAST_CTRL_SIGNAL` and `WAKE_EVENT` are process-global, so every phase /// runs inside this single test to keep them free of cross-test races /// (cargo runs tests on parallel threads). @@ -583,11 +903,7 @@ mod tests { // Phase 5: `wait_or_timeout` consumes a latched console event: it // returns `Interrupted` promptly with the child still running, and // leaves latch and event clear so they cannot satisfy a later wait. - let mut child = Command::new("ping") - .args(["-n", "10", "127.0.0.1"]) - .stdout(Stdio::null()) - .spawn() - .unwrap(); + let mut child = spawn_child(); // SAFETY: as above. assert_eq!(unsafe { console_ctrl_handler(CTRL_C_EVENT) }, 1); let started = Instant::now(); @@ -601,4 +917,130 @@ mod tests { child.kill().unwrap(); child.wait().unwrap(); } + + // The send_signal_to_pid tests touch no process-global state (unlike the + // ctrl-forwarding lifecycle above), so they can run in parallel. + + #[test] + fn send_signal_to_pid_terminates_with_128_plus_signal() { + let mut child = spawn_child(); + send_signal_to_pid(child.id(), 15).unwrap(); + assert_eq!(child.wait().unwrap().code(), Some(143)); + } + + #[test] + fn send_signal_to_pid_probe_ignore_and_exited_semantics() { + let mut child = spawn_child(); + send_signal_to_pid(child.id(), 0).unwrap(); + send_signal_to_pid(child.id(), 17).unwrap(); + assert!(child.try_wait().unwrap().is_none(), "CHLD must be a no-op"); + + child.kill().unwrap(); + child.wait().unwrap(); + // `child` still holds the process handle, pinning the pid: the checks + // below cannot race a pid reuse. + // The message text is asserted end-to-end in tests/by-util/test_kill.rs; + // unit tests run without localization initialized. + let err = send_signal_to_pid(child.id(), 0).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + send_signal_to_pid(child.id(), 9).unwrap(); + } + + #[test] + fn send_signal_to_pid_rejects_invalid_signal_before_any_open() { + assert_eq!( + send_signal_to_pid(std::process::id(), 64) + .unwrap_err() + .kind(), + io::ErrorKind::InvalidInput + ); + } + + #[test] + fn enable_debug_privilege_is_silent_whether_or_not_the_token_holds_it() { + enable_debug_privilege(); + enable_debug_privilege(); + } + + #[test] + fn send_signal_to_pid_reports_idle_pid_as_missing() { + // OpenProcess rejects the System Idle Process like any pid that names + // no process. + assert_eq!( + send_signal_to_pid(0, 9).unwrap_err().kind(), + io::ErrorKind::NotFound + ); + } + + // Nothing below assigns the *current* process to a job: joining one is + // irreversible and process-global, so it would rewrite what every other + // test in this binary sees (cargo runs them on parallel threads) — the same + // hazard that forces the ctrl-forwarding phases into a single test above. + // + // For the same reason, never pass a terminating signal to + // `send_signal_to_own_group` here: this process's group is whatever job the + // test runner provides. That path is covered in tests/by-util/test_kill.rs, + // inside a job the test creates itself. + + #[test] + fn with_self_last_puts_this_process_exactly_once_at_the_end() { + // Empty members *is* the job-less case. + assert_eq!(with_self_last(vec![], 7), vec![7]); + assert_eq!(with_self_last(vec![3, 7], 7), vec![3, 7]); + assert_eq!(with_self_last(vec![7, 3, 5], 7), vec![3, 5, 7]); + // Listed twice, signalled once, so the loop cannot kill itself early. + assert_eq!(with_self_last(vec![7, 3, 7], 7), vec![3, 7]); + } + + /// Also the canary for `QueryInformationJobObject(NULL, ...)`: if the + /// null-handle query were ever denied inside a job we do not own, this + /// fails on CI instead of `kill 0` failing in the field. + #[test] + fn send_signal_to_own_group_probe_and_ignore_are_no_ops() { + let mut child = spawn_child(); + send_signal_to_own_group(0).unwrap(); + send_signal_to_own_group(17).unwrap(); + assert!( + child.try_wait().unwrap().is_none(), + "the group probe terminated a process" + ); + child.kill().unwrap(); + child.wait().unwrap(); + } + + #[test] + fn send_signal_to_own_group_rejects_invalid_signal_before_enumerating() { + assert_eq!( + send_signal_to_own_group(64).unwrap_err().kind(), + io::ErrorKind::InvalidInput + ); + } + + /// Against a job whose membership we control; the caller's own job (`None`) + /// is whatever the machine running the tests happens to provide. + #[test] + fn job_process_ids_lists_members_and_reports_truncation() { + let job = Job::new().unwrap(); + let mut children = [spawn_child(), spawn_child()]; + for child in &children { + job.assign(child).unwrap(); + } + let handle = job.0.as_handle(); + + let pids = job_process_ids(Some(handle)).unwrap(); + for child in &children { + assert!(pids.contains(&child.id())); + } + + // Two processes cannot fit a one-pid buffer. + assert!(matches!( + sys::query_job_process_ids(Some(handle), 1).unwrap(), + sys::JobProcessIds::Truncated + )); + + for child in &mut children { + child.kill().unwrap(); + child.wait().unwrap(); + } + } } diff --git a/tests/by-util/test_kill.rs b/tests/by-util/test_kill.rs index 009285a22b0..eac6b7dcf6f 100644 --- a/tests/by-util/test_kill.rs +++ b/tests/by-util/test_kill.rs @@ -2,39 +2,62 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore IAMNOTASIGNAL RTMAX RTMIN SIGIO SIGRTMAX GHSA +// spell-checker:ignore IAMNOTASIGNAL RTMAX RTMIN SIGIO SIGRTMAX GHSA CHLD SIGSTOP taskkill unreaped use regex::Regex; +#[cfg(windows)] +use std::io::Write; +#[cfg(unix)] use std::os::unix::process::ExitStatusExt; -use std::process::{Child, Command}; +#[cfg(windows)] +use std::process::Stdio; +use std::process::{Child, Command, ExitStatus}; +#[cfg(windows)] +use uucore::process::Job; #[cfg(any(target_os = "linux", target_os = "android"))] use uucore::signals::realtime_signal_bounds; use uutests::new_ucmd; +use uutests::util::get_tests_binary; -// A child process the tests will try to kill. +// A child process the tests will try to kill: ` sleep 30` via the +// multicall test binary, which exists on every test platform (unlike `sleep` +// from PATH on Windows). The natural death after 30s avoids hanging failing +// tests. struct Target { child: Child, killed: bool, } impl Target { - // Creates a target that will naturally die after some time if not killed - // fast enough. - // This timeout avoids hanging failing tests. fn new() -> Self { Self { - child: Command::new("sleep") - .arg("30") + child: Command::new(get_tests_binary()) + .args(["sleep", "30"]) .spawn() .expect("cannot spawn target"), killed: false, } } - // Waits for the target to complete and returns the signal it received if any. - fn wait_for_signal(&mut self) -> Option { - let sig = self.child.wait().expect("cannot wait on target").signal(); + /// Wait for the target to exit, so `Drop` no longer has to kill it. + fn reap(&mut self) -> ExitStatus { + let status = self.child.wait().expect("cannot wait on target"); self.killed = true; - sig + status + } + + /// Reap the target and assert it was killed by `signal` (`128 + n` exit + /// code on windows). + fn assert_signaled(&mut self, signal: i32) { + let status = self.reap(); + #[cfg(unix)] + assert_eq!(status.signal(), Some(signal)); + #[cfg(windows)] + assert_eq!(status.code(), Some(128 + signal)); + } + + #[cfg(windows)] + fn assert_alive(&mut self) { + assert!(self.child.try_wait().expect("cannot poll target").is_none()); } fn pid(&self) -> u32 { @@ -52,6 +75,66 @@ impl Drop for Target { } } +/// A `cmd.exe` in a freshly created Job object, waiting for a command line on +/// its stdin pipe. +/// +/// The only safe way to run a *terminating* `kill 0` from a test: `kill 0` +/// signals the killer's immediate job, which here is `job` and nothing else, so +/// it cannot reach the test runner, another test's children, nextest's per-test +/// job or a CI agent — the ambient job is `job`'s *parent*, and a parent's +/// members are not in a child job's list. +/// +/// The gate is what makes that true rather than merely likely: `cmd` gets no +/// command until `assign` has succeeded, so the window between `CreateProcess` +/// and `AssignProcessToJobObject` contains nothing that could signal. Fail-safe +/// too — if anything panics before [`JobShell::run`], the pipe closes, `cmd` +/// sees EOF and exits having executed nothing. +#[cfg(windows)] +struct JobShell { + job: Job, + shell: Child, +} + +#[cfg(windows)] +impl JobShell { + fn new() -> Self { + let job = Job::new().expect("cannot create job object"); + let shell = Command::new("cmd") + // `/d` skips the AutoRun registry command, so a machine that has + // one cannot run it inside this job or write to the stderr the + // assertions quote. + .args(["/d", "/q"]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .expect("cannot spawn cmd"); + job.assign(&shell) + .expect("cannot assign cmd to the fresh job"); + Self { job, shell } + } + + /// So a group signal from inside reaches `child`. + fn adopt(&self, child: &Child) { + self.job + .assign(child) + .expect("cannot assign process to the fresh job"); + } + + /// Closes stdin afterwards so cmd exits at EOF rather than blocking forever + /// if the command fails to kill it. + fn run(mut self, command: &str) -> (Option, String) { + let mut stdin = self.shell.stdin.take().expect("cmd has no stdin pipe"); + write!(stdin, "{command}\r\n").expect("cannot write command"); + drop(stdin); + let output = self.shell.wait_with_output().expect("cannot wait on cmd"); + ( + output.status.code(), + String::from_utf8_lossy(&output.stderr).into_owned(), + ) + } +} + #[test] fn test_invalid_arg() { new_ucmd!().arg("--definitely-invalid").fails_with_code(1); @@ -266,10 +349,15 @@ fn test_kill_out_of_range_signal_is_rejected_not_sent() { .arg(format!("{}", target.pid())) .fails_with_code(1) .stderr_contains("invalid signal"); - // The target must have survived: kill it for real and confirm it was - // the SIGKILL we just sent, not an earlier stray SIGTERM. + // The target must have survived: kill it for real and confirm the + // exit came from std's kill (SIGKILL / code 1), not a stray uu-kill + // TERM (which would report 143 on windows). target.child.kill().expect("cannot kill surviving target"); - assert_eq!(target.wait_for_signal(), Some(libc::SIGKILL)); + let status = target.reap(); + #[cfg(unix)] + assert_eq!(status.signal(), Some(9)); + #[cfg(windows)] + assert_eq!(status.code(), Some(1)); } } @@ -277,7 +365,7 @@ fn test_kill_out_of_range_signal_is_rejected_not_sent() { fn test_kill_with_default_signal() { let mut target = Target::new(); new_ucmd!().arg(format!("{}", target.pid())).succeeds(); - assert_eq!(target.wait_for_signal(), Some(libc::SIGTERM)); + target.assert_signaled(15); } #[test] @@ -287,7 +375,7 @@ fn test_kill_with_signal_number_old_form() { .arg("-9") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(9)); + target.assert_signaled(9); } #[test] @@ -298,7 +386,7 @@ fn test_kill_with_signal_name_old_form() { .arg(arg) .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(libc::SIGKILL)); + target.assert_signaled(9); } } @@ -319,7 +407,7 @@ fn test_kill_with_signal_prefixed_name_old_form() { .arg("-SIGKILL") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(libc::SIGKILL)); + target.assert_signaled(9); } #[test] @@ -330,7 +418,7 @@ fn test_kill_with_signal_number_new_form() { .arg("9") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(9)); + target.assert_signaled(9); } #[test] @@ -341,7 +429,7 @@ fn test_kill_with_signal_name_new_form() { .arg("KILL") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(libc::SIGKILL)); + target.assert_signaled(9); } #[test] @@ -352,7 +440,7 @@ fn test_kill_with_signal_name_new_form_ignore_case() { .arg("KiLl") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(libc::SIGKILL)); + target.assert_signaled(9); } #[test] @@ -363,7 +451,7 @@ fn test_kill_with_signal_prefixed_name_new_form() { .arg("SIGKILL") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(libc::SIGKILL)); + target.assert_signaled(9); } #[test] @@ -374,7 +462,7 @@ fn test_kill_with_signal_prefixed_name_new_form_ignore_case() { .arg("SiGKiLl") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(libc::SIGKILL)); + target.assert_signaled(9); } #[test] @@ -413,7 +501,7 @@ fn test_kill_with_signal_number_hidden_compatibility_option() { .arg("9") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(9)); + target.assert_signaled(9); } #[test] @@ -587,10 +675,147 @@ fn test_kill_signal_zero_nonexistent() { #[test] fn test_kill_signal_zero_current_process_group() { - // kill -0 0 should succeed (checks current process group) + // Signal 0 never terminates anything (windows opens group members with + // SYNCHRONIZE only), so this is safe un-isolated. It proves only that the + // enumeration does not error: probing yourself always succeeds, so it would + // pass on an empty pid list too. new_ucmd!().arg("-0").arg("0").succeeds(); } +#[cfg(windows)] +#[test] +fn test_kill_windows_help_has_platform_notes() { + // Every asserted phrase sits entirely inside one `kill-after-help-windows` + // continuation line: clap's `wrap_help` re-wraps lines longer than the + // terminal width but never joins short ones, so the .ftl line breaks + // survive verbatim and these assertions cannot straddle a break. + new_ucmd!() + .arg("--help") + .succeeds() + .stdout_contains("Windows notes") + .stdout_contains("PID 0 targets the Job object") + .stdout_contains("Outside a job") + .stdout_contains("a Job object is usually not yours"); +} + +#[cfg(windows)] +#[test] +fn test_kill_windows_int_and_hup_terminate_directly() { + for (name, sig) in [("INT", 2), ("HUP", 1)] { + let mut target = Target::new(); + new_ucmd!() + .args(&["-s", name, &target.pid().to_string()]) + .succeeds(); + target.assert_signaled(sig); + } +} + +#[cfg(windows)] +#[test] +fn test_kill_windows_ignored_signals_are_noops() { + let mut target = Target::new(); + for sig in ["CHLD", "CONT"] { + new_ucmd!() + .args(&["-s", sig, &target.pid().to_string()]) + .succeeds(); + } + target.assert_alive(); +} + +#[cfg(windows)] +#[test] +fn test_kill_windows_stop_is_rejected() { + let mut target = Target::new(); + new_ucmd!() + .args(&["-s", "STOP", &target.pid().to_string()]) + .fails_with_code(1) + .stderr_contains("unsupported signal"); + target.assert_alive(); +} + +#[cfg(windows)] +#[test] +fn test_kill_windows_negative_pid_unsupported() { + // `-1` is the dangerous one on unix ("every process you may signal"); on + // windows it must be inert, and `u32::try_from` fails before any Win32 call. + new_ucmd!() + .args(&["-9", "-1"]) + .fails_with_code(1) + .stderr_contains("a negative PID"); + + let mut target = Target::new(); + new_ucmd!() + .arg("--") + .arg(format!("-{}", target.pid())) + .fails_with_code(1) + .stderr_contains("a negative PID"); + target.assert_alive(); +} + +/// `kill 0` must reach *other* members of the job, not just the caller. +/// +/// The victim is the proof: it is neither the killer's parent nor its child, so +/// the only path by which it can die is the job pid list. An implementation +/// that regressed to "signal only myself" would still make cmd exit 137, so the +/// exit code alone would not catch it. +#[cfg(windows)] +#[test] +fn test_kill_windows_pid_zero_terminates_the_whole_job() { + for (args, signal) in [("-9 0", 9), ("0", 15)] { + let shell = JobShell::new(); + let mut victim = Target::new(); + shell.adopt(&victim.child); + + // Only now can anything in the job run. + let (code, stderr) = shell.run(&format!("\"{}\" kill {args}", get_tests_binary())); + + // Read cmd's status first, so a broken kill surfaces its own message + // rather than only a 30-second victim timeout below. + assert_eq!( + code, + Some(128 + signal), + "cmd survived `kill {args}`; kill said: {stderr}" + ); + victim.assert_signaled(signal); + } +} + +// The "not in a job" fallback has no integration test on purpose: nothing can +// guarantee a spawned process is job-less. `CREATE_BREAKAWAY_FROM_JOB` detaches +// only from the immediate job, so under a nested chain (`cargo nextest` inside +// `cargo`) the child stays in an ancestor job and `kill -9 0` terminates the +// test runner — an earlier version of this test did exactly that. Covered by +// `with_self_last` in uucore instead, whose empty-member case is that case. + +#[cfg(windows)] +#[test] +fn test_kill_windows_nonexistent_pid_no_such_process() { + new_ucmd!() + .arg("999999999") + .fails_with_code(1) + .stderr_contains("No such process"); + new_ucmd!() + .args(&["-0", "999999999"]) + .fails_with_code(1) + .stderr_contains("No such process"); +} + +#[cfg(windows)] +#[test] +fn test_kill_windows_exited_target_with_held_handle() { + // The Child handle pins the exited process object: terminating still + // succeeds (unix kill-on-unreaped parity) while -0 reports it gone. + let mut target = Target::new(); + target.child.kill().expect("cannot kill target"); + target.reap(); + let pid = target.pid().to_string(); + new_ucmd!().arg(&pid).succeeds(); + new_ucmd!() + .args(&["-0", &pid]) + .fails_with_code(1) + .stderr_contains("No such process"); +} + #[cfg(any(target_os = "linux", target_os = "android"))] #[test] fn test_kill_realtime_signal() { @@ -601,7 +826,7 @@ fn test_kill_realtime_signal() { .arg("RTMIN") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(libc::SIGRTMIN())); + target.assert_signaled(libc::SIGRTMIN()); } #[cfg(any(target_os = "linux", target_os = "android"))] @@ -616,7 +841,7 @@ fn test_kill_with_rtmax_offset() { .arg("SIGRTMAX-7") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(sig)); + target.assert_signaled(sig); } #[cfg(any(target_os = "linux", target_os = "android"))] @@ -631,5 +856,5 @@ fn test_kill_with_rtmin_offset() { .arg("SIGRTMIN+7") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(sig)); + target.assert_signaled(sig); }