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