procutils-common 0.2.0

Shared utilities for procutils tools (utmp, signal, procmatch, fmt, uid)
Documentation
//! Process selection logic shared by `pgrep`, `pkill`, `skill`,
//! `snice`.
//!
//! [`find_matching_processes`] is the workhorse: it walks `/proc`,
//! reads each process's `stat`, `status`, `cmdline` (and `environ` if
//! `--env` is set), and returns a [`Vec<ProcessInfo>`] for everything
//! that satisfies the supplied [`MatchOptions`]. Filters across
//! categories (PID, UID, parent, session, ...) are AND'd together;
//! within a category the values are OR'd.
//!
//! Two helpers are also exposed for tools' CLI parsing:
//!
//! - [`resolve_uid`] — `name → u32` via `/etc/passwd`, falling back to
//!   numeric parsing.
//! - [`read_pidfile`] — parses a `-F`/`--pidfile` file into a
//!   `Vec<i32>` with a useful error string on malformed lines.

use crate::uid;
use procfs::{prelude::*, process};
use std::process::ExitCode;

/// Per-process snapshot built up while walking `/proc`. Carries the
/// fields filters operate on (PID, comm/cmdline, real/effective UID
/// and GID), plus the underlying [`procfs::process::Stat`] for tools
/// that need additional fields like start time or process group.
pub struct ProcessInfo {
    pub pid: i32,
    pub comm: String,
    pub cmdline: String,
    pub stat: procfs::process::Stat,
    pub euid: u32,
    pub ruid: u32,
    pub rgid: u32,
}

impl ProcessInfo {
    /// Returns the text the regex pattern should be matched against —
    /// the full command line if `full` is true (corresponds to `-f`),
    /// otherwise the `comm` field.
    pub fn match_text(&self, full: bool) -> &str {
        if full { &self.cmdline } else { &self.comm }
    }
}

/// Bag of filter and matching options. Each field maps to a
/// command-line flag in `pgrep` / `pkill` / `skill` / `snice`. `None`
/// (or an empty string for `pattern`) means "no filter for this
/// category"; multiple-valued fields are OR'd within the category and
/// AND'd across categories.
pub struct MatchOptions {
    pub pattern: String,
    pub full: bool,
    pub ignore_case: bool,
    pub exact: bool,
    pub inverse: bool,
    pub newest: bool,
    pub oldest: bool,
    pub older: Option<f64>,
    pub pid: Option<Vec<i32>>,
    pub parent: Option<Vec<i32>>,
    pub pgroup: Option<Vec<i32>>,
    pub group: Option<Vec<u32>>,
    pub session: Option<Vec<i32>>,
    pub terminal: Option<Vec<String>>,
    pub euid: Option<Vec<String>>,
    pub uid: Option<Vec<String>>,
    pub runstates: Option<Vec<char>>,
    /// `--env NAME` (any process whose environment has NAME) or
    /// `--env NAME=VALUE` (NAME must be present with exactly VALUE).
    pub env: Option<String>,
}

enum EnvSpec {
    NameOnly(String),
    NameValue(String, String),
}

fn parse_env_spec(s: &str) -> EnvSpec {
    match s.split_once('=') {
        Some((k, v)) => EnvSpec::NameValue(k.to_string(), v.to_string()),
        None => EnvSpec::NameOnly(s.to_string()),
    }
}

fn process_matches_env(proc: &process::Process, spec: &EnvSpec) -> bool {
    let environ = match proc.environ() {
        Ok(e) => e,
        Err(_) => return false,
    };
    use std::ffi::OsStr;
    let want_key: &OsStr = match spec {
        EnvSpec::NameOnly(k) | EnvSpec::NameValue(k, _) => OsStr::new(k),
    };
    let value = match environ.get(want_key) {
        Some(v) => v,
        None => return false,
    };
    match spec {
        EnvSpec::NameOnly(_) => true,
        EnvSpec::NameValue(_, v) => value == OsStr::new(v),
    }
}

