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".
55pub const FORMAT: &str = "amont-attest-v1";
56
57/// The notes ref, spelled the way `git notes --ref` wants it.
58pub const NOTES_REF: &str = "amont-attest";
59
60/// The same ref, fully qualified — the push refspec and `update-ref -d` both
61/// need it.
62pub const NOTES_FULL_REF: &str = "refs/notes/amont-attest";
63
64/// The `ssh-keygen -Y` namespace, on both the signing and verifying side.
65/// Namespaces exist so a signature minted for one purpose cannot be replayed
66/// for another; an `allowed_signers` entry pinned to this namespace accepts
67/// nothing else.
68pub const NAMESPACE: &str = "amont-attest";
69
70/// Environment marker carried by the notes push so the recursive pre-push
71/// invocation stands down. See the module doc.
72pub const PUSH_GUARD: &str = "AMONT_ATTEST_PUSH";
73
74/// The opt-in switch. Off by default: an attestation is a statement to
75/// another system, and amont does not speak for a repository that never
76/// asked it to.
77const TOGGLE: &str = "amont.attest";
78
79/// Where the signing key lives when the repository does not say.
80const KEY_CONFIG: &str = "amont.attestKey";
81const KEY_DEFAULT: &str = ".ssh/amont-attest";
82
83/// Is the recursive-push marker set on THIS invocation?
84pub fn push_guard_active() -> bool {
85    std::env::var_os(PUSH_GUARD).is_some()
86}
87
88/// Has this repository opted in?
89pub fn enabled() -> bool {
90    crate::config::boolean_or(TOGGLE, false)
91}
92
93/// The signing key path: `amont.attestKey`, else `~/.ssh/amont-attest`.
94///
95/// Read like `amont.knownIdentity` is — a raw string through git, unset
96/// collapsing to the default — because a path has no shape git could
97/// validate for us anyway.
98fn key_path() -> Option<PathBuf> {
99    if let Some(k) = crate::git::stdout(&["config", "--get", KEY_CONFIG]) {
100        if !k.is_empty() {
101            return Some(PathBuf::from(k));
102        }
103    }
104    let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
105    Some(PathBuf::from(home).join(KEY_DEFAULT))
106}
107
108/// The exact bytes the signature covers. One datum per line, trailing
109/// newline included — CI reconstructs this from the note text, so the shape
110/// is a contract, not a convenience.
111pub fn payload(tree: &str, gates: &[String]) -> String {
112    format!(
113        "{FORMAT}\ntree {tree}\ngates {}\namont {}\n",
114        gates.join(" "),
115        env!("CARGO_PKG_VERSION")
116    )
117}
118
119/// `ssh-keygen -Y sign` over `payload`, armored signature back. `None` for
120/// every failure — a missing binary, a missing key, a signer that said no —
121/// because an attestation we cannot mint is simply one CI never sees.
122fn sign(payload: &str, key: &std::path::Path) -> Option<String> {
123    use std::io::Write;
124    let mut child = Command::new("ssh-keygen")
125        .args(["-Y", "sign", "-n", NAMESPACE, "-f"])
126        .arg(key)
127        .stdin(Stdio::piped())
128        .stdout(Stdio::piped())
129        .stderr(Stdio::null())
130        .spawn()
131        .ok()?;
132    child.stdin.take()?.write_all(payload.as_bytes()).ok()?;
133    let out = child.wait_with_output().ok()?;
134    if !out.status.success() {
135        return None;
136    }
137    let sig = String::from_utf8_lossy(&out.stdout).trim().to_string();
138    sig.starts_with("-----BEGIN SSH SIGNATURE-----")
139        .then_some(sig)
140}
141
142/// `ssh-keygen -Y verify`: is `sig` a valid signature over `payload` by a
143/// `principal` key listed in `allowed_signers` for our namespace?
144///
145/// The runtime never gates on this — CI verifies with its own stock tooling —
146/// but owning the verifying half keeps the roundtrip honest in tests and
147/// gives a future `amont attest verify` its engine.
148pub fn verify(
149    payload: &str,
150    sig: &str,
151    allowed_signers: &std::path::Path,
152    principal: &str,
153) -> bool {
154    use std::io::Write;
155    // -Y verify takes the signature as a FILE; the payload rides stdin.
156    let sig_file = std::env::temp_dir().join(format!(
157        "amont-attest-verify-{}-{:p}.sig",
158        std::process::id(),
159        &sig
160    ));
161    if std::fs::write(&sig_file, format!("{sig}\n")).is_err() {
162        return false;
163    }
164    let ok = (|| {
165        let mut child = Command::new("ssh-keygen")
166            .args(["-Y", "verify", "-n", NAMESPACE, "-I", principal, "-f"])
167            .arg(allowed_signers)
168            .arg("-s")
169            .arg(&sig_file)
170            .stdin(Stdio::piped())
171            .stdout(Stdio::null())
172            .stderr(Stdio::null())
173            .spawn()
174            .ok()?;
175        child.stdin.take()?.write_all(payload.as_bytes()).ok()?;
176        child.wait().ok().map(|s| s.success())
177    })()
178    .unwrap_or(false);
179    let _ = std::fs::remove_file(&sig_file);
180    ok
181}
182
183/// pre-push, after every block gate has passed: attest each pushed tip and
184/// send the notes ref to the remote being pushed.
185///
186/// `gates` is what the dispatcher saw actually PASS — `Warned` and
187/// `Unavailable` never appear in it, because "could not run" is not
188/// "passed". Empty means nothing testlike ran, and an attestation listing
189/// no gates would be a signed way of saying nothing.
190///
191/// Best-effort throughout, and quiet about it: pre-push has already printed
192/// its verdicts, and a push that works minus its CI shortcut is not a
193/// problem anyone needs to solve at push time.
194pub fn attest_push(remote: &str, refs: &[PushRef], gates: &[String]) {
195    if gates.is_empty() || remote.is_empty() || !enabled() {
196        return;
197    }
198    let Some(key) = key_path() else { return };
199    if !key.exists() {
200        crate::config::complain(
201            TOGGLE,
202            &format!("signing key {} does not exist", key.display()),
203            "no attestation (CI will run the tests)",
204        );
205        return;
206    }
207    let mut noted = false;
208    for r in refs {
209        if is_zero(&r.local_oid) {
210            continue; // deleting a ref pushes no code
211        }
212        let spec = format!("{}^{{tree}}", r.local_oid);
213        let Some(tree) = crate::git::stdout(&["rev-parse", &spec]) else {
214            continue;
215        };
216        let body = match sign(&payload(&tree, gates), &key) {
217            Some(sig) => format!("{}\n{sig}", payload(&tree, gates)),
218            None => continue,
219        };
220        if crate::git::succeeds(&[
221            "notes",
222            "--ref",
223            NOTES_REF,
224            "add",
225            "-f",
226            "-m",
227            &body,
228            &r.local_oid,
229        ]) {
230            noted = true;
231        }
232    }
233    if noted && push_notes(remote) {
234        crate::say!(
235            "{} attested {} for CI ({})",
236            crate::ui::valid_sign(),
237            crate::ui::highlight(&gates.join(" ")),
238            NOTES_REF,
239        );
240    }
241}
242
243/// Push the notes ref, marked so the recursive pre-push yields.
244///
245/// Not `git::succeeds` — that helper cannot set an environment variable, and
246/// the guard is the entire point of this wrapper existing.
247fn push_notes(remote: &str) -> bool {
248    let refspec = format!("{NOTES_FULL_REF}:{NOTES_FULL_REF}");
249    Command::new("git")
250        .args(["push", remote, &refspec])
251        .env(PUSH_GUARD, "1")
252        .stdin(Stdio::null())
253        .stdout(Stdio::null())
254        .stderr(Stdio::null())
255        .status()
256        .map(|s| s.success())
257        .unwrap_or(false)
258}
259
260/// A ref oid that is all zeros — git's spelling of "no object" in the
261/// pre-push ref list, for any hash width.
262fn is_zero(oid: &str) -> bool {
263    !oid.is_empty() && oid.bytes().all(|b| b == b'0')
264}
265
266/// uninstall: forget the local ref. The copies already pushed to remotes are
267/// statements we made and stand by; only OUR bookkeeping is removed — the
268/// same line `gate_stamp::forget` draws.
269pub fn forget() {
270    let _ = crate::git::succeeds(&["update-ref", "-d", NOTES_FULL_REF]);
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use std::path::Path;
277
278    /// Real repositories and real keys: every function here is a conversation
279    /// with git or ssh-keygen, and a mocked conversation tests the one we
280    /// imagined. Same doctrine as `gate_stamp`'s tests.
281    fn dir(name: &str) -> PathBuf {
282        let d = std::env::temp_dir().join(format!("attest-{name}-{}", std::process::id()));
283        let _ = std::fs::remove_dir_all(&d);
284        std::fs::create_dir_all(&d).unwrap();
285        d
286    }
287
288    fn git(dir: &Path, args: &[&str]) -> String {
289        let out = std::process::Command::new("git")
290            .arg("-C")
291            .arg(dir)
292            .args(args)
293            .output()
294            .expect("git");
295        String::from_utf8_lossy(&out.stdout).trim().to_string()
296    }
297
298    fn repo(name: &str) -> PathBuf {
299        let d = dir(name);
300        git(&d, &["init", "-q", "--template=", "."]);
301        git(&d, &["config", "user.email", "t@t.test"]);
302        git(&d, &["config", "user.name", "t"]);
303        d
304    }
305
306    /// A throwaway ed25519 key plus the `allowed_signers` line CI would
307    /// commit for it, namespace-pinned exactly as the docs instruct.
308    fn keypair(d: &Path) -> (PathBuf, PathBuf) {
309        let key = d.join("attest_key");
310        let ok = std::process::Command::new("ssh-keygen")
311            .args(["-q", "-t", "ed25519", "-N", "", "-C", "test", "-f"])
312            .arg(&key)
313            .status()
314            .expect("ssh-keygen must exist for these tests")
315            .success();
316        assert!(ok, "keygen failed");
317        let pubkey = std::fs::read_to_string(key.with_extension("pub")).unwrap();
318        let signers = d.join("allowed_signers");
319        std::fs::write(
320            &signers,
321            format!("t@t.test namespaces=\"{NAMESPACE}\" {pubkey}"),
322        )
323        .unwrap();
324        (key, signers)
325    }
326
327    /// The module talks to the repo at the process cwd; serialised against
328    /// every other cwd-moving test via the crate-wide lock.
329    fn in_repo<T>(dir: &Path, f: impl FnOnce() -> T) -> T {
330        let _guard = crate::TEST_CWD.lock().unwrap_or_else(|p| p.into_inner());
331        let prev = std::env::current_dir().unwrap();
332        std::env::set_current_dir(dir).unwrap();
333        let r = f();
334        std::env::set_current_dir(prev).unwrap();
335        r
336    }
337
338    #[test]
339    fn the_payload_is_the_documented_contract() {
340        let p = payload(
341            "abc123",
342            &["pre-push-pytest".into(), "pre-push-cargo-test".into()],
343        );
344        let lines: Vec<&str> = p.lines().collect();
345        assert_eq!(lines[0], FORMAT);
346        assert_eq!(lines[1], "tree abc123");
347        assert_eq!(lines[2], "gates pre-push-pytest pre-push-cargo-test");
348        assert_eq!(lines[3], format!("amont {}", env!("CARGO_PKG_VERSION")));
349        assert!(
350            p.ends_with('\n'),
351            "CI reconstructs these bytes; the trailing newline is part of them"
352        );
353    }
354
355    #[test]
356    fn sign_verify_roundtrip_and_tamper_rejection() {
357        let d = dir("roundtrip");
358        let (key, signers) = keypair(&d);
359        let p = payload("deadbeef", &["pre-push-pytest".into()]);
360        let sig = sign(&p, &key).expect("signing with a real key succeeds");
361        assert!(verify(&p, &sig, &signers, "t@t.test"));
362        // One byte of the tree changed: the signature must not carry over —
363        // this is the entire difference between this module and gate_stamp.
364        let tampered = payload("deadbeee", &["pre-push-pytest".into()]);
365        assert!(!verify(&tampered, &sig, &signers, "t@t.test"));
366        // The right payload under the wrong principal is also no.
367        assert!(!verify(&p, &sig, &signers, "someone@else.test"));
368        let _ = std::fs::remove_dir_all(&d);
369    }
370
371    #[test]
372    fn a_missing_key_signs_nothing() {
373        assert!(sign("anything", Path::new("/nonexistent/key")).is_none());
374    }
375
376    /// The full journey: a repo with the toggle on pushes, and the BARE
377    /// remote ends up holding a note whose payload verifies and matches the
378    /// pushed tree. This is everything CI relies on, minus CI.
379    #[test]
380    fn an_enabled_push_leaves_a_verifiable_note_on_the_remote() {
381        let d = dir("e2e");
382        let (key, signers) = keypair(&d);
383        let remote = d.join("remote.git");
384        std::fs::create_dir_all(&remote).unwrap();
385        git(&remote, &["init", "-q", "--bare", "--template=", "."]);
386        let work = repo("e2e-work");
387        git(
388            &work,
389            &["remote", "add", "origin", remote.to_str().unwrap()],
390        );
391        git(&work, &["config", "amont.attest", "true"]);
392        git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
393        std::fs::write(work.join("a.ts"), "x").unwrap();
394        git(&work, &["add", "a.ts"]);
395        git(&work, &["commit", "-qm", "chore: a"]);
396        let head = git(&work, &["rev-parse", "HEAD"]);
397        let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
398        let push_ref = PushRef {
399            local_ref: "refs/heads/main".into(),
400            local_oid: head.clone(),
401            remote_ref: "refs/heads/main".into(),
402            remote_oid: "0".repeat(40),
403        };
404        in_repo(&work, || {
405            attest_push("origin", &[push_ref], &["pre-push-run-tests-js".into()]);
406        });
407        // The note exists on the REMOTE — the whole point is that it travels.
408        let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &head]);
409        assert!(!body.is_empty(), "no note reached the remote");
410        let (p, sig) = body
411            .split_once("\n\n")
412            .expect("payload, blank line, signature");
413        let p = format!("{p}\n"); // the blank-line split ate payload's trailing newline
414        assert!(p.starts_with(FORMAT));
415        assert!(
416            p.contains(&format!("tree {tree}")),
417            "attests the pushed tree"
418        );
419        assert!(
420            verify(&p, sig, &signers, "t@t.test"),
421            "the remote copy verifies"
422        );
423        let _ = std::fs::remove_dir_all(&d);
424        let _ = std::fs::remove_dir_all(&work);
425    }
426
427    /// Off by default: a repo that never opted in makes no statement, even
428    /// with everything else in place.
429    #[test]
430    fn no_opt_in_means_no_note() {
431        let d = dir("optout");
432        let (key, _) = keypair(&d);
433        let remote = d.join("remote.git");
434        std::fs::create_dir_all(&remote).unwrap();
435        git(&remote, &["init", "-q", "--bare", "--template=", "."]);
436        let work = repo("optout-work");
437        git(
438            &work,
439            &["remote", "add", "origin", remote.to_str().unwrap()],
440        );
441        git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
442        std::fs::write(work.join("a.ts"), "x").unwrap();
443        git(&work, &["add", "a.ts"]);
444        git(&work, &["commit", "-qm", "chore: a"]);
445        let head = git(&work, &["rev-parse", "HEAD"]);
446        let push_ref = PushRef {
447            local_ref: "refs/heads/main".into(),
448            local_oid: head.clone(),
449            remote_ref: "refs/heads/main".into(),
450            remote_oid: "0".repeat(40),
451        };
452        in_repo(&work, || {
453            attest_push("origin", &[push_ref], &["pre-push-run-tests-js".into()]);
454        });
455        assert!(
456            git(&remote, &["notes", "--ref", NOTES_REF, "list"]).is_empty(),
457            "an un-opted-in repo attested something"
458        );
459        let _ = std::fs::remove_dir_all(&d);
460        let _ = std::fs::remove_dir_all(&work);
461    }
462
463    /// A deletion pushes no code; an empty gate list says nothing. Neither
464    /// may produce a note even in an enabled repo.
465    #[test]
466    fn deletions_and_empty_gates_attest_nothing() {
467        let d = dir("nothing");
468        let (key, _) = keypair(&d);
469        let remote = d.join("remote.git");
470        std::fs::create_dir_all(&remote).unwrap();
471        git(&remote, &["init", "-q", "--bare", "--template=", "."]);
472        let work = repo("nothing-work");
473        git(
474            &work,
475            &["remote", "add", "origin", remote.to_str().unwrap()],
476        );
477        git(&work, &["config", "amont.attest", "true"]);
478        git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
479        std::fs::write(work.join("a.ts"), "x").unwrap();
480        git(&work, &["add", "a.ts"]);
481        git(&work, &["commit", "-qm", "chore: a"]);
482        let head = git(&work, &["rev-parse", "HEAD"]);
483        let deletion = PushRef {
484            local_ref: "(delete)".into(),
485            local_oid: "0".repeat(40),
486            remote_ref: "refs/heads/gone".into(),
487            remote_oid: head.clone(),
488        };
489        let real = PushRef {
490            local_ref: "refs/heads/main".into(),
491            local_oid: head,
492            remote_ref: "refs/heads/main".into(),
493            remote_oid: "0".repeat(40),
494        };
495        in_repo(&work, || {
496            attest_push("origin", &[deletion], &["pre-push-pytest".into()]);
497            attest_push("origin", &[real], &[]);
498        });
499        assert!(git(&remote, &["notes", "--ref", NOTES_REF, "list"]).is_empty());
500        let _ = std::fs::remove_dir_all(&d);
501        let _ = std::fs::remove_dir_all(&work);
502    }
503
504    #[test]
505    fn zero_oids_of_any_width_are_zero() {
506        assert!(is_zero(&"0".repeat(40)));
507        assert!(is_zero(&"0".repeat(64)));
508        assert!(!is_zero("0a0000"));
509        assert!(!is_zero(""));
510    }
511
512    #[test]
513    fn forget_removes_the_local_ref() {
514        let work = repo("forget");
515        std::fs::write(work.join("a.ts"), "x").unwrap();
516        git(&work, &["add", "a.ts"]);
517        git(&work, &["commit", "-qm", "chore: a"]);
518        git(
519            &work,
520            &["notes", "--ref", NOTES_REF, "add", "-m", "x", "HEAD"],
521        );
522        in_repo(&work, forget);
523        assert!(git(&work, &["notes", "--ref", NOTES_REF, "list"]).is_empty());
524        let _ = std::fs::remove_dir_all(&work);
525    }
526}