amont-runtime 1.47.3

The amont hook logic: registry, dispatchers, checks and the trust model
Documentation
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
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
//! Thin wrappers over the `git` calls the hooks make.

use std::process::{Command, Stdio};

/// Run `cmd`, retrying the transient SPAWN failures a loaded machine
/// produces: EINTR, EAGAIN (fork pressure), ETXTBSY (another thread's
/// fork-to-exec window still holding a write descriptor on the executable).
/// A NON-ZERO EXIT IS NEVER RETRIED — that is git answering; this covers
/// only "git could not be asked".
///
/// The failure this ends: `gate_stamp`'s tests — and, invisibly, real
/// hooks on a loaded machine — watched a single failed fork turn
/// `bind_to_head` into "nothing to stamp". The hooks' fail-open reading of
/// `None` is right for a git that is genuinely absent; three attempts over
/// ~130ms is the difference between that and a scheduler hiccup.
fn retrying<T>(mut attempt: impl FnMut() -> std::io::Result<T>) -> std::io::Result<T> {
    let mut delay = std::time::Duration::from_millis(10);
    for tries_left in [2u8, 1, 0] {
        match attempt() {
            Err(e) if tries_left > 0 && transient(&e) => {
                std::thread::sleep(delay);
                delay *= 3;
            }
            other => return other,
        }
    }
    unreachable!("the zero-tries arm returns")
}

/// The retryable kinds, matched on raw OS codes because the precise
/// `io::ErrorKind` variants (`ExecutableFileBusy`, `ResourceBusy`) are not
/// stable at this crate's MSRV: EINTR(4), EAGAIN(11 linux / 35 mac),
/// ETXTBSY(26).
fn transient(e: &std::io::Error) -> bool {
    if matches!(
        e.kind(),
        std::io::ErrorKind::Interrupted | std::io::ErrorKind::WouldBlock
    ) {
        return true;
    }
    matches!(e.raw_os_error(), Some(4 | 11 | 26 | 35))
}

/// stdout of a git command, trimmed. `None` when git itself failed — which the
/// hooks treat as "cannot tell, do not block", never as "empty".
pub fn stdout(args: &[&str]) -> Option<String> {
    let mut cmd = Command::new("git");
    cmd.args(args).stderr(Stdio::null());
    let out = retrying(|| cmd.output()).ok()?;
    if !out.status.success() {
        return None;
    }
    Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
}

/// The same, run inside `dir`.
///
/// The dashboard asks about repositories it is not standing in, and must get
/// the answer git would give THERE — config is per-repository, so asking from
/// the wrong directory returns the wrong severity.
pub fn stdout_in(dir: &std::path::Path, args: &[&str]) -> Option<String> {
    let mut cmd = Command::new("git");
    cmd.arg("-C").arg(dir).args(args).stderr(Stdio::null());
    let out = retrying(|| cmd.output()).ok()?;
    if !out.status.success() {
        return None;
    }
    Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
}

/// [`succeeds`] for a repository this process is not standing in — the shape
/// the fleet needs to UNDO something (delete a ref it wrote) rather than ask
/// about it. Output discarded; `false` covers "git could not run" too.
pub fn succeeds_in(dir: &std::path::Path, args: &[&str]) -> bool {
    let mut cmd = Command::new("git");
    cmd.arg("-C")
        .arg(dir)
        .args(args)
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null());
    retrying(|| cmd.status())
        .map(|s| s.success())
        .unwrap_or(false)
}

/// stdout of a git command that itself reads a list from stdin — `diff-tree
/// --stdin`, fed a list of commits, is the only caller today. Lossy but
/// untrimmed: every line is a path, and the caller trims those itself.
pub fn stdout_piped(args: &[&str], stdin: &str) -> Option<String> {
    use std::io::Write;
    let mut cmd = Command::new("git");
    cmd.args(args)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::null());
    let mut child = retrying(|| cmd.spawn()).ok()?;
    child.stdin.take()?.write_all(stdin.as_bytes()).ok()?;
    let out = child.wait_with_output().ok()?;
    out.status
        .success()
        .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
}

/// As `stdout_piped`, but returning the RAW bytes.
///
/// Needed by the one caller that must both feed git a list on stdin and read a
/// `-z` path list back — `diff-tree --stdin -z`. `stdout_paths` cannot serve it
/// (no stdin) and `stdout_piped` cannot either (lossy `String`, and the NUL
/// separators are the whole point).
pub fn stdout_piped_raw(args: &[&str], stdin: &str) -> Option<Vec<u8>> {
    use std::io::Write;
    let mut cmd = Command::new("git");
    cmd.args(args)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::null());
    let mut child = retrying(|| cmd.spawn()).ok()?;
    child.stdin.take()?.write_all(stdin.as_bytes()).ok()?;
    let out = child.wait_with_output().ok()?;
    out.status.success().then_some(out.stdout)
}

