Skip to main content

git_policy_cli/process/
process_identity.rs

1//! What:
2//!  Process-birth identities:
3//!  a string that names one process for its whole life and
4//!       changes when its PID is reused,
5//!  in the incumbent's exact spelling.
6//! Why:
7//!  Every lock and transaction records its owner's PID and birth identity;
8//!  an owner is
9//!      alive only while its PID names a process with the recorded identity.
10//!  Both wrappers
11//!      judge each other's locks,
12//!  so the strings must agree byte for byte
13//!      (`src/policy-engine/commit-transaction-process-identity.ts`;
14//!  the decision in
15//!      `doc/handover/cli-git-rust-implementation.md`,
16//!  "Adopted without a question";
17//!  that section moved on 2026-11-03 to
18//!  `doc/planning/cli-git-rust-open-decisions.md`).
19//!
20//! In TS you'd write (pseudocode):
21//! ```ts
22//! const identity = await resolveProcessBirthIdentity(pid); // 'linux:8423337' or PROCESS_IDENTITY_ABSENT
23//! ```
24
25/// Text trimming with the incumbent's `.trim()` semantics.
26use crate::js_text::trim_javascript;
27/// `OnceLock` holds a value computed at most once per process.
28use std::sync::OnceLock;
29
30/// What:
31///  Why a birth identity could not be decided.
32///  Absence (the process exited) is not an
33///       error;
34///  these are states the incumbent also throws on.
35/// Why:
36///   A lock acquirer that cannot tell whether an owner lives must stop,
37///  not guess.
38///
39/// In TS you'd write (pseudocode):
40/// ```ts
41/// class ProcessIdentityError extends TypeError {}
42/// ```
43#[derive(Debug)]
44pub enum ProcessIdentityError {
45    /// `/proc/<pid>/stat` has no `)` or no start-time field.
46    MalformedStat {
47        /// The process whose record was read.
48        pid: i64,
49    },
50    /// The record could not be read for a reason other than the process being gone.
51    Io {
52        /// The process whose record was read.
53        pid: i64,
54        /// The operating system's refusal.
55        error: std::io::Error,
56    },
57    /// This platform has no identity source.
58    UnsupportedPlatform,
59    /// The current process has no identity,
60    ///  so it cannot own a lock.
61    CurrentUnavailable,
62    /// The `/proc` this process sees describes another PID namespace:
63    ///  its own record carries another PID than the one the process has,
64    ///  so a PID read there names the wrong process.
65    ProcMismatch {
66        /// The PID in the record `/proc/self/stat` gave.
67        procfs: i64,
68        /// The PID the operating system gave this process.
69        own: u32,
70    },
71    /// The boot ID of the host cannot be read,
72    ///  so a marker could not be told from one of another boot.
73    BootUnavailable,
74    /// The PID namespace of this process cannot be read,
75    ///  so a marker could not say which numbering its PID belongs to.
76    ScopeUnavailable,
77}
78
79/// What:
80///  Human-readable text for each failure.
81/// Why:
82///   Diagnostics name the process and the reason in plain words.
83///
84/// In TS you'd write (pseudocode):
85/// ```ts
86/// error.message
87/// ```
88impl std::fmt::Display for ProcessIdentityError {
89    /// Writes one sentence naming the process and what went wrong.
90    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        match self {
92            Self::MalformedStat { pid } => {
93                return write!(
94                    formatter,
95                    "the Linux process record of PID {pid} is malformed"
96                );
97            }
98            Self::Io { pid, error } => {
99                return write!(
100                    formatter,
101                    "the process record of PID {pid} could not be read: {error}"
102                );
103            }
104            Self::UnsupportedPlatform => {
105                return write!(
106                    formatter,
107                    "this platform has no process birth identity source"
108                );
109            }
110            Self::CurrentUnavailable => {
111                return write!(
112                    formatter,
113                    "the birth identity of the current process is unavailable"
114                );
115            }
116            Self::BootUnavailable => {
117                return write!(
118                    formatter,
119                    "the boot ID of this host cannot be read from /proc/sys/kernel/random/boot_id, so a lock could not be told from one of an earlier boot"
120                );
121            }
122            Self::ScopeUnavailable => {
123                return write!(
124                    formatter,
125                    "the PID namespace of this process cannot be read from /proc/self/ns/pid, so a lock could not say which numbering its process number belongs to"
126                );
127            }
128            Self::ProcMismatch { procfs, own } => {
129                return write!(
130                    formatter,
131                    "the /proc this process sees describes another PID namespace: it reports PID {procfs} for the process that has PID {own}"
132                );
133            }
134        }
135    }
136}
137
138/// What:
139///  The one way a `/proc/<pid>/stat` text can be malformed for this purpose.
140/// Why:
141///   The caller knows the PID and turns this into a named error.
142///
143/// In TS you'd write (pseudocode):
144/// ```ts
145/// class MalformedStat extends TypeError {}
146/// ```
147#[derive(Clone, Copy, Debug, Eq, PartialEq)]
148pub struct MalformedStat;
149
150/// Linux process states of a process that has exited:
151///  zombie and dead.
152const EXITED_LINUX_STATES: [&str; 2] = ["Z", "X"];
153
154/// Index of the start-time field among the fields after the command name:
155///  field 22 is the
156/// 20th field counted from field 3,
157///  the state.
158const LINUX_START_FIELD_INDEX: usize = 19;
159
160/// What:
161///  The birth identity in one `/proc/<pid>/stat` text,
162///  or nothing for an exited process.
163///       `Result<Option<String>, MalformedStat>` is "an identity,
164///  nothing,
165///  or a malformed record".
166/// Why:
167///   The incumbent cuts after the last `)` (the command name may hold spaces and `)`),
168///       skips `) `,
169///  trims,
170///  splits on single spaces,
171///  treats states `Z` and `X` as exited
172///       (a zombie under a container init that never reaps keeps its PID forever),
173///  and
174///       answers `linux:` plus the start tick.
175///  An empty start field is malformed.
176///
177/// In TS you'd write (pseudocode):
178/// ```ts
179/// function linuxIdentityFromStat(stat: string): string | typeof ABSENT; // throws when malformed
180/// ```
181pub fn linux_identity_from_stat(stat: &str) -> Result<Option<String>, MalformedStat> {
182    // `rfind` gives the byte position of the last `)`, the end of the command name.
183    let Some(command_end) = stat.rfind(')') else {
184        return Err(MalformedStat);
185    };
186    // `get(a..)` is the text from byte `a` on, or nothing past the end; `) ` is two bytes.
187    let after: &str = stat.get(command_end + 2..).unwrap_or("");
188    // `.split(' ')` yields every piece between single spaces, empty pieces included.
189    let fields: Vec<&str> = trim_javascript(after).split(' ').collect();
190    // `fields[0]` always exists: splitting yields at least one piece.
191    if EXITED_LINUX_STATES.contains(&fields[0]) {
192        return Ok(None);
193    }
194    match fields.get(LINUX_START_FIELD_INDEX) {
195        Some(tick) if !tick.is_empty() => return Ok(Some(format!("linux:{tick}"))),
196        Some(_) | None => return Err(MalformedStat),
197    }
198}
199
200/// What:
201///  The identity a platform inspector printed,
202///  as `<prefix>:<trimmed output>`,
203///  or nothing
204///       when it printed only whitespace.
205///       `&[u8]` borrows the raw standard output.
206/// Why:
207///   On macOS and Windows the incumbent runs a command and trims its output;
208///  empty output
209///       means the process is gone.
210///
211/// In TS you'd write (pseudocode):
212/// ```ts
213/// function commandIdentity(prefix: string, stdout: string): string | typeof ABSENT;
214/// ```
215pub fn command_identity(prefix: &str, stdout: &[u8]) -> Option<String> {
216    // `from_utf8_lossy` decodes as Node does for a child's output, replacing invalid bytes.
217    let text: std::borrow::Cow<'_, str> = String::from_utf8_lossy(stdout);
218    let value: &str = trim_javascript(text.as_ref());
219    if value.is_empty() {
220        return None;
221    }
222    return Some(format!("{prefix}:{value}"));
223}
224
225/// What:
226///  The arguments the incumbent passes to PowerShell for one PID on Windows.
227///       `Vec<String>` is an owned list of owned text.
228/// Why:
229///   The Windows identity is whatever this exact command prints,
230///  so running the same
231///       command reproduces it by construction;
232///  the conversion through `GetProcessTimes`
233///       that would avoid PowerShell is unproven (`doc/handover/cli-git-native-transactions.md`).
234///
235/// In TS you'd write (pseudocode):
236/// ```ts
237/// ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `(Get-Process -Id ${pid}).StartTime.ToUniversalTime().Ticks`]
238/// ```
239pub fn windows_identity_arguments(pid: i64) -> Vec<String> {
240    return vec![
241        String::from("-NoLogo"),
242        String::from("-NoProfile"),
243        String::from("-NonInteractive"),
244        String::from("-Command"),
245        format!("(Get-Process -Id {pid}).StartTime.ToUniversalTime().Ticks"),
246    ];
247}
248
249/// What:
250///  The arguments the incumbent passes to `ps` for one PID on macOS.
251/// Why:
252///   `ps -o lstart=` prints the start time in the C locale;
253///  running it reproduces the
254///       incumbent's string by construction.
255///
256/// In TS you'd write (pseudocode):
257/// ```ts
258/// ['-o', 'lstart=', '-p', String(pid)]
259/// ```
260pub fn darwin_identity_arguments(pid: i64) -> Vec<String> {
261    return vec![
262        String::from("-o"),
263        String::from("lstart="),
264        String::from("-p"),
265        pid.to_string(),
266    ];
267}
268
269/// What:
270///  Run a platform inspector in the C locale and read its identity line.
271/// Why:
272///   The incumbent's `nano-spawn` call adds `LC_ALL=C` to the inherited environment and
273///       maps every failure to start or finish (a non-zero exit) to "absent".
274///
275/// In TS you'd write (pseudocode):
276/// ```ts
277/// async function resolveCommandIdentity({ command, args, prefix }): Promise<string | typeof ABSENT>;
278/// ```
279#[cfg_attr(
280    all(target_os = "linux", not(test)),
281    allow(
282        dead_code,
283        reason = "the release build uses it on macOS and Windows only"
284    )
285)]
286fn inspector_identity(command: &str, arguments: &[String], prefix: &str) -> Option<String> {
287    let output: std::process::Output = std::process::Command::new(command)
288        .args(arguments)
289        .env("LC_ALL", "C")
290        .stdin(std::process::Stdio::null())
291        .stderr(std::process::Stdio::null())
292        .output()
293        .ok()?;
294    if !output.status.success() {
295        return None;
296    }
297    return command_identity(prefix, output.stdout.as_slice());
298}
299
300/// What:
301///  Whether a failure to read a process's record says the process is gone:
302/// no such file (`ENOENT`),
303/// or the process exited during the read (`ESRCH`).
304/// Why:
305///   Any other failure (a refusal,
306/// a record that is a directory) says nothing about the process,
307/// and reading it as "gone" would let a lock of a running service be taken.
308///
309/// In TS you'd write (pseudocode):
310/// ```ts
311/// ['ENOENT', 'ESRCH'].includes(error.code)
312/// ```
313#[cfg(target_os = "linux")]
314pub(crate) fn record_is_gone(error: &std::io::Error) -> bool {
315    return error.kind() == std::io::ErrorKind::NotFound || error.raw_os_error() == Some(libc::ESRCH);
316}
317
318/// What:
319///  Read `/proc/<pid>/stat` and decide the Linux identity.
320/// Why:
321///   A missing record (`ENOENT`) or a process that exits mid-read (`ESRCH`) is an exited
322///       process;
323///  any other read failure is an error,
324///  as in the incumbent.
325///
326/// In TS you'd write (pseudocode):
327/// ```ts
328/// async function resolveLinuxIdentity(pid: number): Promise<string | typeof ABSENT>;
329/// ```
330#[cfg(target_os = "linux")]
331fn linux_identity(pid: i64) -> Result<Option<String>, ProcessIdentityError> {
332    return linux_identity_read_by(pid, std::fs::read::<String>);
333}
334
335/// What:
336///  `linux_identity` with the read of the record given,
337///  so a failure the host cannot be made to show (a refusal,
338/// an I/O error) can be shown to it.
339/// Why:
340///   Which failures mean "gone" and which are errors is the whole decision of this function.
341#[cfg(target_os = "linux")]
342pub(crate) fn linux_identity_read_by(
343    pid: i64,
344    read_record: impl FnOnce(String) -> std::io::Result<Vec<u8>>,
345) -> Result<Option<String>, ProcessIdentityError> {
346    let bytes: Vec<u8> = match read_record(format!("/proc/{pid}/stat")) {
347        Ok(read) => read,
348        Err(error) if record_is_gone(&error) => return Ok(None),
349        Err(error) => return Err(ProcessIdentityError::Io { pid, error }),
350    };
351    // Node reads the file as UTF-8, replacing invalid bytes in the command name.
352    let stat: std::borrow::Cow<'_, str> = String::from_utf8_lossy(bytes.as_slice());
353    match linux_identity_from_stat(stat.as_ref()) {
354        Ok(identity) => return Ok(identity),
355        Err(MalformedStat) => return Err(ProcessIdentityError::MalformedStat { pid }),
356    }
357}
358
359/// What:
360///  The birth identity of the process `pid` names now,
361///  or nothing when none runs.
362///       `i64` holds any PID a record can carry.
363/// Why:
364///   This is the one question every liveness check asks.
365///
366/// In TS you'd write (pseudocode):
367/// ```ts
368/// function resolveProcessBirthIdentity(pid: number): Promise<string | typeof PROCESS_IDENTITY_ABSENT>;
369/// ```
370pub fn process_birth_identity(pid: i64) -> Result<Option<String>, ProcessIdentityError> {
371    #[cfg(target_os = "linux")]
372    {
373        return linux_identity(pid);
374    }
375    #[cfg(target_os = "macos")]
376    {
377        return Ok(inspector_identity(
378            "ps",
379            darwin_identity_arguments(pid).as_slice(),
380            "darwin",
381        ));
382    }
383    // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
384    #[cfg(windows)]
385    {
386        return Ok(inspector_identity(
387            "powershell.exe",
388            windows_identity_arguments(pid).as_slice(),
389            "win32",
390        ));
391    }
392    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
393    {
394        let _ = pid;
395        return Err(ProcessIdentityError::UnsupportedPlatform);
396    }
397}
398
399/// The current process's identity,
400///  computed once:
401///  it never changes while the process runs.
402static CURRENT_IDENTITY: OnceLock<String> = OnceLock::new();
403
404/// What:
405///  The birth identity of this process,
406///  computed once per process.
407/// Why:
408///   Every lock this process takes records it,
409///  and on macOS and Windows each computation
410///       starts a process.
411///
412/// In TS you'd write (pseudocode):
413/// ```ts
414/// const own = await resolveProcessBirthIdentity(process.pid);
415/// ```
416pub fn current_birth_identity() -> Result<String, ProcessIdentityError> {
417    if let Some(known) = CURRENT_IDENTITY.get() {
418        return Ok(known.clone());
419    }
420    let computed: String = process_birth_identity(i64::from(std::process::id()))?
421        .ok_or(ProcessIdentityError::CurrentUnavailable)?;
422    // Another thread may have stored it meanwhile; both values are the same, so a lost race
423    // to store is harmless.
424    let _ = CURRENT_IDENTITY.set(computed.clone());
425    return Ok(computed);
426}
427
428/// What:
429///  Whether a signal could be sent to `pid` (it names some process),
430///  on Unix.
431///       `Ok(false)` means no such process;
432///  an error is a refusal such as `EPERM`.
433/// Why:
434///   The incumbent's `processIsAlive` asks `kill(pid, 0)` before comparing identities;
435///       a process of another account answers `EPERM`,
436///  which callers judge themselves.
437///
438/// In TS you'd write (pseudocode):
439/// ```ts
440/// function processIsAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch (e) { if (e.code === 'ESRCH') return false; throw e; } }
441/// ```
442#[cfg(unix)]
443pub fn signal_probe(pid: i64) -> std::io::Result<bool> {
444    // A PID beyond `pid_t` cannot name a process.
445    let Ok(target) = libc::pid_t::try_from(pid) else {
446        return Ok(false);
447    };
448    if target <= 0 {
449        return Ok(false);
450    }
451    // SAFETY: `kill` with signal 0 sends nothing; it only checks that the target exists and
452    // may be signaled. It reads no memory of this process.
453    let status: libc::c_int = unsafe { libc::kill(target, 0) };
454    if status == 0 {
455        return Ok(true);
456    }
457    let error: std::io::Error = std::io::Error::last_os_error();
458    if error.raw_os_error() == Some(libc::ESRCH) {
459        return Ok(false);
460    }
461    return Err(error);
462}
463
464/// Parser controls and live-process probes stay out of the release executable.
465#[cfg(test)]
466#[path = "process_identity_tests.rs"]
467mod tests;