1use std::path::PathBuf;
48use std::process::{Command, Stdio};
49
50use crate::pushrefs::PushRef;
51
52pub const FORMAT: &str = "amont-attest-v2";
61
62pub const NOTES_REF: &str = "amont-attest";
64
65pub const NOTES_FULL_REF: &str = "refs/notes/amont-attest";
68
69pub const NAMESPACE: &str = "amont-attest";
74
75pub const PUSH_GUARD: &str = "AMONT_ATTEST_PUSH";
78
79const TOGGLE: &str = "amont.attest";
83
84const KEY_CONFIG: &str = "amont.attestKey";
86const KEY_DEFAULT: &str = ".ssh/amont-attest";
87
88pub fn push_guard_active() -> bool {
90 std::env::var_os(PUSH_GUARD).is_some()
91}
92
93pub fn enabled() -> bool {
95 crate::config::boolean_or(TOGGLE, false)
96}
97
98fn 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
113pub fn platform() -> String {
120 format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS)
121}
122
123pub 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
140fn 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
163pub 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 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
204pub 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; }
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
264pub 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 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 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; }
330 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
343pub fn default_signers() -> Option<PathBuf> {
347 [".forgejo/allowed_signers", ".github/allowed_signers"]
348 .into_iter()
349 .map(PathBuf::from)
350 .find(|p| p.exists())
351}
352
353pub fn first_principal(signers: &std::path::Path) -> Option<String> {
358 let body = std::fs::read_to_string(signers).ok()?;
359 body.lines()
360 .map(str::trim)
361 .find(|l| !l.is_empty() && !l.starts_with('#'))
362 .and_then(|l| l.split_whitespace().next())
363 .map(str::to_string)
364}
365
366fn split_note(body: &str) -> Option<(String, String)> {
370 let (payload, sig) = body.split_once("\n\n")?;
371 if !sig.starts_with("-----BEGIN SSH SIGNATURE-----") {
372 return None;
373 }
374 Some((format!("{payload}\n"), sig.to_string()))
375}
376
377fn push_notes(remote: &str) -> bool {
382 let refspec = format!("{NOTES_FULL_REF}:{NOTES_FULL_REF}");
383 Command::new("git")
384 .args(["push", remote, &refspec])
385 .env(PUSH_GUARD, "1")
386 .stdin(Stdio::null())
387 .stdout(Stdio::null())
388 .stderr(Stdio::null())
389 .status()
390 .map(|s| s.success())
391 .unwrap_or(false)
392}
393
394fn is_zero(oid: &str) -> bool {
397 !oid.is_empty() && oid.bytes().all(|b| b == b'0')
398}
399
400pub fn forget() {
404 let _ = crate::git::succeeds(&["update-ref", "-d", NOTES_FULL_REF]);
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410 use std::path::Path;
411
412 fn dir(name: &str) -> PathBuf {
416 let d = std::env::temp_dir().join(format!("attest-{name}-{}", std::process::id()));
417 let _ = std::fs::remove_dir_all(&d);
418 std::fs::create_dir_all(&d).unwrap();
419 d
420 }
421
422 fn git(dir: &Path, args: &[&str]) -> String {
423 let out = std::process::Command::new("git")
424 .arg("-C")
425 .arg(dir)
426 .args(args)
427 .output()
428 .expect("git");
429 String::from_utf8_lossy(&out.stdout).trim().to_string()
430 }
431
432 fn repo(name: &str) -> PathBuf {
433 let d = dir(name);
434 git(&d, &["init", "-q", "--template=", "."]);
435 git(&d, &["config", "user.email", "t@t.test"]);
436 git(&d, &["config", "user.name", "t"]);
437 d
438 }
439
440 fn keypair(d: &Path) -> (PathBuf, PathBuf) {
443 let key = d.join("attest_key");
444 let ok = std::process::Command::new("ssh-keygen")
445 .args(["-q", "-t", "ed25519", "-N", "", "-C", "test", "-f"])
446 .arg(&key)
447 .status()
448 .expect("ssh-keygen must exist for these tests")
449 .success();
450 assert!(ok, "keygen failed");
451 let pubkey = std::fs::read_to_string(key.with_extension("pub")).unwrap();
452 let signers = d.join("allowed_signers");
453 std::fs::write(
454 &signers,
455 format!("t@t.test namespaces=\"{NAMESPACE}\" {pubkey}"),
456 )
457 .unwrap();
458 (key, signers)
459 }
460
461 fn in_repo<T>(dir: &Path, f: impl FnOnce() -> T) -> T {
464 let _guard = crate::TEST_CWD.lock().unwrap_or_else(|p| p.into_inner());
465 let prev = std::env::current_dir().unwrap();
466 std::env::set_current_dir(dir).unwrap();
467 let r = f();
468 std::env::set_current_dir(prev).unwrap();
469 r
470 }
471
472 #[test]
473 fn the_payload_is_the_documented_contract() {
474 let p = payload(
475 "abc123",
476 &["pre-push-pytest".into(), "pre-push-cargo-test".into()],
477 );
478 let lines: Vec<&str> = p.lines().collect();
479 assert_eq!(lines[0], FORMAT);
480 assert_eq!(lines[1], "tree abc123");
481 assert_eq!(lines[2], "gates pre-push-pytest pre-push-cargo-test");
482 assert_eq!(lines[3], format!("platform {}", platform()));
483 assert_eq!(lines[4], format!("amont {}", env!("CARGO_PKG_VERSION")));
484 assert!(
485 p.ends_with('\n'),
486 "CI reconstructs these bytes; the trailing newline is part of them"
487 );
488 }
489
490 #[test]
491 fn sign_verify_roundtrip_and_tamper_rejection() {
492 let d = dir("roundtrip");
493 let (key, signers) = keypair(&d);
494 let p = payload("deadbeef", &["pre-push-pytest".into()]);
495 let sig = sign(&p, &key).expect("signing with a real key succeeds");
496 assert!(verify(&p, &sig, &signers, "t@t.test"));
497 let tampered = payload("deadbeee", &["pre-push-pytest".into()]);
500 assert!(!verify(&tampered, &sig, &signers, "t@t.test"));
501 assert!(!verify(&p, &sig, &signers, "someone@else.test"));
503 let _ = std::fs::remove_dir_all(&d);
504 }
505
506 #[test]
507 fn a_missing_key_signs_nothing() {
508 assert!(sign("anything", Path::new("/nonexistent/key")).is_none());
509 }
510
511 #[test]
515 fn an_enabled_push_leaves_a_verifiable_note_on_the_remote() {
516 let d = dir("e2e");
517 let (key, signers) = keypair(&d);
518 let remote = d.join("remote.git");
519 std::fs::create_dir_all(&remote).unwrap();
520 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
521 let work = repo("e2e-work");
522 git(
523 &work,
524 &["remote", "add", "origin", remote.to_str().unwrap()],
525 );
526 git(&work, &["config", "amont.attest", "true"]);
527 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
528 std::fs::write(work.join("a.ts"), "x").unwrap();
529 git(&work, &["add", "a.ts"]);
530 git(&work, &["commit", "-qm", "chore: a"]);
531 let head = git(&work, &["rev-parse", "HEAD"]);
532 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
533 let push_ref = PushRef {
534 local_ref: "refs/heads/main".into(),
535 local_oid: head.clone(),
536 remote_ref: "refs/heads/main".into(),
537 remote_oid: "0".repeat(40),
538 };
539 in_repo(&work, || {
540 attest_push("origin", &[push_ref], &["pre-push-run-tests-js".into()]);
541 });
542 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &head]);
544 assert!(!body.is_empty(), "no note reached the remote");
545 let (p, sig) = body
546 .split_once("\n\n")
547 .expect("payload, blank line, signature");
548 let p = format!("{p}\n"); assert!(p.starts_with(FORMAT));
550 assert!(
551 p.contains(&format!("tree {tree}")),
552 "attests the pushed tree"
553 );
554 assert!(
555 verify(&p, sig, &signers, "t@t.test"),
556 "the remote copy verifies"
557 );
558 let _ = std::fs::remove_dir_all(&d);
559 let _ = std::fs::remove_dir_all(&work);
560 }
561
562 #[test]
565 fn no_opt_in_means_no_note() {
566 let d = dir("optout");
567 let (key, _) = keypair(&d);
568 let remote = d.join("remote.git");
569 std::fs::create_dir_all(&remote).unwrap();
570 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
571 let work = repo("optout-work");
572 git(
573 &work,
574 &["remote", "add", "origin", remote.to_str().unwrap()],
575 );
576 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
577 std::fs::write(work.join("a.ts"), "x").unwrap();
578 git(&work, &["add", "a.ts"]);
579 git(&work, &["commit", "-qm", "chore: a"]);
580 let head = git(&work, &["rev-parse", "HEAD"]);
581 let push_ref = PushRef {
582 local_ref: "refs/heads/main".into(),
583 local_oid: head.clone(),
584 remote_ref: "refs/heads/main".into(),
585 remote_oid: "0".repeat(40),
586 };
587 in_repo(&work, || {
588 attest_push("origin", &[push_ref], &["pre-push-run-tests-js".into()]);
589 });
590 assert!(
591 git(&remote, &["notes", "--ref", NOTES_REF, "list"]).is_empty(),
592 "an un-opted-in repo attested something"
593 );
594 let _ = std::fs::remove_dir_all(&d);
595 let _ = std::fs::remove_dir_all(&work);
596 }
597
598 #[test]
601 fn deletions_and_empty_gates_attest_nothing() {
602 let d = dir("nothing");
603 let (key, _) = keypair(&d);
604 let remote = d.join("remote.git");
605 std::fs::create_dir_all(&remote).unwrap();
606 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
607 let work = repo("nothing-work");
608 git(
609 &work,
610 &["remote", "add", "origin", remote.to_str().unwrap()],
611 );
612 git(&work, &["config", "amont.attest", "true"]);
613 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
614 std::fs::write(work.join("a.ts"), "x").unwrap();
615 git(&work, &["add", "a.ts"]);
616 git(&work, &["commit", "-qm", "chore: a"]);
617 let head = git(&work, &["rev-parse", "HEAD"]);
618 let deletion = PushRef {
619 local_ref: "(delete)".into(),
620 local_oid: "0".repeat(40),
621 remote_ref: "refs/heads/gone".into(),
622 remote_oid: head.clone(),
623 };
624 let real = PushRef {
625 local_ref: "refs/heads/main".into(),
626 local_oid: head,
627 remote_ref: "refs/heads/main".into(),
628 remote_oid: "0".repeat(40),
629 };
630 in_repo(&work, || {
631 attest_push("origin", &[deletion], &["pre-push-pytest".into()]);
632 attest_push("origin", &[real], &[]);
633 });
634 assert!(git(&remote, &["notes", "--ref", NOTES_REF, "list"]).is_empty());
635 let _ = std::fs::remove_dir_all(&d);
636 let _ = std::fs::remove_dir_all(&work);
637 }
638
639 #[test]
644 fn covered_answers_in_a_fresh_clone_and_rejects_drift_and_forgery() {
645 let d = dir("covered");
646 let (key, signers) = keypair(&d);
647 let remote = d.join("remote.git");
648 std::fs::create_dir_all(&remote).unwrap();
649 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
650 git(&remote, &["symbolic-ref", "HEAD", "refs/heads/main"]);
656 let work = repo("covered-work");
657 git(
658 &work,
659 &["remote", "add", "origin", remote.to_str().unwrap()],
660 );
661 git(&work, &["config", "amont.attest", "true"]);
662 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
663 std::fs::write(work.join("a.ts"), "x").unwrap();
664 git(&work, &["add", "a.ts"]);
665 git(&work, &["commit", "-qm", "chore: a"]);
666 git(&work, &["push", "-q", "origin", "HEAD:main"]);
667 let head = git(&work, &["rev-parse", "HEAD"]);
668 let push_ref = PushRef {
669 local_ref: "refs/heads/main".into(),
670 local_oid: head.clone(),
671 remote_ref: "refs/heads/main".into(),
672 remote_oid: "0".repeat(40),
673 };
674 in_repo(&work, || {
675 attest_push("origin", &[push_ref], &["pre-push-pytest".into()]);
676 });
677 let clone = d.join("ci-checkout");
678 git(
679 &d,
680 &[
681 "clone",
682 "-q",
683 "--template=",
684 remote.to_str().unwrap(),
685 clone.to_str().unwrap(),
686 ],
687 );
688 in_repo(&clone, || {
689 assert_eq!(
690 covered(&signers, "t@t.test", Some(&platform())).as_deref(),
691 Some("pre-push-pytest"),
692 "a fresh clone verifies the attestation and reads the gates"
693 );
694 assert_eq!(
695 covered(&signers, "t@t.test", None).as_deref(),
696 Some("pre-push-pytest"),
697 "`any` covers a platform-independent suite"
698 );
699 assert_eq!(
702 covered(&signers, "t@t.test", Some("s390x-aix")),
703 None,
704 "a pass on one platform is not evidence about another"
705 );
706 assert_eq!(
707 covered(&signers, "someone@else.test", None),
708 None,
709 "an unlisted principal covers nothing"
710 );
711 });
712 std::fs::write(clone.join("b.ts"), "y").unwrap();
714 git(&clone, &["config", "user.email", "t@t.test"]);
715 git(&clone, &["config", "user.name", "t"]);
716 git(&clone, &["add", "b.ts"]);
717 git(&clone, &["commit", "-qm", "chore: b"]);
718 in_repo(&clone, || {
719 assert_eq!(covered(&signers, "t@t.test", None), None, "drifted tree");
720 });
721 git(
724 &work,
725 &[
726 "notes", "--ref", NOTES_REF, "add", "-f", "-m", "garbage", &head,
727 ],
728 );
729 git(
730 &work,
731 &[
732 "push",
733 "-q",
734 "origin",
735 &format!("+{NOTES_FULL_REF}:{NOTES_FULL_REF}"),
736 ],
737 );
738 git(&clone, &["reset", "-q", "--hard", &head]);
739 in_repo(&clone, || {
740 assert_eq!(
741 covered(&signers, "t@t.test", None),
742 None,
743 "a foreign note is not a stamp"
744 );
745 });
746 let _ = std::fs::remove_dir_all(&d);
747 let _ = std::fs::remove_dir_all(&work);
748 }
749
750 #[test]
751 fn zero_oids_of_any_width_are_zero() {
752 assert!(is_zero(&"0".repeat(40)));
753 assert!(is_zero(&"0".repeat(64)));
754 assert!(!is_zero("0a0000"));
755 assert!(!is_zero(""));
756 }
757
758 #[test]
759 fn forget_removes_the_local_ref() {
760 let work = repo("forget");
761 std::fs::write(work.join("a.ts"), "x").unwrap();
762 git(&work, &["add", "a.ts"]);
763 git(&work, &["commit", "-qm", "chore: a"]);
764 git(
765 &work,
766 &["notes", "--ref", NOTES_REF, "add", "-m", "x", "HEAD"],
767 );
768 in_repo(&work, forget);
769 assert!(git(&work, &["notes", "--ref", NOTES_REF, "list"]).is_empty());
770 let _ = std::fs::remove_dir_all(&work);
771 }
772}