monochromatic-git-policy-cli 0.5.24

Repository-owned Git policy wrapper: forwards Git commands and checks commits against configured policies.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
//! What:
//!  Process-birth identities:
//!  a string that names one process for its whole life and
//!       changes when its PID is reused,
//!  in the incumbent's exact spelling.
//! Why:
//!  Every lock and transaction records its owner's PID and birth identity;
//!  an owner is
//!      alive only while its PID names a process with the recorded identity.
//!  Both wrappers
//!      judge each other's locks,
//!  so the strings must agree byte for byte
//!      (`src/policy-engine/commit-transaction-process-identity.ts`;
//!  the decision in
//!      `doc/handover/cli-git-rust-implementation.md`,
//!  "Adopted without a question").
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! const identity = await resolveProcessBirthIdentity(pid); // 'linux:8423337' or PROCESS_IDENTITY_ABSENT
//! ```

/// Text trimming with the incumbent's `.trim()` semantics.
use super::js_text::trim_javascript;
/// `OnceLock` holds a value computed at most once per process.
use std::sync::OnceLock;

/// What:
///  Why a birth identity could not be decided.
///  Absence (the process exited) is not an
///       error;
///  these are states the incumbent also throws on.
/// Why:
///   A lock acquirer that cannot tell whether an owner lives must stop,
///  not guess.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// class ProcessIdentityError extends TypeError {}
/// ```
#[derive(Debug)]
pub enum ProcessIdentityError {
    /// `/proc/<pid>/stat` has no `)` or no start-time field.
    MalformedStat {
        /// The process whose record was read.
        pid: i64,
    },
    /// The record could not be read for a reason other than the process being gone.
    Io {
        /// The process whose record was read.
        pid: i64,
        /// The operating system's refusal.
        error: std::io::Error,
    },
    /// This platform has no identity source.
    UnsupportedPlatform,
    /// The current process has no identity,
    ///  so it cannot own a lock.
    CurrentUnavailable,
    /// The `/proc` this process sees describes another PID namespace:
    ///  its own record carries another PID than the one the process has,
    ///  so a PID read there names the wrong process.
    ProcMismatch {
        /// The PID in the record `/proc/self/stat` gave.
        procfs: i64,
        /// The PID the operating system gave this process.
        own: u32,
    },
    /// The boot ID of the host cannot be read,
    ///  so a marker could not be told from one of another boot.
    BootUnavailable,
    /// The PID namespace of this process cannot be read,
    ///  so a marker could not say which numbering its PID belongs to.
    ScopeUnavailable,
}

/// What:
///  Human-readable text for each failure.
/// Why:
///   Diagnostics name the process and the reason in plain words.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// error.message
/// ```
impl std::fmt::Display for ProcessIdentityError {
    /// Writes one sentence naming the process and what went wrong.
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::MalformedStat { pid } => {
                return write!(
                    formatter,
                    "the Linux process record of PID {pid} is malformed"
                );
            }
            Self::Io { pid, error } => {
                return write!(
                    formatter,
                    "the process record of PID {pid} could not be read: {error}"
                );
            }
            Self::UnsupportedPlatform => {
                return write!(
                    formatter,
                    "this platform has no process birth identity source"
                );
            }
            Self::CurrentUnavailable => {
                return write!(
                    formatter,
                    "the birth identity of the current process is unavailable"
                );
            }
            Self::BootUnavailable => {
                return write!(
                    formatter,
                    "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"
                );
            }
            Self::ScopeUnavailable => {
                return write!(
                    formatter,
                    "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"
                );
            }
            Self::ProcMismatch { procfs, own } => {
                return write!(
                    formatter,
                    "the /proc this process sees describes another PID namespace: it reports PID {procfs} for the process that has PID {own}"
                );
            }
        }
    }
}

/// What:
///  The one way a `/proc/<pid>/stat` text can be malformed for this purpose.
/// Why:
///   The caller knows the PID and turns this into a named error.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// class MalformedStat extends TypeError {}
/// ```
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct MalformedStat;

/// Linux process states of a process that has exited:
///  zombie and dead.
const EXITED_LINUX_STATES: [&str; 2] = ["Z", "X"];

/// Index of the start-time field among the fields after the command name:
///  field 22 is the
/// 20th field counted from field 3,
///  the state.
const LINUX_START_FIELD_INDEX: usize = 19;

