Skip to main content

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