Skip to main content

amont_runtime/
attest.rs

1//! The signed successor to [`crate::gate_stamp`]: an attestation CI can trust.
2//!
3//! `gate_stamp` answers a LOCAL question — "did the push gate already run on
4//! this commit?" — and its notes deliberately never leave the machine, because
5//! an unsigned note is only as honest as whoever can write the ref. This
6//! module answers the REMOTE version of the same question: "may CI skip a
7//! test job because the equivalent gate already passed here?" — and for that
8//! the note has to travel, so it has to be signed.
9//!
10//! Shape of the note, attached to each pushed tip in `refs/notes/amont-attest`:
11//!
12//! ```text
13//! amont-attest-v1
14//! tree <tree the gates ran against>
15//! gates <names of the pre-push checks that PASSED>
16//! amont <version that produced it>
17//!
18//! -----BEGIN SSH SIGNATURE-----
19//! …signature over the four lines above…
20//! -----END SSH SIGNATURE-----
21//! ```
22//!
23//! The signature covers the **tree**, not the commit: tests read content, not
24//! messages, so a reword or a tree-preserving rebase keeps its attestation —
25//! the same reasoning as `gate_stamp`'s tree binding. CI's skip condition is
26//! tree equality with its own checkout plus a valid signature over exactly
27//! that tree, verified with stock `ssh-keygen -Y verify` against an
28//! `allowed_signers` file committed in the consuming repository. amont itself
29//! still never runs in CI (`docs/ci.md`) — CI verifies a document.
30//!
31//! # Where each half lives now
32//!
33//! The **producer** — [`attest_push`], `sign`, `key_path`, [`enabled`] — is
34//! this crate's, and stays. It needs the gate names only `dispatch` knows,
35//! reads amont's own config, and coordinates the recursive-push guard below.
36//!
37//! The **consumer** — [`verify`], [`covered`], [`split_note`],
38//! [`default_signers`], [`first_principal`] — was extracted to
39//! <https://github.com/fredericrous/attest>, because reading a signed document
40//! is the part every OTHER repository needs and amont is a strange thing to
41//! install just to do it. The CI templates call that action instead of the
42//! ~30 lines of shell they used to carry.
43//!
44//! The consumer copy here is therefore **frozen: bug fixes only**. New
45//! verifying work — better diagnostics, other forges — happens in that
46//! repository, and its `tests/conformance.sh` is the contract both sides
47//! answer to. `amont attest covered` keeps working for anyone already calling
48//! it. The producer follows that repository's `SPEC.md` as it grows: since
49//! attest 1.3.0 a committed `.github/attest-inputs` names the paths each gate
50//! reads, and the block carries an `input <gate> <fingerprint>` line per
51//! declared gate and is filed under that fingerprint in a second notes ref,
52//! `refs/notes/amont-attest-inputs`, so an unrelated change on main no longer
53//! voids it.
54//!
55//! Signing uses `ssh-keygen -Y sign` as a subprocess, like every other tool
56//! this crate talks to. Hand-rolling ed25519 in a zero-dependency crate would
57//! be the one thing worse than a dependency.
58//!
59//! Every failure mode points the same direction as `gate_stamp`'s: no key, a
60//! signer that errors, a note git refused, a notes push the remote rejected —
61//! all mean "no attestation", and no attestation means CI RUNS the tests.
62//! Nothing here can let an untested tree skip CI; it can only cost a
63//! redundant run.
64//!
65//! One sharp edge is the notes push itself: `git push` from inside pre-push
66//! runs pre-push again. The child carries [`PUSH_GUARD`] in its environment
67//! and the dispatcher yields immediately when it sees it — checking a ref
68//! list that is only ever `refs/notes/amont-attest` would be work spent
69//! proving nothing.
70
71use std::path::PathBuf;
72use std::process::{Command, Stdio};
73
74use crate::pushrefs::PushRef;
75
76/// First token of every note body. Versioned like `gate_stamp::FORMAT`: a
77/// future amont that changes the payload bumps this, and CI's verifier reads
78/// an unknown version as "no attestation".
79///
80/// v1 → v2 added the `platform` line. The bump is the point: a v1 verifier
81/// has no idea the tests it is about to skip ran on a different operating
82/// system, and reading v2 as unknown makes it run them. Fail-safe in the
83/// only direction this module ever fails.
84pub const FORMAT: &str = "amont-attest-v2";
85
86/// The notes ref, spelled the way `git notes --ref` wants it.
87pub const NOTES_REF: &str = "amont-attest";
88
89/// The same ref, fully qualified — the push refspec and `update-ref -d` both
90/// need it.
91pub const NOTES_FULL_REF: &str = "refs/notes/amont-attest";
92
93/// The second ref, keyed by input fingerprint (attest 1.3.0). Every key there
94/// is an oid that is not an object — `git notes` accepts that — which is why
95/// it is its own ref: `git notes prune` on it would drop everything.
96pub const INPUTS_REF: &str = "amont-attest-inputs";
97pub const INPUTS_FULL_REF: &str = "refs/notes/amont-attest-inputs";
98
99/// Where a repository declares what each gate reads, in order of precedence.
100const SPEC_PATHS: [&str; 2] = [".forgejo/attest-inputs", ".github/attest-inputs"];
101
102/// The `ssh-keygen -Y` namespace, on both the signing and verifying side.
103/// Namespaces exist so a signature minted for one purpose cannot be replayed
104/// for another; an `allowed_signers` entry pinned to this namespace accepts
105/// nothing else.
106pub const NAMESPACE: &str = "amont-attest";
107
108/// Environment marker carried by the notes push so the recursive pre-push
109/// invocation stands down. See the module doc.
110pub const PUSH_GUARD: &str = "AMONT_ATTEST_PUSH";
111
112/// The opt-in switch. Off by default: an attestation is a statement to
113/// another system, and amont does not speak for a repository that never
114/// asked it to.
115const TOGGLE: &str = "amont.attest";
116
117/// Where the signing key lives when the repository does not say.
118const KEY_CONFIG: &str = "amont.attestKey";
119const KEY_DEFAULT: &str = ".ssh/amont-attest";
120
121/// Is the recursive-push marker set on THIS invocation?
122pub fn push_guard_active() -> bool {
123    std::env::var_os(PUSH_GUARD).is_some()
124}
125
126/// Has this repository opted in?
127pub fn enabled(settings: &crate::config::Settings) -> bool {
128    crate::config::boolean_or(settings, TOGGLE, false)
129}
130
131/// The signing key path: `amont.attestKey`, else `~/.ssh/amont-attest`.
132///
133/// Read like `amont.knownIdentity` is — a raw string through git, unset
134/// collapsing to the default — because a path has no shape git could
135/// validate for us anyway.
136fn key_path() -> Option<PathBuf> {
137    if let Some(k) = crate::git::stdout(&["config", "--get", KEY_CONFIG]) {
138        if !k.is_empty() {
139            return Some(PathBuf::from(k));
140        }
141    }
142    let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
143    Some(PathBuf::from(home).join(KEY_DEFAULT))
144}
145
146/// Where a suite ran, as `<arch>-<os>` — `aarch64-macos`, `x86_64-linux`,
147/// `x86_64-windows`.
148///
149/// Coarser than a target triple on purpose: the libc flavour is not
150/// something `std` can answer, and the question a CI matrix actually asks is
151/// "did this run on MY leg". Coarse and honest beats precise and guessed.
152pub fn platform() -> String {
153    format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS)
154}
155
156/// The exact bytes the signature covers. One datum per line, trailing
157/// newline included — CI reconstructs this from the note text, so the shape
158/// is a contract, not a convenience.
159///
160/// `platform` is signed alongside the gates because a pass is a pass **on
161/// something**: `cargo test` green on an arm64 Mac says nothing about the
162/// Windows leg of a matrix, and a note that omitted where it ran invited
163/// exactly that skip.
164///
165/// `inputs` are the `input <gate> <fingerprint>` lines (attest 1.3.0), after
166/// `platform` and before `amont`, in spec order — additive, so a verifier that
167/// predates them reads the block exactly as before.
168pub fn payload(tree: &str, gates: &[String], inputs: &[(String, String)]) -> String {
169    let mut p = format!(
170        "{FORMAT}\ntree {tree}\ngates {}\nplatform {}\n",
171        gates.join(" "),
172        platform()
173    );
174    for (gate, fp) in inputs {
175        p.push_str(&format!("input {gate} {fp}\n"));
176    }
177    p.push_str(&format!("amont {}\n", env!("CARGO_PKG_VERSION")));
178    p
179}
180
181// ---------------------------------------------------------------------------
182// Input fingerprints. attest's SPEC.md, "Input fingerprints": the grammar and
183// the hashing are that repository's contract, ported here verbatim in
184// behaviour — a fingerprint this hook writes must be the one its verifiers
185// compute, byte for byte.
186// ---------------------------------------------------------------------------
187
188const MAX_SPEC_BYTES: usize = 65536;
189const MAX_SPEC_GATES: usize = 64;
190const MAX_SPEC_PATHS: usize = 64;
191
192/// A gate name as the spec allows it: `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`.
193fn valid_gate(name: &str) -> bool {
194    let mut chars = name.chars();
195    match chars.next() {
196        Some(c) if c.is_ascii_alphanumeric() => {}
197        _ => return false,
198    }
199    name.len() <= 64 && chars.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
200}
201
202/// One declared path. `?build.rs` in the spec is `optional` (attest 1.4.0):
203/// it may be absent, and its absence is then part of the fingerprint. The
204/// marker is stripped here, once, so nothing downstream can hand `?x` to git
205/// — where it would match nothing and silently drop out of the listing.
206#[derive(Debug, Clone, PartialEq)]
207struct PathTok {
208    path: String,
209    optional: bool,
210}
211
212/// A path token that is not a literal, root-relative file or directory.
213/// `git ls-tree` does not glob, so a wildcard would fingerprint nothing. One
214/// leading `?` is the optional marker, not a wildcard; the rest obeys every
215/// rule, so `??x` and `?/x` are bad, and so is a lone `?`.
216fn bad_path(tok: &str) -> bool {
217    let tok = match tok.strip_prefix('?') {
218        Some("") => return true,
219        Some(rest) => rest,
220        None => tok,
221    };
222    tok.starts_with(':')
223        || tok.starts_with('/')
224        || tok.starts_with("./")
225        || tok.starts_with("../")
226        || tok.ends_with('/')
227        || tok
228            .chars()
229            .any(|c| matches!(c, '*' | '?' | '[' | ']' | '\\'))
230        || tok
231            .split('/')
232            .any(|c| c.is_empty() || c == "." || c == "..")
233}
234
235/// The spec, byte-strict; any violation invalidates the WHOLE spec.
236fn parse_spec(bytes: &[u8]) -> Option<Vec<(String, Vec<PathTok>)>> {
237    if bytes.len() > MAX_SPEC_BYTES
238        || bytes
239            .iter()
240            .any(|&b| !(b == b' ' || b == b'\t' || b == b'\n' || (0x21..=0x7e).contains(&b)))
241    {
242        return None;
243    }
244    let text = std::str::from_utf8(bytes).ok()?;
245    let mut gates: Vec<(String, Vec<PathTok>)> = Vec::new();
246    for line in text.split('\n') {
247        let mut toks = line.split([' ', '\t']).filter(|t| !t.is_empty());
248        let Some(gate) = toks.next() else { continue };
249        if gate.starts_with('#') {
250            continue;
251        }
252        if !valid_gate(gate) || gates.iter().any(|(g, _)| g == gate) {
253            return None;
254        }
255        let paths: Vec<String> = toks.map(String::from).collect();
256        if paths.is_empty() || paths.len() > MAX_SPEC_PATHS || paths.iter().any(|p| bad_path(p)) {
257            return None;
258        }
259        let paths = paths
260            .into_iter()
261            .map(|p| match p.strip_prefix('?') {
262                Some(rest) => PathTok {
263                    path: rest.to_string(),
264                    optional: true,
265                },
266                None => PathTok {
267                    path: p,
268                    optional: false,
269                },
270            })
271            .collect();
272        gates.push((gate.to_string(), paths));
273        if gates.len() > MAX_SPEC_GATES {
274            return None;
275        }
276    }
277    Some(gates)
278}
279
280/// The spec at `tree`, read from the tree (never the working copy). `None`
281/// when there is none, when both locations exist, or when it is invalid —
282/// the hook then attests by tree alone, as before.
283fn spec_at(tree: &str) -> Option<Vec<(String, Vec<PathTok>)>> {
284    let present: Vec<&str> = SPEC_PATHS
285        .iter()
286        .copied()
287        .filter(|p| crate::git::succeeds(&["cat-file", "-e", &format!("{tree}:{p}")]))
288        .collect();
289    let [path] = present.as_slice() else {
290        return None;
291    };
292    let bytes = crate::git::stdout_raw(&["cat-file", "blob", &format!("{tree}:{path}")])?;
293    parse_spec(&bytes)
294}
295
296/// The paths every fingerprint lists besides the gate's own: both spec
297/// locations, `.gitmodules`, and `.gitattributes` at the root and at every
298/// ancestor directory of every declared path. Deduplicated.
299fn implicit_inputs(tokens: &[String]) -> Vec<String> {
300    let mut attrs: Vec<String> = vec![".gitattributes".to_string()];
301    for t in tokens {
302        let comps: Vec<&str> = t.split('/').collect();
303        let mut prefix = String::new();
304        for c in &comps[..comps.len().saturating_sub(1)] {
305            if !prefix.is_empty() {
306                prefix.push('/');
307            }
308            prefix.push_str(c);
309            attrs.push(format!("{prefix}/.gitattributes"));
310        }
311    }
312    attrs.sort();
313    attrs.dedup();
314    let mut out: Vec<String> = SPEC_PATHS.iter().map(|s| s.to_string()).collect();
315    out.push(".gitmodules".to_string());
316    out.extend(attrs);
317    out
318}
319
320fn is_oid(s: &str) -> bool {
321    (s.len() == 40 || s.len() == 64)
322        && s.bytes()
323            .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
324}
325
326/// The fingerprint of `gate` on `tree`: `git hash-object` over the
327/// `ls-tree -r -z --full-tree` listing of the implicit paths and the gate's
328/// declared ones. `None` when a REQUIRED declared path does not resolve on
329/// that tree, when git fails at any step, or when the listing is empty. An
330/// optional path is listed when it exists and bound by its absence when not.
331fn fingerprint(tree: &str, toks: &[PathTok]) -> Option<String> {
332    let required: Vec<&str> = toks
333        .iter()
334        .filter(|t| !t.optional)
335        .map(|t| t.path.as_str())
336        .collect();
337    if !required.is_empty() {
338        let names: String = required.iter().map(|p| format!("{tree}:{p}\n")).collect();
339        let answers = crate::git::stdout_piped(&["cat-file", "--batch-check"], &names)?;
340        if answers.lines().count() != required.len()
341            || answers.lines().any(|l| l.ends_with(" missing"))
342        {
343            return None;
344        }
345    }
346    let paths: Vec<String> = toks.iter().map(|t| t.path.clone()).collect();
347    let mut args: Vec<String> = vec![
348        "ls-tree".into(),
349        "-r".into(),
350        "-z".into(),
351        "--full-tree".into(),
352        tree.to_string(),
353        "--".into(),
354    ];
355    args.extend(implicit_inputs(&paths));
356    args.extend(paths.iter().cloned());
357    let argv: Vec<&str> = args.iter().map(String::as_str).collect();
358    let listing = crate::git::stdout_raw(&argv)?;
359    if listing.is_empty() {
360        return None;
361    }
362    crate::git::stdout_piped_in(
363        std::path::Path::new("."),
364        &["hash-object", "--stdin"],
365        &listing,
366    )
367    .filter(|s| is_oid(s))
368}
369
370/// The `input` lines for the gates being attested that the spec at `tree`
371/// declares, in spec order.
372fn inputs_for(tree: &str, gates: &[String]) -> Vec<(String, String)> {
373    let Some(spec) = spec_at(tree) else {
374        return Vec::new();
375    };
376    spec.iter()
377        .filter(|(g, _)| gates.iter().any(|x| x == g))
378        .filter_map(|(g, paths)| fingerprint(tree, paths).map(|fp| (g.clone(), fp)))
379        .collect()
380}
381
382/// The synthetic note key for (gate, fingerprint).
383fn input_key(gate: &str, fp: &str) -> Option<String> {
384    let pre = format!("amont-attest-input {gate} {fp}\n");
385    crate::git::stdout_piped_in(
386        std::path::Path::new("."),
387        &["hash-object", "--stdin"],
388        pre.as_bytes(),
389    )
390    .filter(|s| is_oid(s))
391}
392
393/// `ssh-keygen -Y sign` over `payload`, armored signature back. `None` for
394/// every failure — a missing binary, a missing key, a signer that said no —
395/// because an attestation we cannot mint is simply one CI never sees.
396fn sign(payload: &str, key: &std::path::Path) -> Option<String> {
397    use std::io::Write;
398    let mut child = Command::new("ssh-keygen")
399        .args(["-Y", "sign", "-n", NAMESPACE, "-f"])
400        .arg(key)
401        .stdin(Stdio::piped())
402        .stdout(Stdio::piped())
403        .stderr(Stdio::null())
404        .spawn()
405        .ok()?;
406    child.stdin.take()?.write_all(payload.as_bytes()).ok()?;
407    let out = child.wait_with_output().ok()?;
408    if !out.status.success() {
409        return None;
410    }
411    let sig = String::from_utf8_lossy(&out.stdout).trim().to_string();
412    sig.starts_with("-----BEGIN SSH SIGNATURE-----")
413        .then_some(sig)
414}
415
416/// `ssh-keygen -Y verify`: is `sig` a valid signature over `payload` by a
417/// `principal` key listed in `allowed_signers` for our namespace?
418///
419/// The runtime never gates on this — CI verifies with its own stock tooling —
420/// but owning the verifying half keeps the roundtrip honest in tests and
421/// gives a future `amont attest verify` its engine.
422/// Create `path` for writing, refusing anything already there.
423///
424/// `std::fs::write` is `O_CREAT|O_TRUNC` and FOLLOWS a symlink, which on a
425/// shared `/tmp` is the whole bug: anyone who guesses the name and pre-creates
426/// it as a link to a file we can write gets that file truncated and
427/// overwritten on our behalf. The sticky bit does not help — it stops you
428/// replacing someone else's file, not creating a name nobody has taken.
429///
430/// `create_new` is `O_CREAT|O_EXCL`, which refuses an existing path of any
431/// kind, symlink included, and never follows it. The attack becomes a failed
432/// verification, which is the safe direction for a signature check to fail.
433fn create_exclusive(path: &std::path::Path) -> Option<std::fs::File> {
434    std::fs::OpenOptions::new()
435        .write(true)
436        .create_new(true)
437        .open(path)
438        .ok()
439}
440
441pub fn verify(
442    payload: &str,
443    sig: &str,
444    allowed_signers: &std::path::Path,
445    principal: &str,
446) -> bool {
447    use std::io::Write;
448    // -Y verify takes the signature as a FILE; the payload rides stdin.
449    //
450    // The nanosecond is not decoration. The name was pid + a pointer, which
451    // a squatter on a shared /tmp can sit on: `create_exclusive` then refuses
452    // every call and verification fails permanently. Varying the name per
453    // call makes that a race to lose rather than a door to hold shut.
454    let nonce = std::time::SystemTime::now()
455        .duration_since(std::time::UNIX_EPOCH)
456        .map(|d| d.subsec_nanos())
457        .unwrap_or(0);
458    let sig_file = std::env::temp_dir().join(format!(
459        "amont-attest-verify-{}-{:p}-{nonce}.sig",
460        std::process::id(),
461        &sig
462    ));
463    let Some(mut f) = create_exclusive(&sig_file) else {
464        return false;
465    };
466    if f.write_all(format!("{sig}\n").as_bytes()).is_err() {
467        let _ = std::fs::remove_file(&sig_file);
468        return false;
469    }
470    // Closed before ssh-keygen opens it.
471    drop(f);
472    let ok = (|| {
473        let mut child = Command::new("ssh-keygen")
474            .args(["-Y", "verify", "-n", NAMESPACE, "-I", principal, "-f"])
475            .arg(allowed_signers)
476            .arg("-s")
477            .arg(&sig_file)
478            .stdin(Stdio::piped())
479            .stdout(Stdio::null())
480            .stderr(Stdio::null())
481            .spawn()
482            .ok()?;
483        child.stdin.take()?.write_all(payload.as_bytes()).ok()?;
484        child.wait().ok().map(|s| s.success())
485    })()
486    .unwrap_or(false);
487    let _ = std::fs::remove_file(&sig_file);
488    ok
489}
490
491/// pre-push, after every block gate has passed: attest each pushed tip and
492/// send the notes ref to the remote being pushed.
493///
494/// `gates` is what the dispatcher saw actually PASS — `Warned` and
495/// `Unavailable` never appear in it, because "could not run" is not
496/// "passed". Empty means nothing testlike ran, and an attestation listing
497/// no gates would be a signed way of saying nothing.
498///
499/// Best-effort throughout, and quiet about it: pre-push has already printed
500/// its verdicts, and a push that works minus its CI shortcut is not a
501/// problem anyone needs to solve at push time.
502pub fn attest_push(
503    settings: &crate::config::Settings,
504    remote: &str,
505    refs: &[PushRef],
506    gates: &[String],
507) {
508    if gates.is_empty() || remote.is_empty() || !enabled(settings) {
509        return;
510    }
511    let Some(key) = key_path() else { return };
512    if !key.exists() {
513        crate::config::complain(
514            TOGGLE,
515            &format!("signing key {} does not exist", key.display()),
516            "no attestation (CI will run the tests)",
517        );
518        return;
519    }
520    let mut blocks: Vec<(String, String)> = Vec::new();
521    let mut input_blocks: Vec<(String, String)> = Vec::new();
522    for r in refs {
523        if is_zero(&r.local_oid) {
524            continue; // deleting a ref pushes no code
525        }
526        let spec = format!("{}^{{tree}}", r.local_oid);
527        let Some(tree) = crate::git::stdout(&["rev-parse", &spec]) else {
528            continue;
529        };
530        // Input fingerprints, from the spec in THIS tip's tree — the tree
531        // the gates ran against — for the gates that passed.
532        let inputs = inputs_for(&tree, gates);
533        let p = payload(&tree, gates, &inputs);
534        let body = match sign(&p, &key) {
535            Some(sig) => format!("{p}\n{sig}"),
536            None => continue,
537        };
538        for (gate, fp) in &inputs {
539            if let Some(k) = input_key(gate, fp) {
540                input_blocks.push((k, body.clone()));
541            }
542        }
543        // The note goes on the TREE as well as the commit, and the tree is
544        // the key that matches what the signature already covers.
545        //
546        // Keying only by commit is why the verifier had to HUNT — `HEAD`,
547        // then `HEAD^2` for a pull request's merge commit — and the hunt has
548        // a floor it cannot reach past: a squash-merge onto a main that has
549        // moved produces a commit with neither the note nor a parent that
550        // has it, while an attestation for that exact tree may be sitting in
551        // the ref. Signed for the content, findable only by the container.
552        //
553        // Keyed by tree, the lookup is one step and survives squash-merge,
554        // amend and rebase — every rewrite that preserves content. Which is
555        // the whole claim the payload makes: `tree <sha>`, signed.
556        //
557        // Both, not either: the commit note is what `git log --notes` shows
558        // and what an older verifier looks for, so dropping it would break
559        // consumers mid-upgrade for no gain.
560        blocks.push((tree, body.clone()));
561        blocks.push((r.local_oid.clone(), body));
562    }
563    if blocks.is_empty() {
564        return;
565    }
566    // Each ref on its own: a main ref that is already up to date never
567    // short-circuits the inputs ref, which is how a push whose inputs ref
568    // failed earlier repairs the missing keys.
569    let main_ok = publish(remote, NOTES_FULL_REF, &blocks);
570    let inputs_ok = input_blocks.is_empty() || publish(remote, INPUTS_FULL_REF, &input_blocks);
571    if main_ok {
572        crate::say!(
573            "{} attested {} for CI ({}{})",
574            crate::ui::valid_sign(),
575            crate::ui::highlight(&gates.join(" ")),
576            NOTES_REF,
577            if input_blocks.is_empty() {
578                String::new()
579            } else if inputs_ok {
580                format!(", {} input fingerprints", input_blocks.len())
581            } else {
582                ", input fingerprints not published".to_string()
583            },
584        );
585    }
586}
587
588/// How many times a push that lost a race is retried before giving up.
589const PUSH_ATTEMPTS: u32 = 4;
590
591/// Attach each block to its object on the REMOTE's copy of the notes ref, and
592/// push the result. Never `notes add -f`, never a blind push of the local ref.
593///
594/// A note is one per object and may already hold blocks by other producers —
595/// since attest 1.2.0, CI signs the same tree on its own platform, and a
596/// teammate's push may have attested it too. `add -f` erased them all; a push
597/// of the local ref, which never fetched, was rejected as non-fast-forward the
598/// moment anyone else had written the ref, and the failure was silent. So:
599///
600/// 1. fetch the remote's ref into a TEMPORARY ref, leaving the local
601///    `refs/notes/amont-attest` alone whatever happens next;
602/// 2. `notes append` each block there, unless that exact block is already
603///    present (ed25519 signatures are deterministic, so a re-push of the
604///    same tree produces the same bytes);
605/// 3. push the temporary ref to the remote's ref. A non-fast-forward
606///    rejection means another producer wrote meanwhile: go back to 1.
607///
608/// On success the local ref follows what was published. The temporary ref is
609/// removed on every path. `notes_ref` is the fully qualified ref to publish
610/// to — the main ref or the inputs ref.
611fn publish(remote: &str, notes_ref: &str, blocks: &[(String, String)]) -> bool {
612    let short = notes_ref.trim_start_matches("refs/notes/");
613    let tmp = format!("amont-attest-push-{}-{short}", std::process::id());
614    let tmp_full = format!("refs/notes/{tmp}");
615    let fetch_spec = format!("+{notes_ref}:{tmp_full}");
616    let push_spec = format!("{tmp_full}:{notes_ref}");
617    let mut published = false;
618    for _ in 0..PUSH_ATTEMPTS {
619        let _ = crate::git::succeeds(&["update-ref", "-d", &tmp_full]);
620        // A remote with no such ref yet fails the fetch, harmlessly: append
621        // then creates the note from nothing.
622        let _ = crate::git::succeeds(&["fetch", "--quiet", remote, &fetch_spec]);
623        let mut appended = false;
624        for (object, body) in blocks {
625            let existing =
626                crate::git::stdout(&["notes", "--ref", &tmp, "show", object]).unwrap_or_default();
627            if existing.contains(body.trim_end()) {
628                continue;
629            }
630            if crate::git::succeeds(&["notes", "--ref", &tmp, "append", "-m", body, object]) {
631                appended = true;
632            }
633        }
634        if !appended {
635            // Everything is already there — a re-push of an attested tree.
636            published = true;
637            break;
638        }
639        match push_notes(remote, &push_spec) {
640            Push::Done => {
641                published = true;
642                break;
643            }
644            Push::Raced => continue,
645            Push::Refused => break,
646        }
647    }
648    if published {
649        let _ = crate::git::succeeds(&["update-ref", notes_ref, &tmp_full]);
650    }
651    let _ = crate::git::succeeds(&["update-ref", "-d", &tmp_full]);
652    published
653}
654
655/// What a notes push came back with.
656enum Push {
657    Done,
658    /// Rejected as non-fast-forward: another producer wrote the ref between
659    /// our fetch and our push. Worth another round.
660    Raced,
661    /// Anything else — no network, no permission, no remote. Not worth one.
662    Refused,
663}
664
665/// `amont attest covered` — the verifying side, as CI's one-liner.
666///
667/// Answers "which gates does a VALID attestation cover for the tree checked
668/// out here?", doing everything the workflow snippet used to spell out in
669/// sh: freshen the notes ref (best-effort), look for a note on `HEAD` and —
670/// for a PR's merge commit — on `HEAD^2`, insist on the format version,
671/// insist the attested tree is byte-for-byte `HEAD^{tree}`, and verify the
672/// signature against `allowed_signers`. Thirty lines of workflow copied into
673/// every repository is exactly the drift this binary exists to end.
674///
675/// `None` for every failure, and the CLI prints nothing and exits 0 on
676/// `None` — fail-open is the caller's contract, not its option. A CI step
677/// reading empty output runs its tests, which is always the safe answer.
678///
679/// This is amont running in CI, which `docs/ci.md` forbids for CHECKS — the
680/// line held is narrower than the slogan: CI still never runs a check
681/// through amont; this verifies a document about checks that already ran.
682/// `require_platform` is the leg asking. `Some("x86_64-linux")` covers only
683/// a suite that ran there; `None` is the caller stating that this suite's
684/// result does not depend on where it ran (a pure-JS unit run, say) and is
685/// spelled `--platform any` in a committed workflow, where it is reviewed
686/// like any other line of the repository.
687pub fn covered(
688    signers: &std::path::Path,
689    principal: &str,
690    require_platform: Option<&str>,
691) -> Option<String> {
692    covered_within(signers, principal, require_platform, REMOTE_BUDGET_SECS)
693}
694
695/// Seconds each remote call of `covered` may take, as attest's verifier.
696const REMOTE_BUDGET_SECS: u64 = 15;
697
698/// What syncing the local notes ref with origin allows.
699#[derive(Debug, PartialEq)]
700enum Sync {
701    /// Judge the local ref: it is origin's, or there is no origin at all.
702    Judge,
703    /// Judge nothing, for the reason given (already reported on stderr).
704    Skip,
705}
706
707/// The local `refs/notes/amont-attest` as ORIGIN'S MIRROR (attest 1.4.0,
708/// SPEC.md "Which refs are read"). Origin is the only place an attestation
709/// can be revoked, so a ref origin no longer has is deleted here, and a copy
710/// nobody could refresh is not judged: a stale mirror on a persistent runner
711/// must not outlive a revocation.
712///
713/// The fetch lands in a THROWAWAY ref and the main ref moves by a local
714/// compare-and-swap: the deadline kills git with SIGKILL, and a fetch killed
715/// while writing the main ref leaves `amont-attest.lock`, which would fail
716/// every later fetch and delete — coverage lost for good, silently. The
717/// throwaway lives outside `refs/notes/`, so no notes push or pruning fetch
718/// ever touches it.
719fn sync_mirror(budget: u64) -> Sync {
720    if !crate::git::succeeds(&["remote", "get-url", "origin"]) {
721        // For fixtures and local use; CI always has an origin.
722        return Sync::Judge;
723    }
724    sweep_sync_refs();
725    let old = crate::git::stdout(&["rev-parse", "--verify", "--quiet", NOTES_FULL_REF]);
726    let tmp = format!("{SYNC_NAMESPACE}{}/amont-attest", std::process::id());
727    let fetched = crate::git::probe_remote(
728        &[
729            "fetch",
730            "--quiet",
731            "origin",
732            &format!("+{NOTES_FULL_REF}:{tmp}"),
733        ],
734        budget,
735    );
736    let verdict = match fetched {
737        crate::git::Probe::Exit(0) => {
738            let new = crate::git::stdout(&["rev-parse", "--verify", "--quiet", &tmp]);
739            // Compare-and-swap against what was there before the fetch: a
740            // producer that published meanwhile is not overwritten.
741            let expected = old.clone().unwrap_or_default();
742            match new {
743                None => {
744                    // The throwaway vanished under us: nothing was fetched.
745                    say_skip(&format!(
746                        "the fetch of {NOTES_FULL_REF} left nothing to read; local mirror not judged"
747                    ));
748                    Sync::Skip
749                }
750                Some(new)
751                    if crate::git::succeeds(&["update-ref", NOTES_FULL_REF, &new, &expected]) =>
752                {
753                    Sync::Judge
754                }
755                Some(new) => {
756                    // The swap failed. Judge only if the ref now holds EXACTLY
757                    // what origin just gave us (a concurrent fetch or publish
758                    // got there first); anything else — a stale lock, a ref
759                    // nobody refreshed — is not origin's, and judging it would
760                    // honour a revoked attestation.
761                    let now =
762                        crate::git::stdout(&["rev-parse", "--verify", "--quiet", NOTES_FULL_REF]);
763                    if now.as_deref() == Some(new.as_str()) {
764                        Sync::Judge
765                    } else {
766                        say_skip(&format!(
767                            "cannot update {NOTES_FULL_REF} to origin's copy; local mirror not judged"
768                        ));
769                        Sync::Skip
770                    }
771                }
772            }
773        }
774        crate::git::Probe::TimedOut(secs) => {
775            // Origin did not answer; asking it again would only double the wait.
776            say_skip(&format!(
777                "origin did not answer within {secs} s for {NOTES_FULL_REF}; local mirror not judged, running everything"
778            ));
779            Sync::Skip
780        }
781        _ => match crate::git::probe_remote(
782            &["ls-remote", "--exit-code", "origin", NOTES_FULL_REF],
783            budget,
784        ) {
785            crate::git::Probe::Exit(2) => drop_mirror(old.as_deref()),
786            crate::git::Probe::Exit(0) => {
787                say_skip(&format!(
788                    "origin has {NOTES_FULL_REF} but fetching it failed; local mirror not judged, running everything"
789                ));
790                Sync::Skip
791            }
792            crate::git::Probe::Exit(code) => {
793                say_skip(&format!(
794                    "cannot fetch {NOTES_FULL_REF} from origin (exit {code}; no credentials? persist-credentials: false?); local mirror not judged, running everything"
795                ));
796                Sync::Skip
797            }
798            crate::git::Probe::TimedOut(secs) => {
799                say_skip(&format!(
800                    "origin did not answer within {secs} s for {NOTES_FULL_REF}; local mirror not judged, running everything"
801                ));
802                Sync::Skip
803            }
804            crate::git::Probe::Failed => {
805                say_skip(&format!(
806                    "cannot fetch {NOTES_FULL_REF} from origin (git did not run); local mirror not judged, running everything"
807                ));
808                Sync::Skip
809            }
810        },
811    };
812    let _ = crate::git::succeeds(&["update-ref", "-d", &tmp]);
813    report_stale_lock();
814    verdict
815}
816
817/// Origin answered and has no such ref: delete the mirror, if there is one,
818/// and say how to undo it. Compare-and-delete against the oid read before
819/// the fetch, so a ref a concurrent publish just wrote is left alone.
820fn drop_mirror(old: Option<&str>) -> Sync {
821    let Some(oid) = old else {
822        return Sync::Skip; // never attested here: nothing to delete, nothing to say
823    };
824    if crate::git::succeeds(&["update-ref", "-d", NOTES_FULL_REF, oid]) {
825        say(format!(
826            "amont: origin has no {NOTES_FULL_REF}; deleted the local mirror (was {oid})"
827        ));
828        say(format!(
829            "amont:   undo locally: git update-ref {NOTES_FULL_REF} {oid}   (undo the revocation: git push origin {oid}:{NOTES_FULL_REF})"
830        ));
831    } else {
832        say_skip(&format!(
833            "origin has no {NOTES_FULL_REF} but the local mirror could not be deleted (read-only .git?); local mirror not judged"
834        ));
835    }
836    Sync::Skip
837}
838
839/// Where the throwaway sync refs live: one per process id.
840const SYNC_NAMESPACE: &str = "refs/amont-tmp/";
841
842/// Remove the throwaways of runs that were killed before their cleanup —
843/// only those whose process is gone, so a concurrent run in the same clone
844/// keeps its own. Where liveness cannot be asked (no `kill`), nothing is
845/// swept: a leftover outside `refs/notes/` is inert, never pushed or read.
846fn sweep_sync_refs() {
847    let Some(refs) = crate::git::stdout(&["for-each-ref", "--format=%(refname)", SYNC_NAMESPACE])
848    else {
849        return;
850    };
851    for r in refs.lines() {
852        let Some(pid) = r
853            .strip_prefix(SYNC_NAMESPACE)
854            .and_then(|rest| rest.split('/').next())
855            .filter(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
856        else {
857            continue;
858        };
859        if pid == std::process::id().to_string() || process_alive(pid) != Some(false) {
860            continue;
861        }
862        let _ = crate::git::succeeds(&["update-ref", "-d", r]);
863    }
864}
865
866/// `Some(false)` only when the system says no such process exists.
867fn process_alive(pid: &str) -> Option<bool> {
868    if !cfg!(unix) {
869        return None;
870    }
871    // LC_ALL=C: the answer is read from kill's English message below.
872    let out = Command::new("kill")
873        .args(["-0", pid])
874        .env("LC_ALL", "C")
875        .stdin(Stdio::null())
876        .stdout(Stdio::null())
877        .stderr(Stdio::piped())
878        .output()
879        .ok()?;
880    if out.status.success() {
881        return Some(true);
882    }
883    // Gone only on a positive "no such process"; anything else (EPERM: it
884    // exists, we may not signal it) counts as alive, so nothing live is swept.
885    let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
886    Some(!err.contains("no such process"))
887}
888
889/// A lock a killed git left on the MAIN ref fails every later fetch and
890/// delete; say so, with the command, rather than lose coverage in silence.
891fn report_stale_lock() {
892    let lock = format!("{NOTES_FULL_REF}.lock");
893    if let Some(path) = crate::git::stdout(&["rev-parse", "--git-path", &lock]) {
894        if std::path::Path::new(&path).exists() {
895            say_skip(&format!(
896                "a git that was killed left a lock on {NOTES_FULL_REF}; if no git is running: rm {path}"
897            ));
898        }
899    }
900}
901
902/// Why nothing is covered, on stderr. Stdout stays the gates or nothing.
903fn say_skip(why: &str) {
904    say(format!("amont: {why}"));
905}
906
907thread_local! {
908    /// Lines `covered` wrote to stderr, recorded when a test asks — so the
909    /// exact wording, and the undo command, are asserted rather than trusted.
910    static CAPTURE: std::cell::RefCell<Option<Vec<String>>> = const { std::cell::RefCell::new(None) };
911}
912
913/// One line on stderr (and into [`CAPTURE`] when a test is recording).
914fn say(line: String) {
915    CAPTURE.with(|c| {
916        if let Some(v) = c.borrow_mut().as_mut() {
917            v.push(line.clone());
918        }
919    });
920    eprintln!("{line}");
921}
922
923/// [`covered`] with the per-call budget as a parameter, for tests.
924fn covered_within(
925    signers: &std::path::Path,
926    principal: &str,
927    require_platform: Option<&str>,
928    budget: u64,
929) -> Option<String> {
930    if sync_mirror(budget) == Sync::Skip {
931        return None;
932    }
933    let head_tree = crate::git::stdout(&["rev-parse", "HEAD^{tree}"])?;
934    // HEAD first: a push event's checkout IS the attested commit. HEAD^2
935    // second: a PR checkout is a merge commit git made a moment ago, whose
936    // second parent is the pushed tip that carries the note — and the tree
937    // comparison below still measures against what is ACTUALLY checked out,
938    // so a merge whose tree drifted from the tested tip never skips.
939    // The TREE first, which is what the signature covers and therefore the
940    // only key that cannot go stale: it finds the attestation after a
941    // squash-merge, an amend or a rebase, none of which the commit-shaped
942    // candidates below survive when main has moved underneath.
943    //
944    // `HEAD` and `HEAD^2` stay after it, for notes written by an amont that
945    // only ever keyed by commit. They cost one `rev-parse` each and only
946    // when the tree lookup found nothing.
947    for candidate in [head_tree.as_str(), "HEAD", "HEAD^2"] {
948        let Some(object) = crate::git::stdout(&["rev-parse", "--verify", candidate]) else {
949            continue;
950        };
951        let Some(body) = crate::git::stdout(&["notes", "--ref", NOTES_REF, "show", &object]) else {
952            continue;
953        };
954        let Some((payload, sig)) = split_note(&body) else {
955            continue;
956        };
957        // By prefix, not by position: the payload has grown a line once
958        // already, and a positional reader silently mis-assigns every field
959        // after an insertion rather than failing.
960        let mut lines = payload.lines();
961        if lines.next() != Some(FORMAT) {
962            continue;
963        }
964        let field = |name: &str| {
965            payload
966                .lines()
967                .find_map(|l| l.strip_prefix(name).and_then(|r| r.strip_prefix(' ')))
968                .map(str::trim)
969        };
970        let (Some(tree), Some(gates), Some(ran_on)) =
971            (field("tree"), field("gates"), field("platform"))
972        else {
973            continue;
974        };
975        if tree != head_tree || gates.is_empty() {
976            continue; // wrong content, or a signed way of saying nothing
977        }
978        // The leg asking is not the leg that ran: a macOS `cargo test` is no
979        // evidence about Windows. `None` means the caller has stated this
980        // suite is platform-independent.
981        if require_platform.is_some_and(|want| want != ran_on) {
982            continue;
983        }
984        if verify(&payload, &sig, signers, principal) {
985            return Some(gates.to_string());
986        }
987    }
988    None
989}
990
991/// Where a repository keeps its `allowed_signers` when the caller does not
992/// say — the Forgejo location first, the GitHub one second. `None` when
993/// neither exists, which the CLI reads as "nothing is covered".
994/// Resolved from the REPOSITORY ROOT, not the working directory. A workflow
995/// that sets `working-directory` (a monorepo running a matrix inside
996/// `packages/<x>`, say) puts the step in a subdirectory, where a relative
997/// `.forgejo/allowed_signers` does not exist — and the CLI would then find no
998/// signers, print nothing, and fail open FOREVER. Silently: the suite still
999/// runs, CI still passes, and nothing anywhere says the gate is dead. That is
1000/// the worst shape a fail-open can take, so the path is anchored.
1001pub fn default_signers() -> Option<PathBuf> {
1002    let root = crate::git::stdout(&["rev-parse", "--show-toplevel"]).map(PathBuf::from);
1003    [".forgejo/allowed_signers", ".github/allowed_signers"]
1004        .into_iter()
1005        .map(|rel| match &root {
1006            Some(root) => root.join(rel),
1007            None => PathBuf::from(rel),
1008        })
1009        .find(|p| p.exists())
1010}
1011
1012/// The first principal an `allowed_signers` file names — the identity to
1013/// verify against when the caller does not pass `--principal`. One key, one
1014/// principal is the overwhelmingly common shape of this file; a multi-signer
1015/// team passes the flag.
1016pub fn first_principal(signers: &std::path::Path) -> Option<String> {
1017    let body = std::fs::read_to_string(signers).ok()?;
1018    body.lines()
1019        .map(str::trim)
1020        .find(|l| !l.is_empty() && !l.starts_with('#'))
1021        .and_then(|l| l.split_whitespace().next())
1022        .map(str::to_string)
1023}
1024
1025/// A note body back into the exact bytes that were signed, plus the
1026/// signature block. The blank-line split ate the payload's trailing newline;
1027/// it is part of the signed bytes, so it goes back.
1028fn split_note(body: &str) -> Option<(String, String)> {
1029    let (payload, sig) = body.split_once("\n\n")?;
1030    if !sig.starts_with("-----BEGIN SSH SIGNATURE-----") {
1031        return None;
1032    }
1033    Some((format!("{payload}\n"), sig.to_string()))
1034}
1035
1036/// Push `refspec`, marked so the recursive pre-push yields.
1037///
1038/// Not `git::succeeds` — that helper cannot set an environment variable, and
1039/// the guard is the entire point of this wrapper existing. stderr is read
1040/// only to tell a lost race from a real refusal.
1041fn push_notes(remote: &str, refspec: &str) -> Push {
1042    let out = Command::new("git")
1043        .args(["push", "--quiet", remote, refspec])
1044        .env(PUSH_GUARD, "1")
1045        .stdin(Stdio::null())
1046        .stdout(Stdio::null())
1047        .output();
1048    let Ok(out) = out else { return Push::Refused };
1049    if out.status.success() {
1050        return Push::Done;
1051    }
1052    match classify_push_stderr(&String::from_utf8_lossy(&out.stderr)) {
1053        PushFailure::Raced => Push::Raced,
1054        PushFailure::Unreachable | PushFailure::Refused => Push::Refused,
1055    }
1056}
1057
1058/// Why a push that exited non-zero failed, read from git's stderr.
1059///
1060/// Three classes, because the callers act differently on each: a lost race
1061/// is worth one more fetch-and-try, an unreachable remote says nothing about
1062/// the push itself, and everything else is a refusal to report verbatim.
1063/// An unrecognised message is `Refused` — the direction that never claims a
1064/// push was merely unlucky.
1065#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1066pub enum PushFailure {
1067    Raced,
1068    Unreachable,
1069    Refused,
1070}
1071
1072/// The markers behind [`PushFailure`]. Raced wins over Unreachable: a
1073/// message naming both is a race that happened on a reachable remote.
1074pub fn classify_push_stderr(err: &str) -> PushFailure {
1075    const RACED: [&str; 5] = [
1076        "non-fast-forward",
1077        "fetch first",
1078        "stale info",
1079        "cannot lock ref",
1080        "failed to lock",
1081    ];
1082    const UNREACHABLE: [&str; 5] = [
1083        "Could not resolve host",
1084        "Connection refused",
1085        "Connection timed out",
1086        "unable to access",
1087        "Could not read from remote repository",
1088    ];
1089    // A hook's own output rides the same stream, and a gate log that happens
1090    // to say "Connection refused" is not the remote being down. Unreachable
1091    // is read only from the lines git and ssh themselves print.
1092    let own = |l: &&str| {
1093        let l = l.trim_start();
1094        l.starts_with("fatal:") || l.starts_with("ssh:") || l.starts_with("error:")
1095    };
1096    if RACED.iter().any(|m| err.contains(m)) {
1097        PushFailure::Raced
1098    } else if err
1099        .lines()
1100        .filter(own)
1101        .any(|l| UNREACHABLE.iter().any(|m| l.contains(m)))
1102    {
1103        PushFailure::Unreachable
1104    } else {
1105        PushFailure::Refused
1106    }
1107}
1108
1109/// A ref oid that is all zeros — git's spelling of "no object" in the
1110/// pre-push ref list, for any hash width.
1111fn is_zero(oid: &str) -> bool {
1112    !oid.is_empty() && oid.bytes().all(|b| b == b'0')
1113}
1114
1115/// uninstall: forget the local ref. The copies already pushed to remotes are
1116/// statements we made and stand by; only OUR bookkeeping is removed — the
1117/// same line `gate_stamp::forget` draws.
1118pub fn forget() -> bool {
1119    crate::git::succeeds(&["update-ref", "-d", NOTES_FULL_REF])
1120}
1121
1122/// The same, for a repository this process is not standing in.
1123pub fn forget_in(repo: &std::path::Path) -> bool {
1124    crate::git::succeeds_in(repo, &["update-ref", "-d", NOTES_FULL_REF])
1125}
1126
1127#[cfg(test)]
1128mod tests {
1129    use super::*;
1130
1131    #[test]
1132    fn push_stderr_is_sorted_into_raced_unreachable_and_refused() {
1133        assert_eq!(
1134            classify_push_stderr(" ! [rejected] feat -> feat (non-fast-forward)"),
1135            PushFailure::Raced
1136        );
1137        assert_eq!(
1138            classify_push_stderr("error: failed to lock ref"),
1139            PushFailure::Raced
1140        );
1141        for line in [
1142            "fatal: unable to access 'https://h/x.git/': Could not resolve host: h",
1143            "ssh: connect to host h port 22: Connection refused",
1144            "fatal: Could not read from remote repository.",
1145            "fatal: unable to access 'u': Connection timed out after 10001 ms",
1146        ] {
1147            assert_eq!(
1148                classify_push_stderr(line),
1149                PushFailure::Unreachable,
1150                "{line}"
1151            );
1152        }
1153        assert_eq!(
1154            classify_push_stderr("remote: permission denied"),
1155            PushFailure::Refused
1156        );
1157        assert_eq!(classify_push_stderr(""), PushFailure::Refused);
1158    }
1159
1160    #[test]
1161    fn a_hooks_own_words_do_not_make_the_remote_unreachable() {
1162        // A gate log that mentions a refused connection is the gate's news.
1163        assert_eq!(
1164            classify_push_stderr("integration: Connection refused by the fixture\nhook failed"),
1165            PushFailure::Refused
1166        );
1167    }
1168
1169    /// `amont.attest` and the key path come from git config, which each test
1170    /// sets on its own repo; no policy is in play.
1171    fn test_settings() -> crate::config::Settings {
1172        crate::config::Settings::default()
1173    }
1174
1175    use std::path::Path;
1176
1177    /// Real repositories and real keys: every function here is a conversation
1178    /// with git or ssh-keygen, and a mocked conversation tests the one we
1179    /// imagined. Same doctrine as `gate_stamp`'s tests.
1180    fn dir(name: &str) -> PathBuf {
1181        let d = std::env::temp_dir().join(format!("attest-{name}-{}", std::process::id()));
1182        let _ = std::fs::remove_dir_all(&d);
1183        std::fs::create_dir_all(&d).unwrap();
1184        d
1185    }
1186
1187    /// A fixture git call that FAILS where it fails.
1188    ///
1189    /// This used to discard the exit status, and that is how a rare flake
1190    /// stayed unreadable for a day: if the setup `git commit` did not
1191    /// happen, the test carried on to an unborn HEAD, and the panic landed
1192    /// three lines later on a missing gate stamp — a product-shaped
1193    /// failure for a fixture-shaped cause. Same rule the checks obey:
1194    /// git failing is not git answering.
1195    fn git(dir: &Path, args: &[&str]) -> String {
1196        let out = std::process::Command::new("git")
1197            .arg("-C")
1198            .arg(dir)
1199            .args(args)
1200            .output()
1201            .expect("git");
1202        assert!(
1203            out.status.success(),
1204            "fixture: git {args:?} in {} exited {:?}: {}",
1205            dir.display(),
1206            out.status.code(),
1207            String::from_utf8_lossy(&out.stderr).trim()
1208        );
1209        String::from_utf8_lossy(&out.stdout).trim().to_string()
1210    }
1211
1212    fn repo(name: &str) -> PathBuf {
1213        let d = dir(name);
1214        git(&d, &["init", "-q", "--template=", "."]);
1215        git(&d, &["config", "user.email", "t@t.test"]);
1216        git(&d, &["config", "user.name", "t"]);
1217        d
1218    }
1219
1220    /// A throwaway ed25519 key plus the `allowed_signers` line CI would
1221    /// commit for it, namespace-pinned exactly as the docs instruct.
1222    fn keypair(d: &Path) -> (PathBuf, PathBuf) {
1223        let key = d.join("attest_key");
1224        let ok = std::process::Command::new("ssh-keygen")
1225            .args(["-q", "-t", "ed25519", "-N", "", "-C", "test", "-f"])
1226            .arg(&key)
1227            .status()
1228            .expect("ssh-keygen must exist for these tests")
1229            .success();
1230        assert!(ok, "keygen failed");
1231        let pubkey = std::fs::read_to_string(key.with_extension("pub")).unwrap();
1232        let signers = d.join("allowed_signers");
1233        std::fs::write(
1234            &signers,
1235            format!("t@t.test namespaces=\"{NAMESPACE}\" {pubkey}"),
1236        )
1237        .unwrap();
1238        (key, signers)
1239    }
1240
1241    /// The module talks to the repo at the process cwd; serialised against
1242    /// every other cwd-moving test via the crate-wide lock.
1243    fn in_repo<T>(dir: &Path, f: impl FnOnce() -> T) -> T {
1244        let _guard = crate::TEST_CWD.lock().unwrap_or_else(|p| p.into_inner());
1245        let prev = std::env::current_dir().unwrap();
1246        std::env::set_current_dir(dir).unwrap();
1247        let r = f();
1248        std::env::set_current_dir(prev).unwrap();
1249        r
1250    }
1251
1252    /// The signature file must never be written THROUGH something already at
1253    /// its path. `std::fs::write` would follow a symlink and truncate whatever
1254    /// it points at — on a shared `/tmp`, a file chosen by whoever guessed the
1255    /// name first.
1256    #[test]
1257    fn the_signature_file_refuses_to_follow_what_is_already_there() {
1258        let dir = std::env::temp_dir().join(format!("amont-excl-{}", std::process::id()));
1259        let _ = std::fs::remove_dir_all(&dir);
1260        std::fs::create_dir_all(&dir).expect("temp dir");
1261
1262        // A plain file already there: refused, and left untouched.
1263        let taken = dir.join("taken");
1264        std::fs::write(&taken, "original").expect("seed");
1265        assert!(create_exclusive(&taken).is_none());
1266        assert_eq!(std::fs::read_to_string(&taken).unwrap(), "original");
1267
1268        // The real attack: the path is a symlink pointing somewhere valuable.
1269        #[cfg(unix)]
1270        {
1271            let victim = dir.join("victim");
1272            std::fs::write(&victim, "precious").expect("seed");
1273            let link = dir.join("link");
1274            std::os::unix::fs::symlink(&victim, &link).expect("symlink");
1275            assert!(
1276                create_exclusive(&link).is_none(),
1277                "a symlink must be refused, not followed"
1278            );
1279            assert_eq!(
1280                std::fs::read_to_string(&victim).unwrap(),
1281                "precious",
1282                "the link target was written through"
1283            );
1284        }
1285
1286        // And a free name still works, or the fix would break verification.
1287        let fresh = dir.join("fresh");
1288        assert!(create_exclusive(&fresh).is_some());
1289
1290        let _ = std::fs::remove_dir_all(&dir);
1291    }
1292
1293    #[test]
1294    fn the_payload_is_the_documented_contract() {
1295        let p = payload(
1296            "abc123",
1297            &["pre-push-pytest".into(), "pre-push-cargo-test".into()],
1298            &[],
1299        );
1300        let lines: Vec<&str> = p.lines().collect();
1301        assert_eq!(lines[0], FORMAT);
1302        assert_eq!(lines[1], "tree abc123");
1303        assert_eq!(lines[2], "gates pre-push-pytest pre-push-cargo-test");
1304        assert_eq!(lines[3], format!("platform {}", platform()));
1305        assert_eq!(lines[4], format!("amont {}", env!("CARGO_PKG_VERSION")));
1306        assert!(
1307            p.ends_with('\n'),
1308            "CI reconstructs these bytes; the trailing newline is part of them"
1309        );
1310    }
1311
1312    #[test]
1313    fn sign_verify_roundtrip_and_tamper_rejection() {
1314        let d = dir("roundtrip");
1315        let (key, signers) = keypair(&d);
1316        let p = payload("deadbeef", &["pre-push-pytest".into()], &[]);
1317        let sig = sign(&p, &key).expect("signing with a real key succeeds");
1318        assert!(verify(&p, &sig, &signers, "t@t.test"));
1319        // One byte of the tree changed: the signature must not carry over —
1320        // this is the entire difference between this module and gate_stamp.
1321        let tampered = payload("deadbeee", &["pre-push-pytest".into()], &[]);
1322        assert!(!verify(&tampered, &sig, &signers, "t@t.test"));
1323        // The right payload under the wrong principal is also no.
1324        assert!(!verify(&p, &sig, &signers, "someone@else.test"));
1325        let _ = std::fs::remove_dir_all(&d);
1326    }
1327
1328    #[test]
1329    fn a_missing_key_signs_nothing() {
1330        assert!(sign("anything", Path::new("/nonexistent/key")).is_none());
1331    }
1332
1333    /// The full journey: a repo with the toggle on pushes, and the BARE
1334    /// remote ends up holding a note whose payload verifies and matches the
1335    /// pushed tree. This is everything CI relies on, minus CI.
1336    #[test]
1337    fn an_enabled_push_leaves_a_verifiable_note_on_the_remote() {
1338        let d = dir("e2e");
1339        let (key, signers) = keypair(&d);
1340        let remote = d.join("remote.git");
1341        std::fs::create_dir_all(&remote).unwrap();
1342        git(&remote, &["init", "-q", "--bare", "--template=", "."]);
1343        let work = repo("e2e-work");
1344        git(
1345            &work,
1346            &["remote", "add", "origin", remote.to_str().unwrap()],
1347        );
1348        git(&work, &["config", "amont.attest", "true"]);
1349        git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
1350        std::fs::write(work.join("a.ts"), "x").unwrap();
1351        git(&work, &["add", "a.ts"]);
1352        git(&work, &["commit", "-qm", "chore: a"]);
1353        let head = git(&work, &["rev-parse", "HEAD"]);
1354        let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1355        let push_ref = PushRef {
1356            local_ref: "refs/heads/main".into(),
1357            local_oid: head.clone(),
1358            remote_ref: "refs/heads/main".into(),
1359            remote_oid: "0".repeat(40),
1360        };
1361        in_repo(&work, || {
1362            attest_push(
1363                &test_settings(),
1364                "origin",
1365                &[push_ref],
1366                &["pre-push-run-tests-js".into()],
1367            );
1368        });
1369        // The note exists on the REMOTE — the whole point is that it travels.
1370        let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &head]);
1371        assert!(!body.is_empty(), "no note reached the remote");
1372        let (p, sig) = body
1373            .split_once("\n\n")
1374            .expect("payload, blank line, signature");
1375        let p = format!("{p}\n"); // the blank-line split ate payload's trailing newline
1376        assert!(p.starts_with(FORMAT));
1377        assert!(
1378            p.contains(&format!("tree {tree}")),
1379            "attests the pushed tree"
1380        );
1381        assert!(
1382            verify(&p, sig, &signers, "t@t.test"),
1383            "the remote copy verifies"
1384        );
1385        let _ = std::fs::remove_dir_all(&d);
1386        let _ = std::fs::remove_dir_all(&work);
1387    }
1388
1389    /// A bare remote plus a work repo opted in, ready to push.
1390    fn remote_and_work(name: &str) -> (PathBuf, PathBuf, PathBuf, PathBuf) {
1391        let d = dir(name);
1392        let (key, signers) = keypair(&d);
1393        let remote = d.join("remote.git");
1394        std::fs::create_dir_all(&remote).unwrap();
1395        git(&remote, &["init", "-q", "--bare", "--template=", "."]);
1396        let work = repo(&format!("{name}-work"));
1397        git(
1398            &work,
1399            &["remote", "add", "origin", remote.to_str().unwrap()],
1400        );
1401        git(&work, &["config", "amont.attest", "true"]);
1402        git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
1403        std::fs::write(work.join("a.ts"), "x").unwrap();
1404        git(&work, &["add", "a.ts"]);
1405        git(&work, &["commit", "-qm", "chore: a"]);
1406        (d, work, remote, signers)
1407    }
1408
1409    fn push_ref_for(work: &Path) -> PushRef {
1410        PushRef {
1411            local_ref: "refs/heads/main".into(),
1412            local_oid: git(work, &["rev-parse", "HEAD"]),
1413            remote_ref: "refs/heads/main".into(),
1414            remote_oid: "0".repeat(40),
1415        }
1416    }
1417
1418    fn blocks_in(body: &str) -> usize {
1419        body.matches("-----BEGIN SSH SIGNATURE-----").count()
1420    }
1421
1422    /// The remote already holds a block on this tree — CI's, or a teammate's.
1423    /// Ours is APPENDED beside it; nothing is erased, and the local notes ref
1424    /// was never consulted for what the remote has.
1425    #[test]
1426    fn a_block_already_on_the_remote_survives_and_ours_is_appended() {
1427        let (d, work, remote, _) = remote_and_work("append");
1428        let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1429        // Someone else's block, written straight into the remote.
1430        let seed = repo("append-seed");
1431        git(
1432            &seed,
1433            &["remote", "add", "origin", remote.to_str().unwrap()],
1434        );
1435        git(&seed, &["fetch", "-q", "origin"]);
1436        std::fs::write(seed.join("a.ts"), "x").unwrap();
1437        git(&seed, &["add", "a.ts"]);
1438        git(&seed, &["commit", "-qm", "chore: a"]);
1439        let foreign = "amont-attest-v2\ntree x\ngates ci-fmt\nplatform s390x-aix\namont other\n\n-----BEGIN SSH SIGNATURE-----\nnope\n-----END SSH SIGNATURE-----";
1440        git(
1441            &seed,
1442            &["notes", "--ref", NOTES_REF, "add", "-m", foreign, &tree],
1443        );
1444        git(
1445            &seed,
1446            &[
1447                "push",
1448                "-q",
1449                "origin",
1450                &format!("{NOTES_FULL_REF}:{NOTES_FULL_REF}"),
1451            ],
1452        );
1453        in_repo(&work, || {
1454            attest_push(
1455                &test_settings(),
1456                "origin",
1457                &[push_ref_for(&work)],
1458                &["pre-push-run-tests-js".into()],
1459            );
1460        });
1461        let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &tree]);
1462        assert_eq!(blocks_in(&body), 2, "both blocks on the remote:\n{body}");
1463        assert!(body.contains("gates ci-fmt"), "the foreign block survives");
1464        assert!(
1465            body.contains("gates pre-push-run-tests-js"),
1466            "ours was appended"
1467        );
1468        let _ = std::fs::remove_dir_all(&d);
1469        let _ = std::fs::remove_dir_all(&work);
1470        let _ = std::fs::remove_dir_all(&seed);
1471    }
1472
1473    /// A local notes ref that is behind the remote — the shape after CI has
1474    /// signed since our last push — used to make the push non-fast-forward
1475    /// and silently lose the attestation. The remote's copy is fetched first,
1476    /// so the push lands, and the local ref then follows it.
1477    #[test]
1478    fn a_stale_local_notes_ref_no_longer_loses_the_push() {
1479        let (d, work, remote, _) = remote_and_work("stale");
1480        let head = git(&work, &["rev-parse", "HEAD"]);
1481        // An unrelated local note, never pushed: the local ref exists and has
1482        // nothing in common with what the remote will hold.
1483        git(
1484            &work,
1485            &[
1486                "notes",
1487                "--ref",
1488                NOTES_REF,
1489                "add",
1490                "-m",
1491                "stale local",
1492                &head,
1493            ],
1494        );
1495        let stale = git(&work, &["rev-parse", NOTES_FULL_REF]);
1496        // Meanwhile the remote got a note from someone else on another object.
1497        let seed = repo("stale-seed");
1498        std::fs::write(seed.join("b.ts"), "y").unwrap();
1499        git(&seed, &["add", "b.ts"]);
1500        git(&seed, &["commit", "-qm", "chore: b"]);
1501        git(
1502            &seed,
1503            &["remote", "add", "origin", remote.to_str().unwrap()],
1504        );
1505        git(
1506            &seed,
1507            &[
1508                "notes",
1509                "--ref",
1510                NOTES_REF,
1511                "add",
1512                "-m",
1513                "remote first",
1514                "HEAD",
1515            ],
1516        );
1517        git(&seed, &["push", "-q", "origin", "HEAD:refs/heads/other"]);
1518        git(
1519            &seed,
1520            &[
1521                "push",
1522                "-q",
1523                "origin",
1524                &format!("{NOTES_FULL_REF}:{NOTES_FULL_REF}"),
1525            ],
1526        );
1527        in_repo(&work, || {
1528            attest_push(
1529                &test_settings(),
1530                "origin",
1531                &[push_ref_for(&work)],
1532                &["pre-push-run-tests-js".into()],
1533            );
1534        });
1535        let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &head]);
1536        assert!(
1537            body.contains("gates pre-push-run-tests-js"),
1538            "the push landed:\n{body}"
1539        );
1540        assert_ne!(
1541            git(&work, &["rev-parse", NOTES_FULL_REF]),
1542            stale,
1543            "the local ref followed the published state"
1544        );
1545        assert!(
1546            git(&work, &["for-each-ref", "refs/notes/amont-attest-push-*"]).is_empty(),
1547            "no temporary ref left behind"
1548        );
1549        let _ = std::fs::remove_dir_all(&d);
1550        let _ = std::fs::remove_dir_all(&work);
1551        let _ = std::fs::remove_dir_all(&seed);
1552    }
1553
1554    /// Pushing the same tree twice appends nothing the second time: the block
1555    /// is byte-identical (ed25519 is deterministic) and already there.
1556    #[test]
1557    fn a_second_push_of_the_same_tree_appends_nothing() {
1558        let (d, work, remote, _) = remote_and_work("twice");
1559        let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1560        for _ in 0..2 {
1561            in_repo(&work, || {
1562                attest_push(
1563                    &test_settings(),
1564                    "origin",
1565                    &[push_ref_for(&work)],
1566                    &["pre-push-run-tests-js".into()],
1567                );
1568            });
1569        }
1570        let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &tree]);
1571        assert_eq!(blocks_in(&body), 1, "one block, not two:\n{body}");
1572        let _ = std::fs::remove_dir_all(&d);
1573        let _ = std::fs::remove_dir_all(&work);
1574    }
1575
1576    /// A committed spec: the block carries an `input` line per declared gate
1577    /// and is filed under the fingerprint key in the inputs ref — the
1578    /// fingerprint being exactly what attest's SPEC.md computes.
1579    #[test]
1580    fn a_spec_yields_input_lines_and_fingerprint_keyed_notes() {
1581        let (d, work, remote, _) = remote_and_work("inputs");
1582        std::fs::create_dir_all(work.join(".github")).unwrap();
1583        std::fs::write(
1584            work.join(".github/attest-inputs"),
1585            "# what each gate reads\npre-push-run-tests-js a.ts\nother nope\n",
1586        )
1587        .unwrap();
1588        git(&work, &["add", ".github/attest-inputs"]);
1589        git(&work, &["commit", "-qm", "chore: spec"]);
1590        let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1591        // The reference fingerprint, by hand: implicit paths then the token.
1592        let listing = std::process::Command::new("git")
1593            .args([
1594                "-C",
1595                work.to_str().unwrap(),
1596                "ls-tree",
1597                "-r",
1598                "-z",
1599                "--full-tree",
1600                &tree,
1601                "--",
1602                ".forgejo/attest-inputs",
1603                ".github/attest-inputs",
1604                ".gitmodules",
1605                ".gitattributes",
1606                "a.ts",
1607            ])
1608            .output()
1609            .unwrap()
1610            .stdout;
1611        let expected =
1612            crate::git::stdout_piped_in(&work, &["hash-object", "--stdin"], &listing).unwrap();
1613        in_repo(&work, || {
1614            attest_push(
1615                &test_settings(),
1616                "origin",
1617                &[push_ref_for(&work)],
1618                &["pre-push-run-tests-js".into(), "other".into()],
1619            );
1620        });
1621        let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &tree]);
1622        assert!(
1623            body.contains(&format!("input pre-push-run-tests-js {expected}\n")),
1624            "the input line carries the reference fingerprint:\n{body}"
1625        );
1626        assert!(
1627            !body.contains("input other "),
1628            "a gate whose path does not exist gets no line"
1629        );
1630        let key = crate::git::stdout_piped_in(
1631            &work,
1632            &["hash-object", "--stdin"],
1633            format!("amont-attest-input pre-push-run-tests-js {expected}\n").as_bytes(),
1634        )
1635        .unwrap();
1636        let under_key = git(&remote, &["notes", "--ref", INPUTS_REF, "show", &key]);
1637        assert_eq!(
1638            under_key, body,
1639            "the same block is filed under the fingerprint key"
1640        );
1641        // A second push appends nothing anywhere.
1642        in_repo(&work, || {
1643            attest_push(
1644                &test_settings(),
1645                "origin",
1646                &[push_ref_for(&work)],
1647                &["pre-push-run-tests-js".into(), "other".into()],
1648            );
1649        });
1650        assert_eq!(
1651            git(&remote, &["notes", "--ref", INPUTS_REF, "show", &key])
1652                .matches("BEGIN SSH SIGNATURE")
1653                .count(),
1654            1
1655        );
1656        let _ = std::fs::remove_dir_all(&d);
1657        let _ = std::fs::remove_dir_all(&work);
1658    }
1659
1660    /// No spec, or an invalid one: no `input` line, no inputs ref, the tree
1661    /// note exactly as before.
1662    #[test]
1663    fn without_a_valid_spec_the_block_is_tree_keyed_only() {
1664        let (d, work, remote, _) = remote_and_work("nospec");
1665        std::fs::create_dir_all(work.join(".github")).unwrap();
1666        std::fs::write(
1667            work.join(".github/attest-inputs"),
1668            "pre-push-run-tests-js src/*.ts\n",
1669        )
1670        .unwrap();
1671        git(&work, &["add", ".github/attest-inputs"]);
1672        git(&work, &["commit", "-qm", "chore: bad spec"]);
1673        let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1674        in_repo(&work, || {
1675            attest_push(
1676                &test_settings(),
1677                "origin",
1678                &[push_ref_for(&work)],
1679                &["pre-push-run-tests-js".into()],
1680            );
1681        });
1682        let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &tree]);
1683        assert!(!body.contains("\ninput "), "no input line:\n{body}");
1684        assert!(
1685            !crate::git::succeeds_in(
1686                &remote,
1687                &["rev-parse", "--verify", "--quiet", INPUTS_FULL_REF]
1688            ),
1689            "no inputs ref was created"
1690        );
1691        let _ = std::fs::remove_dir_all(&d);
1692        let _ = std::fs::remove_dir_all(&work);
1693    }
1694
1695    #[test]
1696    fn the_spec_grammar_is_attests() {
1697        assert!(parse_spec(b"# c\ng src Cargo.toml\n").is_some());
1698        for bad in [
1699            b"g src/*.rs\n".as_slice(),
1700            b"g :!x\n",
1701            b"g\n",
1702            b"g src\ng x\n",
1703            b"g src\r\n",
1704            b"g sr\xc3\xa9\n",
1705            b"-g src\n",
1706            b"g ./src\n",
1707            b"g src/\n",
1708            b"g a/../b\n",
1709        ] {
1710            assert!(parse_spec(bad).is_none(), "{bad:?}");
1711        }
1712        assert_eq!(
1713            implicit_inputs(&["crates/foo/src".into()]),
1714            [
1715                ".forgejo/attest-inputs",
1716                ".github/attest-inputs",
1717                ".gitmodules",
1718                ".gitattributes",
1719                "crates/.gitattributes",
1720                "crates/foo/.gitattributes"
1721            ]
1722        );
1723    }
1724
1725    /// Off by default: a repo that never opted in makes no statement, even
1726    /// with everything else in place.
1727    #[test]
1728    fn no_opt_in_means_no_note() {
1729        let d = dir("optout");
1730        let (key, _) = keypair(&d);
1731        let remote = d.join("remote.git");
1732        std::fs::create_dir_all(&remote).unwrap();
1733        git(&remote, &["init", "-q", "--bare", "--template=", "."]);
1734        let work = repo("optout-work");
1735        git(
1736            &work,
1737            &["remote", "add", "origin", remote.to_str().unwrap()],
1738        );
1739        git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
1740        std::fs::write(work.join("a.ts"), "x").unwrap();
1741        git(&work, &["add", "a.ts"]);
1742        git(&work, &["commit", "-qm", "chore: a"]);
1743        let head = git(&work, &["rev-parse", "HEAD"]);
1744        let push_ref = PushRef {
1745            local_ref: "refs/heads/main".into(),
1746            local_oid: head.clone(),
1747            remote_ref: "refs/heads/main".into(),
1748            remote_oid: "0".repeat(40),
1749        };
1750        in_repo(&work, || {
1751            attest_push(
1752                &test_settings(),
1753                "origin",
1754                &[push_ref],
1755                &["pre-push-run-tests-js".into()],
1756            );
1757        });
1758        assert!(
1759            git(&remote, &["notes", "--ref", NOTES_REF, "list"]).is_empty(),
1760            "an un-opted-in repo attested something"
1761        );
1762        let _ = std::fs::remove_dir_all(&d);
1763        let _ = std::fs::remove_dir_all(&work);
1764    }
1765
1766    /// A deletion pushes no code; an empty gate list says nothing. Neither
1767    /// may produce a note even in an enabled repo.
1768    #[test]
1769    fn deletions_and_empty_gates_attest_nothing() {
1770        let d = dir("nothing");
1771        let (key, _) = keypair(&d);
1772        let remote = d.join("remote.git");
1773        std::fs::create_dir_all(&remote).unwrap();
1774        git(&remote, &["init", "-q", "--bare", "--template=", "."]);
1775        let work = repo("nothing-work");
1776        git(
1777            &work,
1778            &["remote", "add", "origin", remote.to_str().unwrap()],
1779        );
1780        git(&work, &["config", "amont.attest", "true"]);
1781        git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
1782        std::fs::write(work.join("a.ts"), "x").unwrap();
1783        git(&work, &["add", "a.ts"]);
1784        git(&work, &["commit", "-qm", "chore: a"]);
1785        let head = git(&work, &["rev-parse", "HEAD"]);
1786        let deletion = PushRef {
1787            local_ref: "(delete)".into(),
1788            local_oid: "0".repeat(40),
1789            remote_ref: "refs/heads/gone".into(),
1790            remote_oid: head.clone(),
1791        };
1792        let real = PushRef {
1793            local_ref: "refs/heads/main".into(),
1794            local_oid: head,
1795            remote_ref: "refs/heads/main".into(),
1796            remote_oid: "0".repeat(40),
1797        };
1798        in_repo(&work, || {
1799            attest_push(
1800                &test_settings(),
1801                "origin",
1802                &[deletion],
1803                &["pre-push-pytest".into()],
1804            );
1805            attest_push(&test_settings(), "origin", &[real], &[]);
1806        });
1807        assert!(git(&remote, &["notes", "--ref", NOTES_REF, "list"]).is_empty());
1808        let _ = std::fs::remove_dir_all(&d);
1809        let _ = std::fs::remove_dir_all(&work);
1810    }
1811
1812    /// The half CI actually calls, from CI's own vantage point: a fresh
1813    /// clone. `covered` fetches the notes ref itself, verifies, and answers
1814    /// with the gates — then stops answering the moment the tree drifts or
1815    /// the note is replaced by something unsigned.
1816    #[test]
1817    fn covered_answers_in_a_fresh_clone_and_rejects_drift_and_forgery() {
1818        let d = dir("covered");
1819        let (key, signers) = keypair(&d);
1820        let remote = d.join("remote.git");
1821        std::fs::create_dir_all(&remote).unwrap();
1822        git(&remote, &["init", "-q", "--bare", "--template=", "."]);
1823        // The fixture pushes to `main`; a bare init on a machine whose
1824        // init.defaultBranch is the historical default leaves HEAD on
1825        // `master`, and a clone of that repository checks out NOTHING —
1826        // `covered` then answers None with a perfectly good note sitting in
1827        // the ref. Caught only in CI: dev machines set main globally.
1828        git(&remote, &["symbolic-ref", "HEAD", "refs/heads/main"]);
1829        let work = repo("covered-work");
1830        git(
1831            &work,
1832            &["remote", "add", "origin", remote.to_str().unwrap()],
1833        );
1834        git(&work, &["config", "amont.attest", "true"]);
1835        git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
1836        std::fs::write(work.join("a.ts"), "x").unwrap();
1837        git(&work, &["add", "a.ts"]);
1838        git(&work, &["commit", "-qm", "chore: a"]);
1839        git(&work, &["push", "-q", "origin", "HEAD:main"]);
1840        let head = git(&work, &["rev-parse", "HEAD"]);
1841        let push_ref = PushRef {
1842            local_ref: "refs/heads/main".into(),
1843            local_oid: head.clone(),
1844            remote_ref: "refs/heads/main".into(),
1845            remote_oid: "0".repeat(40),
1846        };
1847        in_repo(&work, || {
1848            attest_push(
1849                &test_settings(),
1850                "origin",
1851                &[push_ref],
1852                &["pre-push-pytest".into()],
1853            );
1854        });
1855        let clone = d.join("ci-checkout");
1856        git(
1857            &d,
1858            &[
1859                "clone",
1860                "-q",
1861                "--template=",
1862                remote.to_str().unwrap(),
1863                clone.to_str().unwrap(),
1864            ],
1865        );
1866        in_repo(&clone, || {
1867            assert_eq!(
1868                covered(&signers, "t@t.test", Some(&platform())).as_deref(),
1869                Some("pre-push-pytest"),
1870                "a fresh clone verifies the attestation and reads the gates"
1871            );
1872            assert_eq!(
1873                covered(&signers, "t@t.test", None).as_deref(),
1874                Some("pre-push-pytest"),
1875                "`any` covers a platform-independent suite"
1876            );
1877            // The matrix case this exists for: another leg asking about a
1878            // suite that never ran there.
1879            assert_eq!(
1880                covered(&signers, "t@t.test", Some("s390x-aix")),
1881                None,
1882                "a pass on one platform is not evidence about another"
1883            );
1884            assert_eq!(
1885                covered(&signers, "someone@else.test", None),
1886                None,
1887                "an unlisted principal covers nothing"
1888            );
1889        });
1890        // Tree drift: a new commit in the checkout is not the attested tree.
1891        std::fs::write(clone.join("b.ts"), "y").unwrap();
1892        git(&clone, &["config", "user.email", "t@t.test"]);
1893        git(&clone, &["config", "user.name", "t"]);
1894        git(&clone, &["add", "b.ts"]);
1895        git(&clone, &["commit", "-qm", "chore: b"]);
1896        in_repo(&clone, || {
1897            assert_eq!(covered(&signers, "t@t.test", None), None, "drifted tree");
1898        });
1899        // Forgery: replace the remote's notes with unsigned ones. covered's
1900        // own fetch pulls them in, and they must read as "no attestation".
1901        //
1902        // BOTH keys, because an attestation is now findable by the tree as
1903        // well as by the commit. Forging one and leaving the other is not a
1904        // forgery — it is a genuine signed note the attacker failed to
1905        // reach, and covered is right to honour it. The test said "a foreign
1906        // note is not a stamp" and has to forge every place a note lives to
1907        // mean that.
1908        let head_tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1909        for object in [&head, &head_tree] {
1910            git(
1911                &work,
1912                &[
1913                    "notes", "--ref", NOTES_REF, "add", "-f", "-m", "garbage", object,
1914                ],
1915            );
1916        }
1917        git(
1918            &work,
1919            &[
1920                "push",
1921                "-q",
1922                "origin",
1923                &format!("+{NOTES_FULL_REF}:{NOTES_FULL_REF}"),
1924            ],
1925        );
1926        git(&clone, &["reset", "-q", "--hard", &head]);
1927        in_repo(&clone, || {
1928            assert_eq!(
1929                covered(&signers, "t@t.test", None),
1930                None,
1931                "a foreign note is not a stamp"
1932            );
1933        });
1934        let _ = std::fs::remove_dir_all(&d);
1935        let _ = std::fs::remove_dir_all(&work);
1936    }
1937
1938    /// The silent-death case: a workflow step running with a
1939    /// `working-directory` inside the repo must still find the committed
1940    /// signers file. Before this, `default_signers` looked relative to the
1941    /// cwd, found nothing, and every such repo fail-opened forever with no
1942    /// symptom — CI stayed green and the gate simply never fired.
1943    #[test]
1944    fn default_signers_is_found_from_a_subdirectory() {
1945        let work = repo("signers-subdir");
1946        std::fs::create_dir_all(work.join(".forgejo")).unwrap();
1947        std::fs::write(work.join(".forgejo/allowed_signers"), "t@t.test x\n").unwrap();
1948        let sub = work.join("packages").join("thing");
1949        std::fs::create_dir_all(&sub).unwrap();
1950        in_repo(&sub, || {
1951            let found = default_signers().expect("resolved from the repo root, not the cwd");
1952            assert!(found.ends_with(".forgejo/allowed_signers"));
1953            assert!(found.exists(), "the path it returns must be usable as-is");
1954            assert_eq!(
1955                first_principal(&found).as_deref(),
1956                Some("t@t.test"),
1957                "and readable from there"
1958            );
1959        });
1960        let _ = std::fs::remove_dir_all(&work);
1961    }
1962
1963    #[test]
1964    fn zero_oids_of_any_width_are_zero() {
1965        assert!(is_zero(&"0".repeat(40)));
1966        assert!(is_zero(&"0".repeat(64)));
1967        assert!(!is_zero("0a0000"));
1968        assert!(!is_zero(""));
1969    }
1970
1971    #[test]
1972    fn forget_removes_the_local_ref() {
1973        let work = repo("forget");
1974        std::fs::write(work.join("a.ts"), "x").unwrap();
1975        git(&work, &["add", "a.ts"]);
1976        git(&work, &["commit", "-qm", "chore: a"]);
1977        git(
1978            &work,
1979            &["notes", "--ref", NOTES_REF, "add", "-m", "x", "HEAD"],
1980        );
1981        in_repo(&work, forget);
1982        assert!(git(&work, &["notes", "--ref", NOTES_REF, "list"]).is_empty());
1983        let _ = std::fs::remove_dir_all(&work);
1984    }
1985
1986    /// The attestation survives the commit being rewritten around the same
1987    /// content — which is how work reaches `main`.
1988    ///
1989    /// The verifier used to look on `HEAD` and then `HEAD^2`, and that hunt
1990    /// has a floor it cannot reach past: a squash-merge onto a main that has
1991    /// moved produces a commit carrying neither the note nor a parent that
1992    /// has one, while an attestation for that exact tree sits in the ref.
1993    /// Signed for the content, findable only by the container.
1994    ///
1995    /// The tree key is the same claim the payload already makes — `tree
1996    /// <sha>` — so this is not a widening: `covered` still refuses unless
1997    /// the payload's tree equals the checked-out tree AND the signature
1998    /// verifies. Both are asserted below by the sibling test; this one
1999    /// asserts only that a legitimate attestation is still FOUND.
2000    #[test]
2001    fn an_attestation_survives_a_rewrite_that_keeps_the_tree() {
2002        let d = dir("rewritten");
2003        let (key, signers) = keypair(&d);
2004        let remote = d.join("remote.git");
2005        std::fs::create_dir_all(&remote).unwrap();
2006        git(&remote, &["init", "-q", "--bare", "--template=", "."]);
2007        git(&remote, &["symbolic-ref", "HEAD", "refs/heads/main"]);
2008        let work = repo("rewritten-work");
2009        git(
2010            &work,
2011            &["remote", "add", "origin", remote.to_str().unwrap()],
2012        );
2013        git(&work, &["config", "amont.attest", "true"]);
2014        git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
2015        std::fs::write(work.join("a.ts"), "x").unwrap();
2016        git(&work, &["add", "a.ts"]);
2017        git(&work, &["commit", "-qm", "feat: on a branch"]);
2018        git(&work, &["push", "-q", "origin", "HEAD:main"]);
2019        let branch_tip = git(&work, &["rev-parse", "HEAD"]);
2020        let push_ref = PushRef {
2021            local_ref: "refs/heads/main".into(),
2022            local_oid: branch_tip.clone(),
2023            remote_ref: "refs/heads/main".into(),
2024            remote_oid: "0".repeat(40),
2025        };
2026        in_repo(&work, || {
2027            attest_push(
2028                &test_settings(),
2029                "origin",
2030                &[push_ref],
2031                &["pre-push-pytest".into()],
2032            );
2033        });
2034
2035        // Stand in for the forge's squash: a DIFFERENT commit object with the
2036        // SAME tree, and — crucially — NOT a parent of anything the verifier
2037        // would reach, so `HEAD^2` cannot save it.
2038        git(
2039            &work,
2040            &[
2041                "commit",
2042                "-q",
2043                "--amend",
2044                "-m",
2045                "feat: squashed by the forge",
2046            ],
2047        );
2048        let rewritten = git(&work, &["rev-parse", "HEAD"]);
2049        assert_ne!(rewritten, branch_tip, "the fixture must rewrite the commit");
2050        assert_eq!(
2051            git(&work, &["rev-parse", "HEAD^{tree}"]),
2052            git(&work, &["rev-parse", &format!("{branch_tip}^{{tree}}")]),
2053            "…while preserving the tree, which is the premise"
2054        );
2055        git(&work, &["push", "-q", "-f", "origin", "HEAD:main"]);
2056
2057        let clone = d.join("ci-checkout");
2058        git(
2059            &d,
2060            &[
2061                "clone",
2062                "-q",
2063                "--template=",
2064                remote.to_str().unwrap(),
2065                clone.to_str().unwrap(),
2066            ],
2067        );
2068        in_repo(&clone, || {
2069            assert_eq!(
2070                covered(&signers, "t@t.test", None).as_deref(),
2071                Some("pre-push-pytest"),
2072                "the attestation must be found by the tree it signed"
2073            );
2074        });
2075        let _ = std::fs::remove_dir_all(&d);
2076    }
2077
2078    // --- attest 1.4.0 parity: optional paths --------------------------------
2079
2080    #[test]
2081    fn optional_paths_are_marked_once_and_stripped() {
2082        let spec = parse_spec(b"g src ?build.rs ?a/b/c\n").expect("valid");
2083        assert_eq!(
2084            spec[0].1,
2085            [
2086                PathTok {
2087                    path: "src".into(),
2088                    optional: false
2089                },
2090                PathTok {
2091                    path: "build.rs".into(),
2092                    optional: true
2093                },
2094                PathTok {
2095                    path: "a/b/c".into(),
2096                    optional: true
2097                },
2098            ]
2099        );
2100        assert!(
2101            parse_spec(b"g ?nope\n").is_some(),
2102            "an all-optional gate is a gate"
2103        );
2104        for bad in [
2105            "?", "??x", "?/x", "?./x", "?x/", "?:x", "x?", "?a//b", "?a/../b",
2106        ] {
2107            assert!(
2108                parse_spec(format!("g {bad}\n").as_bytes()).is_none(),
2109                "{bad} should be invalid"
2110            );
2111        }
2112    }
2113
2114    /// The fingerprint of `g src ?nope` equals the hash of a listing built BY
2115    /// HAND from attest's SPEC — the optional path is absent, so the listing
2116    /// is the spec and `src` — and changes the moment the path appears.
2117    #[test]
2118    fn an_absent_optional_path_is_bound_by_its_absence() {
2119        let work = repo("optional-fp");
2120        std::fs::create_dir_all(work.join("src")).unwrap();
2121        std::fs::create_dir_all(work.join(".github")).unwrap();
2122        std::fs::write(work.join("src/main.rs"), "fn main() {}\n").unwrap();
2123        std::fs::write(work.join(".github/attest-inputs"), "g src ?nope\n").unwrap();
2124        git(&work, &["add", "-A"]);
2125        git(&work, &["commit", "-qm", "chore: spec"]);
2126        let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
2127        let spec_oid = git(
2128            &work,
2129            &["rev-parse", &format!("{tree}:.github/attest-inputs")],
2130        );
2131        let main_oid = git(&work, &["rev-parse", &format!("{tree}:src/main.rs")]);
2132        let listing = format!(
2133            "100644 blob {spec_oid}\t.github/attest-inputs\x00100644 blob {main_oid}\tsrc/main.rs\x00"
2134        );
2135        let hand =
2136            crate::git::stdout_piped_in(&work, &["hash-object", "--stdin"], listing.as_bytes())
2137                .unwrap();
2138        let before = in_repo(&work, || {
2139            let spec = spec_at(&tree).expect("the spec parses");
2140            fingerprint(&tree, &spec[0].1)
2141        });
2142        assert_eq!(before.as_deref(), Some(hand.as_str()));
2143        std::fs::write(work.join("nope"), "now it exists\n").unwrap();
2144        git(&work, &["add", "nope"]);
2145        git(&work, &["commit", "-qm", "chore: nope"]);
2146        let tree2 = git(&work, &["rev-parse", "HEAD^{tree}"]);
2147        let after = in_repo(&work, || {
2148            let spec = spec_at(&tree2).expect("the spec parses");
2149            fingerprint(&tree2, &spec[0].1)
2150        });
2151        assert!(
2152            after.is_some() && after != before,
2153            "its appearance changes the fingerprint"
2154        );
2155        let _ = std::fs::remove_dir_all(&work);
2156    }
2157
2158    // --- attest 1.4.0 parity: the notes ref is origin's mirror --------------
2159
2160    /// A work repo that pushed an attestation, and a fresh clone of its remote
2161    /// that `covered` answers for.
2162    fn attested_clone(name: &str) -> (PathBuf, PathBuf, PathBuf, PathBuf) {
2163        let (d, work, remote, signers) = remote_and_work(name);
2164        git(&remote, &["symbolic-ref", "HEAD", "refs/heads/main"]);
2165        git(&work, &["push", "-q", "origin", "HEAD:main"]);
2166        in_repo(&work, || {
2167            attest_push(
2168                &test_settings(),
2169                "origin",
2170                &[push_ref_for(&work)],
2171                &["pre-push-pytest".into()],
2172            );
2173        });
2174        let clone = d.join("ci-checkout");
2175        git(
2176            &d,
2177            &[
2178                "clone",
2179                "-q",
2180                "--template=",
2181                remote.to_str().unwrap(),
2182                clone.to_str().unwrap(),
2183            ],
2184        );
2185        (d, remote, clone, signers)
2186    }
2187
2188    fn covers(clone: &Path, signers: &Path, budget: u64) -> Option<String> {
2189        covers_logged(clone, signers, budget).0
2190    }
2191
2192    /// [`covers`], with the stderr lines it wrote.
2193    fn covers_logged(clone: &Path, signers: &Path, budget: u64) -> (Option<String>, Vec<String>) {
2194        in_repo(clone, || {
2195            CAPTURE.with(|c| *c.borrow_mut() = Some(Vec::new()));
2196            let got = covered_within(signers, "t@t.test", None, budget);
2197            let lines = CAPTURE.with(|c| c.borrow_mut().take()).unwrap_or_default();
2198            (got, lines)
2199        })
2200    }
2201
2202    #[test]
2203    fn a_ref_revoked_on_origin_stops_covering_and_the_mirror_goes() {
2204        let (d, remote, clone, signers) = attested_clone("revoked");
2205        assert_eq!(
2206            covers(&clone, &signers, 15).as_deref(),
2207            Some("pre-push-pytest")
2208        );
2209        let oid = git(&clone, &["rev-parse", NOTES_FULL_REF]);
2210        git(&remote, &["update-ref", "-d", NOTES_FULL_REF]);
2211        let (got, lines) = covers_logged(&clone, &signers, 15);
2212        assert_eq!(got, None, "revoked on origin");
2213        assert_eq!(
2214            lines[0],
2215            format!("amont: origin has no {NOTES_FULL_REF}; deleted the local mirror (was {oid})")
2216        );
2217        assert!(
2218            in_repo(&clone, || crate::git::stdout(&[
2219                "rev-parse",
2220                "--verify",
2221                "--quiet",
2222                NOTES_FULL_REF
2223            ]))
2224            .is_none(),
2225            "the local mirror was deleted"
2226        );
2227        // The undo command, run exactly as printed.
2228        let undo = lines[1]
2229            .split("undo locally: git ")
2230            .nth(1)
2231            .and_then(|r| r.split("   (").next())
2232            .expect("an undo command");
2233        git(&clone, &undo.split(' ').collect::<Vec<_>>());
2234        assert_eq!(git(&clone, &["rev-parse", NOTES_FULL_REF]), oid);
2235        let _ = std::fs::remove_dir_all(&d);
2236    }
2237
2238    #[test]
2239    fn an_unreachable_origin_covers_nothing_and_keeps_the_mirror() {
2240        let (d, _remote, clone, signers) = attested_clone("unreachable");
2241        assert_eq!(
2242            covers(&clone, &signers, 15).as_deref(),
2243            Some("pre-push-pytest")
2244        );
2245        git(
2246            &clone,
2247            &[
2248                "remote",
2249                "set-url",
2250                "origin",
2251                "file:///nonexistent/amont-origin.git",
2252            ],
2253        );
2254        let (got, lines) = covers_logged(&clone, &signers, 15);
2255        assert_eq!(got, None, "a stale mirror is not judged");
2256        assert!(
2257            lines.iter().any(|l| l.starts_with(&format!(
2258                "amont: cannot fetch {NOTES_FULL_REF} from origin (exit "
2259            ))),
2260            "the reason is on stderr: {lines:?}"
2261        );
2262        assert!(
2263            !git(&clone, &["rev-parse", NOTES_FULL_REF]).is_empty(),
2264            "and it is kept"
2265        );
2266        let _ = std::fs::remove_dir_all(&d);
2267    }
2268
2269    /// A guard, not a reproduction: without an origin the local ref was
2270    /// always judged, and still is — fixtures and local use rely on it.
2271    #[test]
2272    fn without_an_origin_the_local_ref_is_judged() {
2273        let (d, _remote, clone, signers) = attested_clone("no-origin");
2274        assert_eq!(
2275            covers(&clone, &signers, 15).as_deref(),
2276            Some("pre-push-pytest")
2277        );
2278        git(&clone, &["remote", "remove", "origin"]);
2279        assert_eq!(
2280            covers(&clone, &signers, 15).as_deref(),
2281            Some("pre-push-pytest")
2282        );
2283        let _ = std::fs::remove_dir_all(&d);
2284    }
2285
2286    /// A remote that never answers is cut off by the deadline, leaves no lock
2287    /// on the main ref, and does not stop the next good fetch.
2288    #[cfg(unix)]
2289    #[test]
2290    fn a_silent_origin_is_cut_off_and_leaves_nothing_behind() {
2291        use std::os::unix::fs::PermissionsExt;
2292        let (d, remote, clone, signers) = attested_clone("silent");
2293        let hang = d.join("hang-ssh");
2294        std::fs::write(&hang, "#!/bin/sh\nexec sleep 60\n").unwrap();
2295        std::fs::set_permissions(&hang, std::fs::Permissions::from_mode(0o755)).unwrap();
2296        // The user's own ssh command is honoured, which is how this one hangs.
2297        git(
2298            &clone,
2299            &["config", "core.sshCommand", hang.to_str().unwrap()],
2300        );
2301        git(
2302            &clone,
2303            &["remote", "set-url", "origin", "ssh://example.invalid/x.git"],
2304        );
2305        // Timed INSIDE the working-directory lock these tests share, so the
2306        // wait for other tests is not counted.
2307        let (got, took) = in_repo(&clone, || {
2308            let t0 = std::time::Instant::now();
2309            let got = covered_within(&signers, "t@t.test", None, 2);
2310            (got, t0.elapsed())
2311        });
2312        assert_eq!(got, None);
2313        // The 2 s budget plus process overhead — far from the remote's 60 s.
2314        assert!(took.as_secs() < 6, "took {took:?}");
2315        let lock = in_repo(&clone, || {
2316            crate::git::stdout(&["rev-parse", "--git-path", &format!("{NOTES_FULL_REF}.lock")])
2317        })
2318        .unwrap();
2319        assert!(
2320            !clone.join(&lock).exists() && !Path::new(&lock).exists(),
2321            "no lock left"
2322        );
2323        git(&clone, &["config", "--unset", "core.sshCommand"]);
2324        git(
2325            &clone,
2326            &["remote", "set-url", "origin", remote.to_str().unwrap()],
2327        );
2328        assert_eq!(
2329            covers(&clone, &signers, 15).as_deref(),
2330            Some("pre-push-pytest")
2331        );
2332        let _ = std::fs::remove_dir_all(&d);
2333    }
2334
2335    #[test]
2336    fn a_stale_lock_on_the_mirror_means_nothing_is_judged() {
2337        let (d, remote, clone, signers) = attested_clone("stale-lock");
2338        assert_eq!(
2339            covers(&clone, &signers, 15).as_deref(),
2340            Some("pre-push-pytest")
2341        );
2342        let lock = in_repo(&clone, || {
2343            crate::git::stdout(&["rev-parse", "--git-path", &format!("{NOTES_FULL_REF}.lock")])
2344        })
2345        .unwrap();
2346        let lock = if Path::new(&lock).is_absolute() {
2347            PathBuf::from(lock)
2348        } else {
2349            clone.join(lock)
2350        };
2351        std::fs::write(&lock, "").unwrap();
2352        // Origin unchanged: the locked copy IS origin's, so it may be judged.
2353        assert_eq!(
2354            covers(&clone, &signers, 15).as_deref(),
2355            Some("pre-push-pytest"),
2356            "an unchanged origin: the copy is origin's"
2357        );
2358        // The case that matters: origin moved on (a new attestation, or a
2359        // revocation rewrite) while the lock pins the stale copy.
2360        let main = git(&remote, &["rev-parse", "main"]);
2361        // A bare remote has no committer identity on a fresh runner.
2362        git(
2363            &remote,
2364            &[
2365                "-c",
2366                "user.email=t@t.test",
2367                "-c",
2368                "user.name=t",
2369                "notes",
2370                "--ref",
2371                NOTES_REF,
2372                "add",
2373                "-f",
2374                "-m",
2375                "rewritten",
2376                &main,
2377            ],
2378        );
2379        let (got, lines) = covers_logged(&clone, &signers, 15);
2380        assert_eq!(got, None, "a pinned stale copy is never judged");
2381        assert!(
2382            lines.iter().any(|l| l.contains(&format!(
2383                "left a lock on {NOTES_FULL_REF}; if no git is running: rm "
2384            ))),
2385            "the lock is reported with its rm: {lines:?}"
2386        );
2387        std::fs::remove_file(&lock).unwrap();
2388        assert_eq!(
2389            covers(&clone, &signers, 15).as_deref(),
2390            Some("pre-push-pytest")
2391        );
2392        let _ = std::fs::remove_dir_all(&d);
2393    }
2394
2395    /// Unix only: elsewhere liveness cannot be asked, so nothing is swept —
2396    /// by design, since a leftover outside `refs/notes/` is inert.
2397    #[cfg(unix)]
2398    #[test]
2399    fn leftover_sync_refs_are_swept() {
2400        let (d, _remote, clone, signers) = attested_clone("sweep");
2401        let head = git(&clone, &["rev-parse", "HEAD"]);
2402        git(
2403            &clone,
2404            // A pid no process has: 2^22 + 1 exceeds pid_max on Linux and macOS.
2405            &["update-ref", "refs/amont-tmp/4194305/amont-attest", &head],
2406        );
2407        assert_eq!(
2408            covers(&clone, &signers, 15).as_deref(),
2409            Some("pre-push-pytest")
2410        );
2411        assert_eq!(git(&clone, &["for-each-ref", "refs/amont-tmp/"]), "");
2412        let _ = std::fs::remove_dir_all(&d);
2413    }
2414
2415    #[test]
2416    fn the_remote_environment_never_prompts() {
2417        let (args, env) = crate::git::remote_env(crate::git::Ssh::Batch);
2418        assert_eq!(
2419            args,
2420            [
2421                "-c",
2422                "http.lowSpeedLimit=1",
2423                "-c",
2424                "http.lowSpeedTime=10",
2425                "-c",
2426                "credential.interactive=never"
2427            ]
2428        );
2429        assert!(env.contains(&("GIT_TERMINAL_PROMPT", "0")));
2430        assert!(
2431            env.contains(&("GIT_ASKPASS", "")),
2432            "present and EMPTY disables every askpass"
2433        );
2434        assert!(env.contains(&("GCM_INTERACTIVE", "never")));
2435        assert!(env.contains(&(
2436            "GIT_SSH_COMMAND",
2437            "ssh -o BatchMode=yes -o ConnectTimeout=10"
2438        )));
2439        let (_, env) = crate::git::remote_env(crate::git::Ssh::User);
2440        assert!(
2441            !env.iter().any(|(k, _)| *k == "GIT_SSH_COMMAND"),
2442            "the user's own ssh command is left alone"
2443        );
2444    }
2445
2446    /// An http origin that demands credentials: through the remote
2447    /// environment no askpass runs; without it the same askpass does — the
2448    /// negative control that keeps this test from passing vacuously.
2449    #[cfg(unix)]
2450    #[test]
2451    fn no_askpass_runs_against_an_origin_that_wants_credentials() {
2452        use std::io::{Read, Write};
2453        use std::os::unix::fs::PermissionsExt;
2454        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2455        let port = listener.local_addr().unwrap().port();
2456        std::thread::spawn(move || {
2457            for stream in listener.incoming().flatten() {
2458                let mut s = stream;
2459                let mut buf = [0u8; 4096];
2460                let _ = s.read(&mut buf);
2461                let _ = s.write_all(
2462                    b"HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"x\"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
2463                );
2464            }
2465        });
2466        let work = repo("askpass");
2467        let marker = work.join("asked");
2468        let askpass = work.join("askpass.sh");
2469        std::fs::write(
2470            &askpass,
2471            format!("#!/bin/sh\ntouch '{}'\necho x\n", marker.display()),
2472        )
2473        .unwrap();
2474        std::fs::set_permissions(&askpass, std::fs::Permissions::from_mode(0o755)).unwrap();
2475        git(
2476            &work,
2477            &["config", "core.askPass", askpass.to_str().unwrap()],
2478        );
2479        git(&work, &["config", "credential.helper", ""]);
2480        let url = format!("http://127.0.0.1:{port}/x.git");
2481        in_repo(&work, || {
2482            let _ = crate::git::probe_remote(&["ls-remote", &url], 10);
2483        });
2484        assert!(
2485            !marker.exists(),
2486            "an askpass ran through the remote environment"
2487        );
2488        // Plain git, prompts off, the ambient askpass variables removed: the
2489        // repo's core.askPass is then what git runs — the very thing the
2490        // remote environment must stop.
2491        let _ = std::process::Command::new("git")
2492            .current_dir(&work)
2493            .args(["ls-remote", &url])
2494            .env("GIT_TERMINAL_PROMPT", "0")
2495            .env_remove("GIT_ASKPASS")
2496            .env_remove("SSH_ASKPASS")
2497            .stdin(std::process::Stdio::null())
2498            .output();
2499        assert!(
2500            marker.exists(),
2501            "negative control: without it the askpass does run"
2502        );
2503        let _ = std::fs::remove_dir_all(&work);
2504    }
2505}