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> {
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
364pub 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
377fn 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
388fn 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
405fn is_zero(oid: &str) -> bool {
408 !oid.is_empty() && oid.bytes().all(|b| b == b'0')
409}
410
411pub fn forget() -> bool {
415 crate::git::succeeds(&["update-ref", "-d", NOTES_FULL_REF])
416}
417
418pub 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 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 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 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 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 let tampered = payload("deadbeee", &["pre-push-pytest".into()]);
531 assert!(!verify(&tampered, &sig, &signers, "t@t.test"));
532 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 #[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 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"); 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 #[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 #[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 #[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 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 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 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 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 #[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}