Skip to main content

lean_ctx_fs/
process.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Process liveness probe shared by the atomic-write orphan sweep and the
3//! engine's process management (`ipc::process` re-exports it).
4//!
5//! [`signal_target`] and [`group_target`] are the only gates between a PID
6//! and a signal syscall in LeanCTX. On Unix `kill(2)` gives PIDs outside
7//! `1..=i32::MAX` a meaning nobody wants from a PID file: `0` is the caller's
8//! own process group and a `u32` above `i32::MAX` wraps to a negative
9//! `pid_t`, i.e. a process group, or with `u32::MAX` (`-1`) every process the
10//! user may signal. Twice (2026-11-05, 2026-11-07) every process of a user
11//! on pounce received SIGTERM; LeanCTX must never be able to do that.
12
13/// Why a PID may not be handed to a signal syscall.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
15pub enum PidRefusal {
16    /// `kill(0, …)` addresses the caller's own process group.
17    #[error("PID 0 would address this process's own process group")]
18    Zero,
19    /// Above `i32::MAX` the value wraps to a negative `pid_t`: a process
20    /// group, or (`u32::MAX` = `-1`) every process the user may signal.
21    #[error("PID {0} is above i32::MAX and would address a process group or every process")]
22    OutOfRange(u32),
23    /// PID 1 is init (or a user's systemd); never a LeanCTX target.
24    #[error("PID 1 is init and is never signalled")]
25    Init,
26    /// The caller itself.
27    #[error("PID {0} is this process")]
28    OwnProcess(u32),
29    /// The process group the caller belongs to.
30    #[error("process group {0} contains this process")]
31    OwnGroup(u32),
32}
33
34/// Why the text of a PID file does not name a process.
35#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
36pub enum PidFileError {
37    /// Nothing but whitespace.
38    #[error("the PID file is empty")]
39    Empty,
40    /// Not a decimal integer.
41    #[error("the PID file does not hold a number: {0:?}")]
42    NotANumber(String),
43    /// A negative number (`-1` is "every process" for `kill(2)`).
44    #[error("the PID file holds a negative number: {0}")]
45    Negative(String),
46    /// Zero, PID 1, or larger than `i32::MAX`.
47    #[error("the PID file holds an impossible PID: {0}")]
48    Impossible(String),
49}
50
51/// The highest PID `pid_t` can hold; no supported OS hands out a larger one.
52pub const MAX_PID: u32 = i32::MAX as u32;
53
54fn in_signal_range(pid: u32) -> Result<i32, PidRefusal> {
55    match pid {
56        0 => Err(PidRefusal::Zero),
57        1 => Err(PidRefusal::Init),
58        p => i32::try_from(p).map_err(|_| PidRefusal::OutOfRange(p)),
59    }
60}
61
62/// The PID as a positive `pid_t` that addresses exactly one process, or why
63/// it may not be signalled: 0, 1, above `i32::MAX`, or this process.
64///
65/// Every `kill`/`TerminateProcess` path in LeanCTX goes through this (or
66/// [`group_target`] for process groups) before the syscall.
67pub fn signal_target(pid: u32) -> Result<i32, PidRefusal> {
68    let raw = in_signal_range(pid)?;
69    if pid == std::process::id() {
70        return Err(PidRefusal::OwnProcess(pid));
71    }
72    Ok(raw)
73}
74
75/// The process-group id as a positive value for `killpg(pgid, …)` or
76/// `kill(-pgid, …)`, or why it may not be signalled: everything
77/// [`signal_target`] refuses, plus the group this process belongs to.
78pub fn group_target(pgid: u32) -> Result<i32, PidRefusal> {
79    let raw = signal_target(pgid)?;
80    #[cfg(unix)]
81    {
82        // SAFETY: getpgrp has no preconditions and cannot fail.
83        if unsafe { libc::getpgrp() } == raw {
84            return Err(PidRefusal::OwnGroup(pgid));
85        }
86    }
87    Ok(raw)
88}
89
90/// Parse the text of a PID file (its first line, trimmed). Only a decimal
91/// PID in `2..=i32::MAX` is accepted; empty text, garbage, signs, zero,
92/// PID 1 and overflowing values are refused, so a corrupt file can never
93/// become a signal target.
94pub fn parse_pid_file(text: &str) -> Result<u32, PidFileError> {
95    let value = text.lines().next().unwrap_or("").trim();
96    if value.is_empty() {
97        return Err(PidFileError::Empty);
98    }
99    let (negative, digits) = match value.strip_prefix('-') {
100        Some(rest) => (true, rest),
101        None => (false, value),
102    };
103    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
104        return Err(PidFileError::NotANumber(value.to_owned()));
105    }
106    if negative {
107        return Err(PidFileError::Negative(value.to_owned()));
108    }
109    match digits.parse::<u32>() {
110        Ok(pid) if in_signal_range(pid).is_ok() => Ok(pid),
111        _ => Err(PidFileError::Impossible(value.to_owned())),
112    }
113}
114
115/// Check whether a process with the given PID is still running.
116///
117/// Routed through [`signal_target`] like every signal path: 0 and values
118/// above `i32::MAX` are never probed and answer `false`; this process and
119/// PID 1 answer `true` without a syscall.
120pub fn is_alive(pid: u32) -> bool {
121    let raw = match signal_target(pid) {
122        Ok(raw) => raw,
123        Err(PidRefusal::OwnProcess(_) | PidRefusal::Init) => return true,
124        Err(_) => return false,
125    };
126    #[cfg(unix)]
127    {
128        // SAFETY: `kill` takes the PID and signal (0 = existence probe) by
129        // value; `raw` is a positive pid_t naming one process, never a group
130        // or "every process". No pointer is dereferenced.
131        unsafe { libc::kill(raw, 0) == 0 }
132    }
133    #[cfg(windows)]
134    {
135        use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE, WAIT_TIMEOUT};
136        use windows_sys::Win32::System::Threading::{
137            GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, WaitForSingleObject,
138        };
139        let _ = raw;
140
141        // SAFETY: every Win32 call below takes integer args plus the local
142        // `exit_code` out-pointer; the handle is null-checked and closed on
143        // every return path, so no resource leaks or invalid pointers occur.
144        unsafe {
145            let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
146            if handle.is_null() {
147                return false;
148            }
149            let wait = WaitForSingleObject(handle, 0);
150            if wait == WAIT_TIMEOUT {
151                CloseHandle(handle);
152                return true;
153            }
154            let mut exit_code: u32 = 0;
155            GetExitCodeProcess(handle, &raw mut exit_code);
156            CloseHandle(handle);
157            exit_code == STILL_ACTIVE as u32
158        }
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn current_process_is_alive() {
168        assert!(is_alive(std::process::id()));
169    }
170
171    #[test]
172    fn bogus_pid_is_not_alive() {
173        assert!(!is_alive(u32::MAX - 42));
174    }
175
176    #[test]
177    fn wrapping_pids_are_never_probed_and_never_alive() {
178        // As pid_t these are 0, -1 and i32::MIN: own group, every process,
179        // a group. The gate answers before any syscall.
180        for pid in [0, u32::MAX, MAX_PID + 1] {
181            assert!(!is_alive(pid), "{pid}");
182        }
183    }
184
185    #[test]
186    fn own_process_and_init_are_alive_without_a_probe() {
187        assert!(is_alive(1));
188        assert!(is_alive(std::process::id()));
189    }
190
191    #[test]
192    fn signal_target_accepts_only_other_pids_in_range() {
193        assert_eq!(signal_target(0), Err(PidRefusal::Zero));
194        assert_eq!(signal_target(1), Err(PidRefusal::Init));
195        assert_eq!(
196            signal_target(u32::MAX),
197            Err(PidRefusal::OutOfRange(u32::MAX))
198        );
199        assert_eq!(
200            signal_target(MAX_PID + 1),
201            Err(PidRefusal::OutOfRange(MAX_PID + 1))
202        );
203        let me = std::process::id();
204        assert_eq!(signal_target(me), Err(PidRefusal::OwnProcess(me)));
205        assert_eq!(signal_target(MAX_PID), Ok(i32::MAX));
206        assert_eq!(signal_target(2), Ok(2));
207    }
208
209    #[cfg(unix)]
210    #[test]
211    fn group_target_refuses_own_group_and_wrapping_ids() {
212        // SAFETY: getpgrp has no preconditions.
213        let own = unsafe { libc::getpgrp() } as u32;
214        assert!(group_target(own).is_err());
215        assert_eq!(group_target(0), Err(PidRefusal::Zero));
216        assert_eq!(
217            group_target(u32::MAX),
218            Err(PidRefusal::OutOfRange(u32::MAX))
219        );
220    }
221
222    #[test]
223    fn pid_file_garbage_is_refused() {
224        assert_eq!(parse_pid_file(""), Err(PidFileError::Empty));
225        assert_eq!(parse_pid_file("  \n"), Err(PidFileError::Empty));
226        for text in ["abc", "12abc", "-", "+5", "0x10", "1.5"] {
227            assert!(
228                matches!(parse_pid_file(text), Err(PidFileError::NotANumber(_))),
229                "{text}"
230            );
231        }
232        assert!(matches!(
233            parse_pid_file("-1"),
234            Err(PidFileError::Negative(_))
235        ));
236        for text in ["0", "1", "4294967295", "2147483648", "99999999999999999999"] {
237            assert!(
238                matches!(parse_pid_file(text), Err(PidFileError::Impossible(_))),
239                "{text}"
240            );
241        }
242        assert_eq!(parse_pid_file("2147483647"), Ok(MAX_PID));
243        assert_eq!(parse_pid_file(" 4242 \nextra"), Ok(4242));
244    }
245}