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//! Signing uses `ssh-keygen -Y sign` as a subprocess, like every other tool
32//! this crate talks to. Hand-rolling ed25519 in a zero-dependency crate would
33//! be the one thing worse than a dependency.
34//!
35//! Every failure mode points the same direction as `gate_stamp`'s: no key, a
36//! signer that errors, a note git refused, a notes push the remote rejected —
37//! all mean "no attestation", and no attestation means CI RUNS the tests.
38//! Nothing here can let an untested tree skip CI; it can only cost a
39//! redundant run.
40//!
41//! One sharp edge is the notes push itself: `git push` from inside pre-push
42//! runs pre-push again. The child carries [`PUSH_GUARD`] in its environment
43//! and the dispatcher yields immediately when it sees it — checking a ref
44//! list that is only ever `refs/notes/amont-attest` would be work spent
45//! proving nothing.
46
47use std::path::PathBuf;
48use std::process::{Command, Stdio};
49
50use crate::pushrefs::PushRef;
51
52/// First token of every note body. Versioned like `gate_stamp::FORMAT`: a
53/// future amont that changes the payload bumps this, and CI's verifier reads
54/// an unknown version as "no attestation".
55///
56/// v1 → v2 added the `platform` line. The bump is the point: a v1 verifier
57/// has no idea the tests it is about to skip ran on a different operating
58/// system, and reading v2 as unknown makes it run them. Fail-safe in the
59/// only direction this module ever fails.
60pub const FORMAT: &str = "amont-attest-v2";
61
62/// The notes ref, spelled the way `git notes --ref` wants it.
63pub const NOTES_REF: &str = "amont-attest";
64
65/// The same ref, fully qualified — the push refspec and `update-ref -d` both
66/// need it.
67pub const NOTES_FULL_REF: &str = "refs/notes/amont-attest";
68
69/// The `ssh-keygen -Y` namespace, on both the signing and verifying side.
70/// Namespaces exist so a signature minted for one purpose cannot be replayed
71/// for another; an `allowed_signers` entry pinned to this namespace accepts
72/// nothing else.
73pub const NAMESPACE: &str = "amont-attest";
74
75/// Environment marker carried by the notes push so the recursive pre-push
76/// invocation stands down. See the module doc.
77pub const PUSH_GUARD: &str = "AMONT_ATTEST_PUSH";
78
79/// The opt-in switch. Off by default: an attestation is a statement to
80/// another system, and amont does not speak for a repository that never
81/// asked it to.
82const TOGGLE: &str = "amont.attest";
83
84/// Where the signing key lives when the repository does not say.
85const KEY_CONFIG: &str = "amont.attestKey";
86const KEY_DEFAULT: &str = ".ssh/amont-attest";
87
88/// Is the recursive-push marker set on THIS invocation?
89pub fn push_guard_active() -> bool {
90    std::env::var_os(PUSH_GUARD).is_some()
91}
92
93/// Has this repository opted in?
94pub fn enabled() -> bool {
95    crate::config::boolean_or(TOGGLE, false)
96}
97
98/// The signing key path: `amont.attestKey`, else `~/.ssh/amont-attest`.
99///
100/// Read like `amont.knownIdentity` is — a raw string through git, unset
101/// collapsing to the default — because a path has no shape git could
102/// validate for us anyway.
103fn key_path() -> Option<PathBuf> {
104    if let Some(k) = crate::git::stdout(&["config", "--get", KEY_CONFIG]) {
105        if !k.is_empty() {
106            return Some(PathBuf::from(k));
107        }
108    }
109    let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
110    Some(PathBuf::from(home).join(KEY_DEFAULT))
111}
112
113/// Where a suite ran, as `<arch>-<os>` — `aarch64-macos`, `x86_64-linux`,
114/// `x86_64-windows`.
115///
116/// Coarser than a target triple on purpose: the libc flavour is not
117/// something `std` can answer, and the question a CI matrix actually asks is
118/// "did this run on MY leg". Coarse and honest beats precise and guessed.
119pub fn platform() -> String {
120    format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS)
121}
122
123/// The exact bytes the signature covers. One datum per line, trailing
124/// newline included — CI reconstructs this from the note text, so the shape
125/// is a contract, not a convenience.
126///
127/// `platform` is signed alongside the gates because a pass is a pass **on
128/// something**: `cargo test` green on an arm64 Mac says nothing about the
129/// Windows leg of a matrix, and a note that omitted where it ran invited
130/// exactly that skip.
131pub fn payload(tree: &str, gates: &[String]) -> String {
132    format!(
133        "{FORMAT}\ntree {tree}\ngates {}\nplatform {}\namont {}\n",
134        gates.join(" "),
135        platform(),
136        env!("CARGO_PKG_VERSION")
137    )
138}
139
140/// `ssh-keygen -Y sign` over `payload`, armored signature back. `None` for
141/// every failure — a missing binary, a missing key, a signer that said no —
142/// because an attestation we cannot mint is simply one CI never sees.
143fn sign(payload: &str, key: &std::path::Path) -> Option<String> {
144    use std::io::Write;
145    let mut child = Command::new("ssh-keygen")
146        .args(["-Y", "sign", "-n", NAMESPACE, "-f"])
147        .arg(key)
148        .stdin(Stdio::piped())
149        .stdout(Stdio::piped())
150        .stderr(Stdio::null())
151        .spawn()
152        .ok()?;
153    child.stdin.take()?.write_all(payload.as_bytes()).ok()?;
154    let out = child.wait_with_output().ok()?;
155    if !out.status.success() {
156        return None;
157    }
158    let sig = String::from_utf8_lossy(&out.stdout).trim().to_string();
159    sig.starts_with("-----BEGIN SSH SIGNATURE-----")
160        .then_some(sig)
161}
162
163/// `ssh-keygen -Y verify`: is `sig` a valid signature over `payload` by a
164/// `principal` key listed in `allowed_signers` for our namespace?
165///
166/// The runtime never gates on this — CI verifies with its own stock tooling —
167/// but owning the verifying half keeps the roundtrip honest in tests and
168/// gives a future `amont attest verify` its engine.
169pub fn verify(
170    payload: &str,
171    sig: &str,
172    allowed_signers: &std::path::Path,
173    principal: &str,
174) -> bool {
175    use std::io::Write;
176    // -Y verify takes the signature as a FILE; the payload rides stdin.
177    let sig_file = std::env::temp_dir().join(format!(
178        "amont-attest-verify-{}-{:p}.sig",
179        std::process::id(),
180        &sig
181    ));
182    if std::fs::write(&sig_file, format!("{sig}\n")).is_err() {
183        return false;
184    }
185    let ok = (|| {
186        let mut child = Command::new("ssh-keygen")
187            .args(["-Y", "verify", "-n", NAMESPACE, "-I", principal, "-f"])
188            .arg(allowed_signers)
189            .arg("-s")
190            .arg(&sig_file)
191            .stdin(Stdio::piped())
192            .stdout(Stdio::null())
193            .stderr(Stdio::null())
194            .spawn()
195            .ok()?;
196        child.stdin.take()?.write_all(payload.as_bytes()).ok()?;
197        child.wait().ok().map(|s| s.success())
198    })()
199    .unwrap_or(false);
200    let _ = std::fs::remove_file(&sig_file);
201    ok
202}
203
204/// pre-push, after every block gate has passed: attest each pushed tip and
205/// send the notes ref to the remote being pushed.
206///
207/// `gates` is what the dispatcher saw actually PASS — `Warned` and
208/// `Unavailable` never appear in it, because "could not run" is not
209/// "passed". Empty means nothing testlike ran, and an attestation listing
210/// no gates would be a signed way of saying nothing.
211///
212/// Best-effort throughout, and quiet about it: pre-push has already printed
213/// its verdicts, and a push that works minus its CI shortcut is not a
214/// problem anyone needs to solve at push time.
215pub fn attest_push(remote: &str, refs: &[PushRef], gates: &[String]) {
216    if gates.is_empty() || remote.is_empty() || !enabled() {
217        return;
218    }
219    let Some(key) = key_path() else { return };
220    if !key.exists() {
221        crate::config::complain(
222            TOGGLE,
223            &format!("signing key {} does not exist", key.display()),
224            "no attestation (CI will run the tests)",
225        );
226        return;
227    }
228    let mut noted = false;
229    for r in refs {
230        if is_zero(&r.local_oid) {
231            continue; // deleting a ref pushes no code
232        }
233        let spec = format!("{}^{{tree}}", r.local_oid);
234        let Some(tree) = crate::git::stdout(&["rev-parse", &spec]) else {
235            continue;
236        };
237        let body = match sign(&payload(&tree, gates), &key) {
238            Some(sig) => format!("{}\n{sig}", payload(&tree, gates)),
239            None => continue,
240        };
241        if crate::git::succeeds(&[
242            "notes",
243            "--ref",
244            NOTES_REF,
245            "add",
246            "-f",
247            "-m",
248            &body,
249            &r.local_oid,
250        ]) {
251            noted = true;
252        }
253    }
254    if noted && push_notes(remote) {
255        crate::say!(
256            "{} attested {} for CI ({})",
257            crate::ui::valid_sign(),
258            crate::ui::highlight(&gates.join(" ")),
259            NOTES_REF,
260        );
261    }
262}
263
264/// `amont attest covered` — the verifying side, as CI's one-liner.
265///
266/// Answers "which gates does a VALID attestation cover for the tree checked
267/// out here?", doing everything the workflow snippet used to spell out in
268/// sh: freshen the notes ref (best-effort), look for a note on `HEAD` and —
269/// for a PR's merge commit — on `HEAD^2`, insist on the format version,
270/// insist the attested tree is byte-for-byte `HEAD^{tree}`, and verify the
271/// signature against `allowed_signers`. Thirty lines of workflow copied into
272/// every repository is exactly the drift this binary exists to end.
273///
274/// `None` for every failure, and the CLI prints nothing and exits 0 on
275/// `None` — fail-open is the caller's contract, not its option. A CI step
276/// reading empty output runs its tests, which is always the safe answer.
277///
278/// This is amont running in CI, which `docs/ci.md` forbids for CHECKS — the
279/// line held is narrower than the slogan: CI still never runs a check
280/// through amont; this verifies a document about checks that already ran.
281/// `require_platform` is the leg asking. `Some("x86_64-linux")` covers only
282/// a suite that ran there; `None` is the caller stating that this suite's
283/// result does not depend on where it ran (a pure-JS unit run, say) and is
284/// spelled `--platform any` in a committed workflow, where it is reviewed
285/// like any other line of the repository.
286pub fn covered(
287    signers: &std::path::Path,
288    principal: &str,
289    require_platform: Option<&str>,
290) -> Option<String> {
291    let refspec = format!("+{NOTES_FULL_REF}:{NOTES_FULL_REF}");
292    let _ = crate::git::succeeds(&["fetch", "origin", &refspec]);
293    let head_tree = crate::git::stdout(&["rev-parse", "HEAD^{tree}"])?;
294    // HEAD first: a push event's checkout IS the attested commit. HEAD^2
295    // second: a PR checkout is a merge commit git made a moment ago, whose
296    // second parent is the pushed tip that carries the note — and the tree
297    // comparison below still measures against what is ACTUALLY checked out,
298    // so a merge whose tree drifted from the tested tip never skips.
299    for candidate in ["HEAD", "HEAD^2"] {
300        let Some(commit) = crate::git::stdout(&["rev-parse", "--verify", candidate]) else {
301            continue;
302        };
303        let Some(body) = crate::git::stdout(&["notes", "--ref", NOTES_REF, "show", &commit]) else {
304            continue;
305        };
306        let Some((payload, sig)) = split_note(&body) else {
307            continue;
308        };
309        // By prefix, not by position: the payload has grown a line once
310        // already, and a positional reader silently mis-assigns every field
311        // after an insertion rather than failing.
312        let mut lines = payload.lines();
313        if lines.next() != Some(FORMAT) {
314            continue;
315        }
316        let field = |name: &str| {
317            payload
318                .lines()
319                .find_map(|l| l.strip_prefix(name).and_then(|r| r.strip_prefix(' ')))
320                .map(str::trim)
321        };
322        let (Some(tree), Some(gates), Some(ran_on)) =
323            (field("tree"), field("gates"), field("platform"))
324        else {
325            continue;
326        };
327        if tree != head_tree || gates.is_empty() {
328            continue; // wrong content, or a signed way of saying nothing
329        }
330        // The leg asking is not the leg that ran: a macOS `cargo test` is no
331        // evidence about Windows. `None` means the caller has stated this
332        // suite is platform-independent.
333        if require_platform.is_some_and(|want| want != ran_on) {
334            continue;
335        }
336        if verify(&payload, &sig, signers, principal) {
337            return Some(gates.to_string());
338        }
339    }
340    None
341}
342
343/// Where a repository keeps its `allowed_signers` when the caller does not
344/// say — the Forgejo location first, the GitHub one second. `None` when
345/// neither exists, which the CLI reads as "nothing is covered".
346/// Resolved from the REPOSITORY ROOT, not the working directory. A workflow
347/// that sets `working-directory` (a monorepo running a matrix inside
348/// `packages/<x>`, say) puts the step in a subdirectory, where a relative
349/// `.forgejo/allowed_signers` does not exist — and the CLI would then find no
350/// signers, print nothing, and fail open FOREVER. Silently: the suite still
351/// runs, CI still passes, and nothing anywhere says the gate is dead. That is
352/// the worst shape a fail-open can take, so the path is anchored.
353pub fn default_signers() -> Option<PathBuf> {
354    let root = crate::git::stdout(&["rev-parse", "--show-toplevel"]).map(PathBuf::from);
355    [".forgejo/allowed_signers", ".github/allowed_signers"]
356        .into_iter()
357        .map(|rel| match &root {
358            Some(root) => root.join(rel),
359            None => PathBuf::from(rel),
360        })
361        .find(|p| p.exists())
362}
363
364/// The first principal an `allowed_signers` file names — the identity to
365/// verify against when the caller does not pass `--principal`. One key, one
366/// principal is the overwhelmingly common shape of this file; a multi-signer
367/// team passes the flag.
368pub fn first_principal(signers: &std::path::Path) -> Option<String> {
369    let body = std::fs::read_to_string(signers).ok()?;
370    body.lines()
371        .map(str::trim)
372        .find(|l| !l.is_empty() && !l.starts_with('#'))
373        .and_then(|l| l.split_whitespace().next())
374        .map(str::to_string)
375}
376
377/// A note body back into the exact bytes that were signed, plus the
378/// signature block. The blank-line split ate the payload's trailing newline;
379/// it is part of the signed bytes, so it goes back.
380fn split_note(body: &str) -> Option<(String, String)> {
381    let (payload, sig) = body.split_once("\n\n")?;
382    if !sig.starts_with("-----BEGIN SSH SIGNATURE-----") {
383        return None;
384    }
385    Some((format!("{payload}\n"), sig.to_string()))
386}
387
388/// Push the notes ref, marked so the recursive pre-push yields.
389///
390/// Not `git::succeeds` — that helper cannot set an environment variable, and
391/// the guard is the entire point of this wrapper existing.
392fn push_notes(remote: &str) -> bool {
393    let refspec = format!("{NOTES_FULL_REF}:{NOTES_FULL_REF}");
394    Command::new("git")
395        .args(["push", remote, &refspec])
396        .env(PUSH_GUARD, "1")
397        .stdin(Stdio::null())
398        .stdout(Stdio::null())
399        .stderr(Stdio::null())
400        .status()
401        .map(|s| s.success())
402        .unwrap_or(false)
403}
404
405/// A ref oid that is all zeros — git's spelling of "no object" in the
406/// pre-push ref list, for any hash width.
407fn is_zero(oid: &str) -> bool {
408    !oid.is_empty() && oid.bytes().all(|b| b == b'0')
409}
410
411/// uninstall: forget the local ref. The copies already pushed to remotes are
412/// statements we made and stand by; only OUR bookkeeping is removed — the
413/// same line `gate_stamp::forget` draws.
414pub fn forget() -> bool {
415    crate::git::succeeds(&["update-ref", "-d", NOTES_FULL_REF])
416}
417
418/// The same, for a repository this process is not standing in.
419pub fn forget_in(repo: &std::path::Path) -> bool {
420    crate::git::succeeds_in(repo, &["update-ref", "-d", NOTES_FULL_REF])
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use std::path::Path;
427
428    /// Real repositories and real keys: every function here is a conversation
429    /// with git or ssh-keygen, and a mocked conversation tests the one we
430    /// imagined. Same doctrine as `gate_stamp`'s tests.
431    fn dir(name: &str) -> PathBuf {
432        let d = std::env::temp_dir().join(format!("attest-{name}-{}", std::process::id()));
433        let _ = std::fs::remove_dir_all(&d);
434        std::fs::create_dir_all(&d).unwrap();
435        d
436    }
437
438    /// A fixture git call that FAILS where it fails.
439    ///
440    /// This used to discard the exit status, and that is how a rare flake
441    /// stayed unreadable for a day: if the setup `git commit` did not
442    /// happen, the test carried on to an unborn HEAD, and the panic landed
443    /// three lines later on a missing gate stamp — a product-shaped
444    /// failure for a fixture-shaped cause. Same rule the checks obey:
445    /// git failing is not git answering.
446    fn git(dir: &Path, args: &[&str]) -> String {
447        let out = std::process::Command::new("git")
448            .arg("-C")
449            .arg(dir)
450            .args(args)
451            .output()
452            .expect("git");
453        assert!(
454            out.status.success(),
455            "fixture: git {args:?} in {} exited {:?}: {}",
456            dir.display(),
457            out.status.code(),
458            String::from_utf8_lossy(&out.stderr).trim()
459        );
460        String::from_utf8_lossy(&out.stdout).trim().to_string()
461    }
462
463    fn repo(name: &str) -> PathBuf {
464        let d = dir(name);
465        git(&d, &["init", "-q", "--template=", "."]);
466        git(&d, &["config", "user.email", "t@t.test"]);
467        git(&d, &["config", "user.name", "t"]);
468        d
469    }
470
471    /// A throwaway ed25519 key plus the `allowed_signers` line CI would
472    /// commit for it, namespace-pinned exactly as the docs instruct.
473    fn keypair(d: &Path) -> (PathBuf, PathBuf) {
474        let key = d.join("attest_key");
475        let ok = std::process::Command::new("ssh-keygen")
476            .args(["-q", "-t", "ed25519", "-N", "", "-C", "test", "-f"])
477            .arg(&key)
478            .status()
479            .expect("ssh-keygen must exist for these tests")
480            .success();
481        assert!(ok, "keygen failed");
482        let pubkey = std::fs::read_to_string(key.with_extension("pub")).unwrap();
483        let signers = d.join("allowed_signers");
484        std::fs::write(
485            &signers,
486            format!("t@t.test namespaces=\"{NAMESPACE}\" {pubkey}"),
487        )
488        .unwrap();
489        (key, signers)
490    }
491
492    /// The module talks to the repo at the process cwd; serialised against
493    /// every other cwd-moving test via the crate-wide lock.
494    fn in_repo<T>(dir: &Path, f: impl FnOnce() -> T) -> T {
495        let _guard = crate::TEST_CWD.lock().unwrap_or_else(|p| p.into_inner());
496        let prev = std::env::current_dir().unwrap();
497        std::env::set_current_dir(dir).unwrap();
498        let r = f();
499        std::env::set_current_dir(prev).unwrap();
500        r
501    }
502
503    #[test]
504    fn the_payload_is_the_documented_contract() {
505        let p = payload(
506            "abc123",
507            &["pre-push-pytest".into(), "pre-push-cargo-test".into()],
508        );
509        let lines: Vec<&str> = p.lines().collect();
510        assert_eq!(lines[0], FORMAT);
511        assert_eq!(lines[1], "tree abc123");
512        assert_eq!(lines[2], "gates pre-push-pytest pre-push-cargo-test");
513        assert_eq!(lines[3], format!("platform {}", platform()));
514        assert_eq!(lines[4], format!("amont {}", env!("CARGO_PKG_VERSION")));
515        assert!(
516            p.ends_with('\n'),
517            "CI reconstructs these bytes; the trailing newline is part of them"
518        );
519    }
520
521    #[test]
522    fn sign_verify_roundtrip_and_tamper_rejection() {
523        let d = dir("roundtrip");
524        let (key, signers) = keypair(&d);
525        let p = payload("deadbeef", &["pre-push-pytest".into()]);
526        let sig = sign(&p, &key).expect("signing with a real key succeeds");
527        assert!(verify(&p, &sig, &signers, "t@t.test"));
528        // One byte of the tree changed: the signature must not carry over —
529        // this is the entire difference between this module and gate_stamp.
530        let tampered = payload("deadbeee", &["pre-push-pytest".into()]);
531        assert!(!verify(&tampered, &sig, &signers, "t@t.test"));
532        // The right payload under the wrong principal is also no.
533        assert!(!verify(&p, &sig, &signers, "someone@else.test"));
534        let _ = std::fs::remove_dir_all(&d);
535    }
536
537    #[test]
538    fn a_missing_key_signs_nothing() {
539        assert!(sign("anything", Path::new("/nonexistent/key")).is_none());
540    }
541
542    /// The full journey: a repo with the toggle on pushes, and the BARE
543    /// remote ends up holding a note whose payload verifies and matches the
544    /// pushed tree. This is everything CI relies on, minus CI.
545    #[test]
546    fn an_enabled_push_leaves_a_verifiable_note_on_the_remote() {
547        let d = dir("e2e");
548        let (key, signers) = keypair(&d);
549        let remote = d.join("remote.git");
550        std::fs::create_dir_all(&remote).unwrap();
551        git(&remote, &["init", "-q", "--bare", "--template=", "."]);
552        let work = repo("e2e-work");
553        git(
554            &work,
555            &["remote", "add", "origin", remote.to_str().unwrap()],
556        );
557        git(&work, &["config", "amont.attest", "true"]);
558        git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
559        std::fs::write(work.join("a.ts"), "x").unwrap();
560        git(&work, &["add", "a.ts"]);
561        git(&work, &["commit", "-qm", "chore: a"]);
562        let head = git(&work, &["rev-parse", "HEAD"]);
563        let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
564        let push_ref = PushRef {
565            local_ref: "refs/heads/main".into(),
566            local_oid: head.clone(),
567            remote_ref: "refs/heads/main".into(),
568            remote_oid: "0".repeat(40),
569        };
570        in_repo(&work, || {
571            attest_push("origin", &[push_ref], &["pre-push-run-tests-js".into()]);
572        });
573        // The note exists on the REMOTE — the whole point is that it travels.
574        let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &head]);
575        assert!(!body.is_empty(), "no note reached the remote");
576        let (p, sig) = body
577            .split_once("\n\n")
578            .expect("payload, blank line, signature");
579        let p = format!("{p}\n"); // the blank-line split ate payload's trailing newline
580        assert!(p.starts_with(FORMAT));
581        assert!(
582            p.contains(&format!("tree {tree}")),
583            "attests the pushed tree"
584        );
585        assert!(
586            verify(&p, sig, &signers, "t@t.test"),
587            "the remote copy verifies"
588        );
589        let _ = std::fs::remove_dir_all(&d);
590        let _ = std::fs::remove_dir_all(&work);
591    }
592
593    /// Off by default: a repo that never opted in makes no statement, even
594    /// with everything else in place.
595    #[test]
596    fn no_opt_in_means_no_note() {
597        let d = dir("optout");
598        let (key, _) = keypair(&d);
599        let remote = d.join("remote.git");
600        std::fs::create_dir_all(&remote).unwrap();
601        git(&remote, &["init", "-q", "--bare", "--template=", "."]);
602        let work = repo("optout-work");
603        git(
604            &work,
605            &["remote", "add", "origin", remote.to_str().unwrap()],
606        );
607        git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
608        std::fs::write(work.join("a.ts"), "x").unwrap();
609        git(&work, &["add", "a.ts"]);
610        git(&work, &["commit", "-qm", "chore: a"]);
611        let head = git(&work, &["rev-parse", "HEAD"]);
612        let push_ref = PushRef {
613            local_ref: "refs/heads/main".into(),
614            local_oid: head.clone(),
615            remote_ref: "refs/heads/main".into(),
616            remote_oid: "0".repeat(40),
617        };
618        in_repo(&work, || {
619            attest_push("origin", &[push_ref], &["pre-push-run-tests-js".into()]);
620        });
621        assert!(
622            git(&remote, &["notes", "--ref", NOTES_REF, "list"]).is_empty(),
623            "an un-opted-in repo attested something"
624        );
625        let _ = std::fs::remove_dir_all(&d);
626        let _ = std::fs::remove_dir_all(&work);
627    }
628
629    /// A deletion pushes no code; an empty gate list says nothing. Neither
630    /// may produce a note even in an enabled repo.
631    #[test]
632    fn deletions_and_empty_gates_attest_nothing() {
633        let d = dir("nothing");
634        let (key, _) = keypair(&d);
635        let remote = d.join("remote.git");
636        std::fs::create_dir_all(&remote).unwrap();
637        git(&remote, &["init", "-q", "--bare", "--template=", "."]);
638        let work = repo("nothing-work");
639        git(
640            &work,
641            &["remote", "add", "origin", remote.to_str().unwrap()],
642        );
643        git(&work, &["config", "amont.attest", "true"]);
644        git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
645        std::fs::write(work.join("a.ts"), "x").unwrap();
646        git(&work, &["add", "a.ts"]);
647        git(&work, &["commit", "-qm", "chore: a"]);
648        let head = git(&work, &["rev-parse", "HEAD"]);
649        let deletion = PushRef {
650            local_ref: "(delete)".into(),
651            local_oid: "0".repeat(40),
652            remote_ref: "refs/heads/gone".into(),
653            remote_oid: head.clone(),
654        };
655        let real = PushRef {
656            local_ref: "refs/heads/main".into(),
657            local_oid: head,
658            remote_ref: "refs/heads/main".into(),
659            remote_oid: "0".repeat(40),
660        };
661        in_repo(&work, || {
662            attest_push("origin", &[deletion], &["pre-push-pytest".into()]);
663            attest_push("origin", &[real], &[]);
664        });
665        assert!(git(&remote, &["notes", "--ref", NOTES_REF, "list"]).is_empty());
666        let _ = std::fs::remove_dir_all(&d);
667        let _ = std::fs::remove_dir_all(&work);
668    }
669
670    /// The half CI actually calls, from CI's own vantage point: a fresh
671    /// clone. `covered` fetches the notes ref itself, verifies, and answers
672    /// with the gates — then stops answering the moment the tree drifts or
673    /// the note is replaced by something unsigned.
674    #[test]
675    fn covered_answers_in_a_fresh_clone_and_rejects_drift_and_forgery() {
676        let d = dir("covered");
677        let (key, signers) = keypair(&d);
678        let remote = d.join("remote.git");
679        std::fs::create_dir_all(&remote).unwrap();
680        git(&remote, &["init", "-q", "--bare", "--template=", "."]);
681        // The fixture pushes to `main`; a bare init on a machine whose
682        // init.defaultBranch is the historical default leaves HEAD on
683        // `master`, and a clone of that repository checks out NOTHING —
684        // `covered` then answers None with a perfectly good note sitting in
685        // the ref. Caught only in CI: dev machines set main globally.
686        git(&remote, &["symbolic-ref", "HEAD", "refs/heads/main"]);
687        let work = repo("covered-work");
688        git(
689            &work,
690            &["remote", "add", "origin", remote.to_str().unwrap()],
691        );
692        git(&work, &["config", "amont.attest", "true"]);
693        git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
694        std::fs::write(work.join("a.ts"), "x").unwrap();
695        git(&work, &["add", "a.ts"]);
696        git(&work, &["commit", "-qm", "chore: a"]);
697        git(&work, &["push", "-q", "origin", "HEAD:main"]);
698        let head = git(&work, &["rev-parse", "HEAD"]);
699        let push_ref = PushRef {
700            local_ref: "refs/heads/main".into(),
701            local_oid: head.clone(),
702            remote_ref: "refs/heads/main".into(),
703            remote_oid: "0".repeat(40),
704        };
705        in_repo(&work, || {
706            attest_push("origin", &[push_ref], &["pre-push-pytest".into()]);
707        });
708        let clone = d.join("ci-checkout");
709        git(
710            &d,
711            &[
712                "clone",
713                "-q",
714                "--template=",
715                remote.to_str().unwrap(),
716                clone.to_str().unwrap(),
717            ],
718        );
719        in_repo(&clone, || {
720            assert_eq!(
721                covered(&signers, "t@t.test", Some(&platform())).as_deref(),
722                Some("pre-push-pytest"),
723                "a fresh clone verifies the attestation and reads the gates"
724            );
725            assert_eq!(
726                covered(&signers, "t@t.test", None).as_deref(),
727                Some("pre-push-pytest"),
728                "`any` covers a platform-independent suite"
729            );
730            // The matrix case this exists for: another leg asking about a
731            // suite that never ran there.
732            assert_eq!(
733                covered(&signers, "t@t.test", Some("s390x-aix")),
734                None,
735                "a pass on one platform is not evidence about another"
736            );
737            assert_eq!(
738                covered(&signers, "someone@else.test", None),
739                None,
740                "an unlisted principal covers nothing"
741            );
742        });
743        // Tree drift: a new commit in the checkout is not the attested tree.
744        std::fs::write(clone.join("b.ts"), "y").unwrap();
745        git(&clone, &["config", "user.email", "t@t.test"]);
746        git(&clone, &["config", "user.name", "t"]);
747        git(&clone, &["add", "b.ts"]);
748        git(&clone, &["commit", "-qm", "chore: b"]);
749        in_repo(&clone, || {
750            assert_eq!(covered(&signers, "t@t.test", None), None, "drifted tree");
751        });
752        // Forgery: replace the remote's note with an unsigned one. covered's
753        // own fetch pulls it in, and it must read as "no attestation".
754        git(
755            &work,
756            &[
757                "notes", "--ref", NOTES_REF, "add", "-f", "-m", "garbage", &head,
758            ],
759        );
760        git(
761            &work,
762            &[
763                "push",
764                "-q",
765                "origin",
766                &format!("+{NOTES_FULL_REF}:{NOTES_FULL_REF}"),
767            ],
768        );
769        git(&clone, &["reset", "-q", "--hard", &head]);
770        in_repo(&clone, || {
771            assert_eq!(
772                covered(&signers, "t@t.test", None),
773                None,
774                "a foreign note is not a stamp"
775            );
776        });
777        let _ = std::fs::remove_dir_all(&d);
778        let _ = std::fs::remove_dir_all(&work);
779    }
780
781    /// The silent-death case: a workflow step running with a
782    /// `working-directory` inside the repo must still find the committed
783    /// signers file. Before this, `default_signers` looked relative to the
784    /// cwd, found nothing, and every such repo fail-opened forever with no
785    /// symptom — CI stayed green and the gate simply never fired.
786    #[test]
787    fn default_signers_is_found_from_a_subdirectory() {
788        let work = repo("signers-subdir");
789        std::fs::create_dir_all(work.join(".forgejo")).unwrap();
790        std::fs::write(work.join(".forgejo/allowed_signers"), "t@t.test x\n").unwrap();
791        let sub = work.join("packages").join("thing");
792        std::fs::create_dir_all(&sub).unwrap();
793        in_repo(&sub, || {
794            let found = default_signers().expect("resolved from the repo root, not the cwd");
795            assert!(found.ends_with(".forgejo/allowed_signers"));
796            assert!(found.exists(), "the path it returns must be usable as-is");
797            assert_eq!(
798                first_principal(&found).as_deref(),
799                Some("t@t.test"),
800                "and readable from there"
801            );
802        });
803        let _ = std::fs::remove_dir_all(&work);
804    }
805
806    #[test]
807    fn zero_oids_of_any_width_are_zero() {
808        assert!(is_zero(&"0".repeat(40)));
809        assert!(is_zero(&"0".repeat(64)));
810        assert!(!is_zero("0a0000"));
811        assert!(!is_zero(""));
812    }
813
814    #[test]
815    fn forget_removes_the_local_ref() {
816        let work = repo("forget");
817        std::fs::write(work.join("a.ts"), "x").unwrap();
818        git(&work, &["add", "a.ts"]);
819        git(&work, &["commit", "-qm", "chore: a"]);
820        git(
821            &work,
822            &["notes", "--ref", NOTES_REF, "add", "-m", "x", "HEAD"],
823        );
824        in_repo(&work, forget);
825        assert!(git(&work, &["notes", "--ref", NOTES_REF, "list"]).is_empty());
826        let _ = std::fs::remove_dir_all(&work);
827    }
828}