impl MatchOptions {
    /// True if at least one selector field is set. Used to decide
    /// whether `pgrep` / `pkill` can run without an explicit pattern.
    pub fn has_filter(&self) -> bool {
        self.pid.is_some()
            || self.uid.is_some()
            || self.euid.is_some()
            || self.parent.is_some()
            || self.pgroup.is_some()
            || self.group.is_some()
            || self.session.is_some()
            || self.terminal.is_some()
            || self.runstates.is_some()
            || self.env.is_some()
    }
}

/// Re-export of [`crate::uid::resolve_uid`] so callers don't need to
/// import the `uid` module separately.
pub fn resolve_uid(name: &str) -> Option<u32> {
    uid::resolve_uid(name)
}

/// Read PIDs from a file, one per line. Blank lines are skipped and any
/// trailing whitespace on a line is ignored. A line that doesn't parse
/// as a positive integer yields an error string suitable for stderr.
pub fn read_pidfile(path: &std::path::Path) -> Result<Vec<i32>, String> {
    let text = std::fs::read_to_string(path)
        .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
    let mut pids = Vec::new();
    for (i, line) in text.lines().enumerate() {
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        let pid: i32 = trimmed.parse().map_err(|_| {
            format!("{}:{}: not a PID: {trimmed}", path.display(), i + 1)
        })?;
        pids.push(pid);
    }
    Ok(pids)
}

fn tty_nr_to_name(tty_nr: i32) -> Option<String> {
    if tty_nr == 0 {
        return None;
    }
    let major = ((tty_nr >> 8) & 0xff) as u32;
    let minor = ((tty_nr & 0xff) | ((tty_nr >> 12) & 0xfff00)) as u32;
    match major {
        4 if minor < 64 => Some(format!("tty{minor}")),
        4 => Some(format!("ttyS{}", minor - 64)),
        136..=143 => Some(format!("pts/{}", (major - 136) * 256 + minor)),
        _ => Some(format!("{major}/{minor}")),
    }
}

fn system_uptime_ticks() -> Option<u64> {
    let uptime = procfs::Uptime::current().ok()?;
    let ticks_per_sec = procfs::ticks_per_second();
    Some((uptime.uptime * ticks_per_sec as f64) as u64)
}