/// What:
///  The birth identity in one `/proc/<pid>/stat` text,
///  or nothing for an exited process.
///       `Result<Option<String>, MalformedStat>` is "an identity,
///  nothing,
///  or a malformed record".
/// Why:
///   The incumbent cuts after the last `)` (the command name may hold spaces and `)`),
///       skips `) `,
///  trims,
///  splits on single spaces,
///  treats states `Z` and `X` as exited
///       (a zombie under a container init that never reaps keeps its PID forever),
///  and
///       answers `linux:` plus the start tick.
///  An empty start field is malformed.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function linuxIdentityFromStat(stat: string): string | typeof ABSENT; // throws when malformed
/// ```
pub fn linux_identity_from_stat(stat: &str) -> Result<Option<String>, MalformedStat> {
    // `rfind` gives the byte position of the last `)`, the end of the command name.
    let Some(command_end) = stat.rfind(')') else {
        return Err(MalformedStat);
    };
    // `get(a..)` is the text from byte `a` on, or nothing past the end; `) ` is two bytes.
    let after: &str = stat.get(command_end + 2..).unwrap_or("");
    // `.split(' ')` yields every piece between single spaces, empty pieces included.
    let fields: Vec<&str> = trim_javascript(after).split(' ').collect();
    // `fields[0]` always exists: splitting yields at least one piece.
    if EXITED_LINUX_STATES.contains(&fields[0]) {
        return Ok(None);
    }
    match fields.get(LINUX_START_FIELD_INDEX) {
        Some(tick) if !tick.is_empty() => return Ok(Some(format!("linux:{tick}"))),
        Some(_) | None => return Err(MalformedStat),
    }
}

/// What:
///  The identity a platform inspector printed,
///  as `<prefix>:<trimmed output>`,
///  or nothing
///       when it printed only whitespace.
///       `&[u8]` borrows the raw standard output.
/// Why:
///   On macOS and Windows the incumbent runs a command and trims its output;
///  empty output
///       means the process is gone.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function commandIdentity(prefix: string, stdout: string): string | typeof ABSENT;
/// ```
pub fn command_identity(prefix: &str, stdout: &[u8]) -> Option<String> {
    // `from_utf8_lossy` decodes as Node does for a child's output, replacing invalid bytes.
    let text: std::borrow::Cow<'_, str> = String::from_utf8_lossy(stdout);
    let value: &str = trim_javascript(text.as_ref());
    if value.is_empty() {
        return None;
    }
    return Some(format!("{prefix}:{value}"));
}

/// What:
///  The arguments the incumbent passes to PowerShell for one PID on Windows.
///       `Vec<String>` is an owned list of owned text.
/// Why:
///   The Windows identity is whatever this exact command prints,
///  so running the same
///       command reproduces it by construction;
///  the conversion through `GetProcessTimes`
///       that would avoid PowerShell is unproven (`doc/handover/cli-git-native-transactions.md`).
///
/// In TS you'd write (pseudocode):
/// ```ts
/// ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `(Get-Process -Id ${pid}).StartTime.ToUniversalTime().Ticks`]
/// ```
pub fn windows_identity_arguments(pid: i64) -> Vec<String> {
    return vec![
        String::from("-NoLogo"),
        String::from("-NoProfile"),
        String::from("-NonInteractive"),
        String::from("-Command"),
        format!("(Get-Process -Id {pid}).StartTime.ToUniversalTime().Ticks"),
    ];
}

/// What:
///  The arguments the incumbent passes to `ps` for one PID on macOS.
/// Why:
///   `ps -o lstart=` prints the start time in the C locale;
///  running it reproduces the
///       incumbent's string by construction.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// ['-o', 'lstart=', '-p', String(pid)]
/// ```
pub fn darwin_identity_arguments(pid: i64) -> Vec<String> {
    return vec![
        String::from("-o"),
        String::from("lstart="),
        String::from("-p"),
        pid.to_string(),
    ];
}