/// As `stdout_piped`, but inside `dir` and taking raw bytes.
///
/// `-C dir` matters for `hash-object`: a repository configured for SHA-256
/// computes a different id than the default, so the identity has to be asked
/// of THAT repository. Bytes rather than `&str` because the input is a file we
/// have already read and must not re-encode.
pub fn stdout_piped_in(dir: &std::path::Path, args: &[&str], stdin: &[u8]) -> Option<String> {
    use std::io::Write;
    let mut cmd = Command::new("git");
    cmd.arg("-C")
        .arg(dir)
        .args(args)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::null());
    let mut child = retrying(|| cmd.spawn()).ok()?;
    child.stdin.take()?.write_all(stdin).ok()?;
    let out = child.wait_with_output().ok()?;
    out.status
        .success()
        .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
}

/// Raw stdout, untrimmed and not lossy — for a patch, where a trailing newline
/// and any byte in a binary hunk are load-bearing.
pub fn stdout_raw(args: &[&str]) -> Option<Vec<u8>> {
    let mut cmd = Command::new("git");
    cmd.args(args).stderr(Stdio::null());
    let out = retrying(|| cmd.output()).ok()?;
    out.status.success().then_some(out.stdout)
}

/// Everything a git command said: its exit code, its stdout and its stderr.
pub struct Output {
    pub code: i32,
    pub stdout: String,
    pub stderr: String,
}

/// A git command's full result, for the caller that must tell one kind of
/// failure from another.
///
/// [`stdout`] collapses every non-zero exit to `None` and discards stderr,
/// which is the right shape for "cannot tell, do not block". It is the wrong
/// shape for reading configuration: `git config --get` exits **1** for a key
/// nobody set and **128** for a key set to something git itself refuses to
/// parse, and those two must not become the same answer — one is a default,
/// the other is a mistake somebody needs to be told about. See `config`.
pub fn output(args: &[&str]) -> Option<Output> {
    let mut cmd = Command::new("git");
    cmd.args(args);
    full_output(cmd)
}

/// [`output`] run inside `dir` — for a caller that must ask about a
/// repository without moving the whole process there (`set_current_dir` is
/// process-global, and races every test in the binary).
pub fn output_in(dir: &std::path::Path, args: &[&str]) -> Option<Output> {
    let mut cmd = Command::new("git");
    cmd.arg("-C").arg(dir).args(args);
    full_output(cmd)
}

fn full_output(mut cmd: Command) -> Option<Output> {
    cmd.stdin(Stdio::null());
    let out = retrying(|| cmd.output()).ok()?;
    Some(Output {
        // A process killed by a signal has no code. Treat that as "git did not
        // answer" rather than inventing one; the caller falls back.
        code: out.status.code()?,
        stdout: String::from_utf8_lossy(&out.stdout).trim().to_string(),
        stderr: String::from_utf8_lossy(&out.stderr).trim().to_string(),
    })
}

/// How a bounded network probe ended. Exit codes stay visible because the
/// callers need to keep git's answers apart: `ls-remote --exit-code` exits
/// **2** for "connected, no such ref" and **128** for "could not connect",
/// and reading those as one boolean is how offline got reported as
/// "upstream deleted".
pub enum Probe {
    /// Ran to completion with this exit code.
    Exit(i32),
    /// Killed at the deadline; carries the budget it exceeded, in seconds.
    TimedOut(u64),
    /// Could not spawn, or died to a signal — "git did not answer".
    Failed,
}

/// A git command that TALKS TO THE NETWORK, killed at `budget_secs`.
///
/// Every other runner in this module waits forever, which is correct for
/// local plumbing — a `rev-parse` that hangs means the machine is already
/// lost. A network verb hanging is Tuesday: captive portal, VPN split
/// brain, a remote that accepts the TCP connect and then says nothing.
/// Unbounded, that held the push hostage inside the index hold with no
/// deadline anywhere; the learned response is `--no-verify`, permanently.
/// `budget_secs == 0` means no deadline (the same opt-out `amont.timeout`
/// honours). Output is discarded — network callers decide on exit codes.
pub fn probe(args: &[&str], budget_secs: u64) -> Probe {
    probe_env(args, budget_secs, &[])
}

/// Whose ssh a remote call uses.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Ssh {
    /// The user configured their own (`GIT_SSH_COMMAND`, `GIT_SSH` or
    /// `core.sshCommand`); it is left alone — replacing it could drop the
    /// key selection it exists for. It may prompt; the deadline bounds that.
    User,
    /// Ours: batch mode, no prompt, a 10 s connect timeout.
    Batch,
}

