Skip to main content

procutils_common/
procmatch.rs

1//! Process selection logic shared by `pgrep`, `pkill`, `skill`,
2//! `snice`.
3//!
4//! [`find_matching_processes`] is the workhorse: it walks `/proc`,
5//! reads each process's `stat`, `status`, `cmdline` (and `environ` if
6//! `--env` is set), and returns a [`Vec<ProcessInfo>`] for everything
7//! that satisfies the supplied [`MatchOptions`]. Filters across
8//! categories (PID, UID, parent, session, ...) are AND'd together;
9//! within a category the values are OR'd.
10//!
11//! Two helpers are also exposed for tools' CLI parsing:
12//!
13//! - [`resolve_uid`] — `name → u32` via `/etc/passwd`, falling back to
14//!   numeric parsing.
15//! - [`read_pidfile`] — parses a `-F`/`--pidfile` file into a
16//!   `Vec<i32>` with a useful error string on malformed lines.
17
18use crate::uid;
19use procfs::{prelude::*, process};
20use std::process::ExitCode;
21
22/// Per-process snapshot built up while walking `/proc`. Carries the
23/// fields filters operate on (PID, comm/cmdline, real/effective UID
24/// and GID), plus the underlying [`procfs::process::Stat`] for tools
25/// that need additional fields like start time or process group.
26pub struct ProcessInfo {
27    pub pid: i32,
28    pub comm: String,
29    pub cmdline: String,
30    pub stat: procfs::process::Stat,
31    pub euid: u32,
32    pub ruid: u32,
33    pub rgid: u32,
34}
35
36impl ProcessInfo {
37    /// Returns the text the regex pattern should be matched against —
38    /// the full command line if `full` is true (corresponds to `-f`),
39    /// otherwise the `comm` field.
40    pub fn match_text(&self, full: bool) -> &str {
41        if full { &self.cmdline } else { &self.comm }
42    }
43}
44
45/// Bag of filter and matching options. Each field maps to a
46/// command-line flag in `pgrep` / `pkill` / `skill` / `snice`. `None`
47/// (or an empty string for `pattern`) means "no filter for this
48/// category"; multiple-valued fields are OR'd within the category and
49/// AND'd across categories.
50pub struct MatchOptions {
51    pub pattern: String,
52    pub full: bool,
53    pub ignore_case: bool,
54    pub exact: bool,
55    pub inverse: bool,
56    pub newest: bool,
57    pub oldest: bool,
58    pub older: Option<f64>,
59    pub pid: Option<Vec<i32>>,
60    pub parent: Option<Vec<i32>>,
61    pub pgroup: Option<Vec<i32>>,
62    pub group: Option<Vec<u32>>,
63    pub session: Option<Vec<i32>>,
64    pub terminal: Option<Vec<String>>,
65    pub euid: Option<Vec<String>>,
66    pub uid: Option<Vec<String>>,
67    pub runstates: Option<Vec<char>>,
68    /// `--env NAME` (any process whose environment has NAME) or
69    /// `--env NAME=VALUE` (NAME must be present with exactly VALUE).
70    pub env: Option<String>,
71}
72
73enum EnvSpec {
74    NameOnly(String),
75    NameValue(String, String),
76}
77
78fn parse_env_spec(s: &str) -> EnvSpec {
79    match s.split_once('=') {
80        Some((k, v)) => EnvSpec::NameValue(k.to_string(), v.to_string()),
81        None => EnvSpec::NameOnly(s.to_string()),
82    }
83}
84
85fn process_matches_env(proc: &process::Process, spec: &EnvSpec) -> bool {
86    let environ = match proc.environ() {
87        Ok(e) => e,
88        Err(_) => return false,
89    };
90    use std::ffi::OsStr;
91    let want_key: &OsStr = match spec {
92        EnvSpec::NameOnly(k) | EnvSpec::NameValue(k, _) => OsStr::new(k),
93    };
94    let value = match environ.get(want_key) {
95        Some(v) => v,
96        None => return false,
97    };
98    match spec {
99        EnvSpec::NameOnly(_) => true,
100        EnvSpec::NameValue(_, v) => value == OsStr::new(v),
101    }
102}
103
104impl MatchOptions {
105    /// True if at least one selector field is set. Used to decide
106    /// whether `pgrep` / `pkill` can run without an explicit pattern.
107    pub fn has_filter(&self) -> bool {
108        self.pid.is_some()
109            || self.uid.is_some()
110            || self.euid.is_some()
111            || self.parent.is_some()
112            || self.pgroup.is_some()
113            || self.group.is_some()
114            || self.session.is_some()
115            || self.terminal.is_some()
116            || self.runstates.is_some()
117            || self.env.is_some()
118    }
119}
120
121/// Re-export of [`crate::uid::resolve_uid`] so callers don't need to
122/// import the `uid` module separately.
123pub fn resolve_uid(name: &str) -> Option<u32> {
124    uid::resolve_uid(name)
125}
126
127/// Read PIDs from a file, one per line. Blank lines are skipped and any
128/// trailing whitespace on a line is ignored. A line that doesn't parse
129/// as a positive integer yields an error string suitable for stderr.
130pub fn read_pidfile(path: &std::path::Path) -> Result<Vec<i32>, String> {
131    let text = std::fs::read_to_string(path)
132        .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
133    let mut pids = Vec::new();
134    for (i, line) in text.lines().enumerate() {
135        let trimmed = line.trim();
136        if trimmed.is_empty() {
137            continue;
138        }
139        let pid: i32 = trimmed.parse().map_err(|_| {
140            format!("{}:{}: not a PID: {trimmed}", path.display(), i + 1)
141        })?;
142        pids.push(pid);
143    }
144    Ok(pids)
145}
146
147fn tty_nr_to_name(tty_nr: i32) -> Option<String> {
148    if tty_nr == 0 {
149        return None;
150    }
151    let major = ((tty_nr >> 8) & 0xff) as u32;
152    let minor = ((tty_nr & 0xff) | ((tty_nr >> 12) & 0xfff00)) as u32;
153    match major {
154        4 if minor < 64 => Some(format!("tty{minor}")),
155        4 => Some(format!("ttyS{}", minor - 64)),
156        136..=143 => Some(format!("pts/{}", (major - 136) * 256 + minor)),
157        _ => Some(format!("{major}/{minor}")),
158    }
159}
160
161fn system_uptime_ticks() -> Option<u64> {
162    let uptime = procfs::Uptime::current().ok()?;
163    let ticks_per_sec = procfs::ticks_per_second();
164    Some((uptime.uptime * ticks_per_sec as f64) as u64)
165}
166
167/// Walk `/proc`, apply the filters in `opts`, and return every
168/// [`ProcessInfo`] that survives.
169///
170/// The caller's own PID is always excluded from results. Filters
171/// across categories (PID, UID, parent, session, environment, ...) are
172/// combined with AND; values within a category are OR'd. The pattern,
173/// if non-empty, is matched against either `comm` or the full
174/// command line, depending on `opts.full`.
175///
176/// `tool_name` is used as the prefix for any error messages printed to
177/// stderr. Returns `Err(ExitCode)` for fatal errors (e.g. unreadable
178/// `/proc`) and `Err(2)` for invalid regex patterns; `Ok(vec)`
179/// otherwise. An empty `Ok(vec)` means "no matches", which the caller
180/// typically translates to exit code 1.
181pub fn find_matching_processes(
182    opts: &MatchOptions,
183    tool_name: &str,
184) -> Result<Vec<ProcessInfo>, ExitCode> {
185    let pattern = &opts.pattern;
186
187    let regex_pattern = if opts.exact {
188        format!("^{pattern}$")
189    } else {
190        pattern.to_string()
191    };
192
193    let re = match regex::RegexBuilder::new(&regex_pattern)
194        .case_insensitive(opts.ignore_case)
195        .build()
196    {
197        Ok(re) => re,
198        Err(e) => {
199            eprintln!("{tool_name}: invalid pattern: {e}");
200            return Err(ExitCode::from(2));
201        }
202    };
203
204    let uid_filter: Option<Vec<u32>> = opts
205        .uid
206        .as_ref()
207        .map(|uids| uids.iter().filter_map(|u| resolve_uid(u)).collect());
208    let euid_filter: Option<Vec<u32>> = opts
209        .euid
210        .as_ref()
211        .map(|uids| uids.iter().filter_map(|u| resolve_uid(u)).collect());
212
213    let terminal_filter: Option<Vec<String>> =
214        opts.terminal.as_ref().map(|terms| {
215            terms
216                .iter()
217                .map(|t| t.strip_prefix("/dev/").unwrap_or(t).to_string())
218                .collect()
219        });
220
221    let env_spec: Option<EnvSpec> = opts.env.as_deref().map(parse_env_spec);
222
223    let my_pid = std::process::id() as i32;
224
225    let all_procs = match process::all_processes() {
226        Ok(iter) => iter,
227        Err(e) => {
228            eprintln!("{tool_name}: {e}");
229            return Err(ExitCode::from(3));
230        }
231    };
232
233    let uptime_ticks = system_uptime_ticks();
234
235    let mut matches: Vec<ProcessInfo> = Vec::new();
236
237    for proc_result in all_procs {
238        let proc = match proc_result {
239            Ok(p) => p,
240            Err(_) => continue,
241        };
242
243        if proc.pid() == my_pid {
244            continue;
245        }
246
247        let stat = match proc.stat() {
248            Ok(s) => s,
249            Err(_) => continue,
250        };
251
252        let cmdline_vec = proc.cmdline().unwrap_or_default();
253        let cmdline = cmdline_vec.join(" ");
254
255        let status = match proc.status() {
256            Ok(s) => s,
257            Err(_) => continue,
258        };
259
260        let info = ProcessInfo {
261            pid: stat.pid,
262            comm: stat.comm.clone(),
263            cmdline: if cmdline.is_empty() {
264                stat.comm.clone()
265            } else {
266                cmdline
267            },
268            euid: status.euid,
269            ruid: status.ruid,
270            rgid: status.rgid,
271            stat,
272        };
273
274        if let Some(ref pids) = opts.pid
275            && !pids.contains(&info.pid)
276        {
277            continue;
278        }
279
280        if let Some(ref parents) = opts.parent
281            && !parents.contains(&info.stat.ppid)
282        {
283            continue;
284        }
285
286        if let Some(ref pgroups) = opts.pgroup {
287            let pgrp = info.stat.pgrp;
288            if !pgroups.iter().any(|&pg| {
289                if pg == 0 {
290                    pgrp == rustix::process::getpgrp().as_raw_nonzero().get()
291                } else {
292                    pgrp == pg
293                }
294            }) {
295                continue;
296            }
297        }
298
299        if let Some(ref groups) = opts.group
300            && !groups.contains(&info.rgid)
301        {
302            continue;
303        }
304
305        if let Some(ref sessions) = opts.session {
306            let sess = info.stat.session;
307            if !sessions.iter().any(|&s| {
308                if s == 0 {
309                    sess == rustix::process::getsid(None)
310                        .map(|s| s.as_raw_nonzero().get())
311                        .unwrap_or(0)
312                } else {
313                    sess == s
314                }
315            }) {
316                continue;
317            }
318        }
319
320        if let Some(ref terms) = terminal_filter {
321            let proc_tty = tty_nr_to_name(info.stat.tty_nr);
322            match proc_tty {
323                None => continue,
324                Some(ref tty_name) => {
325                    if !terms.iter().any(|t| tty_name == t) {
326                        continue;
327                    }
328                }
329            }
330        }
331
332        if let Some(ref uids) = uid_filter
333            && !uids.contains(&info.ruid)
334        {
335            continue;
336        }
337
338        if let Some(ref euids) = euid_filter
339            && !euids.contains(&info.euid)
340        {
341            continue;
342        }
343
344        if let Some(ref states) = opts.runstates
345            && !states.contains(&info.stat.state)
346        {
347            continue;
348        }
349
350        if let Some(ref spec) = env_spec
351            && !process_matches_env(&proc, spec)
352        {
353            continue;
354        }
355
356        if let Some(older_secs) = opts.older
357            && let Some(up_ticks) = uptime_ticks
358        {
359            let tps = procfs::ticks_per_second();
360            let age_secs = (up_ticks - info.stat.starttime) / tps;
361            if (age_secs as f64) < older_secs {
362                continue;
363            }
364        }
365
366        let text = info.match_text(opts.full);
367        let matched = if pattern.is_empty() {
368            true
369        } else {
370            re.is_match(text)
371        };
372
373        let matched = if opts.inverse { !matched } else { matched };
374
375        if matched {
376            matches.push(info);
377        }
378    }
379
380    matches.sort_by_key(|p| p.pid);
381
382    if opts.newest {
383        if let Some(newest) = matches.iter().max_by_key(|p| p.stat.starttime) {
384            let pid = newest.pid;
385            matches.retain(|p| p.pid == pid);
386        }
387    } else if opts.oldest
388        && let Some(oldest) = matches.iter().min_by_key(|p| p.stat.starttime)
389    {
390        let pid = oldest.pid;
391        matches.retain(|p| p.pid == pid);
392    }
393
394    Ok(matches)
395}