/// What:
///  Run a platform inspector in the C locale and read its identity line.
/// Why:
///   The incumbent's `nano-spawn` call adds `LC_ALL=C` to the inherited environment and
///       maps every failure to start or finish (a non-zero exit) to "absent".
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function resolveCommandIdentity({ command, args, prefix }): Promise<string | typeof ABSENT>;
/// ```
#[cfg_attr(
    all(target_os = "linux", not(test)),
    allow(
        dead_code,
        reason = "the release build uses it on macOS and Windows only"
    )
)]
fn inspector_identity(command: &str, arguments: &[String], prefix: &str) -> Option<String> {
    let output: std::process::Output = std::process::Command::new(command)
        .args(arguments)
        .env("LC_ALL", "C")
        .stdin(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    return command_identity(prefix, output.stdout.as_slice());
}

/// What:
///  Whether a failure to read a process's record says the process is gone:
/// no such file (`ENOENT`),
/// or the process exited during the read (`ESRCH`).
/// Why:
///   Any other failure (a refusal,
/// a record that is a directory) says nothing about the process,
/// and reading it as "gone" would let a lock of a running service be taken.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// ['ENOENT', 'ESRCH'].includes(error.code)
/// ```
#[cfg(target_os = "linux")]
pub(crate) fn record_is_gone(error: &std::io::Error) -> bool {
    return error.kind() == std::io::ErrorKind::NotFound || error.raw_os_error() == Some(libc::ESRCH);
}

/// What:
///  Read `/proc/<pid>/stat` and decide the Linux identity.
/// Why:
///   A missing record (`ENOENT`) or a process that exits mid-read (`ESRCH`) is an exited
///       process;
///  any other read failure is an error,
///  as in the incumbent.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function resolveLinuxIdentity(pid: number): Promise<string | typeof ABSENT>;
/// ```
#[cfg(target_os = "linux")]
fn linux_identity(pid: i64) -> Result<Option<String>, ProcessIdentityError> {
    return linux_identity_read_by(pid, std::fs::read::<String>);
}

/// What:
///  `linux_identity` with the read of the record given,
///  so a failure the host cannot be made to show (a refusal,
/// an I/O error) can be shown to it.
/// Why:
///   Which failures mean "gone" and which are errors is the whole decision of this function.
#[cfg(target_os = "linux")]
pub(crate) fn linux_identity_read_by(
    pid: i64,
    read_record: impl FnOnce(String) -> std::io::Result<Vec<u8>>,
) -> Result<Option<String>, ProcessIdentityError> {
    let bytes: Vec<u8> = match read_record(format!("/proc/{pid}/stat")) {
        Ok(read) => read,
        Err(error) if record_is_gone(&error) => return Ok(None),
        Err(error) => return Err(ProcessIdentityError::Io { pid, error }),
    };
    // Node reads the file as UTF-8, replacing invalid bytes in the command name.
    let stat: std::borrow::Cow<'_, str> = String::from_utf8_lossy(bytes.as_slice());
    match linux_identity_from_stat(stat.as_ref()) {
        Ok(identity) => return Ok(identity),
        Err(MalformedStat) => return Err(ProcessIdentityError::MalformedStat { pid }),
    }
}

/// What:
///  The birth identity of the process `pid` names now,
///  or nothing when none runs.
///       `i64` holds any PID a record can carry.
/// Why:
///   This is the one question every liveness check asks.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function resolveProcessBirthIdentity(pid: number): Promise<string | typeof PROCESS_IDENTITY_ABSENT>;
/// ```
pub fn process_birth_identity(pid: i64) -> Result<Option<String>, ProcessIdentityError> {
    #[cfg(target_os = "linux")]
    {
        return linux_identity(pid);
    }
    #[cfg(target_os = "macos")]
    {
        return Ok(inspector_identity(
            "ps",
            darwin_identity_arguments(pid).as_slice(),
            "darwin",
        ));
    }
    // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
    #[cfg(windows)]
    {
        return Ok(inspector_identity(
            "powershell.exe",
            windows_identity_arguments(pid).as_slice(),
            "win32",
        ));
    }
    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
    {
        let _ = pid;
        return Err(ProcessIdentityError::UnsupportedPlatform);
    }
}

/// The current process's identity,
///  computed once:
///  it never changes while the process runs.
static CURRENT_IDENTITY: OnceLock<String> = OnceLock::new();

/// What:
///  The birth identity of this process,
///  computed once per process.
/// Why:
///   Every lock this process takes records it,
///  and on macOS and Windows each computation
///       starts a process.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const own = await resolveProcessBirthIdentity(process.pid);
/// ```
pub fn current_birth_identity() -> Result<String, ProcessIdentityError> {
    if let Some(known) = CURRENT_IDENTITY.get() {
        return Ok(known.clone());
    }
    let computed: String = process_birth_identity(i64::from(std::process::id()))?
        .ok_or(ProcessIdentityError::CurrentUnavailable)?;
    // Another thread may have stored it meanwhile; both values are the same, so a lost race
    // to store is harmless.
    let _ = CURRENT_IDENTITY.set(computed.clone());
    return Ok(computed);
}

/// What:
///  Whether a signal could be sent to `pid` (it names some process),
///  on Unix.
///       `Ok(false)` means no such process;
///  an error is a refusal such as `EPERM`.
/// Why:
///   The incumbent's `processIsAlive` asks `kill(pid, 0)` before comparing identities;
///       a process of another account answers `EPERM`,
///  which callers judge themselves.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function processIsAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch (e) { if (e.code === 'ESRCH') return false; throw e; } }
/// ```
#[cfg(unix)]
pub fn signal_probe(pid: i64) -> std::io::Result<bool> {
    // A PID beyond `pid_t` cannot name a process.
    let Ok(target) = libc::pid_t::try_from(pid) else {
        return Ok(false);
    };
    if target <= 0 {
        return Ok(false);
    }
    // SAFETY: `kill` with signal 0 sends nothing; it only checks that the target exists and
    // may be signaled. It reads no memory of this process.
    let status: libc::c_int = unsafe { libc::kill(target, 0) };
    if status == 0 {
        return Ok(true);
    }
    let error: std::io::Error = std::io::Error::last_os_error();
    if error.raw_os_error() == Some(libc::ESRCH) {
        return Ok(false);
    }
    return Err(error);
}

/// Parser controls and live-process probes stay out of the release executable.
#[cfg(test)]
#[path = "process_identity_tests.rs"]
mod tests;