diff --git a/Cargo.lock b/Cargo.lock index 94edaa9..0b40665 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "0.6.21" @@ -156,6 +165,16 @@ dependencies = [ "nix", "orx-parallel", "rayon", + "regex", +] + +[[package]] +name = "fuser" +version = "0.0.1" +dependencies = [ + "clap", + "helpers", + "nix", ] [[package]] @@ -169,6 +188,7 @@ name = "helpers" version = "0.0.1" dependencies = [ "clap", + "nix", ] [[package]] @@ -189,6 +209,12 @@ version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + [[package]] name = "nix" version = "0.30.1" @@ -406,6 +432,35 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + [[package]] name = "syn" version = "2.0.111" diff --git a/Cargo.toml b/Cargo.toml index ada94a7..b710571 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["crates/faulx", "crates/procedre", "crates/helpers"] +members = ["crates/faulx", "crates/procedre", "crates/helpers", "crates/fuser"] [workspace.package] authors = ["martabal"] @@ -24,6 +24,11 @@ nix = { version = "0.30.1", default-features = false, features = [ ] } orx-parallel = { version = "3.4.0", default-features = false } rayon = { version = "1.11.0", default-features = false } +regex = { version = "1.11", default-features = false, features = [ + "std", + "perf", + "unicode-case", +] } [profile.release] opt-level = 3 diff --git a/README.md b/README.md index 3205ece..1d46bfc 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,14 @@ Implementation of psmisc utilities in rust. This is an early WIP. -WIP: +Implemented: - faulx (killall implementation) - procedre (pstree implementation) +- fuser (identify processes using files/sockets) TODO: -- fuser - prstat - pslog - peekfd diff --git a/crates/faulx/Cargo.toml b/crates/faulx/Cargo.toml index 3227e9c..8dda1a1 100644 --- a/crates/faulx/Cargo.toml +++ b/crates/faulx/Cargo.toml @@ -18,6 +18,7 @@ workspace = true default = ["orx-parallel"] rayon = ["dep:rayon"] orx-parallel = ["dep:orx-parallel"] +regex = ["dep:regex"] [dependencies] clap.workspace = true @@ -26,3 +27,4 @@ humantime.workspace = true nix.workspace = true orx-parallel = { workspace = true, optional = true } rayon = { workspace = true, optional = true } +regex = { workspace = true, optional = true } diff --git a/crates/faulx/README.md b/crates/faulx/README.md index ec15a58..935c697 100644 --- a/crates/faulx/README.md +++ b/crates/faulx/README.md @@ -14,8 +14,5 @@ options implemented: - interactive mode - ignore case - kill process running as user - -TODO: - - regexp - processes in the same namespace diff --git a/crates/faulx/src/cli.rs b/crates/faulx/src/cli.rs index ed426c9..aac5023 100644 --- a/crates/faulx/src/cli.rs +++ b/crates/faulx/src/cli.rs @@ -54,4 +54,13 @@ pub struct FaulxArgs { /// Report if the signal was successfully sent #[arg(long)] pub verbose: bool, + + /// Interpret process_names as an extended regular expression + #[cfg(feature = "regex")] + #[arg(short = 'r', long)] + pub regexp: bool, + + /// Match processes that belong to the same namespaces as PID + #[arg(short = 'n', long)] + pub namespace: Option, } diff --git a/crates/faulx/src/lib.rs b/crates/faulx/src/lib.rs index d22f72b..da525ee 100644 --- a/crates/faulx/src/lib.rs +++ b/crates/faulx/src/lib.rs @@ -4,7 +4,6 @@ use nix::unistd::{User, setuid}; pub mod cli; pub mod processes; -pub mod signals; #[cfg(all(feature = "rayon", feature = "orx-parallel"))] compile_error!("feature \"rayon\" and feature \"oxc-parallel\" cannot be enabled at the same time"); diff --git a/crates/faulx/src/main.rs b/crates/faulx/src/main.rs index 288e6b4..bf6a512 100644 --- a/crates/faulx/src/main.rs +++ b/crates/faulx/src/main.rs @@ -5,7 +5,7 @@ use std::{ }; use clap::Parser; -use helpers::{macros::QUIET, qprintln}; +use helpers::{list_signals, macros::QUIET, parse_signal, qprintln}; use nix::{ sys::signal::{Signal, kill}, unistd::Pid, @@ -15,7 +15,6 @@ use faulx::{ change_user, cli::{FaulxArgs, MAX_NAMES}, processes::{OptionsPids, list_pids}, - signals::{list_signals, parse_signal}, }; fn main() { @@ -55,6 +54,9 @@ fn main() { younger_than: args.younger_than, older_than: args.older_than, ignore_case: args.ignore_case, + #[cfg(feature = "regex")] + regexp: args.regexp, + namespace: args.namespace.as_deref(), }; let pids = match list_pids(process_name, &opts) { Ok(pids) => pids, diff --git a/crates/faulx/src/processes.rs b/crates/faulx/src/processes.rs index 84dd47f..85415e9 100644 --- a/crates/faulx/src/processes.rs +++ b/crates/faulx/src/processes.rs @@ -6,17 +6,34 @@ use nix::unistd::{SysconfVar, sysconf}; use orx_parallel::{IterIntoParIter, ParIter}; #[cfg(feature = "rayon")] use rayon::iter::{ParallelBridge, ParallelIterator}; +#[cfg(feature = "regex")] +use regex::Regex; -pub struct OptionsPids { +pub struct OptionsPids<'a> { pub use_group: bool, pub younger_than: Option, pub older_than: Option, pub ignore_case: bool, + #[cfg(feature = "regex")] + pub regexp: bool, + pub namespace: Option<&'a str>, } -pub fn list_pids(target_name: &str, opt: &OptionsPids) -> Result, Box> { +pub fn list_pids(target_name: &str, opt: &OptionsPids<'_>) -> Result, Box> { let target_bytes = target_name.as_bytes(); + #[cfg(feature = "regex")] + let regex = if opt.regexp { + let pattern = if opt.ignore_case { + format!("(?i){target_name}") + } else { + target_name.to_string() + }; + Some(Regex::new(&pattern)?) + } else { + None + }; + let entries = fs::read_dir(PROC)?; #[cfg(feature = "rayon")] let iter = entries.par_bridge(); @@ -28,16 +45,28 @@ pub fn list_pids(target_name: &str, opt: &OptionsPids) -> Result, Box = iter .filter_map(std::result::Result::ok) .filter_map(|entry| { + #[cfg(feature = "regex")] + let pid = if opt.regexp { + check_entry_regex(&entry, regex.as_ref()?)? + } else { + check_entry(&entry, target_bytes, opt.ignore_case)? + }; + #[cfg(not(feature = "regex"))] let pid = check_entry(&entry, target_bytes, opt.ignore_case)?; let stat = check_stat(pid)?; - if matches!( + let time_matches = matches!( check_time(&stat, opt.younger_than, opt.older_than), Ok(true) - ) { - Some(pid) - } else { - None + ); + if !time_matches { + return None; } + if let Some(ns) = opt.namespace + && !check_namespace(pid, ns).unwrap_or(false) + { + return None; + } + Some(pid) }) .collect(); @@ -192,6 +221,37 @@ fn check_entry(entry: &fs::DirEntry, target_bytes: &[u8], case_insensitive: bool (name == target_bytes).then_some(pid) } +#[cfg(feature = "regex")] +fn check_entry_regex(entry: &fs::DirEntry, regex: &Regex) -> Option { + let pid = parse_pid_from_bytes(entry.file_name().as_bytes())?; + + let comm_path = format!("{PROC}/{pid}/comm"); + let mut buf = [0u8; 64]; + let len = fs::File::open(&comm_path) + .ok() + .and_then(|mut f| io::Read::read(&mut f, &mut buf).ok())?; + + let name = if len > 0 && buf[len - 1] == b'\n' { + &buf[..len - 1] + } else { + &buf[..len] + }; + + let name_str = std::str::from_utf8(name).ok()?; + regex.is_match(name_str).then_some(pid) +} + +fn check_namespace(pid: i32, ns_type: &str) -> Result> { + let current_pid = std::process::id(); + let current_ns_path = format!("{PROC}/{current_pid}/ns/{ns_type}"); + let target_ns_path = format!("{PROC}/{pid}/ns/{ns_type}"); + + let current_ns = fs::read_link(¤t_ns_path)?; + let target_ns = fs::read_link(&target_ns_path)?; + + Ok(current_ns == target_ns) +} + #[cfg(test)] mod tests { use super::*; @@ -299,6 +359,9 @@ mod tests { younger_than: None, older_than: None, ignore_case: false, + #[cfg(feature = "regex")] + regexp: false, + namespace: None, }; let result = list_pids("systemd", &opts); @@ -313,6 +376,9 @@ mod tests { younger_than: None, older_than: None, ignore_case: false, + #[cfg(feature = "regex")] + regexp: false, + namespace: None, }; assert!(!opts.use_group); @@ -366,6 +432,9 @@ mod tests { younger_than: None, older_than: None, ignore_case: false, + #[cfg(feature = "regex")] + regexp: false, + namespace: None, }; // Search for a process name that definitely doesn't exist @@ -389,6 +458,9 @@ mod tests { younger_than: None, older_than: None, ignore_case: false, + #[cfg(feature = "regex")] + regexp: false, + namespace: None, }; assert!(!opts.use_group); @@ -404,6 +476,9 @@ mod tests { younger_than: None, older_than: None, ignore_case: false, + #[cfg(feature = "regex")] + regexp: false, + namespace: None, }; // Try to find a process that definitely doesn't exist @@ -424,6 +499,9 @@ mod tests { younger_than: None, older_than: None, ignore_case: false, + #[cfg(feature = "regex")] + regexp: false, + namespace: None, }; // Test case insensitive @@ -432,10 +510,48 @@ mod tests { younger_than: None, older_than: None, ignore_case: true, + #[cfg(feature = "regex")] + regexp: false, + namespace: None, }; // Both should work without panicking let _ = list_pids("systemd", &opts_sensitive); let _ = list_pids("SYSTEMD", &opts_insensitive); } + + #[test] + #[cfg(feature = "regex")] + fn test_regex_matching() { + // Test basic regex matching + let regex = Regex::new("bash.*").unwrap(); + assert!(regex.is_match("bash")); + assert!(regex.is_match("bashrc")); + assert!(!regex.is_match("sshd")); + } + + #[test] + #[cfg(feature = "regex")] + fn test_regex_case_insensitive() { + // Test case insensitive regex matching + let regex = Regex::new("(?i)bash").unwrap(); + assert!(regex.is_match("BASH")); + assert!(regex.is_match("bash")); + assert!(regex.is_match("Bash")); + } + + #[test] + fn test_namespace_same_process() { + // Test that a process is in the same namespace as itself + let current_pid = std::process::id() as i32; + // Test common namespace types + for ns_type in &["pid", "net", "mnt", "ipc"] { + if let Ok(result) = check_namespace(current_pid, ns_type) { + assert!( + result, + "Current process should be in its own {ns_type} namespace" + ); + } + } + } } diff --git a/crates/faulx/src/signals.rs b/crates/faulx/src/signals.rs deleted file mode 100644 index d591216..0000000 --- a/crates/faulx/src/signals.rs +++ /dev/null @@ -1,176 +0,0 @@ -use nix::sys::signal::Signal; - -static SIGNALS: &[(&str, Signal)] = &[ - ("INT", Signal::SIGINT), - ("TERM", Signal::SIGTERM), - ("KILL", Signal::SIGKILL), - ("HUP", Signal::SIGHUP), - ("QUIT", Signal::SIGQUIT), - ("USR1", Signal::SIGUSR1), - ("USR2", Signal::SIGUSR2), - ("ALRM", Signal::SIGALRM), - ("CONT", Signal::SIGCONT), - ("STOP", Signal::SIGSTOP), - ("TSTP", Signal::SIGTSTP), - ("CHLD", Signal::SIGCHLD), - ("PIPE", Signal::SIGPIPE), - ("SEGV", Signal::SIGSEGV), - ("ABRT", Signal::SIGABRT), - ("ILL", Signal::SIGILL), - ("TRAP", Signal::SIGTRAP), - ("BUS", Signal::SIGBUS), - ("FPE", Signal::SIGFPE), - ("TTIN", Signal::SIGTTIN), - ("TTOU", Signal::SIGTTOU), - ("URG", Signal::SIGURG), - ("XCPU", Signal::SIGXCPU), - ("XFSZ", Signal::SIGXFSZ), - ("VTALRM", Signal::SIGVTALRM), - ("PROF", Signal::SIGPROF), - ("WINCH", Signal::SIGWINCH), - ("IO", Signal::SIGIO), - ("PWR", Signal::SIGPWR), - ("SYS", Signal::SIGSYS), -]; - -#[must_use] -pub fn parse_signal(name: &str) -> Option { - let upper = name.to_uppercase(); - - SIGNALS - .iter() - .find(|(sig_name, _)| *sig_name == upper.as_str()) - .map(|(_, signal)| *signal) -} - -#[must_use] -pub fn list_signals() -> String { - SIGNALS - .iter() - .map(|(name, _)| *name) - .collect::>() - .join(" ") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_signal_valid() { - assert_eq!(parse_signal("INT"), Some(Signal::SIGINT)); - assert_eq!(parse_signal("TERM"), Some(Signal::SIGTERM)); - assert_eq!(parse_signal("KILL"), Some(Signal::SIGKILL)); - assert_eq!(parse_signal("HUP"), Some(Signal::SIGHUP)); - assert_eq!(parse_signal("QUIT"), Some(Signal::SIGQUIT)); - assert_eq!(parse_signal("USR1"), Some(Signal::SIGUSR1)); - assert_eq!(parse_signal("USR2"), Some(Signal::SIGUSR2)); - } - - #[test] - fn test_parse_signal_case_insensitive() { - // Lowercase - assert_eq!(parse_signal("int"), Some(Signal::SIGINT)); - assert_eq!(parse_signal("term"), Some(Signal::SIGTERM)); - assert_eq!(parse_signal("kill"), Some(Signal::SIGKILL)); - - // Mixed case - assert_eq!(parse_signal("InT"), Some(Signal::SIGINT)); - assert_eq!(parse_signal("TeRm"), Some(Signal::SIGTERM)); - assert_eq!(parse_signal("KiLl"), Some(Signal::SIGKILL)); - } - - #[test] - fn test_parse_signal_invalid() { - assert_eq!(parse_signal("INVALID"), None); - assert_eq!(parse_signal(""), None); - assert_eq!(parse_signal("123"), None); - assert_eq!(parse_signal("SIG"), None); - assert_eq!(parse_signal("SIGINT"), None); // Function expects short signal names without 'SIG' prefix - } - - #[test] - fn test_parse_signal_all_signals() { - // Verify all signals in our list can be parsed - for (name, expected_signal) in SIGNALS { - assert_eq!(parse_signal(name), Some(*expected_signal)); - } - } - - #[test] - fn test_list_signals_contains_expected() { - let signals = list_signals(); - - // Check that common signals are in the list - assert!(signals.contains("INT")); - assert!(signals.contains("TERM")); - assert!(signals.contains("KILL")); - assert!(signals.contains("HUP")); - assert!(signals.contains("QUIT")); - } - - #[test] - fn test_list_signals_format() { - let signals = list_signals(); - - // Should be space-separated - assert!(signals.contains(' ')); - - // Should not have trailing space - assert!(!signals.ends_with(' ')); - - // Should not be empty - assert!(!signals.is_empty()); - } - - #[test] - fn test_list_signals_count() { - let signals = list_signals(); - let count = signals.split(' ').count(); - - // Should match the number of signals we defined - assert_eq!(count, SIGNALS.len()); - } - - #[test] - fn test_common_signals() { - // Test the most common signals used in killall - assert_eq!(parse_signal("TERM"), Some(Signal::SIGTERM)); - assert_eq!(parse_signal("KILL"), Some(Signal::SIGKILL)); - assert_eq!(parse_signal("INT"), Some(Signal::SIGINT)); - assert_eq!(parse_signal("HUP"), Some(Signal::SIGHUP)); - } - - #[test] - fn test_parse_all_listed_signals() { - let signal_list = list_signals(); - let signal_names: Vec<&str> = signal_list.split(' ').collect(); - - // Every signal in the list should be parseable - for name in signal_names { - assert!( - parse_signal(name).is_some(), - "Signal {} should be parseable", - name - ); - } - } - - #[test] - fn test_signal_parsing_roundtrip() { - // Parse a signal and verify it returns the expected value - let common_signals = vec!["INT", "TERM", "KILL", "HUP", "QUIT"]; - - for signal_name in common_signals { - let parsed = parse_signal(signal_name); - assert!(parsed.is_some(), "Failed to parse {}", signal_name); - } - } - - #[test] - fn test_list_signals_not_empty() { - let signals = list_signals(); - assert!(!signals.is_empty()); - assert!(signals.len() > 10); // Should have many signals - } -} diff --git a/crates/fuser/Cargo.toml b/crates/fuser/Cargo.toml new file mode 100644 index 0000000..931052b --- /dev/null +++ b/crates/fuser/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "fuser" +version = "0.0.1" +description = "Rust implementation of fuser" +keywords = ["fuser"] +categories = ["command-line-utilities", "os"] +license.workspace = true +rust-version.workspace = true +homepage.workspace = true +repository.workspace = true +authors.workspace = true +edition.workspace = true + +[lints] +workspace = true + +[dependencies] +clap.workspace = true +helpers = { path = "../helpers" } +nix.workspace = true diff --git a/crates/fuser/README.md b/crates/fuser/README.md new file mode 100644 index 0000000..1cb52e0 --- /dev/null +++ b/crates/fuser/README.md @@ -0,0 +1,30 @@ +# fuser + +Rust implementation of `fuser`. + +Identifies processes using specified files or directories. + +## Features + +- Find processes using files or directories +- Check current working directory, root directory, executable, file descriptors, and memory maps +- Kill processes using specified files +- Custom signal support +- Verbose output showing access types +- Quiet mode for scripting + +## Usage + +```bash +# Find processes using a file +fuser /path/to/file + +# Verbose output +fuser -v /path/to/file + +# Kill processes using a file +fuser -k /path/to/file + +# Kill with custom signal +fuser -k -s TERM /path/to/file +``` diff --git a/crates/fuser/src/cli.rs b/crates/fuser/src/cli.rs new file mode 100644 index 0000000..ea9c3d6 --- /dev/null +++ b/crates/fuser/src/cli.rs @@ -0,0 +1,27 @@ +use clap::Parser; +use helpers::STYLES; + +#[derive(Parser, Debug)] +#[command(styles = STYLES)] +#[command(author, version, about, long_about = None)] +pub struct FuserArgs { + /// Files or directories to check + #[arg(required = true)] + pub names: Vec, + + /// Kill processes accessing the file + #[arg(short = 'k', long)] + pub kill: bool, + + /// Signal to send (default: SIGKILL) + #[arg(short = 's', long)] + pub signal: Option, + + /// Verbose output + #[arg(short = 'v', long)] + pub verbose: bool, + + /// Silent operation + #[arg(short = 'q', long)] + pub quiet: bool, +} diff --git a/crates/fuser/src/finder.rs b/crates/fuser/src/finder.rs new file mode 100644 index 0000000..b142eb3 --- /dev/null +++ b/crates/fuser/src/finder.rs @@ -0,0 +1,126 @@ +use std::{collections::HashMap, error::Error, fs, path::Path}; + +use helpers::{PROC, parse_pid_from_bytes}; + +#[derive(Debug)] +pub struct ProcessInfo { + pub pid: i32, + pub comm: String, + pub accesses: Vec, +} + +#[derive(Debug)] +pub enum AccessType { + Cwd, // Current working directory + Root, // Root directory + Exe, // Executable + Fd, // File descriptor + MemoryMap, // Memory mapped file +} + +impl AccessType { + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::Cwd => "cwd", + Self::Root => "root", + Self::Exe => "exe", + Self::Fd => "fd", + Self::MemoryMap => "mmap", + } + } +} + +pub fn find_processes_using_file(target_path: &str) -> Result, Box> { + let target_path = fs::canonicalize(target_path)?; + let mut results = Vec::new(); + + for entry in fs::read_dir(PROC)? { + let Ok(entry) = entry else { continue }; + + let Some(pid) = parse_pid_from_bytes(entry.file_name().as_encoded_bytes()) else { + continue; + }; + + if let Some(info) = check_process(pid, &target_path) { + results.push(info); + } + } + + Ok(results) +} + +fn check_process(pid: i32, target_path: &Path) -> Option { + let comm = get_process_comm(pid)?; + let mut accesses = Vec::new(); + + if let Ok(cwd) = fs::read_link(format!("{PROC}/{pid}/cwd")) + && cwd == target_path + { + accesses.push(AccessType::Cwd); + } + + if let Ok(root) = fs::read_link(format!("{PROC}/{pid}/root")) + && root == target_path + { + accesses.push(AccessType::Root); + } + + if let Ok(exe) = fs::read_link(format!("{PROC}/{pid}/exe")) + && exe == target_path + { + accesses.push(AccessType::Exe); + } + + if let Ok(fd_entries) = fs::read_dir(format!("{PROC}/{pid}/fd")) { + for fd in fd_entries.filter_map(Result::ok) { + if let Ok(path) = fs::read_link(fd.path()) + && path == target_path + { + accesses.push(AccessType::Fd); + break; + } + } + } + + if let Ok(maps) = fs::read_to_string(format!("{PROC}/{pid}/maps")) { + for line in maps.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 6 && Path::new(parts[5]) == target_path { + accesses.push(AccessType::MemoryMap); + break; + } + } + } + + if accesses.is_empty() { + None + } else { + Some(ProcessInfo { + pid, + comm, + accesses, + }) + } +} + +fn get_process_comm(pid: i32) -> Option { + let comm_path = format!("{PROC}/{pid}/comm"); + fs::read_to_string(comm_path) + .ok() + .map(|s| s.trim().to_string()) +} + +#[must_use] +pub fn group_by_file(infos: Vec) -> HashMap> { + let mut grouped: HashMap> = HashMap::new(); + + for info in infos { + grouped + .entry(format!("{}({})", info.comm, info.pid)) + .or_default() + .push(info); + } + + grouped +} diff --git a/crates/fuser/src/lib.rs b/crates/fuser/src/lib.rs new file mode 100644 index 0000000..6b2b4d4 --- /dev/null +++ b/crates/fuser/src/lib.rs @@ -0,0 +1,2 @@ +pub mod cli; +pub mod finder; diff --git a/crates/fuser/src/main.rs b/crates/fuser/src/main.rs new file mode 100644 index 0000000..f8cf2a4 --- /dev/null +++ b/crates/fuser/src/main.rs @@ -0,0 +1,70 @@ +use std::{collections::HashSet, process}; + +use clap::Parser; +use fuser::{cli::FuserArgs, finder}; +use helpers::parse_signal; +use nix::{ + sys::signal::{Signal, kill}, + unistd::Pid, +}; + +fn main() { + let args = FuserArgs::parse(); + + let mut all_pids = HashSet::new(); + + for name in &args.names { + match finder::find_processes_using_file(name) { + Ok(infos) => { + if infos.is_empty() && !args.quiet { + eprintln!("{name}: No process using this file"); + continue; + } + + if !args.quiet { + print!("{name}:"); + } + + for info in &infos { + all_pids.insert(info.pid); + if !args.quiet { + if args.verbose { + print!(" {}({})[{:?}]", info.comm, info.pid, info.accesses); + } else { + print!(" {}", info.pid); + } + } + } + + if !args.quiet { + println!(); + } + } + Err(e) => { + if !args.quiet { + eprintln!("{name}: {e}"); + } + process::exit(1); + } + } + } + + if args.kill { + let signal = args.signal.as_ref().map_or(Signal::SIGKILL, |sig_name| { + parse_signal(sig_name).unwrap_or_else(|| { + eprintln!("Unknown signal: {sig_name}"); + process::exit(1); + }) + }); + + for pid in all_pids { + if let Err(e) = kill(Pid::from_raw(pid), signal) { + if !args.quiet { + eprintln!("Failed to kill process {pid}: {e}"); + } + } else if args.verbose && !args.quiet { + println!("Killed process {pid}"); + } + } + } +} diff --git a/crates/helpers/Cargo.toml b/crates/helpers/Cargo.toml index 4296cbe..1cb2fa3 100644 --- a/crates/helpers/Cargo.toml +++ b/crates/helpers/Cargo.toml @@ -13,3 +13,4 @@ crate-type = ["lib"] [dependencies] clap.workspace = true +nix.workspace = true diff --git a/crates/helpers/src/lib.rs b/crates/helpers/src/lib.rs index e41ba1e..1744c8b 100644 --- a/crates/helpers/src/lib.rs +++ b/crates/helpers/src/lib.rs @@ -1,4 +1,5 @@ use clap::builder::{Styles, styling::AnsiColor}; +use nix::sys::signal::Signal; pub mod macros; @@ -25,55 +26,177 @@ pub const STYLES: Styles = Styles::styled() .literal(AnsiColor::Cyan.on_default().bold()) .placeholder(AnsiColor::Cyan.on_default()); +static SIGNALS: &[(&str, Signal)] = &[ + ("INT", Signal::SIGINT), + ("TERM", Signal::SIGTERM), + ("KILL", Signal::SIGKILL), + ("HUP", Signal::SIGHUP), + ("QUIT", Signal::SIGQUIT), + ("USR1", Signal::SIGUSR1), + ("USR2", Signal::SIGUSR2), + ("ALRM", Signal::SIGALRM), + ("CONT", Signal::SIGCONT), + ("STOP", Signal::SIGSTOP), + ("TSTP", Signal::SIGTSTP), + ("CHLD", Signal::SIGCHLD), + ("PIPE", Signal::SIGPIPE), + ("SEGV", Signal::SIGSEGV), + ("ABRT", Signal::SIGABRT), + ("ILL", Signal::SIGILL), + ("TRAP", Signal::SIGTRAP), + ("BUS", Signal::SIGBUS), + ("FPE", Signal::SIGFPE), + ("TTIN", Signal::SIGTTIN), + ("TTOU", Signal::SIGTTOU), + ("URG", Signal::SIGURG), + ("XCPU", Signal::SIGXCPU), + ("XFSZ", Signal::SIGXFSZ), + ("VTALRM", Signal::SIGVTALRM), + ("PROF", Signal::SIGPROF), + ("WINCH", Signal::SIGWINCH), + ("IO", Signal::SIGIO), + ("PWR", Signal::SIGPWR), + ("SYS", Signal::SIGSYS), +]; + +#[must_use] +pub fn parse_signal(name: &str) -> Option { + let upper = name.to_uppercase(); + + SIGNALS + .iter() + .find(|(sig_name, _)| *sig_name == upper.as_str()) + .map(|(_, signal)| *signal) +} + +#[must_use] +pub fn list_signals() -> String { + SIGNALS + .iter() + .map(|(name, _)| *name) + .collect::>() + .join(" ") +} + #[cfg(test)] mod tests { use super::*; #[test] - fn test_parse_pid_from_bytes_valid() { - assert_eq!(parse_pid_from_bytes(b"1"), Some(1)); - assert_eq!(parse_pid_from_bytes(b"123"), Some(123)); - assert_eq!(parse_pid_from_bytes(b"12345"), Some(12345)); - assert_eq!(parse_pid_from_bytes(b"999999"), Some(999999)); - assert_eq!(parse_pid_from_bytes(b"2147483647"), Some(2147483647)); // i32::MAX + fn test_parse_signal_valid() { + assert_eq!(parse_signal("INT"), Some(Signal::SIGINT)); + assert_eq!(parse_signal("TERM"), Some(Signal::SIGTERM)); + assert_eq!(parse_signal("KILL"), Some(Signal::SIGKILL)); + assert_eq!(parse_signal("HUP"), Some(Signal::SIGHUP)); + assert_eq!(parse_signal("QUIT"), Some(Signal::SIGQUIT)); + assert_eq!(parse_signal("USR1"), Some(Signal::SIGUSR1)); + assert_eq!(parse_signal("USR2"), Some(Signal::SIGUSR2)); + } + + #[test] + fn test_parse_signal_case_insensitive() { + // Lowercase + assert_eq!(parse_signal("int"), Some(Signal::SIGINT)); + assert_eq!(parse_signal("term"), Some(Signal::SIGTERM)); + assert_eq!(parse_signal("kill"), Some(Signal::SIGKILL)); + + // Mixed case + assert_eq!(parse_signal("InT"), Some(Signal::SIGINT)); + assert_eq!(parse_signal("TeRm"), Some(Signal::SIGTERM)); + assert_eq!(parse_signal("KiLl"), Some(Signal::SIGKILL)); + } + + #[test] + fn test_parse_signal_invalid() { + assert_eq!(parse_signal("INVALID"), None); + assert_eq!(parse_signal(""), None); + assert_eq!(parse_signal("123"), None); + assert_eq!(parse_signal("SIG"), None); + assert_eq!(parse_signal("SIGINT"), None); // Function expects short signal names without 'SIG' prefix + } + + #[test] + fn test_parse_signal_all_signals() { + // Verify all signals in our list can be parsed + for (name, expected_signal) in SIGNALS { + assert_eq!(parse_signal(name), Some(*expected_signal)); + } } #[test] - fn test_parse_pid_from_bytes_invalid() { - // Empty input - assert_eq!(parse_pid_from_bytes(b""), None); - - // Zero should return None - assert_eq!(parse_pid_from_bytes(b"0"), None); - assert_eq!(parse_pid_from_bytes(b"00"), None); - assert_eq!(parse_pid_from_bytes(b"000"), None); - - // Non-digit characters - assert_eq!(parse_pid_from_bytes(b"abc"), None); - assert_eq!(parse_pid_from_bytes(b"12a"), None); - assert_eq!(parse_pid_from_bytes(b"a12"), None); - assert_eq!(parse_pid_from_bytes(b"1 2"), None); - assert_eq!(parse_pid_from_bytes(b"-1"), None); - assert_eq!(parse_pid_from_bytes(b"+1"), None); - - // Too long (more than 10 digits) - assert_eq!(parse_pid_from_bytes(b"12345678901"), None); - - // Overflow i32::MAX - assert_eq!(parse_pid_from_bytes(b"2147483648"), None); - assert_eq!(parse_pid_from_bytes(b"9999999999"), None); + fn test_list_signals_contains_expected() { + let signals = list_signals(); + + // Check that common signals are in the list + assert!(signals.contains("INT")); + assert!(signals.contains("TERM")); + assert!(signals.contains("KILL")); + assert!(signals.contains("HUP")); + assert!(signals.contains("QUIT")); } #[test] - fn test_parse_pid_from_bytes_edge_cases() { - // Single digit - assert_eq!(parse_pid_from_bytes(b"1"), Some(1)); - assert_eq!(parse_pid_from_bytes(b"9"), Some(9)); + fn test_list_signals_format() { + let signals = list_signals(); - // Leading zeros don't affect the final parsed value - assert_eq!(parse_pid_from_bytes(b"01"), Some(1)); + // Should be space-separated + assert!(signals.contains(' ')); - // Maximum valid PID - assert_eq!(parse_pid_from_bytes(b"2147483647"), Some(2147483647)); + // Should not have trailing space + assert!(!signals.ends_with(' ')); + + // Should not be empty + assert!(!signals.is_empty()); + } + + #[test] + fn test_list_signals_count() { + let signals = list_signals(); + let count = signals.split(' ').count(); + + // Should match the number of signals we defined + assert_eq!(count, SIGNALS.len()); + } + + #[test] + fn test_common_signals() { + // Test the most common signals used in killall + assert_eq!(parse_signal("TERM"), Some(Signal::SIGTERM)); + assert_eq!(parse_signal("KILL"), Some(Signal::SIGKILL)); + assert_eq!(parse_signal("INT"), Some(Signal::SIGINT)); + assert_eq!(parse_signal("HUP"), Some(Signal::SIGHUP)); + } + + #[test] + fn test_parse_all_listed_signals() { + let signal_list = list_signals(); + let signal_names: Vec<&str> = signal_list.split(' ').collect(); + + // Every signal in the list should be parseable + for name in signal_names { + assert!( + parse_signal(name).is_some(), + "Signal {} should be parseable", + name + ); + } + } + + #[test] + fn test_signal_parsing_roundtrip() { + // Parse a signal and verify it returns the expected value + let common_signals = vec!["INT", "TERM", "KILL", "HUP", "QUIT"]; + + for signal_name in common_signals { + let parsed = parse_signal(signal_name); + assert!(parsed.is_some(), "Failed to parse {}", signal_name); + } + } + + #[test] + fn test_list_signals_not_empty() { + let signals = list_signals(); + assert!(!signals.is_empty()); + assert!(signals.len() > 10); // Should have many signals } }