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