1use std::path::PathBuf;
72use std::process::{Command, Stdio};
73
74use crate::attest_inputs::{fingerprint, input_key, spec_at};
75#[cfg(test)]
76use crate::attest_inputs::{implicit_inputs, parse_spec, PathTok};
77use crate::pushrefs::PushRef;
78
79pub const FORMAT: &str = "amont-attest-v2";
88
89pub const NOTES_REF: &str = "amont-attest";
91
92pub const NOTES_FULL_REF: &str = "refs/notes/amont-attest";
95
96pub const INPUTS_REF: &str = "amont-attest-inputs";
100pub const INPUTS_FULL_REF: &str = "refs/notes/amont-attest-inputs";
101
102pub const NAMESPACE: &str = "amont-attest";
107
108pub const PUSH_GUARD: &str = "AMONT_ATTEST_PUSH";
111
112const TOGGLE: &str = "amont.attest";
116
117const KEY_CONFIG: &str = "amont.attestKey";
119const KEY_DEFAULT: &str = ".ssh/amont-attest";
120
121pub fn push_guard_active() -> bool {
123 std::env::var_os(PUSH_GUARD).is_some()
124}
125
126pub fn enabled(settings: &crate::config::Settings) -> bool {
128 crate::config::boolean_or(settings, TOGGLE, false)
129}
130
131fn key_path() -> Option<PathBuf> {
137 if let Some(k) = crate::git::stdout(&["config", "--get", KEY_CONFIG]) {
138 if !k.is_empty() {
139 return Some(PathBuf::from(k));
140 }
141 }
142 let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
143 Some(PathBuf::from(home).join(KEY_DEFAULT))
144}
145
146pub fn platform() -> String {
153 format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS)
154}
155
156pub fn payload(tree: &str, gates: &[String], inputs: &[(String, String)]) -> String {
169 let mut p = format!(
170 "{FORMAT}\ntree {tree}\ngates {}\nplatform {}\n",
171 gates.join(" "),
172 platform()
173 );
174 for (gate, fp) in inputs {
175 p.push_str(&format!("input {gate} {fp}\n"));
176 }
177 p.push_str(&format!("amont {}\n", env!("CARGO_PKG_VERSION")));
178 p
179}
180
181fn inputs_for(tree: &str, gates: &[String]) -> Vec<(String, String)> {
184 let Some(spec) = spec_at(tree) else {
185 return Vec::new();
186 };
187 spec.iter()
188 .filter(|(g, _)| gates.iter().any(|x| x == g))
189 .filter_map(|(g, paths)| fingerprint(tree, paths).map(|fp| (g.clone(), fp)))
190 .collect()
191}
192
193fn sign(payload: &str, key: &std::path::Path) -> Option<String> {
197 use std::io::Write;
198 let mut child = Command::new("ssh-keygen")
199 .args(["-Y", "sign", "-n", NAMESPACE, "-f"])
200 .arg(key)
201 .stdin(Stdio::piped())
202 .stdout(Stdio::piped())
203 .stderr(Stdio::null())
204 .spawn()
205 .ok()?;
206 child.stdin.take()?.write_all(payload.as_bytes()).ok()?;
207 let out = child.wait_with_output().ok()?;
208 if !out.status.success() {
209 return None;
210 }
211 let sig = String::from_utf8_lossy(&out.stdout).trim().to_string();
212 sig.starts_with("-----BEGIN SSH SIGNATURE-----")
213 .then_some(sig)
214}
215
216fn create_exclusive(path: &std::path::Path) -> Option<std::fs::File> {
234 std::fs::OpenOptions::new()
235 .write(true)
236 .create_new(true)
237 .open(path)
238 .ok()
239}
240
241pub fn verify(
242 payload: &str,
243 sig: &str,
244 allowed_signers: &std::path::Path,
245 principal: &str,
246) -> bool {
247 use std::io::Write;
248 let nonce = std::time::SystemTime::now()
255 .duration_since(std::time::UNIX_EPOCH)
256 .map(|d| d.subsec_nanos())
257 .unwrap_or(0);
258 let sig_file = std::env::temp_dir().join(format!(
259 "amont-attest-verify-{}-{:p}-{nonce}.sig",
260 std::process::id(),
261 &sig
262 ));
263 let Some(mut f) = create_exclusive(&sig_file) else {
264 return false;
265 };
266 if f.write_all(format!("{sig}\n").as_bytes()).is_err() {
267 let _ = std::fs::remove_file(&sig_file);
268 return false;
269 }
270 drop(f);
272 let ok = (|| {
273 let mut child = Command::new("ssh-keygen")
274 .args(["-Y", "verify", "-n", NAMESPACE, "-I", principal, "-f"])
275 .arg(allowed_signers)
276 .arg("-s")
277 .arg(&sig_file)
278 .stdin(Stdio::piped())
279 .stdout(Stdio::null())
280 .stderr(Stdio::null())
281 .spawn()
282 .ok()?;
283 child.stdin.take()?.write_all(payload.as_bytes()).ok()?;
284 child.wait().ok().map(|s| s.success())
285 })()
286 .unwrap_or(false);
287 let _ = std::fs::remove_file(&sig_file);
288 ok
289}
290
291pub fn attest_push(
303 settings: &crate::config::Settings,
304 remote: &str,
305 refs: &[PushRef],
306 gates: &[String],
307) {
308 if gates.is_empty() || remote.is_empty() || !enabled(settings) {
309 return;
310 }
311 let Some(key) = key_path() else { return };
312 if !key.exists() {
313 crate::config::complain(
314 TOGGLE,
315 &format!("signing key {} does not exist", key.display()),
316 "no attestation (CI will run the tests)",
317 );
318 return;
319 }
320 let mut blocks: Vec<(String, String)> = Vec::new();
321 let mut input_blocks: Vec<(String, String)> = Vec::new();
322 for r in refs {
323 if is_zero(&r.local_oid) {
324 continue; }
326 let spec = format!("{}^{{tree}}", r.local_oid);
327 let Some(tree) = crate::git::stdout(&["rev-parse", &spec]) else {
328 continue;
329 };
330 let inputs = inputs_for(&tree, gates);
333 let p = payload(&tree, gates, &inputs);
334 let body = match sign(&p, &key) {
335 Some(sig) => format!("{p}\n{sig}"),
336 None => continue,
337 };
338 for (gate, fp) in &inputs {
339 if let Some(k) = input_key(gate, fp) {
340 input_blocks.push((k, body.clone()));
341 }
342 }
343 blocks.push((tree, body.clone()));
361 blocks.push((r.local_oid.clone(), body));
362 }
363 if blocks.is_empty() {
364 return;
365 }
366 let main_ok = publish(remote, NOTES_FULL_REF, &blocks);
370 let inputs_ok = input_blocks.is_empty() || publish(remote, INPUTS_FULL_REF, &input_blocks);
371 if main_ok {
372 crate::say!(
373 "{} attested {} for CI ({}{})",
374 crate::ui::valid_sign(),
375 crate::ui::highlight(&gates.join(" ")),
376 NOTES_REF,
377 if input_blocks.is_empty() {
378 String::new()
379 } else if inputs_ok {
380 format!(", {} input fingerprints", input_blocks.len())
381 } else {
382 ", input fingerprints not published".to_string()
383 },
384 );
385 }
386}
387
388const PUSH_ATTEMPTS: u32 = 4;
390
391fn publish(remote: &str, notes_ref: &str, blocks: &[(String, String)]) -> bool {
412 let short = notes_ref.trim_start_matches("refs/notes/");
413 let tmp = format!("amont-attest-push-{}-{short}", std::process::id());
414 let tmp_full = format!("refs/notes/{tmp}");
415 let fetch_spec = format!("+{notes_ref}:{tmp_full}");
416 let push_spec = format!("{tmp_full}:{notes_ref}");
417 let mut published = false;
418 for _ in 0..PUSH_ATTEMPTS {
419 let _ = crate::git::succeeds(&["update-ref", "-d", &tmp_full]);
420 let _ = crate::git::succeeds(&["fetch", "--quiet", remote, &fetch_spec]);
423 let mut appended = false;
424 for (object, body) in blocks {
425 let existing =
426 crate::git::stdout(&["notes", "--ref", &tmp, "show", object]).unwrap_or_default();
427 if existing.contains(body.trim_end()) {
428 continue;
429 }
430 if crate::git::succeeds(&["notes", "--ref", &tmp, "append", "-m", body, object]) {
431 appended = true;
432 }
433 }
434 if !appended {
435 published = true;
437 break;
438 }
439 match push_notes(remote, &push_spec) {
440 Push::Done => {
441 published = true;
442 break;
443 }
444 Push::Raced => continue,
445 Push::Refused => break,
446 }
447 }
448 if published {
449 let _ = crate::git::succeeds(&["update-ref", notes_ref, &tmp_full]);
450 }
451 let _ = crate::git::succeeds(&["update-ref", "-d", &tmp_full]);
452 published
453}
454
455enum Push {
457 Done,
458 Raced,
461 Refused,
463}
464
465pub fn covered(
488 signers: &std::path::Path,
489 principal: &str,
490 require_platform: Option<&str>,
491) -> Option<String> {
492 covered_within(signers, principal, require_platform, REMOTE_BUDGET_SECS)
493}
494
495const REMOTE_BUDGET_SECS: u64 = 15;
497
498#[derive(Debug, PartialEq)]
500enum Sync {
501 Judge,
503 Skip,
505}
506
507fn sync_mirror(budget: u64) -> Sync {
520 if !crate::git::succeeds(&["remote", "get-url", "origin"]) {
521 return Sync::Judge;
523 }
524 sweep_sync_refs();
525 let old = crate::git::stdout(&["rev-parse", "--verify", "--quiet", NOTES_FULL_REF]);
526 let tmp = format!("{SYNC_NAMESPACE}{}/amont-attest", std::process::id());
527 let fetched = crate::git::probe_remote(
528 &[
529 "fetch",
530 "--quiet",
531 "origin",
532 &format!("+{NOTES_FULL_REF}:{tmp}"),
533 ],
534 budget,
535 );
536 let verdict = match fetched {
537 crate::git::Probe::Exit(0) => {
538 let new = crate::git::stdout(&["rev-parse", "--verify", "--quiet", &tmp]);
539 let expected = old.clone().unwrap_or_default();
542 match new {
543 None => {
544 say_skip(&format!(
546 "the fetch of {NOTES_FULL_REF} left nothing to read; local mirror not judged"
547 ));
548 Sync::Skip
549 }
550 Some(new)
551 if crate::git::succeeds(&["update-ref", NOTES_FULL_REF, &new, &expected]) =>
552 {
553 Sync::Judge
554 }
555 Some(new) => {
556 let now =
562 crate::git::stdout(&["rev-parse", "--verify", "--quiet", NOTES_FULL_REF]);
563 if now.as_deref() == Some(new.as_str()) {
564 Sync::Judge
565 } else {
566 say_skip(&format!(
567 "cannot update {NOTES_FULL_REF} to origin's copy; local mirror not judged"
568 ));
569 Sync::Skip
570 }
571 }
572 }
573 }
574 crate::git::Probe::TimedOut(secs) => {
575 say_skip(&format!(
577 "origin did not answer within {secs} s for {NOTES_FULL_REF}; local mirror not judged, running everything"
578 ));
579 Sync::Skip
580 }
581 _ => match crate::git::probe_remote(
582 &["ls-remote", "--exit-code", "origin", NOTES_FULL_REF],
583 budget,
584 ) {
585 crate::git::Probe::Exit(2) => drop_mirror(old.as_deref()),
586 crate::git::Probe::Exit(0) => {
587 say_skip(&format!(
588 "origin has {NOTES_FULL_REF} but fetching it failed; local mirror not judged, running everything"
589 ));
590 Sync::Skip
591 }
592 crate::git::Probe::Exit(code) => {
593 say_skip(&format!(
594 "cannot fetch {NOTES_FULL_REF} from origin (exit {code}; no credentials? persist-credentials: false?); local mirror not judged, running everything"
595 ));
596 Sync::Skip
597 }
598 crate::git::Probe::TimedOut(secs) => {
599 say_skip(&format!(
600 "origin did not answer within {secs} s for {NOTES_FULL_REF}; local mirror not judged, running everything"
601 ));
602 Sync::Skip
603 }
604 crate::git::Probe::Failed => {
605 say_skip(&format!(
606 "cannot fetch {NOTES_FULL_REF} from origin (git did not run); local mirror not judged, running everything"
607 ));
608 Sync::Skip
609 }
610 },
611 };
612 let _ = crate::git::succeeds(&["update-ref", "-d", &tmp]);
613 report_stale_lock();
614 verdict
615}
616
617fn drop_mirror(old: Option<&str>) -> Sync {
621 let Some(oid) = old else {
622 return Sync::Skip; };
624 if crate::git::succeeds(&["update-ref", "-d", NOTES_FULL_REF, oid]) {
625 say(format!(
626 "amont: origin has no {NOTES_FULL_REF}; deleted the local mirror (was {oid})"
627 ));
628 say(format!(
629 "amont: undo locally: git update-ref {NOTES_FULL_REF} {oid} (undo the revocation: git push origin {oid}:{NOTES_FULL_REF})"
630 ));
631 } else {
632 say_skip(&format!(
633 "origin has no {NOTES_FULL_REF} but the local mirror could not be deleted (read-only .git?); local mirror not judged"
634 ));
635 }
636 Sync::Skip
637}
638
639const SYNC_NAMESPACE: &str = "refs/amont-tmp/";
641
642fn sweep_sync_refs() {
647 let Some(refs) = crate::git::stdout(&["for-each-ref", "--format=%(refname)", SYNC_NAMESPACE])
648 else {
649 return;
650 };
651 for r in refs.lines() {
652 let Some(pid) = r
653 .strip_prefix(SYNC_NAMESPACE)
654 .and_then(|rest| rest.split('/').next())
655 .filter(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
656 else {
657 continue;
658 };
659 if pid == std::process::id().to_string() || process_alive(pid) != Some(false) {
660 continue;
661 }
662 let _ = crate::git::succeeds(&["update-ref", "-d", r]);
663 }
664}
665
666fn process_alive(pid: &str) -> Option<bool> {
668 if !cfg!(unix) {
669 return None;
670 }
671 let out = Command::new("kill")
673 .args(["-0", pid])
674 .env("LC_ALL", "C")
675 .stdin(Stdio::null())
676 .stdout(Stdio::null())
677 .stderr(Stdio::piped())
678 .output()
679 .ok()?;
680 if out.status.success() {
681 return Some(true);
682 }
683 let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
686 Some(!err.contains("no such process"))
687}
688
689fn report_stale_lock() {
692 let lock = format!("{NOTES_FULL_REF}.lock");
693 if let Some(path) = crate::git::stdout(&["rev-parse", "--git-path", &lock]) {
694 if std::path::Path::new(&path).exists() {
695 say_skip(&format!(
696 "a git that was killed left a lock on {NOTES_FULL_REF}; if no git is running: rm {path}"
697 ));
698 }
699 }
700}
701
702fn say_skip(why: &str) {
704 say(format!("amont: {why}"));
705}
706
707thread_local! {
708 static CAPTURE: std::cell::RefCell<Option<Vec<String>>> = const { std::cell::RefCell::new(None) };
711}
712
713fn say(line: String) {
715 CAPTURE.with(|c| {
716 if let Some(v) = c.borrow_mut().as_mut() {
717 v.push(line.clone());
718 }
719 });
720 eprintln!("{line}");
721}
722
723fn covered_within(
725 signers: &std::path::Path,
726 principal: &str,
727 require_platform: Option<&str>,
728 budget: u64,
729) -> Option<String> {
730 if sync_mirror(budget) == Sync::Skip {
731 return None;
732 }
733 let head_tree = crate::git::stdout(&["rev-parse", "HEAD^{tree}"])?;
734 for candidate in [head_tree.as_str(), "HEAD", "HEAD^2"] {
748 let Some(object) = crate::git::stdout(&["rev-parse", "--verify", candidate]) else {
749 continue;
750 };
751 let Some(body) = crate::git::stdout(&["notes", "--ref", NOTES_REF, "show", &object]) else {
752 continue;
753 };
754 let Some((payload, sig)) = split_note(&body) else {
755 continue;
756 };
757 let mut lines = payload.lines();
761 if lines.next() != Some(FORMAT) {
762 continue;
763 }
764 let field = |name: &str| {
765 payload
766 .lines()
767 .find_map(|l| l.strip_prefix(name).and_then(|r| r.strip_prefix(' ')))
768 .map(str::trim)
769 };
770 let (Some(tree), Some(gates), Some(ran_on)) =
771 (field("tree"), field("gates"), field("platform"))
772 else {
773 continue;
774 };
775 if tree != head_tree || gates.is_empty() {
776 continue; }
778 if require_platform.is_some_and(|want| want != ran_on) {
782 continue;
783 }
784 if verify(&payload, &sig, signers, principal) {
785 return Some(gates.to_string());
786 }
787 }
788 None
789}
790
791pub fn default_signers() -> Option<PathBuf> {
802 let root = crate::git::stdout(&["rev-parse", "--show-toplevel"]).map(PathBuf::from);
803 [".forgejo/allowed_signers", ".github/allowed_signers"]
804 .into_iter()
805 .map(|rel| match &root {
806 Some(root) => root.join(rel),
807 None => PathBuf::from(rel),
808 })
809 .find(|p| p.exists())
810}
811
812pub fn first_principal(signers: &std::path::Path) -> Option<String> {
817 let body = std::fs::read_to_string(signers).ok()?;
818 body.lines()
819 .map(str::trim)
820 .find(|l| !l.is_empty() && !l.starts_with('#'))
821 .and_then(|l| l.split_whitespace().next())
822 .map(str::to_string)
823}
824
825fn split_note(body: &str) -> Option<(String, String)> {
829 let (payload, sig) = body.split_once("\n\n")?;
830 if !sig.starts_with("-----BEGIN SSH SIGNATURE-----") {
831 return None;
832 }
833 Some((format!("{payload}\n"), sig.to_string()))
834}
835
836fn push_notes(remote: &str, refspec: &str) -> Push {
842 let out = Command::new("git")
843 .args(["push", "--quiet", remote, refspec])
844 .env(PUSH_GUARD, "1")
845 .stdin(Stdio::null())
846 .stdout(Stdio::null())
847 .output();
848 let Ok(out) = out else { return Push::Refused };
849 if out.status.success() {
850 return Push::Done;
851 }
852 match classify_push_stderr(&String::from_utf8_lossy(&out.stderr)) {
853 PushFailure::Raced => Push::Raced,
854 PushFailure::Unreachable | PushFailure::Refused => Push::Refused,
855 }
856}
857
858#[derive(Debug, Clone, Copy, PartialEq, Eq)]
866pub enum PushFailure {
867 Raced,
868 Unreachable,
869 Refused,
870}
871
872pub fn classify_push_stderr(err: &str) -> PushFailure {
875 const RACED: [&str; 5] = [
876 "non-fast-forward",
877 "fetch first",
878 "stale info",
879 "cannot lock ref",
880 "failed to lock",
881 ];
882 const UNREACHABLE: [&str; 5] = [
883 "Could not resolve host",
884 "Connection refused",
885 "Connection timed out",
886 "unable to access",
887 "Could not read from remote repository",
888 ];
889 let own = |l: &&str| {
893 let l = l.trim_start();
894 l.starts_with("fatal:") || l.starts_with("ssh:") || l.starts_with("error:")
895 };
896 if RACED.iter().any(|m| err.contains(m)) {
897 PushFailure::Raced
898 } else if err
899 .lines()
900 .filter(own)
901 .any(|l| UNREACHABLE.iter().any(|m| l.contains(m)))
902 {
903 PushFailure::Unreachable
904 } else {
905 PushFailure::Refused
906 }
907}
908
909fn is_zero(oid: &str) -> bool {
912 !oid.is_empty() && oid.bytes().all(|b| b == b'0')
913}
914
915pub fn forget() -> bool {
919 crate::git::succeeds(&["update-ref", "-d", NOTES_FULL_REF])
920}
921
922pub fn forget_in(repo: &std::path::Path) -> bool {
924 crate::git::succeeds_in(repo, &["update-ref", "-d", NOTES_FULL_REF])
925}
926
927#[cfg(test)]
928mod tests {
929 use super::*;
930
931 #[test]
932 fn push_stderr_is_sorted_into_raced_unreachable_and_refused() {
933 assert_eq!(
934 classify_push_stderr(" ! [rejected] feat -> feat (non-fast-forward)"),
935 PushFailure::Raced
936 );
937 assert_eq!(
938 classify_push_stderr("error: failed to lock ref"),
939 PushFailure::Raced
940 );
941 for line in [
942 "fatal: unable to access 'https://h/x.git/': Could not resolve host: h",
943 "ssh: connect to host h port 22: Connection refused",
944 "fatal: Could not read from remote repository.",
945 "fatal: unable to access 'u': Connection timed out after 10001 ms",
946 ] {
947 assert_eq!(
948 classify_push_stderr(line),
949 PushFailure::Unreachable,
950 "{line}"
951 );
952 }
953 assert_eq!(
954 classify_push_stderr("remote: permission denied"),
955 PushFailure::Refused
956 );
957 assert_eq!(classify_push_stderr(""), PushFailure::Refused);
958 }
959
960 #[test]
961 fn a_hooks_own_words_do_not_make_the_remote_unreachable() {
962 assert_eq!(
964 classify_push_stderr("integration: Connection refused by the fixture\nhook failed"),
965 PushFailure::Refused
966 );
967 }
968
969 fn test_settings() -> crate::config::Settings {
972 crate::config::Settings::default()
973 }
974
975 use std::path::Path;
976
977 fn dir(name: &str) -> PathBuf {
981 let d = std::env::temp_dir().join(format!("attest-{name}-{}", std::process::id()));
982 let _ = std::fs::remove_dir_all(&d);
983 std::fs::create_dir_all(&d).unwrap();
984 d
985 }
986
987 fn git(dir: &Path, args: &[&str]) -> String {
996 let out = std::process::Command::new("git")
997 .arg("-C")
998 .arg(dir)
999 .args(args)
1000 .output()
1001 .expect("git");
1002 assert!(
1003 out.status.success(),
1004 "fixture: git {args:?} in {} exited {:?}: {}",
1005 dir.display(),
1006 out.status.code(),
1007 String::from_utf8_lossy(&out.stderr).trim()
1008 );
1009 String::from_utf8_lossy(&out.stdout).trim().to_string()
1010 }
1011
1012 fn repo(name: &str) -> PathBuf {
1013 let d = dir(name);
1014 git(&d, &["init", "-q", "--template=", "."]);
1015 git(&d, &["config", "user.email", "t@t.test"]);
1016 git(&d, &["config", "user.name", "t"]);
1017 d
1018 }
1019
1020 fn keypair(d: &Path) -> (PathBuf, PathBuf) {
1023 let key = d.join("attest_key");
1024 let ok = std::process::Command::new("ssh-keygen")
1025 .args(["-q", "-t", "ed25519", "-N", "", "-C", "test", "-f"])
1026 .arg(&key)
1027 .status()
1028 .expect("ssh-keygen must exist for these tests")
1029 .success();
1030 assert!(ok, "keygen failed");
1031 let pubkey = std::fs::read_to_string(key.with_extension("pub")).unwrap();
1032 let signers = d.join("allowed_signers");
1033 std::fs::write(
1034 &signers,
1035 format!("t@t.test namespaces=\"{NAMESPACE}\" {pubkey}"),
1036 )
1037 .unwrap();
1038 (key, signers)
1039 }
1040
1041 fn in_repo<T>(dir: &Path, f: impl FnOnce() -> T) -> T {
1044 let _guard = crate::TEST_CWD.lock().unwrap_or_else(|p| p.into_inner());
1045 let prev = std::env::current_dir().unwrap();
1046 std::env::set_current_dir(dir).unwrap();
1047 let r = f();
1048 std::env::set_current_dir(prev).unwrap();
1049 r
1050 }
1051
1052 #[test]
1057 fn the_signature_file_refuses_to_follow_what_is_already_there() {
1058 let dir = std::env::temp_dir().join(format!("amont-excl-{}", std::process::id()));
1059 let _ = std::fs::remove_dir_all(&dir);
1060 std::fs::create_dir_all(&dir).expect("temp dir");
1061
1062 let taken = dir.join("taken");
1064 std::fs::write(&taken, "original").expect("seed");
1065 assert!(create_exclusive(&taken).is_none());
1066 assert_eq!(std::fs::read_to_string(&taken).unwrap(), "original");
1067
1068 #[cfg(unix)]
1070 {
1071 let victim = dir.join("victim");
1072 std::fs::write(&victim, "precious").expect("seed");
1073 let link = dir.join("link");
1074 std::os::unix::fs::symlink(&victim, &link).expect("symlink");
1075 assert!(
1076 create_exclusive(&link).is_none(),
1077 "a symlink must be refused, not followed"
1078 );
1079 assert_eq!(
1080 std::fs::read_to_string(&victim).unwrap(),
1081 "precious",
1082 "the link target was written through"
1083 );
1084 }
1085
1086 let fresh = dir.join("fresh");
1088 assert!(create_exclusive(&fresh).is_some());
1089
1090 let _ = std::fs::remove_dir_all(&dir);
1091 }
1092
1093 #[test]
1094 fn the_payload_is_the_documented_contract() {
1095 let p = payload(
1096 "abc123",
1097 &["pre-push-pytest".into(), "pre-push-cargo-test".into()],
1098 &[],
1099 );
1100 let lines: Vec<&str> = p.lines().collect();
1101 assert_eq!(lines[0], FORMAT);
1102 assert_eq!(lines[1], "tree abc123");
1103 assert_eq!(lines[2], "gates pre-push-pytest pre-push-cargo-test");
1104 assert_eq!(lines[3], format!("platform {}", platform()));
1105 assert_eq!(lines[4], format!("amont {}", env!("CARGO_PKG_VERSION")));
1106 assert!(
1107 p.ends_with('\n'),
1108 "CI reconstructs these bytes; the trailing newline is part of them"
1109 );
1110 }
1111
1112 #[test]
1113 fn sign_verify_roundtrip_and_tamper_rejection() {
1114 let d = dir("roundtrip");
1115 let (key, signers) = keypair(&d);
1116 let p = payload("deadbeef", &["pre-push-pytest".into()], &[]);
1117 let sig = sign(&p, &key).expect("signing with a real key succeeds");
1118 assert!(verify(&p, &sig, &signers, "t@t.test"));
1119 let tampered = payload("deadbeee", &["pre-push-pytest".into()], &[]);
1122 assert!(!verify(&tampered, &sig, &signers, "t@t.test"));
1123 assert!(!verify(&p, &sig, &signers, "someone@else.test"));
1125 let _ = std::fs::remove_dir_all(&d);
1126 }
1127
1128 #[test]
1129 fn a_missing_key_signs_nothing() {
1130 assert!(sign("anything", Path::new("/nonexistent/key")).is_none());
1131 }
1132
1133 #[test]
1137 fn an_enabled_push_leaves_a_verifiable_note_on_the_remote() {
1138 let d = dir("e2e");
1139 let (key, signers) = keypair(&d);
1140 let remote = d.join("remote.git");
1141 std::fs::create_dir_all(&remote).unwrap();
1142 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
1143 let work = repo("e2e-work");
1144 git(
1145 &work,
1146 &["remote", "add", "origin", remote.to_str().unwrap()],
1147 );
1148 git(&work, &["config", "amont.attest", "true"]);
1149 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
1150 std::fs::write(work.join("a.ts"), "x").unwrap();
1151 git(&work, &["add", "a.ts"]);
1152 git(&work, &["commit", "-qm", "chore: a"]);
1153 let head = git(&work, &["rev-parse", "HEAD"]);
1154 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1155 let push_ref = PushRef {
1156 local_ref: "refs/heads/main".into(),
1157 local_oid: head.clone(),
1158 remote_ref: "refs/heads/main".into(),
1159 remote_oid: "0".repeat(40),
1160 };
1161 in_repo(&work, || {
1162 attest_push(
1163 &test_settings(),
1164 "origin",
1165 &[push_ref],
1166 &["pre-push-run-tests-js".into()],
1167 );
1168 });
1169 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &head]);
1171 assert!(!body.is_empty(), "no note reached the remote");
1172 let (p, sig) = body
1173 .split_once("\n\n")
1174 .expect("payload, blank line, signature");
1175 let p = format!("{p}\n"); assert!(p.starts_with(FORMAT));
1177 assert!(
1178 p.contains(&format!("tree {tree}")),
1179 "attests the pushed tree"
1180 );
1181 assert!(
1182 verify(&p, sig, &signers, "t@t.test"),
1183 "the remote copy verifies"
1184 );
1185 let _ = std::fs::remove_dir_all(&d);
1186 let _ = std::fs::remove_dir_all(&work);
1187 }
1188
1189 fn remote_and_work(name: &str) -> (PathBuf, PathBuf, PathBuf, PathBuf) {
1191 let d = dir(name);
1192 let (key, signers) = keypair(&d);
1193 let remote = d.join("remote.git");
1194 std::fs::create_dir_all(&remote).unwrap();
1195 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
1196 let work = repo(&format!("{name}-work"));
1197 git(
1198 &work,
1199 &["remote", "add", "origin", remote.to_str().unwrap()],
1200 );
1201 git(&work, &["config", "amont.attest", "true"]);
1202 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
1203 std::fs::write(work.join("a.ts"), "x").unwrap();
1204 git(&work, &["add", "a.ts"]);
1205 git(&work, &["commit", "-qm", "chore: a"]);
1206 (d, work, remote, signers)
1207 }
1208
1209 fn push_ref_for(work: &Path) -> PushRef {
1210 PushRef {
1211 local_ref: "refs/heads/main".into(),
1212 local_oid: git(work, &["rev-parse", "HEAD"]),
1213 remote_ref: "refs/heads/main".into(),
1214 remote_oid: "0".repeat(40),
1215 }
1216 }
1217
1218 fn blocks_in(body: &str) -> usize {
1219 body.matches("-----BEGIN SSH SIGNATURE-----").count()
1220 }
1221
1222 #[test]
1226 fn a_block_already_on_the_remote_survives_and_ours_is_appended() {
1227 let (d, work, remote, _) = remote_and_work("append");
1228 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1229 let seed = repo("append-seed");
1231 git(
1232 &seed,
1233 &["remote", "add", "origin", remote.to_str().unwrap()],
1234 );
1235 git(&seed, &["fetch", "-q", "origin"]);
1236 std::fs::write(seed.join("a.ts"), "x").unwrap();
1237 git(&seed, &["add", "a.ts"]);
1238 git(&seed, &["commit", "-qm", "chore: a"]);
1239 let foreign = "amont-attest-v2\ntree x\ngates ci-fmt\nplatform s390x-aix\namont other\n\n-----BEGIN SSH SIGNATURE-----\nnope\n-----END SSH SIGNATURE-----";
1240 git(
1241 &seed,
1242 &["notes", "--ref", NOTES_REF, "add", "-m", foreign, &tree],
1243 );
1244 git(
1245 &seed,
1246 &[
1247 "push",
1248 "-q",
1249 "origin",
1250 &format!("{NOTES_FULL_REF}:{NOTES_FULL_REF}"),
1251 ],
1252 );
1253 in_repo(&work, || {
1254 attest_push(
1255 &test_settings(),
1256 "origin",
1257 &[push_ref_for(&work)],
1258 &["pre-push-run-tests-js".into()],
1259 );
1260 });
1261 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &tree]);
1262 assert_eq!(blocks_in(&body), 2, "both blocks on the remote:\n{body}");
1263 assert!(body.contains("gates ci-fmt"), "the foreign block survives");
1264 assert!(
1265 body.contains("gates pre-push-run-tests-js"),
1266 "ours was appended"
1267 );
1268 let _ = std::fs::remove_dir_all(&d);
1269 let _ = std::fs::remove_dir_all(&work);
1270 let _ = std::fs::remove_dir_all(&seed);
1271 }
1272
1273 #[test]
1278 fn a_stale_local_notes_ref_no_longer_loses_the_push() {
1279 let (d, work, remote, _) = remote_and_work("stale");
1280 let head = git(&work, &["rev-parse", "HEAD"]);
1281 git(
1284 &work,
1285 &[
1286 "notes",
1287 "--ref",
1288 NOTES_REF,
1289 "add",
1290 "-m",
1291 "stale local",
1292 &head,
1293 ],
1294 );
1295 let stale = git(&work, &["rev-parse", NOTES_FULL_REF]);
1296 let seed = repo("stale-seed");
1298 std::fs::write(seed.join("b.ts"), "y").unwrap();
1299 git(&seed, &["add", "b.ts"]);
1300 git(&seed, &["commit", "-qm", "chore: b"]);
1301 git(
1302 &seed,
1303 &["remote", "add", "origin", remote.to_str().unwrap()],
1304 );
1305 git(
1306 &seed,
1307 &[
1308 "notes",
1309 "--ref",
1310 NOTES_REF,
1311 "add",
1312 "-m",
1313 "remote first",
1314 "HEAD",
1315 ],
1316 );
1317 git(&seed, &["push", "-q", "origin", "HEAD:refs/heads/other"]);
1318 git(
1319 &seed,
1320 &[
1321 "push",
1322 "-q",
1323 "origin",
1324 &format!("{NOTES_FULL_REF}:{NOTES_FULL_REF}"),
1325 ],
1326 );
1327 in_repo(&work, || {
1328 attest_push(
1329 &test_settings(),
1330 "origin",
1331 &[push_ref_for(&work)],
1332 &["pre-push-run-tests-js".into()],
1333 );
1334 });
1335 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &head]);
1336 assert!(
1337 body.contains("gates pre-push-run-tests-js"),
1338 "the push landed:\n{body}"
1339 );
1340 assert_ne!(
1341 git(&work, &["rev-parse", NOTES_FULL_REF]),
1342 stale,
1343 "the local ref followed the published state"
1344 );
1345 assert!(
1346 git(&work, &["for-each-ref", "refs/notes/amont-attest-push-*"]).is_empty(),
1347 "no temporary ref left behind"
1348 );
1349 let _ = std::fs::remove_dir_all(&d);
1350 let _ = std::fs::remove_dir_all(&work);
1351 let _ = std::fs::remove_dir_all(&seed);
1352 }
1353
1354 #[test]
1357 fn a_second_push_of_the_same_tree_appends_nothing() {
1358 let (d, work, remote, _) = remote_and_work("twice");
1359 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1360 for _ in 0..2 {
1361 in_repo(&work, || {
1362 attest_push(
1363 &test_settings(),
1364 "origin",
1365 &[push_ref_for(&work)],
1366 &["pre-push-run-tests-js".into()],
1367 );
1368 });
1369 }
1370 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &tree]);
1371 assert_eq!(blocks_in(&body), 1, "one block, not two:\n{body}");
1372 let _ = std::fs::remove_dir_all(&d);
1373 let _ = std::fs::remove_dir_all(&work);
1374 }
1375
1376 #[test]
1380 fn a_spec_yields_input_lines_and_fingerprint_keyed_notes() {
1381 let (d, work, remote, _) = remote_and_work("inputs");
1382 std::fs::create_dir_all(work.join(".github")).unwrap();
1383 std::fs::write(
1384 work.join(".github/attest-inputs"),
1385 "# what each gate reads\npre-push-run-tests-js a.ts\nother nope\n",
1386 )
1387 .unwrap();
1388 git(&work, &["add", ".github/attest-inputs"]);
1389 git(&work, &["commit", "-qm", "chore: spec"]);
1390 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1391 let listing = std::process::Command::new("git")
1393 .args([
1394 "-C",
1395 work.to_str().unwrap(),
1396 "ls-tree",
1397 "-r",
1398 "-z",
1399 "--full-tree",
1400 &tree,
1401 "--",
1402 ".forgejo/attest-inputs",
1403 ".github/attest-inputs",
1404 ".gitmodules",
1405 ".gitattributes",
1406 "a.ts",
1407 ])
1408 .output()
1409 .unwrap()
1410 .stdout;
1411 let expected =
1412 crate::git::stdout_piped_in(&work, &["hash-object", "--stdin"], &listing).unwrap();
1413 in_repo(&work, || {
1414 attest_push(
1415 &test_settings(),
1416 "origin",
1417 &[push_ref_for(&work)],
1418 &["pre-push-run-tests-js".into(), "other".into()],
1419 );
1420 });
1421 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &tree]);
1422 assert!(
1423 body.contains(&format!("input pre-push-run-tests-js {expected}\n")),
1424 "the input line carries the reference fingerprint:\n{body}"
1425 );
1426 assert!(
1427 !body.contains("input other "),
1428 "a gate whose path does not exist gets no line"
1429 );
1430 let key = crate::git::stdout_piped_in(
1431 &work,
1432 &["hash-object", "--stdin"],
1433 format!("amont-attest-input pre-push-run-tests-js {expected}\n").as_bytes(),
1434 )
1435 .unwrap();
1436 let under_key = git(&remote, &["notes", "--ref", INPUTS_REF, "show", &key]);
1437 assert_eq!(
1438 under_key, body,
1439 "the same block is filed under the fingerprint key"
1440 );
1441 in_repo(&work, || {
1443 attest_push(
1444 &test_settings(),
1445 "origin",
1446 &[push_ref_for(&work)],
1447 &["pre-push-run-tests-js".into(), "other".into()],
1448 );
1449 });
1450 assert_eq!(
1451 git(&remote, &["notes", "--ref", INPUTS_REF, "show", &key])
1452 .matches("BEGIN SSH SIGNATURE")
1453 .count(),
1454 1
1455 );
1456 let _ = std::fs::remove_dir_all(&d);
1457 let _ = std::fs::remove_dir_all(&work);
1458 }
1459
1460 #[test]
1463 fn without_a_valid_spec_the_block_is_tree_keyed_only() {
1464 let (d, work, remote, _) = remote_and_work("nospec");
1465 std::fs::create_dir_all(work.join(".github")).unwrap();
1466 std::fs::write(
1467 work.join(".github/attest-inputs"),
1468 "pre-push-run-tests-js src/*.ts\n",
1469 )
1470 .unwrap();
1471 git(&work, &["add", ".github/attest-inputs"]);
1472 git(&work, &["commit", "-qm", "chore: bad spec"]);
1473 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1474 in_repo(&work, || {
1475 attest_push(
1476 &test_settings(),
1477 "origin",
1478 &[push_ref_for(&work)],
1479 &["pre-push-run-tests-js".into()],
1480 );
1481 });
1482 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &tree]);
1483 assert!(!body.contains("\ninput "), "no input line:\n{body}");
1484 assert!(
1485 !crate::git::succeeds_in(
1486 &remote,
1487 &["rev-parse", "--verify", "--quiet", INPUTS_FULL_REF]
1488 ),
1489 "no inputs ref was created"
1490 );
1491 let _ = std::fs::remove_dir_all(&d);
1492 let _ = std::fs::remove_dir_all(&work);
1493 }
1494
1495 #[test]
1496 fn the_spec_grammar_is_attests() {
1497 assert!(parse_spec(b"# c\ng src Cargo.toml\n").is_some());
1498 for bad in [
1499 b"g src/*.rs\n".as_slice(),
1500 b"g :!x\n",
1501 b"g\n",
1502 b"g src\ng x\n",
1503 b"g src\r\n",
1504 b"g sr\xc3\xa9\n",
1505 b"-g src\n",
1506 b"g ./src\n",
1507 b"g src/\n",
1508 b"g a/../b\n",
1509 ] {
1510 assert!(parse_spec(bad).is_none(), "{bad:?}");
1511 }
1512 assert_eq!(
1513 implicit_inputs(&["crates/foo/src".into()]),
1514 [
1515 ".forgejo/attest-inputs",
1516 ".github/attest-inputs",
1517 ".gitmodules",
1518 ".gitattributes",
1519 "crates/.gitattributes",
1520 "crates/foo/.gitattributes"
1521 ]
1522 );
1523 }
1524
1525 #[test]
1528 fn no_opt_in_means_no_note() {
1529 let d = dir("optout");
1530 let (key, _) = keypair(&d);
1531 let remote = d.join("remote.git");
1532 std::fs::create_dir_all(&remote).unwrap();
1533 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
1534 let work = repo("optout-work");
1535 git(
1536 &work,
1537 &["remote", "add", "origin", remote.to_str().unwrap()],
1538 );
1539 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
1540 std::fs::write(work.join("a.ts"), "x").unwrap();
1541 git(&work, &["add", "a.ts"]);
1542 git(&work, &["commit", "-qm", "chore: a"]);
1543 let head = git(&work, &["rev-parse", "HEAD"]);
1544 let push_ref = PushRef {
1545 local_ref: "refs/heads/main".into(),
1546 local_oid: head.clone(),
1547 remote_ref: "refs/heads/main".into(),
1548 remote_oid: "0".repeat(40),
1549 };
1550 in_repo(&work, || {
1551 attest_push(
1552 &test_settings(),
1553 "origin",
1554 &[push_ref],
1555 &["pre-push-run-tests-js".into()],
1556 );
1557 });
1558 assert!(
1559 git(&remote, &["notes", "--ref", NOTES_REF, "list"]).is_empty(),
1560 "an un-opted-in repo attested something"
1561 );
1562 let _ = std::fs::remove_dir_all(&d);
1563 let _ = std::fs::remove_dir_all(&work);
1564 }
1565
1566 #[test]
1569 fn deletions_and_empty_gates_attest_nothing() {
1570 let d = dir("nothing");
1571 let (key, _) = keypair(&d);
1572 let remote = d.join("remote.git");
1573 std::fs::create_dir_all(&remote).unwrap();
1574 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
1575 let work = repo("nothing-work");
1576 git(
1577 &work,
1578 &["remote", "add", "origin", remote.to_str().unwrap()],
1579 );
1580 git(&work, &["config", "amont.attest", "true"]);
1581 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
1582 std::fs::write(work.join("a.ts"), "x").unwrap();
1583 git(&work, &["add", "a.ts"]);
1584 git(&work, &["commit", "-qm", "chore: a"]);
1585 let head = git(&work, &["rev-parse", "HEAD"]);
1586 let deletion = PushRef {
1587 local_ref: "(delete)".into(),
1588 local_oid: "0".repeat(40),
1589 remote_ref: "refs/heads/gone".into(),
1590 remote_oid: head.clone(),
1591 };
1592 let real = PushRef {
1593 local_ref: "refs/heads/main".into(),
1594 local_oid: head,
1595 remote_ref: "refs/heads/main".into(),
1596 remote_oid: "0".repeat(40),
1597 };
1598 in_repo(&work, || {
1599 attest_push(
1600 &test_settings(),
1601 "origin",
1602 &[deletion],
1603 &["pre-push-pytest".into()],
1604 );
1605 attest_push(&test_settings(), "origin", &[real], &[]);
1606 });
1607 assert!(git(&remote, &["notes", "--ref", NOTES_REF, "list"]).is_empty());
1608 let _ = std::fs::remove_dir_all(&d);
1609 let _ = std::fs::remove_dir_all(&work);
1610 }
1611
1612 #[test]
1617 fn covered_answers_in_a_fresh_clone_and_rejects_drift_and_forgery() {
1618 let d = dir("covered");
1619 let (key, signers) = keypair(&d);
1620 let remote = d.join("remote.git");
1621 std::fs::create_dir_all(&remote).unwrap();
1622 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
1623 git(&remote, &["symbolic-ref", "HEAD", "refs/heads/main"]);
1629 let work = repo("covered-work");
1630 git(
1631 &work,
1632 &["remote", "add", "origin", remote.to_str().unwrap()],
1633 );
1634 git(&work, &["config", "amont.attest", "true"]);
1635 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
1636 std::fs::write(work.join("a.ts"), "x").unwrap();
1637 git(&work, &["add", "a.ts"]);
1638 git(&work, &["commit", "-qm", "chore: a"]);
1639 git(&work, &["push", "-q", "origin", "HEAD:main"]);
1640 let head = git(&work, &["rev-parse", "HEAD"]);
1641 let push_ref = PushRef {
1642 local_ref: "refs/heads/main".into(),
1643 local_oid: head.clone(),
1644 remote_ref: "refs/heads/main".into(),
1645 remote_oid: "0".repeat(40),
1646 };
1647 in_repo(&work, || {
1648 attest_push(
1649 &test_settings(),
1650 "origin",
1651 &[push_ref],
1652 &["pre-push-pytest".into()],
1653 );
1654 });
1655 let clone = d.join("ci-checkout");
1656 git(
1657 &d,
1658 &[
1659 "clone",
1660 "-q",
1661 "--template=",
1662 remote.to_str().unwrap(),
1663 clone.to_str().unwrap(),
1664 ],
1665 );
1666 in_repo(&clone, || {
1667 assert_eq!(
1668 covered(&signers, "t@t.test", Some(&platform())).as_deref(),
1669 Some("pre-push-pytest"),
1670 "a fresh clone verifies the attestation and reads the gates"
1671 );
1672 assert_eq!(
1673 covered(&signers, "t@t.test", None).as_deref(),
1674 Some("pre-push-pytest"),
1675 "`any` covers a platform-independent suite"
1676 );
1677 assert_eq!(
1680 covered(&signers, "t@t.test", Some("s390x-aix")),
1681 None,
1682 "a pass on one platform is not evidence about another"
1683 );
1684 assert_eq!(
1685 covered(&signers, "someone@else.test", None),
1686 None,
1687 "an unlisted principal covers nothing"
1688 );
1689 });
1690 std::fs::write(clone.join("b.ts"), "y").unwrap();
1692 git(&clone, &["config", "user.email", "t@t.test"]);
1693 git(&clone, &["config", "user.name", "t"]);
1694 git(&clone, &["add", "b.ts"]);
1695 git(&clone, &["commit", "-qm", "chore: b"]);
1696 in_repo(&clone, || {
1697 assert_eq!(covered(&signers, "t@t.test", None), None, "drifted tree");
1698 });
1699 let head_tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1709 for object in [&head, &head_tree] {
1710 git(
1711 &work,
1712 &[
1713 "notes", "--ref", NOTES_REF, "add", "-f", "-m", "garbage", object,
1714 ],
1715 );
1716 }
1717 git(
1718 &work,
1719 &[
1720 "push",
1721 "-q",
1722 "origin",
1723 &format!("+{NOTES_FULL_REF}:{NOTES_FULL_REF}"),
1724 ],
1725 );
1726 git(&clone, &["reset", "-q", "--hard", &head]);
1727 in_repo(&clone, || {
1728 assert_eq!(
1729 covered(&signers, "t@t.test", None),
1730 None,
1731 "a foreign note is not a stamp"
1732 );
1733 });
1734 let _ = std::fs::remove_dir_all(&d);
1735 let _ = std::fs::remove_dir_all(&work);
1736 }
1737
1738 #[test]
1744 fn default_signers_is_found_from_a_subdirectory() {
1745 let work = repo("signers-subdir");
1746 std::fs::create_dir_all(work.join(".forgejo")).unwrap();
1747 std::fs::write(work.join(".forgejo/allowed_signers"), "t@t.test x\n").unwrap();
1748 let sub = work.join("packages").join("thing");
1749 std::fs::create_dir_all(&sub).unwrap();
1750 in_repo(&sub, || {
1751 let found = default_signers().expect("resolved from the repo root, not the cwd");
1752 assert!(found.ends_with(".forgejo/allowed_signers"));
1753 assert!(found.exists(), "the path it returns must be usable as-is");
1754 assert_eq!(
1755 first_principal(&found).as_deref(),
1756 Some("t@t.test"),
1757 "and readable from there"
1758 );
1759 });
1760 let _ = std::fs::remove_dir_all(&work);
1761 }
1762
1763 #[test]
1764 fn zero_oids_of_any_width_are_zero() {
1765 assert!(is_zero(&"0".repeat(40)));
1766 assert!(is_zero(&"0".repeat(64)));
1767 assert!(!is_zero("0a0000"));
1768 assert!(!is_zero(""));
1769 }
1770
1771 #[test]
1772 fn forget_removes_the_local_ref() {
1773 let work = repo("forget");
1774 std::fs::write(work.join("a.ts"), "x").unwrap();
1775 git(&work, &["add", "a.ts"]);
1776 git(&work, &["commit", "-qm", "chore: a"]);
1777 git(
1778 &work,
1779 &["notes", "--ref", NOTES_REF, "add", "-m", "x", "HEAD"],
1780 );
1781 in_repo(&work, forget);
1782 assert!(git(&work, &["notes", "--ref", NOTES_REF, "list"]).is_empty());
1783 let _ = std::fs::remove_dir_all(&work);
1784 }
1785
1786 #[test]
1801 fn an_attestation_survives_a_rewrite_that_keeps_the_tree() {
1802 let d = dir("rewritten");
1803 let (key, signers) = keypair(&d);
1804 let remote = d.join("remote.git");
1805 std::fs::create_dir_all(&remote).unwrap();
1806 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
1807 git(&remote, &["symbolic-ref", "HEAD", "refs/heads/main"]);
1808 let work = repo("rewritten-work");
1809 git(
1810 &work,
1811 &["remote", "add", "origin", remote.to_str().unwrap()],
1812 );
1813 git(&work, &["config", "amont.attest", "true"]);
1814 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
1815 std::fs::write(work.join("a.ts"), "x").unwrap();
1816 git(&work, &["add", "a.ts"]);
1817 git(&work, &["commit", "-qm", "feat: on a branch"]);
1818 git(&work, &["push", "-q", "origin", "HEAD:main"]);
1819 let branch_tip = git(&work, &["rev-parse", "HEAD"]);
1820 let push_ref = PushRef {
1821 local_ref: "refs/heads/main".into(),
1822 local_oid: branch_tip.clone(),
1823 remote_ref: "refs/heads/main".into(),
1824 remote_oid: "0".repeat(40),
1825 };
1826 in_repo(&work, || {
1827 attest_push(
1828 &test_settings(),
1829 "origin",
1830 &[push_ref],
1831 &["pre-push-pytest".into()],
1832 );
1833 });
1834
1835 git(
1839 &work,
1840 &[
1841 "commit",
1842 "-q",
1843 "--amend",
1844 "-m",
1845 "feat: squashed by the forge",
1846 ],
1847 );
1848 let rewritten = git(&work, &["rev-parse", "HEAD"]);
1849 assert_ne!(rewritten, branch_tip, "the fixture must rewrite the commit");
1850 assert_eq!(
1851 git(&work, &["rev-parse", "HEAD^{tree}"]),
1852 git(&work, &["rev-parse", &format!("{branch_tip}^{{tree}}")]),
1853 "…while preserving the tree, which is the premise"
1854 );
1855 git(&work, &["push", "-q", "-f", "origin", "HEAD:main"]);
1856
1857 let clone = d.join("ci-checkout");
1858 git(
1859 &d,
1860 &[
1861 "clone",
1862 "-q",
1863 "--template=",
1864 remote.to_str().unwrap(),
1865 clone.to_str().unwrap(),
1866 ],
1867 );
1868 in_repo(&clone, || {
1869 assert_eq!(
1870 covered(&signers, "t@t.test", None).as_deref(),
1871 Some("pre-push-pytest"),
1872 "the attestation must be found by the tree it signed"
1873 );
1874 });
1875 let _ = std::fs::remove_dir_all(&d);
1876 }
1877
1878 #[test]
1881 fn optional_paths_are_marked_once_and_stripped() {
1882 let spec = parse_spec(b"g src ?build.rs ?a/b/c\n").expect("valid");
1883 assert_eq!(
1884 spec[0].1,
1885 [
1886 PathTok {
1887 path: "src".into(),
1888 optional: false
1889 },
1890 PathTok {
1891 path: "build.rs".into(),
1892 optional: true
1893 },
1894 PathTok {
1895 path: "a/b/c".into(),
1896 optional: true
1897 },
1898 ]
1899 );
1900 assert!(
1901 parse_spec(b"g ?nope\n").is_some(),
1902 "an all-optional gate is a gate"
1903 );
1904 for bad in [
1905 "?", "??x", "?/x", "?./x", "?x/", "?:x", "x?", "?a//b", "?a/../b",
1906 ] {
1907 assert!(
1908 parse_spec(format!("g {bad}\n").as_bytes()).is_none(),
1909 "{bad} should be invalid"
1910 );
1911 }
1912 }
1913
1914 #[test]
1918 fn an_absent_optional_path_is_bound_by_its_absence() {
1919 let work = repo("optional-fp");
1920 std::fs::create_dir_all(work.join("src")).unwrap();
1921 std::fs::create_dir_all(work.join(".github")).unwrap();
1922 std::fs::write(work.join("src/main.rs"), "fn main() {}\n").unwrap();
1923 std::fs::write(work.join(".github/attest-inputs"), "g src ?nope\n").unwrap();
1924 git(&work, &["add", "-A"]);
1925 git(&work, &["commit", "-qm", "chore: spec"]);
1926 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1927 let spec_oid = git(
1928 &work,
1929 &["rev-parse", &format!("{tree}:.github/attest-inputs")],
1930 );
1931 let main_oid = git(&work, &["rev-parse", &format!("{tree}:src/main.rs")]);
1932 let listing = format!(
1933 "100644 blob {spec_oid}\t.github/attest-inputs\x00100644 blob {main_oid}\tsrc/main.rs\x00"
1934 );
1935 let hand =
1936 crate::git::stdout_piped_in(&work, &["hash-object", "--stdin"], listing.as_bytes())
1937 .unwrap();
1938 let before = in_repo(&work, || {
1939 let spec = spec_at(&tree).expect("the spec parses");
1940 fingerprint(&tree, &spec[0].1)
1941 });
1942 assert_eq!(before.as_deref(), Some(hand.as_str()));
1943 std::fs::write(work.join("nope"), "now it exists\n").unwrap();
1944 git(&work, &["add", "nope"]);
1945 git(&work, &["commit", "-qm", "chore: nope"]);
1946 let tree2 = git(&work, &["rev-parse", "HEAD^{tree}"]);
1947 let after = in_repo(&work, || {
1948 let spec = spec_at(&tree2).expect("the spec parses");
1949 fingerprint(&tree2, &spec[0].1)
1950 });
1951 assert!(
1952 after.is_some() && after != before,
1953 "its appearance changes the fingerprint"
1954 );
1955 let _ = std::fs::remove_dir_all(&work);
1956 }
1957
1958 fn attested_clone(name: &str) -> (PathBuf, PathBuf, PathBuf, PathBuf) {
1963 let (d, work, remote, signers) = remote_and_work(name);
1964 git(&remote, &["symbolic-ref", "HEAD", "refs/heads/main"]);
1965 git(&work, &["push", "-q", "origin", "HEAD:main"]);
1966 in_repo(&work, || {
1967 attest_push(
1968 &test_settings(),
1969 "origin",
1970 &[push_ref_for(&work)],
1971 &["pre-push-pytest".into()],
1972 );
1973 });
1974 let clone = d.join("ci-checkout");
1975 git(
1976 &d,
1977 &[
1978 "clone",
1979 "-q",
1980 "--template=",
1981 remote.to_str().unwrap(),
1982 clone.to_str().unwrap(),
1983 ],
1984 );
1985 (d, remote, clone, signers)
1986 }
1987
1988 fn covers(clone: &Path, signers: &Path, budget: u64) -> Option<String> {
1989 covers_logged(clone, signers, budget).0
1990 }
1991
1992 fn covers_logged(clone: &Path, signers: &Path, budget: u64) -> (Option<String>, Vec<String>) {
1994 in_repo(clone, || {
1995 CAPTURE.with(|c| *c.borrow_mut() = Some(Vec::new()));
1996 let got = covered_within(signers, "t@t.test", None, budget);
1997 let lines = CAPTURE.with(|c| c.borrow_mut().take()).unwrap_or_default();
1998 (got, lines)
1999 })
2000 }
2001
2002 #[test]
2003 fn a_ref_revoked_on_origin_stops_covering_and_the_mirror_goes() {
2004 let (d, remote, clone, signers) = attested_clone("revoked");
2005 assert_eq!(
2006 covers(&clone, &signers, 15).as_deref(),
2007 Some("pre-push-pytest")
2008 );
2009 let oid = git(&clone, &["rev-parse", NOTES_FULL_REF]);
2010 git(&remote, &["update-ref", "-d", NOTES_FULL_REF]);
2011 let (got, lines) = covers_logged(&clone, &signers, 15);
2012 assert_eq!(got, None, "revoked on origin");
2013 assert_eq!(
2014 lines[0],
2015 format!("amont: origin has no {NOTES_FULL_REF}; deleted the local mirror (was {oid})")
2016 );
2017 assert!(
2018 in_repo(&clone, || crate::git::stdout(&[
2019 "rev-parse",
2020 "--verify",
2021 "--quiet",
2022 NOTES_FULL_REF
2023 ]))
2024 .is_none(),
2025 "the local mirror was deleted"
2026 );
2027 let undo = lines[1]
2029 .split("undo locally: git ")
2030 .nth(1)
2031 .and_then(|r| r.split(" (").next())
2032 .expect("an undo command");
2033 git(&clone, &undo.split(' ').collect::<Vec<_>>());
2034 assert_eq!(git(&clone, &["rev-parse", NOTES_FULL_REF]), oid);
2035 let _ = std::fs::remove_dir_all(&d);
2036 }
2037
2038 #[test]
2039 fn an_unreachable_origin_covers_nothing_and_keeps_the_mirror() {
2040 let (d, _remote, clone, signers) = attested_clone("unreachable");
2041 assert_eq!(
2042 covers(&clone, &signers, 15).as_deref(),
2043 Some("pre-push-pytest")
2044 );
2045 git(
2046 &clone,
2047 &[
2048 "remote",
2049 "set-url",
2050 "origin",
2051 "file:///nonexistent/amont-origin.git",
2052 ],
2053 );
2054 let (got, lines) = covers_logged(&clone, &signers, 15);
2055 assert_eq!(got, None, "a stale mirror is not judged");
2056 assert!(
2057 lines.iter().any(|l| l.starts_with(&format!(
2058 "amont: cannot fetch {NOTES_FULL_REF} from origin (exit "
2059 ))),
2060 "the reason is on stderr: {lines:?}"
2061 );
2062 assert!(
2063 !git(&clone, &["rev-parse", NOTES_FULL_REF]).is_empty(),
2064 "and it is kept"
2065 );
2066 let _ = std::fs::remove_dir_all(&d);
2067 }
2068
2069 #[test]
2072 fn without_an_origin_the_local_ref_is_judged() {
2073 let (d, _remote, clone, signers) = attested_clone("no-origin");
2074 assert_eq!(
2075 covers(&clone, &signers, 15).as_deref(),
2076 Some("pre-push-pytest")
2077 );
2078 git(&clone, &["remote", "remove", "origin"]);
2079 assert_eq!(
2080 covers(&clone, &signers, 15).as_deref(),
2081 Some("pre-push-pytest")
2082 );
2083 let _ = std::fs::remove_dir_all(&d);
2084 }
2085
2086 #[cfg(unix)]
2089 #[test]
2090 fn a_silent_origin_is_cut_off_and_leaves_nothing_behind() {
2091 use std::os::unix::fs::PermissionsExt;
2092 let (d, remote, clone, signers) = attested_clone("silent");
2093 let hang = d.join("hang-ssh");
2094 std::fs::write(&hang, "#!/bin/sh\nexec sleep 60\n").unwrap();
2095 std::fs::set_permissions(&hang, std::fs::Permissions::from_mode(0o755)).unwrap();
2096 git(
2098 &clone,
2099 &["config", "core.sshCommand", hang.to_str().unwrap()],
2100 );
2101 git(
2102 &clone,
2103 &["remote", "set-url", "origin", "ssh://example.invalid/x.git"],
2104 );
2105 let (got, took) = in_repo(&clone, || {
2108 let t0 = std::time::Instant::now();
2109 let got = covered_within(&signers, "t@t.test", None, 2);
2110 (got, t0.elapsed())
2111 });
2112 assert_eq!(got, None);
2113 assert!(took.as_secs() < 6, "took {took:?}");
2115 let lock = in_repo(&clone, || {
2116 crate::git::stdout(&["rev-parse", "--git-path", &format!("{NOTES_FULL_REF}.lock")])
2117 })
2118 .unwrap();
2119 assert!(
2120 !clone.join(&lock).exists() && !Path::new(&lock).exists(),
2121 "no lock left"
2122 );
2123 git(&clone, &["config", "--unset", "core.sshCommand"]);
2124 git(
2125 &clone,
2126 &["remote", "set-url", "origin", remote.to_str().unwrap()],
2127 );
2128 assert_eq!(
2129 covers(&clone, &signers, 15).as_deref(),
2130 Some("pre-push-pytest")
2131 );
2132 let _ = std::fs::remove_dir_all(&d);
2133 }
2134
2135 #[test]
2136 fn a_stale_lock_on_the_mirror_means_nothing_is_judged() {
2137 let (d, remote, clone, signers) = attested_clone("stale-lock");
2138 assert_eq!(
2139 covers(&clone, &signers, 15).as_deref(),
2140 Some("pre-push-pytest")
2141 );
2142 let lock = in_repo(&clone, || {
2143 crate::git::stdout(&["rev-parse", "--git-path", &format!("{NOTES_FULL_REF}.lock")])
2144 })
2145 .unwrap();
2146 let lock = if Path::new(&lock).is_absolute() {
2147 PathBuf::from(lock)
2148 } else {
2149 clone.join(lock)
2150 };
2151 std::fs::write(&lock, "").unwrap();
2152 assert_eq!(
2154 covers(&clone, &signers, 15).as_deref(),
2155 Some("pre-push-pytest"),
2156 "an unchanged origin: the copy is origin's"
2157 );
2158 let main = git(&remote, &["rev-parse", "main"]);
2161 git(
2163 &remote,
2164 &[
2165 "-c",
2166 "user.email=t@t.test",
2167 "-c",
2168 "user.name=t",
2169 "notes",
2170 "--ref",
2171 NOTES_REF,
2172 "add",
2173 "-f",
2174 "-m",
2175 "rewritten",
2176 &main,
2177 ],
2178 );
2179 let (got, lines) = covers_logged(&clone, &signers, 15);
2180 assert_eq!(got, None, "a pinned stale copy is never judged");
2181 assert!(
2182 lines.iter().any(|l| l.contains(&format!(
2183 "left a lock on {NOTES_FULL_REF}; if no git is running: rm "
2184 ))),
2185 "the lock is reported with its rm: {lines:?}"
2186 );
2187 std::fs::remove_file(&lock).unwrap();
2188 assert_eq!(
2189 covers(&clone, &signers, 15).as_deref(),
2190 Some("pre-push-pytest")
2191 );
2192 let _ = std::fs::remove_dir_all(&d);
2193 }
2194
2195 #[cfg(unix)]
2198 #[test]
2199 fn leftover_sync_refs_are_swept() {
2200 let (d, _remote, clone, signers) = attested_clone("sweep");
2201 let head = git(&clone, &["rev-parse", "HEAD"]);
2202 git(
2203 &clone,
2204 &["update-ref", "refs/amont-tmp/4194305/amont-attest", &head],
2206 );
2207 assert_eq!(
2208 covers(&clone, &signers, 15).as_deref(),
2209 Some("pre-push-pytest")
2210 );
2211 assert_eq!(git(&clone, &["for-each-ref", "refs/amont-tmp/"]), "");
2212 let _ = std::fs::remove_dir_all(&d);
2213 }
2214
2215 #[test]
2216 fn the_remote_environment_never_prompts() {
2217 let (args, env) = crate::git::remote_env(crate::git::Ssh::Batch);
2218 assert_eq!(
2219 args,
2220 [
2221 "-c",
2222 "http.lowSpeedLimit=1",
2223 "-c",
2224 "http.lowSpeedTime=10",
2225 "-c",
2226 "credential.interactive=never"
2227 ]
2228 );
2229 assert!(env.contains(&("GIT_TERMINAL_PROMPT", "0")));
2230 assert!(
2231 env.contains(&("GIT_ASKPASS", "")),
2232 "present and EMPTY disables every askpass"
2233 );
2234 assert!(env.contains(&("GCM_INTERACTIVE", "never")));
2235 assert!(env.contains(&(
2236 "GIT_SSH_COMMAND",
2237 "ssh -o BatchMode=yes -o ConnectTimeout=10"
2238 )));
2239 let (_, env) = crate::git::remote_env(crate::git::Ssh::User);
2240 assert!(
2241 !env.iter().any(|(k, _)| *k == "GIT_SSH_COMMAND"),
2242 "the user's own ssh command is left alone"
2243 );
2244 }
2245
2246 #[cfg(unix)]
2250 #[test]
2251 fn no_askpass_runs_against_an_origin_that_wants_credentials() {
2252 use std::io::{Read, Write};
2253 use std::os::unix::fs::PermissionsExt;
2254 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2255 let port = listener.local_addr().unwrap().port();
2256 std::thread::spawn(move || {
2257 for stream in listener.incoming().flatten() {
2258 let mut s = stream;
2259 let mut buf = [0u8; 4096];
2260 let _ = s.read(&mut buf);
2261 let _ = s.write_all(
2262 b"HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"x\"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
2263 );
2264 }
2265 });
2266 let work = repo("askpass");
2267 let marker = work.join("asked");
2268 let askpass = work.join("askpass.sh");
2269 std::fs::write(
2270 &askpass,
2271 format!("#!/bin/sh\ntouch '{}'\necho x\n", marker.display()),
2272 )
2273 .unwrap();
2274 std::fs::set_permissions(&askpass, std::fs::Permissions::from_mode(0o755)).unwrap();
2275 git(
2276 &work,
2277 &["config", "core.askPass", askpass.to_str().unwrap()],
2278 );
2279 git(&work, &["config", "credential.helper", ""]);
2280 let url = format!("http://127.0.0.1:{port}/x.git");
2281 in_repo(&work, || {
2282 let _ = crate::git::probe_remote(&["ls-remote", &url], 10);
2283 });
2284 assert!(
2285 !marker.exists(),
2286 "an askpass ran through the remote environment"
2287 );
2288 let _ = std::process::Command::new("git")
2292 .current_dir(&work)
2293 .args(["ls-remote", &url])
2294 .env("GIT_TERMINAL_PROMPT", "0")
2295 .env_remove("GIT_ASKPASS")
2296 .env_remove("SSH_ASKPASS")
2297 .stdin(std::process::Stdio::null())
2298 .output();
2299 assert!(
2300 marker.exists(),
2301 "negative control: without it the askpass does run"
2302 );
2303 let _ = std::fs::remove_dir_all(&work);
2304 }
2305}