/// The configuration and environment every call that talks to a REMOTE for a
/// verifier runs with, as data so both branches are testable without
/// touching the process environment.
///
/// Never a prompt: `GIT_TERMINAL_PROMPT=0` alone is not enough, because git
/// runs `GIT_ASKPASS` / `core.askPass` / `SSH_ASKPASS` BEFORE consulting it;
/// a `GIT_ASKPASS` that is present and EMPTY makes git run none of them.
/// `GCM_INTERACTIVE` and `credential.interactive` tell credential managers
/// the same; the helpers themselves stay, so stored credentials still work.
/// Never a stall: curl gives up after 10 s under 1 byte/s, ssh after a 10 s
/// connect — and the caller's own deadline covers the rest.
pub fn remote_env(ssh: Ssh) -> (Vec<&'static str>, Vec<(&'static str, &'static str)>) {
    let args = vec![
        "-c",
        "http.lowSpeedLimit=1",
        "-c",
        "http.lowSpeedTime=10",
        "-c",
        "credential.interactive=never",
    ];
    let mut env = vec![
        ("GIT_TERMINAL_PROMPT", "0"),
        ("GIT_ASKPASS", ""),
        ("GCM_INTERACTIVE", "never"),
    ];
    if ssh == Ssh::Batch {
        env.push((
            "GIT_SSH_COMMAND",
            "ssh -o BatchMode=yes -o ConnectTimeout=10",
        ));
    }
    (args, env)
}

/// Whose ssh this environment configures.
pub fn configured_ssh() -> Ssh {
    let set = |v: &str| std::env::var_os(v).is_some_and(|v| !v.is_empty());
    if set("GIT_SSH_COMMAND") || set("GIT_SSH") || succeeds(&["config", "--get", "core.sshCommand"])
    {
        Ssh::User
    } else {
        Ssh::Batch
    }
}

/// `git <args>` against a remote, with [`remote_env`] and a deadline.
pub fn probe_remote(args: &[&str], budget_secs: u64) -> Probe {
    let (pre, env) = remote_env(configured_ssh());
    let mut all: Vec<&str> = pre;
    all.extend_from_slice(args);
    probe_env(&all, budget_secs, &env)
}

/// [`probe`], with extra environment for the child.
pub fn probe_env(args: &[&str], budget_secs: u64, env: &[(&str, &str)]) -> Probe {
    let mut cmd = Command::new("git");
    cmd.args(args)
        .envs(env.iter().copied())
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null());
    if budget_secs == 0 {
        return match retrying(|| cmd.status()) {
            Ok(s) => s.code().map(Probe::Exit).unwrap_or(Probe::Failed),
            Err(_) => Probe::Failed,
        };
    }
    let Ok(mut child) = retrying(|| cmd.spawn()) else {
        return Probe::Failed;
    };
    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(budget_secs);
    loop {
        match child.try_wait() {
            Ok(Some(s)) => return s.code().map(Probe::Exit).unwrap_or(Probe::Failed),
            Ok(None) => {}
            Err(_) => return Probe::Failed,
        }
        if std::time::Instant::now() >= deadline {
            let _ = child.kill();
            let _ = child.wait();
            return Probe::TimedOut(budget_secs);
        }
        std::thread::sleep(std::time::Duration::from_millis(25));
    }
}

/// True when the command exits 0. Output discarded.
pub fn succeeds(args: &[&str]) -> bool {
    let mut cmd = Command::new("git");
    cmd.args(args)
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null());
    retrying(|| cmd.status())
        .map(|s| s.success())
        .unwrap_or(false)
}

/// A path list from `diff --name-only`, `diff-tree --name-only` or
/// `ls-files` — commands whose output is meant to be split into individual
/// paths, never just read as one blob.
///
/// By default git QUOTES any "unusual" byte in a path, non-ASCII included:
/// `é.json` prints as `"\303\251.json"`. Reading that line as a literal path
/// looks up a file that does not exist — the caller then treats real,
/// unstaged content as absent, which is how `StagedOnly` used to lose it.
/// `-z` disables quoting entirely and NUL-terminates each entry instead, so
/// there is no escaping left to get wrong. Inserted right after the
/// subcommand (`args[0]`), which is always a valid position for it on every
/// command this is used for.
pub fn stdout_paths(args: &[&str]) -> Option<Vec<String>> {
    let (first, rest) = args.split_first()?;
    let mut argv = Vec::with_capacity(args.len() + 1);
    argv.push(*first);
    argv.push("-z");
    argv.extend_from_slice(rest);
    stdout_raw(&argv).map(|raw| split_nul_paths(&raw))
}

