Skip to main content

rch_common/
process_identity.rs

1//! Kernel process birth identity shared by durable clients and the daemon.
2//!
3//! A PID alone, or a display timestamp rounded to seconds, cannot establish
4//! ownership after a restart. Failed inspection never proves that an owner died.
5
6use serde::{Deserialize, Serialize};
7
8#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(tag = "platform", rename_all = "snake_case")]
10pub enum ProcessStart {
11    Linux { ticks: u64 },
12    Darwin { seconds: u64, microseconds: u32 },
13}
14
15#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
16pub struct ProcessIdentity {
17    pub boot_id: String,
18    pub start: ProcessStart,
19}
20
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub enum ProcessObservation {
23    Present(ProcessIdentity),
24    Absent,
25    Unknown,
26}
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum OwnerPresence {
30    Live,
31    Absent,
32    Unknown,
33}
34
35impl ProcessIdentity {
36    pub fn is_valid(&self) -> bool {
37        uuid::Uuid::parse_str(&self.boot_id).is_ok_and(|uuid| uuid.to_string() == self.boot_id)
38            && match self.start {
39                ProcessStart::Linux { .. } => true,
40                ProcessStart::Darwin {
41                    seconds,
42                    microseconds,
43                } => seconds > 0 && microseconds < 1_000_000,
44            }
45    }
46
47    /// Linux's existing durable daemon representation remains byte-compatible.
48    /// The Darwin marker is versioned by kind and retains kernel precision.
49    pub fn to_record(&self) -> Option<String> {
50        self.is_valid().then(|| match self.start {
51            ProcessStart::Linux { ticks } => format!("{}:{ticks}", self.boot_id),
52            ProcessStart::Darwin {
53                seconds,
54                microseconds,
55            } => format!("{}:darwin:{seconds}:{microseconds}", self.boot_id),
56        })
57    }
58
59    pub fn from_record(record: &str) -> Option<Self> {
60        let (boot, marker) = record.split_once(':')?;
61        let start = if let Some(time) = marker.strip_prefix("darwin:") {
62            let (seconds, microseconds) = time.split_once(':')?;
63            ProcessStart::Darwin {
64                seconds: decimal(seconds)?,
65                microseconds: u32::try_from(decimal(microseconds)?).ok()?,
66            }
67        } else {
68            ProcessStart::Linux {
69                ticks: decimal(marker)?,
70            }
71        };
72        let identity = Self {
73            boot_id: boot.into(),
74            start,
75        };
76        identity.is_valid().then_some(identity)
77    }
78
79    pub fn presence(&self, observation: &ProcessObservation) -> OwnerPresence {
80        if !self.is_valid() {
81            return OwnerPresence::Unknown;
82        }
83        match observation {
84            ProcessObservation::Absent => OwnerPresence::Absent,
85            ProcessObservation::Unknown => OwnerPresence::Unknown,
86            ProcessObservation::Present(current) => {
87                if !current.is_valid()
88                    || std::mem::discriminant(&self.start) != std::mem::discriminant(&current.start)
89                {
90                    return OwnerPresence::Unknown;
91                }
92                if self == current {
93                    OwnerPresence::Live
94                } else {
95                    OwnerPresence::Absent
96                }
97            }
98        }
99    }
100}
101
102fn decimal(value: &str) -> Option<u64> {
103    (!value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()))
104        .then(|| value.parse().ok())
105        .flatten()
106}
107
108/// Only call this with the PID whose identity was recorded in the journal.
109/// The result authorizes observation/recovery, never an unfenced kill(pid).
110pub fn owner_presence(pid: u32, expected: Option<&ProcessIdentity>) -> OwnerPresence {
111    if pid <= 1 || i32::try_from(pid).is_err() {
112        return OwnerPresence::Unknown;
113    }
114    observed_owner_presence(expected, &observe_process(pid))
115}
116
117fn observed_owner_presence(
118    expected: Option<&ProcessIdentity>,
119    observed: &ProcessObservation,
120) -> OwnerPresence {
121    if *observed == ProcessObservation::Absent {
122        return OwnerPresence::Absent;
123    }
124    expected.map_or(OwnerPresence::Unknown, |identity| {
125        identity.presence(observed)
126    })
127}
128
129/// Read our own birth marker without accidentally inspecting an unrelated
130/// /proc/<getpid> when procfs belongs to a different PID namespace.
131pub fn current_process_identity() -> Option<ProcessIdentity> {
132    #[cfg(target_os = "linux")]
133    let observation = linux_stat(
134        &std::fs::read_to_string("/proc/self/stat").ok()?,
135        std::process::id(),
136        &boot_identity()?,
137    );
138    #[cfg(not(target_os = "linux"))]
139    let observation = observe_process(std::process::id());
140    match observation {
141        ProcessObservation::Present(identity) => Some(identity),
142        _ => None,
143    }
144}
145
146#[cfg(target_os = "linux")]
147fn boot_identity() -> Option<String> {
148    let boot = std::fs::read_to_string("/proc/sys/kernel/random/boot_id").ok()?;
149    let boot = boot.trim();
150    uuid::Uuid::parse_str(boot).ok()?;
151    Some(boot.into())
152}
153
154#[cfg(target_os = "macos")]
155fn boot_identity() -> Option<String> {
156    static BOOT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
157    if let Some(boot) = BOOT.get() {
158        return Some(boot.clone());
159    }
160    let output = std::process::Command::new("/usr/sbin/sysctl")
161        .args(["-n", "kern.bootsessionuuid"])
162        .output()
163        .ok()?;
164    if !output.status.success() {
165        return None;
166    }
167    let text = std::str::from_utf8(&output.stdout).ok()?.trim();
168    let boot = uuid::Uuid::parse_str(text).ok()?.to_string();
169    // Do not cache failures: a transient inspection error is not permanent.
170    let _ = BOOT.set(boot.clone());
171    Some(boot)
172}
173
174#[cfg(target_os = "linux")]
175pub fn observe_process(pid: u32) -> ProcessObservation {
176    if pid <= 1 || i32::try_from(pid).is_err() {
177        return ProcessObservation::Unknown;
178    }
179    // /proc may have been mounted by a different PID namespace. Neither a
180    // matching numeric entry nor ENOENT there describes our signal namespace.
181    // In particular, never release ownership because a live wrapper's PID is
182    // absent from a foreign procfs view.
183    if !std::fs::read_to_string("/proc/self/stat").is_ok_and(|stat| {
184        stat.split_once(" (").and_then(|(pid, _)| decimal(pid))
185            == Some(u64::from(std::process::id()))
186    }) {
187        return ProcessObservation::Unknown;
188    }
189    let Some(boot) = boot_identity() else {
190        return ProcessObservation::Unknown;
191    };
192    match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
193        Ok(stat) => linux_stat(&stat, pid, &boot),
194        Err(error) if error.kind() == std::io::ErrorKind::NotFound => ProcessObservation::Absent,
195        Err(_) => ProcessObservation::Unknown,
196    }
197}
198
199#[cfg(any(target_os = "linux", test))]
200fn linux_stat(stat: &str, pid: u32, boot: &str) -> ProcessObservation {
201    let Some((prefix, rest)) = stat.rsplit_once(") ") else {
202        return ProcessObservation::Unknown;
203    };
204    if prefix.split_once(" (").and_then(|(pid, _)| decimal(pid)) != Some(u64::from(pid)) {
205        return ProcessObservation::Unknown;
206    }
207    let mut fields = rest.split_whitespace();
208    let state = fields.next();
209    let Some(ticks) = fields.nth(18).and_then(decimal) else {
210        return ProcessObservation::Unknown;
211    };
212    match state {
213        Some("Z" | "X" | "x") => ProcessObservation::Absent,
214        Some("R" | "S" | "D" | "T" | "t" | "K" | "W" | "P" | "I") => {
215            ProcessObservation::Present(ProcessIdentity {
216                boot_id: boot.into(),
217                start: ProcessStart::Linux { ticks },
218            })
219        }
220        _ => ProcessObservation::Unknown,
221    }
222}
223
224#[cfg(target_os = "macos")]
225pub fn observe_process(pid: u32) -> ProcessObservation {
226    if pid <= 1 || i32::try_from(pid).is_err() {
227        return ProcessObservation::Unknown;
228    }
229    let Some(boot) = boot_identity() else {
230        return ProcessObservation::Unknown;
231    };
232    match libproc::proc_pid::pidinfo::<libproc::bsd_info::BSDInfo>(pid as i32, 0) {
233        Ok(info) => darwin_info(
234            pid,
235            info.pbi_pid,
236            info.pbi_status,
237            info.pbi_start_tvsec,
238            info.pbi_start_tvusec,
239            &boot,
240        ),
241        Err(_) => {
242            // libproc's error is textual. Do not infer ESRCH from text or a
243            // stale errno: perform a safe, signal-free kernel existence probe.
244            match rustix::process::test_kill_process(
245                rustix::process::Pid::from_raw(pid as i32).expect("validated positive PID"),
246            ) {
247                Err(rustix::io::Errno::SRCH) => ProcessObservation::Absent,
248                _ => ProcessObservation::Unknown,
249            }
250        }
251    }
252}
253
254#[cfg(any(target_os = "macos", test))]
255fn darwin_info(
256    requested: u32,
257    actual: u32,
258    state: u32,
259    seconds: u64,
260    microseconds: u64,
261    boot: &str,
262) -> ProcessObservation {
263    if actual != requested || seconds == 0 || microseconds >= 1_000_000 {
264        return ProcessObservation::Unknown;
265    }
266    // Darwin bsd/sys/proc.h: SIDL=1, SRUN=2, SSLEEP=3, SSTOP=4, SZOMB=5.
267    // proc_info.c fills pbi_start_tv{sec,usec} directly from p_start.
268    match state {
269        5 => ProcessObservation::Absent,
270        1..=4 => ProcessObservation::Present(ProcessIdentity {
271            boot_id: boot.into(),
272            start: ProcessStart::Darwin {
273                seconds,
274                microseconds: microseconds as u32,
275            },
276        }),
277        _ => ProcessObservation::Unknown,
278    }
279}
280
281#[cfg(not(any(target_os = "linux", target_os = "macos")))]
282pub fn observe_process(_pid: u32) -> ProcessObservation {
283    ProcessObservation::Unknown
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    const BOOT: &str = "3f1c2a9e-5b7d-4e2a-9c1f-0a1b2c3d4e5f";
291
292    #[test]
293    fn native_precision_survives_records_and_rejects_second_resolution_history() {
294        let linux = format!("{BOOT}:123456789");
295        let darwin = format!("{BOOT}:darwin:1791280000:123456");
296        for record in [&linux, &darwin] {
297            let identity = ProcessIdentity::from_record(record).unwrap();
298            assert_eq!(identity.to_record().as_ref(), Some(record));
299            assert_eq!(
300                serde_json::from_str::<ProcessIdentity>(&serde_json::to_string(&identity).unwrap())
301                    .unwrap(),
302                identity
303            );
304        }
305        for record in [
306            format!("{BOOT}:Mon Sep 28 10:48:17 2026"),
307            format!("{BOOT}:darwin:1791280000:1000000"),
308            format!("{BOOT}:darwin:0:123"),
309            format!("{BOOT}:darwin:1791280000:123:4"),
310            format!("{BOOT}:+123"),
311            "unverified-boot:123".into(),
312            format!("{}:123", BOOT.to_uppercase()),
313        ] {
314            assert!(ProcessIdentity::from_record(&record).is_none(), "{record}");
315        }
316    }
317
318    #[test]
319    fn matching_distinguishes_microseconds_reboots_and_unknown_observation() {
320        let owner =
321            ProcessIdentity::from_record(&format!("{BOOT}:darwin:1791280000:123456")).unwrap();
322        assert_eq!(
323            owner.presence(&ProcessObservation::Present(owner.clone())),
324            OwnerPresence::Live
325        );
326        let mut reused = owner.clone();
327        reused.start = ProcessStart::Darwin {
328            seconds: 1791280000,
329            microseconds: 123457,
330        };
331        assert_eq!(
332            owner.presence(&ProcessObservation::Present(reused)),
333            OwnerPresence::Absent
334        );
335        let mut rebooted = owner.clone();
336        rebooted.boot_id = "00000000-0000-0000-0000-000000000001".into();
337        assert_eq!(
338            owner.presence(&ProcessObservation::Present(rebooted)),
339            OwnerPresence::Absent
340        );
341        assert_eq!(
342            owner.presence(&ProcessObservation::Unknown),
343            OwnerPresence::Unknown
344        );
345        assert_eq!(
346            owner.presence(&ProcessObservation::Absent),
347            OwnerPresence::Absent
348        );
349        let linux = ProcessIdentity::from_record(&format!("{BOOT}:123")).unwrap();
350        assert_eq!(
351            owner.presence(&ProcessObservation::Present(linux)),
352            OwnerPresence::Unknown
353        );
354    }
355
356    #[test]
357    fn absent_pid_is_independent_proof_but_unknown_or_legacy_birth_cannot_adopt_a_live_pid() {
358        let current =
359            ProcessIdentity::from_record(&format!("{BOOT}:darwin:1791280000:123456")).unwrap();
360        let legacy = ProcessIdentity::from_record(&format!("{BOOT}:Mon Sep 28 10:48:17 2026"));
361        assert!(legacy.is_none());
362        for expected in [None, legacy.as_ref(), Some(&current)] {
363            assert_eq!(
364                observed_owner_presence(expected, &ProcessObservation::Absent),
365                OwnerPresence::Absent
366            );
367            assert_eq!(
368                observed_owner_presence(expected, &ProcessObservation::Unknown),
369                OwnerPresence::Unknown
370            );
371        }
372        assert_eq!(
373            observed_owner_presence(None, &ProcessObservation::Present(current)),
374            OwnerPresence::Unknown
375        );
376    }
377
378    #[test]
379    fn proc_stat_handles_command_parentheses_and_refuses_wrong_pid_namespace() {
380        let stat = |state| format!("123 (rch (wrapper)) {state} {} 42", vec!["0"; 18].join(" "));
381        let expected = ProcessIdentity::from_record(&format!("{BOOT}:42")).unwrap();
382        assert_eq!(
383            expected.presence(&linux_stat(&stat("S"), 123, BOOT)),
384            OwnerPresence::Live
385        );
386        for state in ["Z", "X", "x"] {
387            assert_eq!(
388                linux_stat(&stat(state), 123, BOOT),
389                ProcessObservation::Absent
390            );
391        }
392        assert_eq!(
393            linux_stat(&stat("S"), 456, BOOT),
394            ProcessObservation::Unknown
395        );
396        assert_eq!(
397            linux_stat(&stat("?"), 123, BOOT),
398            ProcessObservation::Unknown
399        );
400        assert_eq!(
401            linux_stat("truncated", 123, BOOT),
402            ProcessObservation::Unknown
403        );
404    }
405
406    #[test]
407    fn darwin_kernel_records_validate_pid_status_and_fractional_time() {
408        assert!(matches!(
409            darwin_info(42, 42, 2, 1791280000, 123456, BOOT),
410            ProcessObservation::Present(_)
411        ));
412        assert_eq!(
413            darwin_info(42, 42, 5, 1791280000, 123456, BOOT),
414            ProcessObservation::Absent
415        );
416        for (pid, state, seconds, micros) in [
417            (43, 2, 1791280000, 123456),
418            (42, 99, 1791280000, 123456),
419            (42, 2, 0, 1),
420            (42, 2, 1791280000, 1000000),
421        ] {
422            assert_eq!(
423                darwin_info(42, pid, state, seconds, micros, BOOT),
424                ProcessObservation::Unknown
425            );
426        }
427    }
428
429    #[cfg(target_os = "macos")]
430    #[test]
431    fn darwin_native_birth_is_stable_and_a_reaped_child_is_absent() {
432        let own = current_process_identity().expect("native own process identity");
433        assert!(matches!(own.start, ProcessStart::Darwin { .. }));
434        assert_eq!(current_process_identity(), Some(own));
435        let mut child = std::process::Command::new("/bin/sleep")
436            .arg("30")
437            .spawn()
438            .unwrap();
439        let observed = observe_process(child.id());
440        child.kill().unwrap();
441        child.wait().unwrap();
442        assert!(
443            matches!(observed, ProcessObservation::Present(_)),
444            "{observed:?}"
445        );
446        assert_eq!(observe_process(child.id()), ProcessObservation::Absent);
447    }
448}