Skip to main content

subc_os/
process_identity.rs

1//! PID-reuse-safe liveness checks using versioned kernel start-time identities.
2//!
3//! Callers must treat [`Liveness::Unknown`] as alive. A lease or scratch directory
4//! may be reclaimed only on [`Liveness::Dead`]: an unreadable start time or a
5//! different encoding family is never evidence that a PID was reused.
6//! Identities are comparable only on the same host and within the same boot.
7
8use std::fmt;
9
10/// An opaque, versioned kernel start-time identity, suitable for persistence.
11#[derive(Clone, Debug, PartialEq, Eq, Hash)]
12pub struct ProcessStart(String);
13
14impl ProcessStart {
15    /// The stored encoding, without normalization.
16    pub fn as_str(&self) -> &str {
17        &self.0
18    }
19
20    /// Accept a `linux-v1-<u64>` or `macos-v1-<i64>-<i64>` identity.
21    /// Numbers must be decimal digits, with an optional minus sign for macOS.
22    pub fn parse(s: &str) -> Option<Self> {
23        if let Some(ticks) = s.strip_prefix("linux-v1-") {
24            if !decimal_digits(ticks) || ticks.parse::<u64>().is_err() {
25                return None;
26            }
27        } else {
28            let numbers = s.strip_prefix("macos-v1-")?;
29            // Skip the seconds' optional sign when locating the field separator.
30            let unsigned = numbers.strip_prefix('-').unwrap_or(numbers);
31            let (seconds, micros) = unsigned.split_once('-')?;
32            let seconds_len = numbers.len() - unsigned.len() + seconds.len();
33            if !decimal_digits(seconds)
34                || !decimal_digits(micros.strip_prefix('-').unwrap_or(micros))
35                || numbers[..seconds_len].parse::<i64>().is_err()
36                || micros.parse::<i64>().is_err()
37            {
38                return None;
39            }
40        }
41        Some(Self(s.to_owned()))
42    }
43
44    #[cfg(unix)]
45    fn same_family(&self, other: &Self) -> bool {
46        self.0.starts_with("linux-v1-") == other.0.starts_with("linux-v1-")
47    }
48}
49
50fn decimal_digits(s: &str) -> bool {
51    !s.is_empty() && s.bytes().all(|byte| byte.is_ascii_digit())
52}
53
54impl fmt::Display for ProcessStart {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        f.write_str(self.as_str())
57    }
58}
59
60/// The result of a process existence and identity probe.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum Liveness {
63    /// The process exists and, if recorded, its start identity matches.
64    Alive,
65    /// The process is absent, a Linux zombie, or has a different compatible start.
66    Dead,
67    /// Invalid PID, unsupported platform, or an unreadable/incompatible identity.
68    /// Callers must treat this as alive.
69    Unknown,
70}
71
72/// Read a kernel start-time identity without spawning a process.
73/// `None` means unknown, never dead. PID zero and PIDs outside `i32` are invalid.
74pub fn process_start(pid: u32) -> Option<ProcessStart> {
75    let pid = valid_pid(pid)?;
76    platform_process_start(pid)
77}
78
79/// Check whether `pid` still names the recorded process.
80///
81/// Only `ESRCH` from signal zero proves absence; other errors (including `EPERM`)
82/// continue to the identity check. With no recorded identity, an existing process
83/// is alive. Linux zombies are dead even without a recorded identity. Incompatible
84/// encodings or unreadable current starts are unknown, not evidence of PID reuse.
85/// On non-Unix platforms this always returns [`Liveness::Unknown`].
86pub fn liveness(pid: u32, recorded: Option<&ProcessStart>) -> Liveness {
87    let Some(pid) = valid_pid(pid) else {
88        return Liveness::Unknown;
89    };
90    #[cfg(unix)]
91    {
92        // SAFETY: pid is positive and representable as pid_t; signal zero probes
93        // existence and permissions without sending a signal.
94        #[allow(unsafe_code)]
95        let result = unsafe { libc::kill(pid, 0) };
96        if result != 0 && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) {
97            return Liveness::Dead;
98        }
99        #[cfg(target_os = "linux")]
100        if std::fs::read_to_string(format!("/proc/{pid}/stat"))
101            .ok()
102            .is_some_and(|stat| state_from_stat(&stat) == Some("Z"))
103        {
104            return Liveness::Dead;
105        }
106        let Some(recorded) = recorded else {
107            return Liveness::Alive;
108        };
109        let Some(current) = platform_process_start(pid) else {
110            return Liveness::Unknown;
111        };
112        if current == *recorded {
113            Liveness::Alive
114        } else if current.same_family(recorded) {
115            Liveness::Dead
116        } else {
117            // Older or platform-specific encodings cannot prove PID reuse.
118            Liveness::Unknown
119        }
120    }
121    #[cfg(not(unix))]
122    {
123        let _ = (pid, recorded);
124        Liveness::Unknown
125    }
126}
127
128fn valid_pid(pid: u32) -> Option<i32> {
129    i32::try_from(pid).ok().filter(|pid| *pid > 0)
130}
131
132#[cfg(target_os = "linux")]
133fn platform_process_start(pid: i32) -> Option<ProcessStart> {
134    let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
135    let ticks = start_time_from_stat(&stat)?;
136    Some(ProcessStart(format!("linux-v1-{ticks}")))
137}
138
139#[cfg(any(target_os = "linux", test))]
140fn stat_fields(stat: &str) -> Option<std::str::SplitWhitespace<'_>> {
141    // The comm field can contain spaces and parentheses. Field 3 begins after
142    // the last closing parenthesis, not the first one.
143    Some(stat.rsplit_once(')')?.1.split_whitespace())
144}
145
146#[cfg(any(target_os = "linux", test))]
147fn start_time_from_stat(stat: &str) -> Option<u64> {
148    // Field 22 is the twentieth field after comm. Do not reject zombie starts:
149    // the kernel identity remains readable until the process is reaped.
150    stat_fields(stat)?.nth(19)?.parse().ok()
151}
152
153#[cfg(any(target_os = "linux", test))]
154fn state_from_stat(stat: &str) -> Option<&str> {
155    stat_fields(stat)?.next()
156}
157
158#[cfg(target_os = "macos")]
159fn platform_process_start(pid: i32) -> Option<ProcessStart> {
160    // Initialize the entire buffer in safe Rust, so reading the completed reply
161    // needs no unsafe initialization or pointer dereference.
162    let mut info = libc::proc_bsdinfo {
163        pbi_flags: 0,
164        pbi_status: 0,
165        pbi_xstatus: 0,
166        pbi_pid: 0,
167        pbi_ppid: 0,
168        pbi_uid: 0,
169        pbi_gid: 0,
170        pbi_ruid: 0,
171        pbi_rgid: 0,
172        pbi_svuid: 0,
173        pbi_svgid: 0,
174        rfu_1: 0,
175        pbi_comm: [0; 16],
176        pbi_name: [0; 32],
177        pbi_nfiles: 0,
178        pbi_pgid: 0,
179        pbi_pjobc: 0,
180        e_tdev: 0,
181        e_tpgid: 0,
182        pbi_nice: 0,
183        pbi_start_tvsec: 0,
184        pbi_start_tvusec: 0,
185    };
186    let size = std::mem::size_of::<libc::proc_bsdinfo>() as i32;
187    // SAFETY: info is an initialized, aligned, writable buffer of exactly size
188    // bytes. proc_pidinfo writes at most size bytes; the return count is checked.
189    #[allow(unsafe_code)]
190    let read = unsafe {
191        libc::proc_pidinfo(
192            pid,
193            libc::PROC_PIDTBSDINFO,
194            0,
195            std::ptr::from_mut(&mut info).cast(),
196            size,
197        )
198    };
199    if read != size {
200        return None;
201    }
202    Some(ProcessStart(format!(
203        "macos-v1-{}-{}",
204        info.pbi_start_tvsec, info.pbi_start_tvusec
205    )))
206}
207
208#[cfg(not(any(target_os = "linux", target_os = "macos")))]
209fn platform_process_start(_pid: i32) -> Option<ProcessStart> {
210    None
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn parse_accepts_both_versioned_encodings() {
219        for encoding in [
220            "linux-v1-0",
221            "linux-v1-18446744073709551615",
222            "macos-v1-123456789-987654",
223            "macos-v1--9223372036854775808--9223372036854775808",
224            "macos-v1-9223372036854775807-9223372036854775807",
225        ] {
226            let start = ProcessStart::parse(encoding).expect("valid identity");
227            assert_eq!(start.as_str(), encoding);
228            assert_eq!(start.to_string(), encoding);
229        }
230    }
231
232    #[test]
233    fn parse_rejects_malformed_encodings() {
234        for encoding in [
235            "",
236            "garbage",
237            "linux-v2-1",
238            "other-v1-1",
239            // A wrong prefix followed by well-formed numbers must still fail, so
240            // the check can't pass merely because the remainder is malformed.
241            "macos-v2-1-2",
242            "Macos-v1-1-2",
243            "xxxxxxxxx1-2",
244            "linux-v2-5",
245            "linux-v1-",
246            "linux-v1--1",
247            "linux-v1-+1",
248            "linux-v1-1-2",
249            "linux-v1-18446744073709551616",
250            "macos-v1-",
251            "macos-v1-1",
252            "macos-v1-1-",
253            "macos-v1--1-",
254            "macos-v1-1-2-3",
255            "macos-v1-+1-2",
256            "macos-v1-1-+2",
257            "macos-v1-9223372036854775808-0",
258            "macos-v1-0--9223372036854775809",
259            "linux-v1- 1",
260            "macos-v1-1-2\n",
261        ] {
262            assert_eq!(ProcessStart::parse(encoding), None, "{encoding:?}");
263        }
264    }
265
266    #[test]
267    fn invalid_pids_are_unknown() {
268        let recorded = ProcessStart::parse("linux-v1-1").unwrap();
269        for pid in [0, u32::MAX] {
270            assert_eq!(process_start(pid), None);
271            assert_eq!(liveness(pid, None), Liveness::Unknown);
272            assert_eq!(liveness(pid, Some(&recorded)), Liveness::Unknown);
273        }
274    }
275
276    #[test]
277    fn stat_parser_uses_the_last_parenthesis_and_field_22() {
278        let stat = "123 ((a b) (c)) R 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 4242";
279        assert_eq!(start_time_from_stat(stat), Some(4242));
280        assert_eq!(state_from_stat(stat), Some("R"));
281    }
282
283    #[test]
284    fn stat_parser_rejects_truncated_or_invalid_start_times() {
285        for stat in [
286            "",
287            "123 (comm)",
288            "123 (comm) R 4 5",
289            "123 (comm) R 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21",
290            "123 (comm) R 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 invalid",
291        ] {
292            assert_eq!(start_time_from_stat(stat), None, "{stat:?}");
293        }
294    }
295
296    #[cfg(any(target_os = "linux", target_os = "macos"))]
297    #[test]
298    fn own_pid_with_matching_start_is_alive() {
299        let pid = std::process::id();
300        let start = process_start(pid).expect("own kernel start is readable");
301        assert_eq!(ProcessStart::parse(start.as_str()), Some(start.clone()));
302        assert_eq!(liveness(pid, Some(&start)), Liveness::Alive);
303    }
304
305    #[cfg(unix)]
306    #[test]
307    fn own_pid_without_recorded_start_is_alive() {
308        assert_eq!(liveness(std::process::id(), None), Liveness::Alive);
309    }
310
311    #[cfg(any(target_os = "linux", target_os = "macos"))]
312    #[test]
313    fn own_pid_with_different_same_family_start_is_dead() {
314        let pid = std::process::id();
315        let current = process_start(pid).unwrap();
316        let encoding = if cfg!(target_os = "linux") {
317            "linux-v1-0"
318        } else {
319            "macos-v1-0-0"
320        };
321        let mut recorded = ProcessStart::parse(encoding).unwrap();
322        if recorded == current {
323            recorded = ProcessStart::parse(&encoding.replace("-0", "-1")).unwrap();
324        }
325        assert_ne!(current, recorded);
326        assert_eq!(liveness(pid, Some(&recorded)), Liveness::Dead);
327    }
328
329    #[cfg(any(target_os = "linux", target_os = "macos"))]
330    #[test]
331    fn own_pid_with_cross_family_start_is_unknown() {
332        let encoding = if cfg!(target_os = "linux") {
333            "macos-v1-0-0"
334        } else {
335            "linux-v1-0"
336        };
337        let recorded = ProcessStart::parse(encoding).unwrap();
338        assert_eq!(
339            liveness(std::process::id(), Some(&recorded)),
340            Liveness::Unknown
341        );
342    }
343
344    #[cfg(unix)]
345    #[test]
346    fn exited_and_reaped_child_is_dead() {
347        let mut child = std::process::Command::new("true").spawn().unwrap();
348        let pid = child.id();
349        let recorded = process_start(pid);
350        child.wait().unwrap();
351        assert_eq!(liveness(pid, None), Liveness::Dead);
352        assert_eq!(liveness(pid, recorded.as_ref()), Liveness::Dead);
353    }
354
355    #[cfg(target_os = "linux")]
356    #[test]
357    fn exited_unreaped_zombie_child_is_dead() {
358        use std::time::{Duration, Instant};
359
360        let mut child = std::process::Command::new("true").spawn().unwrap();
361        let pid = child.id();
362        let deadline = Instant::now() + Duration::from_secs(5);
363        let zombie = loop {
364            // Observe procfs directly, independently of the liveness probe. Do
365            // not call try_wait: that would reap the child and miss the case.
366            let zombie = std::fs::read_to_string(format!("/proc/{pid}/stat"))
367                .ok()
368                .is_some_and(|stat| {
369                    stat.rsplit_once(')')
370                        .and_then(|(_, fields)| fields.split_whitespace().next())
371                        == Some("Z")
372                });
373            if zombie {
374                break true;
375            }
376            if Instant::now() >= deadline {
377                break false;
378            }
379            std::thread::sleep(Duration::from_millis(10));
380        };
381        let recorded = process_start(pid);
382        let without_start = liveness(pid, None);
383        let with_start = liveness(pid, recorded.as_ref());
384        // Reap before assertions so a failing liveness test leaves no zombie.
385        child.wait().unwrap();
386        assert!(zombie, "child did not reach state Z within five seconds");
387        assert!(
388            recorded.is_some(),
389            "unreaped zombie still has a kernel start"
390        );
391        assert_eq!(without_start, Liveness::Dead);
392        assert_eq!(with_start, Liveness::Dead);
393    }
394
395    #[cfg(not(unix))]
396    #[test]
397    fn unsupported_platform_is_unknown() {
398        let pid = std::process::id();
399        let recorded = ProcessStart::parse("linux-v1-1").unwrap();
400        assert_eq!(process_start(pid), None);
401        assert_eq!(liveness(pid, None), Liveness::Unknown);
402        assert_eq!(liveness(pid, Some(&recorded)), Liveness::Unknown);
403    }
404}