/// The parsing half of [`stdout_paths`], split out so it can be tested on
/// literal bytes rather than a real git process — including the byte
/// sequence a QUOTED path would have produced under the old line-splitting
/// approach, to prove `-z` output is never reinterpreted that way.
pub(crate) fn split_nul_paths(raw: &[u8]) -> Vec<String> {
    raw.split(|&b| b == 0)
        .filter(|s| !s.is_empty())
        .map(|s| String::from_utf8_lossy(s).into_owned())
        .collect()
}

#[cfg(test)]
mod retry_tests {
    use super::*;

    /// The classifier: scheduler hiccups retry, real answers do not.
    #[test]
    fn transient_covers_the_fork_pressure_kinds_and_nothing_else() {
        for code in [4, 11, 26, 35] {
            assert!(
                transient(&std::io::Error::from_raw_os_error(code)),
                "raw {code} is a loaded-machine hiccup"
            );
        }
        assert!(transient(&std::io::Error::from(
            std::io::ErrorKind::Interrupted
        )));
        assert!(!transient(&std::io::Error::from(
            std::io::ErrorKind::NotFound
        )));
        assert!(!transient(&std::io::Error::from_raw_os_error(13))); // EACCES
    }

    /// Three attempts, then the error is the caller's: a git that is
    /// genuinely absent must not cost more than ~130ms of patience.
    #[test]
    fn retrying_gives_up_after_three_transient_failures() {
        let mut calls = 0;
        let r: std::io::Result<()> = retrying(|| {
            calls += 1;
            Err(std::io::Error::from_raw_os_error(11))
        });
        assert!(r.is_err());
        assert_eq!(calls, 3);
    }

    /// A non-transient error returns immediately — a missing git is an
    /// answer, not a hiccup.
    #[test]
    fn a_hard_error_is_not_retried() {
        let mut calls = 0;
        let r: std::io::Result<()> = retrying(|| {
            calls += 1;
            Err(std::io::Error::from(std::io::ErrorKind::NotFound))
        });
        assert!(r.is_err());
        assert_eq!(calls, 1);
    }

    /// A success after a hiccup is a success.
    #[test]
    fn one_hiccup_then_an_answer_is_an_answer() {
        let mut calls = 0;
        let r = retrying(|| {
            calls += 1;
            if calls == 1 {
                Err(std::io::Error::from_raw_os_error(4))
            } else {
                Ok(42)
            }
        });
        assert_eq!(r.unwrap(), 42);
        assert_eq!(calls, 2);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn splits_on_nul_and_drops_the_trailing_empty_segment() {
        assert_eq!(
            split_nul_paths(b"src/main.rs\0Cargo.toml\0"),
            vec!["src/main.rs", "Cargo.toml"]
        );
    }

    #[test]
    fn empty_input_is_no_paths() {
        assert_eq!(split_nul_paths(b""), Vec::<String>::new());
    }

    /// The exact bug this exists to prevent: under `--name-only` without
    /// `-z`, git would have printed `é.json` as the quoted, LINE-oriented
    /// text `"\303\251.json"` — literal backslashes, digits and quotes, nine
    /// bytes standing in for the original two-byte UTF-8 sequence. `-z`
    /// output carries the real UTF-8 bytes of the path with no such
    /// reinterpretation, so splitting on NUL must hand them back unchanged.
    #[test]
    fn a_non_ascii_path_is_not_reinterpreted_as_its_quoted_form() {
        let mut raw = "é.json".as_bytes().to_vec();
        raw.push(0);
        let got = split_nul_paths(&raw);
        assert_eq!(got, vec!["é.json".to_string()]);
        assert_ne!(got[0], "\"\\303\\251.json\"", "must not be the quoted form");
    }
}

/// The branch `HEAD` names, or `None` on a detached head — asked of git ONCE
/// per process and lent to every check that wants it.
///
/// Two always-on pre-commit checks (`branch-pattern`, `branch-protect`) open
/// with this same question. Asked twice it is two spawns on every commit,
/// which is exactly the o(checks) growth `tests/spawn_budget.rs` exists to
/// refuse; asked once it is the price of one check, however many share it.
pub fn current_branch() -> Option<&'static str> {
    static BRANCH: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
    BRANCH
        .get_or_init(|| stdout(&["symbolic-ref", "--quiet", "--short", "HEAD"]))
        .as_deref()
}

/// Whether any remote is configured. Same device, same reason: a contract
/// about pushing has nothing to gate in a repository nothing is pushed from,
/// and more than one check asks before speaking.
pub fn has_remote() -> bool {
    static REMOTE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
    *REMOTE.get_or_init(|| stdout(&["remote"]).is_some_and(|r| !r.is_empty()))
}