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