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