procutils-pkill 0.2.0

Signal processes based on name and other attributes
Documentation
//! Compatibility tests implementing the same scenarios as procps-ng's
//! `testsuite/pkill.test/pkill.exp`.
//!
//! pkill's signal-delivery tests need a live process (the kernel's
//! `kill(2)` returns ESRCH for non-existent PIDs, so signal 0 against
//! a fake fixture PID always fails). We use short-lived `sleep`
//! children for the tests where signal delivery matters, and the
//! existing pgrep compat suite covers the matching engine itself.

use regex::Regex;
use std::process::{Child, Command, Stdio};

const BIN: &str = env!("CARGO_BIN_EXE_pkill");

/// RAII handle for a `sleep` child. Killed automatically when dropped
/// so test panics don't leak processes.
struct SleepChild(Child);

impl Drop for SleepChild {
    fn drop(&mut self) {
        let _ = self.0.kill();
        let _ = self.0.wait();
    }
}

fn spawn_sleep() -> SleepChild {
    let c = Command::new("sleep")
        .arg("30") // generous; we kill it explicitly below
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .spawn()
        .expect("spawn sleep");
    SleepChild(c)
}

#[track_caller]
fn run(args: &[&str]) -> std::process::Output {
    Command::new(BIN).args(args).output().expect("spawn pkill")
}

#[track_caller]
fn assert_matches(haystack: &str, pattern: &str) {
    let re = Regex::new(pattern).expect("valid regex");
    assert!(
        re.is_match(haystack),
        "pattern did not match\n--- pattern ---\n{pattern}\n--- output ---\n{haystack}"
    );
}

// ===========================================================================
// "pkill with no arguments"
// procps: ^(lt-)?pkill: no matching criteria specified\s*
// Divergence: procutils says "pattern is required".
// ===========================================================================

#[test]
#[ignore = "intentional: error wording differs (procps says 'no matching criteria specified')"]
fn no_arguments() {
    let out = run(&[]);
    assert!(!out.status.success());
}

// ===========================================================================
// "pkill find both test pids" / "pkill signal option order"
//
// procps spawns two test processes via make_testproc, then asserts
// `pkill -0 -e $comm` echoes one "killed" line per match. We replicate
// by spawning two `sleep` children and using `-p PID,PID` to match
// exactly those two (avoiding interference with whatever else might
// be running on the host).
// ===========================================================================

#[test]
fn find_both_test_pids() {
    let c1 = spawn_sleep();
    let c2 = spawn_sleep();
    let pid1 = c1.0.id() as i32;
    let pid2 = c2.0.id() as i32;

    let out = run(&["-0", "-e", "-p", &format!("{pid1},{pid2}")]);
    assert!(
        out.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let stdout = String::from_utf8_lossy(&out.stdout);
    let count = stdout.matches("killed (pid").count();
    assert_eq!(
        count, 2,
        "expected two 'killed (pid …)' lines, got:\n{stdout}"
    );
}

#[test]
fn signal_option_order() {
    // Same as find_both_test_pids but with the signal flag after the
    // other options. procps's preprocessing accepts the flag in any
    // position; ours does too.
    let c1 = spawn_sleep();
    let c2 = spawn_sleep();
    let pid1 = c1.0.id() as i32;
    let pid2 = c2.0.id() as i32;

    let out = run(&["-e", "-p", &format!("{pid1},{pid2}"), "-0"]);
    assert!(
        out.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let stdout = String::from_utf8_lossy(&out.stdout);
    let count = stdout.matches("killed (pid").count();
    assert_eq!(
        count, 2,
        "expected two 'killed (pid …)' lines, got:\n{stdout}"
    );
}

// ===========================================================================
// "pkill with trailing garbage on int signal"
// procps: invalid option -- '0'
// Divergence: clap rejects the unknown flag with its own format.
// ===========================================================================

#[test]
#[ignore = "intentional: clap's invalid-flag error wording differs from procps's"]
fn trailing_garbage_on_int_signal() {
    let c = spawn_sleep();
    let pid = c.0.id() as i32;
    let out = run(&["-0garbage", "-p", &pid.to_string()]);
    assert!(!out.status.success());
}

// ===========================================================================
// "pkill with SIGUSR1" / "with SIGUSR2"
//
// procps uses `make_pipeproc` to spawn a child that listens for
// signals on a pipe and reports back which signal it caught. Verifying
// signal *delivery* (vs just rejection) needs that kind of helper —
// not yet implemented.
//
// We can confirm here that a SIGUSR1 invocation succeeds against a
// real PID without erroring, but verifying the child actually caught
// it would require the helper.
// ===========================================================================

#[test]
fn sigusr1_invocation_succeeds() {
    let c = spawn_sleep();
    let pid = c.0.id() as i32;
    let out = run(&["-USR1", "-e", "-p", &pid.to_string()]);
    // sleep doesn't catch USR1 — the default action is termination, so
    // delivery is observable via exit status of the child. Here we only
    // assert pkill itself succeeded.
    assert!(
        out.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert_matches(&stdout, &format!(r"killed \(pid {pid}\)"));
}

#[test]
fn sigusr2_invocation_succeeds() {
    let c = spawn_sleep();
    let pid = c.0.id() as i32;
    let out = run(&["-USR2", "-e", "-p", &pid.to_string()]);
    assert!(
        out.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert_matches(&stdout, &format!(r"killed \(pid {pid}\)"));
}

// ===========================================================================
// "pkill -F pidfile" — read PIDs from a file. Use signal 0 (no-op) to
// check that the matching engine sees the file's contents. Mirrors how
// pgrep's pidfile_filters_to_listed_pids works on the read side.
// ===========================================================================

#[test]
fn pidfile_filters_to_listed_pids() {
    let c = spawn_sleep();
    let pid = c.0.id();
    let dir = tempfile::tempdir().expect("tmp");
    let path = dir.path().join("pkill.pid");
    std::fs::write(&path, format!("{pid}\n")).expect("write pidfile");
    // Signal 0 → kernel just checks reachability and does NOT actually
    // signal. Combined with -e (echo), this confirms our filter list
    // saw the right PID.
    let out = run(&["-0", "-e", "-F", path.to_str().unwrap()]);
    assert!(out.status.success());
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(
        stdout.contains(&format!("(pid {pid})")),
        "expected `(pid {pid})` in {stdout:?}",
    );
}

#[test]
fn pidfile_conflict_with_p() {
    let out = run(&["-p", "1", "-F", "/dev/null"]);
    assert!(!out.status.success());
}

// ===========================================================================
// "pkill with queued int" — sigqueue with a value (-q VALUE)
//
// procps's test_process helper installs a SIGUSR1 handler with
// SA_SIGINFO and prints `SIG SIGUSR1 value=42` on receipt. We don't
// have that helper, so we substitute a plain sleep child and verify
// that pkill -q VALUE accepts the flag and the call succeeds. The
// kernel guarantees sigqueue carries the integer payload through to
// the receiver's siginfo_t::si_value.sival_int.
// ===========================================================================

#[test]
fn sigqueue_value() {
    let c = spawn_sleep();
    let pid = c.0.id();
    let out = run(&["-USR1", "-e", "-q", "42", "-p", &pid.to_string()]);
    assert!(out.status.success());
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(
        stdout.contains(&format!("(pid {pid})")),
        "expected `(pid {pid})` in {stdout:?}",
    );
}

#[test]
fn sigqueue_rejects_unparseable_value() {
    let out = run(&["-USR1", "-q", "not-an-int", "sleep"]);
    assert!(!out.status.success());
}