diff --git a/Cargo.lock b/Cargo.lock index 79e71f5..efd3589 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -418,6 +418,7 @@ dependencies = [ "find", "findutils", "itertools", + "ntkill", "phf", "phf_codegen", "regex", @@ -1654,6 +1655,15 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "ntkill" +version = "0.0.0" +dependencies = [ + "clap", + "uucore", + "windows-sys 0.59.0", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" diff --git a/Cargo.toml b/Cargo.toml index 5e97da5..ff813cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -125,6 +125,7 @@ uptime = { package = "uu_uptime", path = "deps/coreutils/src/uu/uptime" } findutils = { package = "findutils", path = "deps/findutils" } grep = { package = "uu_grep", path = "deps/grep" } ntfind = { package = "find", path = "deps/ntfind" } +ntkill = { path = "deps/ntkill" } # For registry access in main.rs [dependencies.windows-sys] diff --git a/README.md b/README.md index b639e12..b0b44df 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Legend: ✅ ships and works · ⚠️ ships but conflicts with a built-in · | `expand` | 🛑 | 🛑 | Conflicts with the built-in DOS command | | `find` | ✅ | ✅ | Integrated port of the original DOS command | | `hostname` | ✅ | ✅ | Superset of the Windows built-in | -| `kill` | 🛑 | 🛑 | Unavailable due to lack of signals on Windows; Implementing a form of SIGTERM/SIGKILL may be possible in the future however | +| `kill` | ✅ | ⚠️ | Terminates processes by PID. `0` probes access, while `TERM` and `KILL` both map to Windows process termination. Unix process groups aren't supported. | | `ls` | ✅ | ⚠️ | | | `mkdir` | ⚠️ | ⚠️ | | | `more` | 🛑 | 🛑 | Conflicts with the built-in DOS command (consider `edit` as an alternative) | @@ -72,7 +72,7 @@ Legend: ✅ ships and works · ⚠️ ships but conflicts with a built-in · | `sleep` | ✅ | ⚠️ | | | `sort` | ✅ | ✅ | Integrated port of the original DOS command | | `tee` | ✅ | ⚠️ | | -| `timeout` | 🛑 | 🛑 | Relies on `kill`'s functionality | +| `timeout` | 🛑 | 🛑 | Requires broader timeout and process-management support | | `uptime` | ✅ | ⚠️ | | | `whoami` | 🛑 | 🛑 | Conflicts with the built-in Windows command | diff --git a/build.rs b/build.rs index 4752313..94a26f0 100644 --- a/build.rs +++ b/build.rs @@ -61,6 +61,10 @@ fn generate_uutils_map() { entries.push(("sort".into(), "(sort_uumain, sort_uu_app)".into())); } + if !coreutils.iter().any(|(util, _)| util == "kill") { + entries.push(("kill".into(), "(kill_uumain, kill_uu_app)".into())); + } + entries.sort(); let mut phf_map = phf_codegen::OrderedMap::new(); diff --git a/deps/ntkill/Cargo.toml b/deps/ntkill/Cargo.toml new file mode 100644 index 0000000..c00f33c --- /dev/null +++ b/deps/ntkill/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "ntkill" +edition = "2024" +license = "MIT" +repository = "https://github.com/microsoft/coreutils" +rust-version = "1.88.0" +version = "0.0.0" +publish = false + +[dependencies] +clap = { version = "4.5", features = ["wrap_help", "cargo", "color"] } +uucore = { path = "../coreutils/src/uucore" } + +[dependencies.windows-sys] +version = "*" +features = [ + "Win32_Foundation", + "Win32_System_Threading", +] diff --git a/deps/ntkill/src/lib.rs b/deps/ntkill/src/lib.rs new file mode 100644 index 0000000..942a1f2 --- /dev/null +++ b/deps/ntkill/src/lib.rs @@ -0,0 +1,388 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::ffi::OsString; +use std::io; + +use clap::Command; +use uucore::Args; +use windows_sys::Win32::Foundation::CloseHandle; +use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_TERMINATE, TerminateProcess}; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum KillSignal { + Zero, + Term, + Kill, +} + +impl KillSignal { + fn exit_code(self) -> u32 { + match self { + Self::Zero => 0, + Self::Term => 15, + Self::Kill => 9, + } + } +} + +#[derive(Debug, Eq, PartialEq)] +enum KillAction { + Help, + Version, + List(Vec), + Table, + Terminate { signal: KillSignal, pids: Vec }, +} + +struct SignalInfo { + signal: KillSignal, + name: &'static str, +} + +const SIGNALS: &[SignalInfo] = &[ + SignalInfo { + signal: KillSignal::Zero, + name: "0", + }, + SignalInfo { + signal: KillSignal::Term, + name: "TERM", + }, + SignalInfo { + signal: KillSignal::Kill, + name: "KILL", + }, +]; + +pub fn uumain(args: T) -> i32 { + let mut args = args.into_iter(); + let program = args.next().unwrap_or_else(|| OsString::from("kill")); + let program = program.to_string_lossy(); + let args: Vec = args.collect(); + + match parse_args(&args) { + Ok(KillAction::Help) => { + print_help(&program); + 0 + } + Ok(KillAction::Version) => { + println!("kill {VERSION}"); + 0 + } + Ok(KillAction::List(values)) => list_signals(&values), + Ok(KillAction::Table) => table_signals(), + Ok(KillAction::Terminate { signal, pids }) => { + let mut status = 0; + for pid in pids { + if let Err(err) = signal_pid(pid, signal) { + eprintln!("kill: ({pid}) - {err}"); + status = 1; + } + } + status + } + Err(err) => { + eprintln!("kill: {err}"); + 1 + } + } +} + +pub fn uu_app() -> Command { + Command::new("kill") + .version(VERSION) + .about("Terminate Windows processes by process ID") +} + +fn parse_args(args: &[OsString]) -> Result { + let mut signal = KillSignal::Term; + let mut pids = Vec::new(); + let mut options_done = false; + let mut idx = 0; + + while idx < args.len() { + let arg = args[idx].to_string_lossy(); + + if !options_done { + match arg.as_ref() { + "--" => { + options_done = true; + idx += 1; + continue; + } + "--help" | "-h" => return Ok(KillAction::Help), + "--version" | "-V" => return Ok(KillAction::Version), + "--list" | "-l" => return Ok(KillAction::List(args[idx + 1..].to_vec())), + "--table" | "-t" => return Ok(KillAction::Table), + "--signal" | "-s" => { + idx += 1; + let Some(value) = args.get(idx) else { + return Err(format!("option '{arg}' requires an argument")); + }; + signal = parse_signal(&value.to_string_lossy())?; + idx += 1; + continue; + } + _ if arg.starts_with("--signal=") => { + signal = parse_signal(&arg["--signal=".len()..])?; + idx += 1; + continue; + } + _ if arg.starts_with('-') && arg.len() > 1 => { + signal = parse_signal(&arg[1..]) + .map_err(|_| format!("unsupported option '{arg}'"))?; + idx += 1; + continue; + } + _ => {} + } + } + + pids.push(parse_pid(&arg)?); + idx += 1; + } + + if pids.is_empty() { + return Err("missing process id".to_string()); + } + + Ok(KillAction::Terminate { signal, pids }) +} + +fn parse_signal(value: &str) -> Result { + match value.to_ascii_uppercase().trim_start_matches("SIG") { + "0" => Ok(KillSignal::Zero), + "TERM" | "15" => Ok(KillSignal::Term), + "KILL" | "9" => Ok(KillSignal::Kill), + _ => Err(format!("unsupported signal '{value}'")), + } +} + +fn parse_pid(value: &str) -> Result { + if value == "0" || value.starts_with('-') { + return Err(format!( + "process groups are not supported on Windows: '{value}'" + )); + } + + value + .parse() + .map_err(|err| format!("invalid process id '{value}': {err}")) +} + +fn list_signals(values: &[OsString]) -> i32 { + if values.is_empty() { + println!("0 TERM KILL"); + return 0; + } + + let mut status = 0; + for value in values { + let value = value.to_string_lossy(); + match parse_signal(&value) { + Ok(signal) => println!("{}", signal_list_value(signal, &value)), + Err(err) => { + eprintln!("kill: {err}"); + status = 1; + } + } + } + status +} + +fn table_signals() -> i32 { + for info in SIGNALS { + println!("{:>2} {}", info.signal.exit_code(), info.name); + } + 0 +} + +fn signal_list_value(signal: KillSignal, input: &str) -> String { + if input.parse::().is_ok() { + signal_name(signal).to_string() + } else { + signal.exit_code().to_string() + } +} + +fn signal_name(signal: KillSignal) -> &'static str { + SIGNALS + .iter() + .find(|info| info.signal == signal) + .map_or("UNKNOWN", |info| info.name) +} + +fn signal_pid(pid: u32, signal: KillSignal) -> io::Result<()> { + if signal == KillSignal::Zero { + probe_pid(pid) + } else { + terminate_pid(pid, signal) + } +} + +fn probe_pid(pid: u32) -> io::Result<()> { + let handle = unsafe { OpenProcess(PROCESS_TERMINATE, 0, pid) }; + if handle.is_null() { + return Err(io::Error::last_os_error()); + } + + unsafe { CloseHandle(handle) }; + Ok(()) +} + +fn terminate_pid(pid: u32, signal: KillSignal) -> io::Result<()> { + let handle = unsafe { OpenProcess(PROCESS_TERMINATE, 0, pid) }; + if handle.is_null() { + return Err(io::Error::last_os_error()); + } + + let ret = unsafe { TerminateProcess(handle, signal.exit_code()) }; + let result = if ret == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + }; + + unsafe { CloseHandle(handle) }; + result +} + +fn print_help(program: &str) { + println!( + "\ +Usage: {program} [OPTION] PID... + +Terminate Windows processes by process ID. + +Options: + -s, --signal SIGNAL accept 0, TERM, KILL, 15, or 9 + -l, --list [SIGNAL] list supported signal names or convert SIGNAL + -t, --table list supported signals in a table + --help display this help and exit + --version output version information and exit" + ); +} + +#[cfg(test)] +mod tests { + use super::{ + KillAction, KillSignal, list_signals, parse_args, parse_pid, parse_signal, + signal_list_value, + }; + use std::ffi::OsString; + + fn args(values: &[&str]) -> Vec { + values.iter().map(OsString::from).collect() + } + + #[test] + fn parses_default_term_signal() { + assert_eq!( + parse_args(&args(&["123", "456"])), + Ok(KillAction::Terminate { + signal: KillSignal::Term, + pids: vec![123, 456], + }) + ); + } + + #[test] + fn parses_signal_options() { + assert_eq!(parse_signal("TERM"), Ok(KillSignal::Term)); + assert_eq!(parse_signal("sigterm"), Ok(KillSignal::Term)); + assert_eq!(parse_signal("15"), Ok(KillSignal::Term)); + assert_eq!(parse_signal("KILL"), Ok(KillSignal::Kill)); + assert_eq!(parse_signal("9"), Ok(KillSignal::Kill)); + assert_eq!(parse_signal("0"), Ok(KillSignal::Zero)); + } + + #[test] + fn parses_obsolete_signal_form() { + assert_eq!( + parse_args(&args(&["-9", "123"])), + Ok(KillAction::Terminate { + signal: KillSignal::Kill, + pids: vec![123], + }) + ); + } + + #[test] + fn parses_long_signal_form() { + assert_eq!( + parse_args(&args(&["--signal=KILL", "123"])), + Ok(KillAction::Terminate { + signal: KillSignal::Kill, + pids: vec![123], + }) + ); + + assert_eq!( + parse_args(&args(&["-s", "TERM", "123"])), + Ok(KillAction::Terminate { + signal: KillSignal::Term, + pids: vec![123], + }) + ); + } + + #[test] + fn parses_end_of_options() { + assert_eq!( + parse_args(&args(&["--", "123"])), + Ok(KillAction::Terminate { + signal: KillSignal::Term, + pids: vec![123], + }) + ); + } + + #[test] + fn rejects_invalid_pid() { + assert!(parse_pid("0").is_err()); + assert!(parse_pid("-123").is_err()); + assert!(parse_pid("abc").is_err()); + } + + #[test] + fn rejects_unsupported_signal() { + assert!(parse_args(&args(&["-HUP", "123"])).is_err()); + assert!(parse_signal("HUP").is_err()); + } + + #[test] + fn parses_zero_signal_probe() { + assert_eq!( + parse_args(&args(&["-0", "123"])), + Ok(KillAction::Terminate { + signal: KillSignal::Zero, + pids: vec![123], + }) + ); + } + + #[test] + fn parses_list_and_table_modes() { + assert_eq!( + parse_args(&args(&["-l", "9", "TERM"])), + Ok(KillAction::List(args(&["9", "TERM"]))) + ); + assert_eq!(parse_args(&args(&["--table"])), Ok(KillAction::Table)); + } + + #[test] + fn converts_list_values() { + assert_eq!(signal_list_value(KillSignal::Kill, "9"), "KILL"); + assert_eq!(signal_list_value(KillSignal::Term, "TERM"), "15"); + assert_eq!(list_signals(&args(&["9", "TERM"])), 0); + } + + #[test] + fn rejects_process_groups() { + let err = parse_args(&args(&["--", "-123"])).unwrap_err(); + assert!(err.contains("process groups are not supported")); + } +} diff --git a/src/main.rs b/src/main.rs index b0838c7..fcbb17b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -320,3 +320,11 @@ fn sort_uumain(args: T) -> i32 { fn sort_uu_app() -> Command { sort::uu_app() } + +fn kill_uumain(args: T) -> i32 { + ntkill::uumain(args) +} + +fn kill_uu_app() -> Command { + ntkill::uu_app() +} diff --git a/src/pwsh-install-template.ps1 b/src/pwsh-install-template.ps1 index 335124f..2398efc 100644 --- a/src/pwsh-install-template.ps1 +++ b/src/pwsh-install-template.ps1 @@ -7,7 +7,7 @@ $script:__COREUTILS__ = [System.Collections.Generic.HashSet[string]]::new( 'csplit', 'cut', 'date', 'df', 'dirname', 'du', 'echo', 'env', 'expr', 'factor', 'false', 'find', 'fmt', 'fold', 'grep', - 'head', 'hostname', 'join', 'la', 'link', + 'head', 'hostname', 'join', 'kill', 'la', 'link', 'ln', 'ls', 'md5sum', 'mkdir', 'mktemp', 'mv', 'nl', 'nproc', 'numfmt', 'od', 'pathchk', 'pr', 'printenv', 'printf', 'ptx',