/// Walk `/proc`, apply the filters in `opts`, and return every
/// [`ProcessInfo`] that survives.
///
/// The caller's own PID is always excluded from results. Filters
/// across categories (PID, UID, parent, session, environment, ...) are
/// combined with AND; values within a category are OR'd. The pattern,
/// if non-empty, is matched against either `comm` or the full
/// command line, depending on `opts.full`.
///
/// `tool_name` is used as the prefix for any error messages printed to
/// stderr. Returns `Err(ExitCode)` for fatal errors (e.g. unreadable
/// `/proc`) and `Err(2)` for invalid regex patterns; `Ok(vec)`
/// otherwise. An empty `Ok(vec)` means "no matches", which the caller
/// typically translates to exit code 1.
pub fn find_matching_processes(
    opts: &MatchOptions,
    tool_name: &str,
) -> Result<Vec<ProcessInfo>, ExitCode> {
    let pattern = &opts.pattern;

    let regex_pattern = if opts.exact {
        format!("^{pattern}$")
    } else {
        pattern.to_string()
    };

    let re = match regex::RegexBuilder::new(&regex_pattern)
        .case_insensitive(opts.ignore_case)
        .build()
    {
        Ok(re) => re,
        Err(e) => {
            eprintln!("{tool_name}: invalid pattern: {e}");
            return Err(ExitCode::from(2));
        }
    };

    let uid_filter: Option<Vec<u32>> = opts
        .uid
        .as_ref()
        .map(|uids| uids.iter().filter_map(|u| resolve_uid(u)).collect());
    let euid_filter: Option<Vec<u32>> = opts
        .euid
        .as_ref()
        .map(|uids| uids.iter().filter_map(|u| resolve_uid(u)).collect());

    let terminal_filter: Option<Vec<String>> =
        opts.terminal.as_ref().map(|terms| {
            terms
                .iter()
                .map(|t| t.strip_prefix("/dev/").unwrap_or(t).to_string())
                .collect()
        });

    let env_spec: Option<EnvSpec> = opts.env.as_deref().map(parse_env_spec);

    let my_pid = std::process::id() as i32;

    let all_procs = match process::all_processes() {
        Ok(iter) => iter,
        Err(e) => {
            eprintln!("{tool_name}: {e}");
            return Err(ExitCode::from(3));
        }
    };

    let uptime_ticks = system_uptime_ticks();

    let mut matches: Vec<ProcessInfo> = Vec::new();

    for proc_result in all_procs {
        let proc = match proc_result {
            Ok(p) => p,
            Err(_) => continue,
        };

        if proc.pid() == my_pid {
            continue;
        }

        let stat = match proc.stat() {
            Ok(s) => s,
            Err(_) => continue,
        };

        let cmdline_vec = proc.cmdline().unwrap_or_default();
        let cmdline = cmdline_vec.join(" ");

        let status = match proc.status() {
            Ok(s) => s,
            Err(_) => continue,
        };

        let info = ProcessInfo {
            pid: stat.pid,
            comm: stat.comm.clone(),
            cmdline: if cmdline.is_empty() {
                stat.comm.clone()
            } else {
                cmdline
            },
            euid: status.euid,
            ruid: status.ruid,
            rgid: status.rgid,
            stat,
        };

        if let Some(ref pids) = opts.pid
            && !pids.contains(&info.pid)
        {
            continue;
        }

        if let Some(ref parents) = opts.parent
            && !parents.contains(&info.stat.ppid)
        {
            continue;
        }

        if let Some(ref pgroups) = opts.pgroup {
            let pgrp = info.stat.pgrp;
            if !pgroups.iter().any(|&pg| {
                if pg == 0 {
                    pgrp == rustix::process::getpgrp().as_raw_nonzero().get()
                } else {
                    pgrp == pg
                }
            }) {
                continue;
            }
        }

        if let Some(ref groups) = opts.group
            && !groups.contains(&info.rgid)
        {
            continue;
        }

        if let Some(ref sessions) = opts.session {
            let sess = info.stat.session;
            if !sessions.iter().any(|&s| {
                if s == 0 {
                    sess == rustix::process::getsid(None)
                        .map(|s| s.as_raw_nonzero().get())
                        .unwrap_or(0)
                } else {
                    sess == s
                }
            }) {
                continue;
            }
        }

        if let Some(ref terms) = terminal_filter {
            let proc_tty = tty_nr_to_name(info.stat.tty_nr);
            match proc_tty {
                None => continue,
                Some(ref tty_name) => {
                    if !terms.iter().any(|t| tty_name == t) {
                        continue;
                    }
                }
            }
        }

        if let Some(ref uids) = uid_filter
            && !uids.contains(&info.ruid)
        {
            continue;
        }

        if let Some(ref euids) = euid_filter
            && !euids.contains(&info.euid)
        {
            continue;
        }

        if let Some(ref states) = opts.runstates
            && !states.contains(&info.stat.state)
        {
            continue;
        }

        if let Some(ref spec) = env_spec
            && !process_matches_env(&proc, spec)
        {
            continue;
        }

        if let Some(older_secs) = opts.older
            && let Some(up_ticks) = uptime_ticks
        {
            let tps = procfs::ticks_per_second();
            let age_secs = (up_ticks - info.stat.starttime) / tps;
            if (age_secs as f64) < older_secs {
                continue;
            }
        }

        let text = info.match_text(opts.full);
        let matched = if pattern.is_empty() {
            true
        } else {
            re.is_match(text)
        };

        let matched = if opts.inverse { !matched } else { matched };

        if matched {
            matches.push(info);
        }
    }

    matches.sort_by_key(|p| p.pid);

    if opts.newest {
        if let Some(newest) = matches.iter().max_by_key(|p| p.stat.starttime) {
            let pid = newest.pid;
            matches.retain(|p| p.pid == pid);
        }
    } else if opts.oldest
        && let Some(oldest) = matches.iter().min_by_key(|p| p.stat.starttime)
    {
        let pid = oldest.pid;
        matches.retain(|p| p.pid == pid);
    }

    Ok(matches)
}