1use std::path::PathBuf;
72use std::process::{Command, Stdio};
73
74use crate::attest_inputs::{canon_for_attest, canon_key, 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(
171 tree: &str,
172 gates: &[String],
173 inputs: &[(String, String)],
174 canon: Option<&str>,
175) -> String {
176 let mut p = format!(
177 "{FORMAT}\ntree {tree}\ngates {}\nplatform {}\n",
178 gates.join(" "),
179 platform()
180 );
181 for (gate, fp) in inputs {
182 p.push_str(&format!("input {gate} {fp}\n"));
183 }
184 if let Some(c) = canon {
185 p.push_str(&format!("canon {c}\n"));
186 }
187 p.push_str(&format!("amont {}\n", env!("CARGO_PKG_VERSION")));
188 p
189}
190
191fn inputs_for(tree: &str, gates: &[String]) -> Vec<(String, String)> {
194 let Some(spec) = spec_at(tree) else {
195 return Vec::new();
196 };
197 spec.iter()
198 .filter(|(g, _)| gates.iter().any(|x| x == g))
199 .filter_map(|(g, paths)| fingerprint(tree, paths).map(|fp| (g.clone(), fp)))
200 .collect()
201}
202
203fn sign(payload: &str, key: &std::path::Path) -> Option<String> {
207 use std::io::Write;
208 let mut child = Command::new("ssh-keygen")
209 .args(["-Y", "sign", "-n", NAMESPACE, "-f"])
210 .arg(key)
211 .stdin(Stdio::piped())
212 .stdout(Stdio::piped())
213 .stderr(Stdio::null())
214 .spawn()
215 .ok()?;
216 child.stdin.take()?.write_all(payload.as_bytes()).ok()?;
217 let out = child.wait_with_output().ok()?;
218 if !out.status.success() {
219 return None;
220 }
221 let sig = String::from_utf8_lossy(&out.stdout).trim().to_string();
222 sig.starts_with("-----BEGIN SSH SIGNATURE-----")
223 .then_some(sig)
224}
225
226fn create_exclusive(path: &std::path::Path) -> Option<std::fs::File> {
244 std::fs::OpenOptions::new()
245 .write(true)
246 .create_new(true)
247 .open(path)
248 .ok()
249}
250
251pub fn verify(
252 payload: &str,
253 sig: &str,
254 allowed_signers: &std::path::Path,
255 principal: &str,
256) -> bool {
257 use std::io::Write;
258 let nonce = std::time::SystemTime::now()
265 .duration_since(std::time::UNIX_EPOCH)
266 .map(|d| d.subsec_nanos())
267 .unwrap_or(0);
268 let sig_file = std::env::temp_dir().join(format!(
269 "amont-attest-verify-{}-{:p}-{nonce}.sig",
270 std::process::id(),
271 &sig
272 ));
273 let Some(mut f) = create_exclusive(&sig_file) else {
274 return false;
275 };
276 if f.write_all(format!("{sig}\n").as_bytes()).is_err() {
277 let _ = std::fs::remove_file(&sig_file);
278 return false;
279 }
280 drop(f);
282 let ok = (|| {
283 let mut child = Command::new("ssh-keygen")
284 .args(["-Y", "verify", "-n", NAMESPACE, "-I", principal, "-f"])
285 .arg(allowed_signers)
286 .arg("-s")
287 .arg(&sig_file)
288 .stdin(Stdio::piped())
289 .stdout(Stdio::null())
290 .stderr(Stdio::null())
291 .spawn()
292 .ok()?;
293 child.stdin.take()?.write_all(payload.as_bytes()).ok()?;
294 child.wait().ok().map(|s| s.success())
295 })()
296 .unwrap_or(false);
297 let _ = std::fs::remove_file(&sig_file);
298 ok
299}
300
301pub fn attest_push(
313 settings: &crate::config::Settings,
314 remote: &str,
315 refs: &[PushRef],
316 gates: &[String],
317) {
318 if gates.is_empty() || remote.is_empty() || !enabled(settings) {
319 return;
320 }
321 let Some(key) = key_path() else { return };
322 if !key.exists() {
323 crate::config::complain(
324 TOGGLE,
325 &format!("signing key {} does not exist", key.display()),
326 "no attestation (CI will run the tests)",
327 );
328 return;
329 }
330 let mut blocks: Vec<(String, String)> = Vec::new();
331 let mut input_blocks: Vec<(String, String)> = Vec::new();
332 let mut canon_blocks: Vec<(String, String)> = Vec::new();
333 for r in refs {
334 if is_zero(&r.local_oid) {
335 continue; }
337 let spec = format!("{}^{{tree}}", r.local_oid);
338 let Some(tree) = crate::git::stdout(&["rev-parse", &spec]) else {
339 continue;
340 };
341 let inputs = inputs_for(&tree, gates);
344 let canon = canon_for_attest(&tree);
349 let p = payload(&tree, gates, &inputs, canon.as_deref());
350 let body = match sign(&p, &key) {
351 Some(sig) => format!("{p}\n{sig}"),
352 None => continue,
353 };
354 for (gate, fp) in &inputs {
355 if let Some(k) = input_key(gate, fp) {
356 input_blocks.push((k, body.clone()));
357 }
358 }
359 if let Some(k) = canon.as_deref().and_then(canon_key) {
360 canon_blocks.push((k, body.clone()));
361 }
362 blocks.push((tree, body.clone()));
380 blocks.push((r.local_oid.clone(), body));
381 }
382 if blocks.is_empty() {
383 return;
384 }
385 let main_ok = publish(remote, NOTES_FULL_REF, &blocks);
389 let n_inputs = input_blocks.len();
390 input_blocks.extend(canon_blocks);
391 let inputs_ok = input_blocks.is_empty() || publish(remote, INPUTS_FULL_REF, &input_blocks);
392 if main_ok {
393 crate::say!(
394 "{} attested {} for CI ({}{})",
395 crate::ui::valid_sign(),
396 crate::ui::highlight(&gates.join(" ")),
397 NOTES_REF,
398 if input_blocks.is_empty() {
399 String::new()
400 } else if inputs_ok {
401 let canonical = input_blocks.len() - n_inputs;
402 match (n_inputs, canonical) {
403 (0, _) => ", canonical tree".to_string(),
404 (n, 0) => format!(", {n} input fingerprints"),
405 (n, _) => format!(", {n} input fingerprints, canonical tree"),
406 }
407 } else {
408 ", input fingerprints not published".to_string()
409 },
410 );
411 }
412}
413
414const PUSH_ATTEMPTS: u32 = 4;
416
417fn publish(remote: &str, notes_ref: &str, blocks: &[(String, String)]) -> bool {
438 let short = notes_ref.trim_start_matches("refs/notes/");
439 let tmp = format!("amont-attest-push-{}-{short}", std::process::id());
440 let tmp_full = format!("refs/notes/{tmp}");
441 let fetch_spec = format!("+{notes_ref}:{tmp_full}");
442 let push_spec = format!("{tmp_full}:{notes_ref}");
443 let mut published = false;
444 for _ in 0..PUSH_ATTEMPTS {
445 let _ = crate::git::succeeds(&["update-ref", "-d", &tmp_full]);
446 let _ = crate::git::succeeds(&["fetch", "--quiet", remote, &fetch_spec]);
449 let mut appended = false;
450 for (object, body) in blocks {
451 let existing =
452 crate::git::stdout(&["notes", "--ref", &tmp, "show", object]).unwrap_or_default();
453 if existing.contains(body.trim_end()) {
454 continue;
455 }
456 if crate::git::succeeds(&["notes", "--ref", &tmp, "append", "-m", body, object]) {
457 appended = true;
458 }
459 }
460 if !appended {
461 published = true;
463 break;
464 }
465 match push_notes(remote, &push_spec) {
466 Push::Done => {
467 published = true;
468 break;
469 }
470 Push::Raced => continue,
471 Push::Refused => break,
472 }
473 }
474 if published {
475 let _ = crate::git::succeeds(&["update-ref", notes_ref, &tmp_full]);
476 }
477 let _ = crate::git::succeeds(&["update-ref", "-d", &tmp_full]);
478 published
479}
480
481enum Push {
483 Done,
484 Raced,
487 Refused,
489}
490
491pub fn covered(
514 signers: &std::path::Path,
515 principal: &str,
516 require_platform: Option<&str>,
517) -> Option<String> {
518 covered_within(signers, principal, require_platform, REMOTE_BUDGET_SECS)
519}
520
521const REMOTE_BUDGET_SECS: u64 = 15;
523
524#[derive(Debug, PartialEq)]
526enum Sync {
527 Judge,
529 Skip,
531}
532
533fn sync_mirror(budget: u64) -> Sync {
546 if !crate::git::succeeds(&["remote", "get-url", "origin"]) {
547 return Sync::Judge;
549 }
550 sweep_sync_refs();
551 let old = crate::git::stdout(&["rev-parse", "--verify", "--quiet", NOTES_FULL_REF]);
552 let tmp = format!("{SYNC_NAMESPACE}{}/amont-attest", std::process::id());
553 let fetched = crate::git::probe_remote(
554 &[
555 "fetch",
556 "--quiet",
557 "origin",
558 &format!("+{NOTES_FULL_REF}:{tmp}"),
559 ],
560 budget,
561 );
562 let verdict = match fetched {
563 crate::git::Probe::Exit(0) => {
564 let new = crate::git::stdout(&["rev-parse", "--verify", "--quiet", &tmp]);
565 let expected = old.clone().unwrap_or_default();
568 match new {
569 None => {
570 say_skip(&format!(
572 "the fetch of {NOTES_FULL_REF} left nothing to read; local mirror not judged"
573 ));
574 Sync::Skip
575 }
576 Some(new)
577 if crate::git::succeeds(&["update-ref", NOTES_FULL_REF, &new, &expected]) =>
578 {
579 Sync::Judge
580 }
581 Some(new) => {
582 let now =
588 crate::git::stdout(&["rev-parse", "--verify", "--quiet", NOTES_FULL_REF]);
589 if now.as_deref() == Some(new.as_str()) {
590 Sync::Judge
591 } else {
592 say_skip(&format!(
593 "cannot update {NOTES_FULL_REF} to origin's copy; local mirror not judged"
594 ));
595 Sync::Skip
596 }
597 }
598 }
599 }
600 crate::git::Probe::TimedOut(secs) => {
601 say_skip(&format!(
603 "origin did not answer within {secs} s for {NOTES_FULL_REF}; local mirror not judged, running everything"
604 ));
605 Sync::Skip
606 }
607 _ => match crate::git::probe_remote(
608 &["ls-remote", "--exit-code", "origin", NOTES_FULL_REF],
609 budget,
610 ) {
611 crate::git::Probe::Exit(2) => drop_mirror(old.as_deref()),
612 crate::git::Probe::Exit(0) => {
613 say_skip(&format!(
614 "origin has {NOTES_FULL_REF} but fetching it failed; local mirror not judged, running everything"
615 ));
616 Sync::Skip
617 }
618 crate::git::Probe::Exit(code) => {
619 say_skip(&format!(
620 "cannot fetch {NOTES_FULL_REF} from origin (exit {code}; no credentials? persist-credentials: false?); local mirror not judged, running everything"
621 ));
622 Sync::Skip
623 }
624 crate::git::Probe::TimedOut(secs) => {
625 say_skip(&format!(
626 "origin did not answer within {secs} s for {NOTES_FULL_REF}; local mirror not judged, running everything"
627 ));
628 Sync::Skip
629 }
630 crate::git::Probe::Failed => {
631 say_skip(&format!(
632 "cannot fetch {NOTES_FULL_REF} from origin (git did not run); local mirror not judged, running everything"
633 ));
634 Sync::Skip
635 }
636 },
637 };
638 let _ = crate::git::succeeds(&["update-ref", "-d", &tmp]);
639 report_stale_lock();
640 verdict
641}
642
643fn drop_mirror(old: Option<&str>) -> Sync {
647 let Some(oid) = old else {
648 return Sync::Skip; };
650 if crate::git::succeeds(&["update-ref", "-d", NOTES_FULL_REF, oid]) {
651 say(format!(
652 "amont: origin has no {NOTES_FULL_REF}; deleted the local mirror (was {oid})"
653 ));
654 say(format!(
655 "amont: undo locally: git update-ref {NOTES_FULL_REF} {oid} (undo the revocation: git push origin {oid}:{NOTES_FULL_REF})"
656 ));
657 } else {
658 say_skip(&format!(
659 "origin has no {NOTES_FULL_REF} but the local mirror could not be deleted (read-only .git?); local mirror not judged"
660 ));
661 }
662 Sync::Skip
663}
664
665const SYNC_NAMESPACE: &str = "refs/amont-tmp/";
667
668fn sweep_sync_refs() {
673 let Some(refs) = crate::git::stdout(&["for-each-ref", "--format=%(refname)", SYNC_NAMESPACE])
674 else {
675 return;
676 };
677 for r in refs.lines() {
678 let Some(pid) = r
679 .strip_prefix(SYNC_NAMESPACE)
680 .and_then(|rest| rest.split('/').next())
681 .filter(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
682 else {
683 continue;
684 };
685 if pid == std::process::id().to_string() || process_alive(pid) != Some(false) {
686 continue;
687 }
688 let _ = crate::git::succeeds(&["update-ref", "-d", r]);
689 }
690}
691
692fn process_alive(pid: &str) -> Option<bool> {
694 if !cfg!(unix) {
695 return None;
696 }
697 let out = Command::new("kill")
699 .args(["-0", pid])
700 .env("LC_ALL", "C")
701 .stdin(Stdio::null())
702 .stdout(Stdio::null())
703 .stderr(Stdio::piped())
704 .output()
705 .ok()?;
706 if out.status.success() {
707 return Some(true);
708 }
709 let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
712 Some(!err.contains("no such process"))
713}
714
715fn report_stale_lock() {
718 let lock = format!("{NOTES_FULL_REF}.lock");
719 if let Some(path) = crate::git::stdout(&["rev-parse", "--git-path", &lock]) {
720 if std::path::Path::new(&path).exists() {
721 say_skip(&format!(
722 "a git that was killed left a lock on {NOTES_FULL_REF}; if no git is running: rm {path}"
723 ));
724 }
725 }
726}
727
728fn say_skip(why: &str) {
730 say(format!("amont: {why}"));
731}
732
733thread_local! {
734 static CAPTURE: std::cell::RefCell<Option<Vec<String>>> = const { std::cell::RefCell::new(None) };
737}
738
739fn say(line: String) {
741 CAPTURE.with(|c| {
742 if let Some(v) = c.borrow_mut().as_mut() {
743 v.push(line.clone());
744 }
745 });
746 eprintln!("{line}");
747}
748
749fn covered_within(
751 signers: &std::path::Path,
752 principal: &str,
753 require_platform: Option<&str>,
754 budget: u64,
755) -> Option<String> {
756 if sync_mirror(budget) == Sync::Skip {
757 return None;
758 }
759 let head_tree = crate::git::stdout(&["rev-parse", "HEAD^{tree}"])?;
760 for candidate in [head_tree.as_str(), "HEAD", "HEAD^2"] {
774 let Some(object) = crate::git::stdout(&["rev-parse", "--verify", candidate]) else {
775 continue;
776 };
777 let Some(body) = crate::git::stdout(&["notes", "--ref", NOTES_REF, "show", &object]) else {
778 continue;
779 };
780 let Some((payload, sig)) = split_note(&body) else {
781 continue;
782 };
783 let mut lines = payload.lines();
787 if lines.next() != Some(FORMAT) {
788 continue;
789 }
790 let field = |name: &str| {
791 payload
792 .lines()
793 .find_map(|l| l.strip_prefix(name).and_then(|r| r.strip_prefix(' ')))
794 .map(str::trim)
795 };
796 let (Some(tree), Some(gates), Some(ran_on)) =
797 (field("tree"), field("gates"), field("platform"))
798 else {
799 continue;
800 };
801 if tree != head_tree || gates.is_empty() {
802 continue; }
804 if require_platform.is_some_and(|want| want != ran_on) {
808 continue;
809 }
810 if verify(&payload, &sig, signers, principal) {
811 return Some(gates.to_string());
812 }
813 }
814 None
815}
816
817pub fn default_signers() -> Option<PathBuf> {
828 let root = crate::git::stdout(&["rev-parse", "--show-toplevel"]).map(PathBuf::from);
829 [".forgejo/allowed_signers", ".github/allowed_signers"]
830 .into_iter()
831 .map(|rel| match &root {
832 Some(root) => root.join(rel),
833 None => PathBuf::from(rel),
834 })
835 .find(|p| p.exists())
836}
837
838pub fn first_principal(signers: &std::path::Path) -> Option<String> {
843 let body = std::fs::read_to_string(signers).ok()?;
844 body.lines()
845 .map(str::trim)
846 .find(|l| !l.is_empty() && !l.starts_with('#'))
847 .and_then(|l| l.split_whitespace().next())
848 .map(str::to_string)
849}
850
851fn split_note(body: &str) -> Option<(String, String)> {
855 let (payload, sig) = body.split_once("\n\n")?;
856 if !sig.starts_with("-----BEGIN SSH SIGNATURE-----") {
857 return None;
858 }
859 Some((format!("{payload}\n"), sig.to_string()))
860}
861
862fn push_notes(remote: &str, refspec: &str) -> Push {
868 let out = Command::new("git")
869 .args(["push", "--quiet", remote, refspec])
870 .env(PUSH_GUARD, "1")
871 .stdin(Stdio::null())
872 .stdout(Stdio::null())
873 .output();
874 let Ok(out) = out else { return Push::Refused };
875 if out.status.success() {
876 return Push::Done;
877 }
878 match classify_push_stderr(&String::from_utf8_lossy(&out.stderr)) {
879 PushFailure::Raced => Push::Raced,
880 PushFailure::Unreachable | PushFailure::Refused => Push::Refused,
881 }
882}
883
884#[derive(Debug, Clone, Copy, PartialEq, Eq)]
892pub enum PushFailure {
893 Raced,
894 Unreachable,
895 Refused,
896}
897
898pub fn classify_push_stderr(err: &str) -> PushFailure {
901 const RACED: [&str; 5] = [
902 "non-fast-forward",
903 "fetch first",
904 "stale info",
905 "cannot lock ref",
906 "failed to lock",
907 ];
908 const UNREACHABLE: [&str; 5] = [
909 "Could not resolve host",
910 "Connection refused",
911 "Connection timed out",
912 "unable to access",
913 "Could not read from remote repository",
914 ];
915 let own = |l: &&str| {
919 let l = l.trim_start();
920 l.starts_with("fatal:") || l.starts_with("ssh:") || l.starts_with("error:")
921 };
922 if RACED.iter().any(|m| err.contains(m)) {
923 PushFailure::Raced
924 } else if err
925 .lines()
926 .filter(own)
927 .any(|l| UNREACHABLE.iter().any(|m| l.contains(m)))
928 {
929 PushFailure::Unreachable
930 } else {
931 PushFailure::Refused
932 }
933}
934
935fn is_zero(oid: &str) -> bool {
938 !oid.is_empty() && oid.bytes().all(|b| b == b'0')
939}
940
941pub fn forget() -> bool {
945 crate::git::succeeds(&["update-ref", "-d", NOTES_FULL_REF])
946}
947
948pub fn forget_in(repo: &std::path::Path) -> bool {
950 crate::git::succeeds_in(repo, &["update-ref", "-d", NOTES_FULL_REF])
951}
952
953#[cfg(test)]
954mod tests {
955 use super::*;
956
957 #[test]
958 fn push_stderr_is_sorted_into_raced_unreachable_and_refused() {
959 assert_eq!(
960 classify_push_stderr(" ! [rejected] feat -> feat (non-fast-forward)"),
961 PushFailure::Raced
962 );
963 assert_eq!(
964 classify_push_stderr("error: failed to lock ref"),
965 PushFailure::Raced
966 );
967 for line in [
968 "fatal: unable to access 'https://h/x.git/': Could not resolve host: h",
969 "ssh: connect to host h port 22: Connection refused",
970 "fatal: Could not read from remote repository.",
971 "fatal: unable to access 'u': Connection timed out after 10001 ms",
972 ] {
973 assert_eq!(
974 classify_push_stderr(line),
975 PushFailure::Unreachable,
976 "{line}"
977 );
978 }
979 assert_eq!(
980 classify_push_stderr("remote: permission denied"),
981 PushFailure::Refused
982 );
983 assert_eq!(classify_push_stderr(""), PushFailure::Refused);
984 }
985
986 #[test]
987 fn a_hooks_own_words_do_not_make_the_remote_unreachable() {
988 assert_eq!(
990 classify_push_stderr("integration: Connection refused by the fixture\nhook failed"),
991 PushFailure::Refused
992 );
993 }
994
995 fn test_settings() -> crate::config::Settings {
998 crate::config::Settings::default()
999 }
1000
1001 use std::path::Path;
1002
1003 fn dir(name: &str) -> PathBuf {
1007 let d = std::env::temp_dir().join(format!("attest-{name}-{}", std::process::id()));
1008 let _ = std::fs::remove_dir_all(&d);
1009 std::fs::create_dir_all(&d).unwrap();
1010 d
1011 }
1012
1013 fn git(dir: &Path, args: &[&str]) -> String {
1022 let out = std::process::Command::new("git")
1023 .arg("-C")
1024 .arg(dir)
1025 .args(args)
1026 .output()
1027 .expect("git");
1028 assert!(
1029 out.status.success(),
1030 "fixture: git {args:?} in {} exited {:?}: {}",
1031 dir.display(),
1032 out.status.code(),
1033 String::from_utf8_lossy(&out.stderr).trim()
1034 );
1035 String::from_utf8_lossy(&out.stdout).trim().to_string()
1036 }
1037
1038 fn repo(name: &str) -> PathBuf {
1039 let d = dir(name);
1040 git(&d, &["init", "-q", "--template=", "."]);
1041 git(&d, &["config", "user.email", "t@t.test"]);
1042 git(&d, &["config", "user.name", "t"]);
1043 d
1044 }
1045
1046 fn keypair(d: &Path) -> (PathBuf, PathBuf) {
1049 let key = d.join("attest_key");
1050 let ok = std::process::Command::new("ssh-keygen")
1051 .args(["-q", "-t", "ed25519", "-N", "", "-C", "test", "-f"])
1052 .arg(&key)
1053 .status()
1054 .expect("ssh-keygen must exist for these tests")
1055 .success();
1056 assert!(ok, "keygen failed");
1057 let pubkey = std::fs::read_to_string(key.with_extension("pub")).unwrap();
1058 let signers = d.join("allowed_signers");
1059 std::fs::write(
1060 &signers,
1061 format!("t@t.test namespaces=\"{NAMESPACE}\" {pubkey}"),
1062 )
1063 .unwrap();
1064 (key, signers)
1065 }
1066
1067 fn in_repo<T>(dir: &Path, f: impl FnOnce() -> T) -> T {
1070 let _guard = crate::TEST_CWD.lock().unwrap_or_else(|p| p.into_inner());
1071 let prev = std::env::current_dir().unwrap();
1072 std::env::set_current_dir(dir).unwrap();
1073 let r = f();
1074 std::env::set_current_dir(prev).unwrap();
1075 r
1076 }
1077
1078 #[test]
1083 fn the_signature_file_refuses_to_follow_what_is_already_there() {
1084 let dir = std::env::temp_dir().join(format!("amont-excl-{}", std::process::id()));
1085 let _ = std::fs::remove_dir_all(&dir);
1086 std::fs::create_dir_all(&dir).expect("temp dir");
1087
1088 let taken = dir.join("taken");
1090 std::fs::write(&taken, "original").expect("seed");
1091 assert!(create_exclusive(&taken).is_none());
1092 assert_eq!(std::fs::read_to_string(&taken).unwrap(), "original");
1093
1094 #[cfg(unix)]
1096 {
1097 let victim = dir.join("victim");
1098 std::fs::write(&victim, "precious").expect("seed");
1099 let link = dir.join("link");
1100 std::os::unix::fs::symlink(&victim, &link).expect("symlink");
1101 assert!(
1102 create_exclusive(&link).is_none(),
1103 "a symlink must be refused, not followed"
1104 );
1105 assert_eq!(
1106 std::fs::read_to_string(&victim).unwrap(),
1107 "precious",
1108 "the link target was written through"
1109 );
1110 }
1111
1112 let fresh = dir.join("fresh");
1114 assert!(create_exclusive(&fresh).is_some());
1115
1116 let _ = std::fs::remove_dir_all(&dir);
1117 }
1118
1119 #[test]
1120 fn the_payload_is_the_documented_contract() {
1121 let p = payload(
1122 "abc123",
1123 &["pre-push-pytest".into(), "pre-push-cargo-test".into()],
1124 &[],
1125 None,
1126 );
1127 let lines: Vec<&str> = p.lines().collect();
1128 assert_eq!(lines[0], FORMAT);
1129 assert_eq!(lines[1], "tree abc123");
1130 assert_eq!(lines[2], "gates pre-push-pytest pre-push-cargo-test");
1131 assert_eq!(lines[3], format!("platform {}", platform()));
1132 assert_eq!(lines[4], format!("amont {}", env!("CARGO_PKG_VERSION")));
1133 assert!(
1134 p.ends_with('\n'),
1135 "CI reconstructs these bytes; the trailing newline is part of them"
1136 );
1137 }
1138
1139 #[test]
1140 fn sign_verify_roundtrip_and_tamper_rejection() {
1141 let d = dir("roundtrip");
1142 let (key, signers) = keypair(&d);
1143 let p = payload("deadbeef", &["pre-push-pytest".into()], &[], None);
1144 let sig = sign(&p, &key).expect("signing with a real key succeeds");
1145 assert!(verify(&p, &sig, &signers, "t@t.test"));
1146 let tampered = payload("deadbeee", &["pre-push-pytest".into()], &[], None);
1149 assert!(!verify(&tampered, &sig, &signers, "t@t.test"));
1150 assert!(!verify(&p, &sig, &signers, "someone@else.test"));
1152 let _ = std::fs::remove_dir_all(&d);
1153 }
1154
1155 #[test]
1156 fn a_missing_key_signs_nothing() {
1157 assert!(sign("anything", Path::new("/nonexistent/key")).is_none());
1158 }
1159
1160 #[test]
1164 fn an_enabled_push_leaves_a_verifiable_note_on_the_remote() {
1165 let d = dir("e2e");
1166 let (key, signers) = keypair(&d);
1167 let remote = d.join("remote.git");
1168 std::fs::create_dir_all(&remote).unwrap();
1169 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
1170 let work = repo("e2e-work");
1171 git(
1172 &work,
1173 &["remote", "add", "origin", remote.to_str().unwrap()],
1174 );
1175 git(&work, &["config", "amont.attest", "true"]);
1176 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
1177 std::fs::write(work.join("a.ts"), "x").unwrap();
1178 git(&work, &["add", "a.ts"]);
1179 git(&work, &["commit", "-qm", "chore: a"]);
1180 let head = git(&work, &["rev-parse", "HEAD"]);
1181 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1182 let push_ref = PushRef {
1183 local_ref: "refs/heads/main".into(),
1184 local_oid: head.clone(),
1185 remote_ref: "refs/heads/main".into(),
1186 remote_oid: "0".repeat(40),
1187 };
1188 in_repo(&work, || {
1189 attest_push(
1190 &test_settings(),
1191 "origin",
1192 &[push_ref],
1193 &["pre-push-run-tests-js".into()],
1194 );
1195 });
1196 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &head]);
1198 assert!(!body.is_empty(), "no note reached the remote");
1199 let (p, sig) = body
1200 .split_once("\n\n")
1201 .expect("payload, blank line, signature");
1202 let p = format!("{p}\n"); assert!(p.starts_with(FORMAT));
1204 assert!(
1205 p.contains(&format!("tree {tree}")),
1206 "attests the pushed tree"
1207 );
1208 assert!(
1209 verify(&p, sig, &signers, "t@t.test"),
1210 "the remote copy verifies"
1211 );
1212 let _ = std::fs::remove_dir_all(&d);
1213 let _ = std::fs::remove_dir_all(&work);
1214 }
1215
1216 fn remote_and_work(name: &str) -> (PathBuf, PathBuf, PathBuf, PathBuf) {
1218 let d = dir(name);
1219 let (key, signers) = keypair(&d);
1220 let remote = d.join("remote.git");
1221 std::fs::create_dir_all(&remote).unwrap();
1222 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
1223 let work = repo(&format!("{name}-work"));
1224 git(
1225 &work,
1226 &["remote", "add", "origin", remote.to_str().unwrap()],
1227 );
1228 git(&work, &["config", "amont.attest", "true"]);
1229 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
1230 std::fs::write(work.join("a.ts"), "x").unwrap();
1231 git(&work, &["add", "a.ts"]);
1232 git(&work, &["commit", "-qm", "chore: a"]);
1233 (d, work, remote, signers)
1234 }
1235
1236 fn push_ref_for(work: &Path) -> PushRef {
1237 PushRef {
1238 local_ref: "refs/heads/main".into(),
1239 local_oid: git(work, &["rev-parse", "HEAD"]),
1240 remote_ref: "refs/heads/main".into(),
1241 remote_oid: "0".repeat(40),
1242 }
1243 }
1244
1245 fn blocks_in(body: &str) -> usize {
1246 body.matches("-----BEGIN SSH SIGNATURE-----").count()
1247 }
1248
1249 #[test]
1253 fn a_block_already_on_the_remote_survives_and_ours_is_appended() {
1254 let (d, work, remote, _) = remote_and_work("append");
1255 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1256 let seed = repo("append-seed");
1258 git(
1259 &seed,
1260 &["remote", "add", "origin", remote.to_str().unwrap()],
1261 );
1262 git(&seed, &["fetch", "-q", "origin"]);
1263 std::fs::write(seed.join("a.ts"), "x").unwrap();
1264 git(&seed, &["add", "a.ts"]);
1265 git(&seed, &["commit", "-qm", "chore: a"]);
1266 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-----";
1267 git(
1268 &seed,
1269 &["notes", "--ref", NOTES_REF, "add", "-m", foreign, &tree],
1270 );
1271 git(
1272 &seed,
1273 &[
1274 "push",
1275 "-q",
1276 "origin",
1277 &format!("{NOTES_FULL_REF}:{NOTES_FULL_REF}"),
1278 ],
1279 );
1280 in_repo(&work, || {
1281 attest_push(
1282 &test_settings(),
1283 "origin",
1284 &[push_ref_for(&work)],
1285 &["pre-push-run-tests-js".into()],
1286 );
1287 });
1288 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &tree]);
1289 assert_eq!(blocks_in(&body), 2, "both blocks on the remote:\n{body}");
1290 assert!(body.contains("gates ci-fmt"), "the foreign block survives");
1291 assert!(
1292 body.contains("gates pre-push-run-tests-js"),
1293 "ours was appended"
1294 );
1295 let _ = std::fs::remove_dir_all(&d);
1296 let _ = std::fs::remove_dir_all(&work);
1297 let _ = std::fs::remove_dir_all(&seed);
1298 }
1299
1300 #[test]
1305 fn a_stale_local_notes_ref_no_longer_loses_the_push() {
1306 let (d, work, remote, _) = remote_and_work("stale");
1307 let head = git(&work, &["rev-parse", "HEAD"]);
1308 git(
1311 &work,
1312 &[
1313 "notes",
1314 "--ref",
1315 NOTES_REF,
1316 "add",
1317 "-m",
1318 "stale local",
1319 &head,
1320 ],
1321 );
1322 let stale = git(&work, &["rev-parse", NOTES_FULL_REF]);
1323 let seed = repo("stale-seed");
1325 std::fs::write(seed.join("b.ts"), "y").unwrap();
1326 git(&seed, &["add", "b.ts"]);
1327 git(&seed, &["commit", "-qm", "chore: b"]);
1328 git(
1329 &seed,
1330 &["remote", "add", "origin", remote.to_str().unwrap()],
1331 );
1332 git(
1333 &seed,
1334 &[
1335 "notes",
1336 "--ref",
1337 NOTES_REF,
1338 "add",
1339 "-m",
1340 "remote first",
1341 "HEAD",
1342 ],
1343 );
1344 git(&seed, &["push", "-q", "origin", "HEAD:refs/heads/other"]);
1345 git(
1346 &seed,
1347 &[
1348 "push",
1349 "-q",
1350 "origin",
1351 &format!("{NOTES_FULL_REF}:{NOTES_FULL_REF}"),
1352 ],
1353 );
1354 in_repo(&work, || {
1355 attest_push(
1356 &test_settings(),
1357 "origin",
1358 &[push_ref_for(&work)],
1359 &["pre-push-run-tests-js".into()],
1360 );
1361 });
1362 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &head]);
1363 assert!(
1364 body.contains("gates pre-push-run-tests-js"),
1365 "the push landed:\n{body}"
1366 );
1367 assert_ne!(
1368 git(&work, &["rev-parse", NOTES_FULL_REF]),
1369 stale,
1370 "the local ref followed the published state"
1371 );
1372 assert!(
1373 git(&work, &["for-each-ref", "refs/notes/amont-attest-push-*"]).is_empty(),
1374 "no temporary ref left behind"
1375 );
1376 let _ = std::fs::remove_dir_all(&d);
1377 let _ = std::fs::remove_dir_all(&work);
1378 let _ = std::fs::remove_dir_all(&seed);
1379 }
1380
1381 #[test]
1384 fn a_second_push_of_the_same_tree_appends_nothing() {
1385 let (d, work, remote, _) = remote_and_work("twice");
1386 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1387 for _ in 0..2 {
1388 in_repo(&work, || {
1389 attest_push(
1390 &test_settings(),
1391 "origin",
1392 &[push_ref_for(&work)],
1393 &["pre-push-run-tests-js".into()],
1394 );
1395 });
1396 }
1397 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &tree]);
1398 assert_eq!(blocks_in(&body), 1, "one block, not two:\n{body}");
1399 let _ = std::fs::remove_dir_all(&d);
1400 let _ = std::fs::remove_dir_all(&work);
1401 }
1402
1403 #[test]
1407 fn a_spec_yields_input_lines_and_fingerprint_keyed_notes() {
1408 let (d, work, remote, _) = remote_and_work("inputs");
1409 std::fs::create_dir_all(work.join(".github")).unwrap();
1410 std::fs::write(
1411 work.join(".github/attest-inputs"),
1412 "# what each gate reads\npre-push-run-tests-js a.ts\nother nope\n",
1413 )
1414 .unwrap();
1415 git(&work, &["add", ".github/attest-inputs"]);
1416 git(&work, &["commit", "-qm", "chore: spec"]);
1417 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1418 let listing = std::process::Command::new("git")
1420 .args([
1421 "-C",
1422 work.to_str().unwrap(),
1423 "ls-tree",
1424 "-r",
1425 "-z",
1426 "--full-tree",
1427 &tree,
1428 "--",
1429 ".forgejo/attest-inputs",
1430 ".github/attest-inputs",
1431 ".gitmodules",
1432 ".gitattributes",
1433 "a.ts",
1434 ])
1435 .output()
1436 .unwrap()
1437 .stdout;
1438 let expected =
1439 crate::git::stdout_piped_in(&work, &["hash-object", "--stdin"], &listing).unwrap();
1440 in_repo(&work, || {
1441 attest_push(
1442 &test_settings(),
1443 "origin",
1444 &[push_ref_for(&work)],
1445 &["pre-push-run-tests-js".into(), "other".into()],
1446 );
1447 });
1448 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &tree]);
1449 assert!(
1450 body.contains(&format!("input pre-push-run-tests-js {expected}\n")),
1451 "the input line carries the reference fingerprint:\n{body}"
1452 );
1453 assert!(
1454 !body.contains("input other "),
1455 "a gate whose path does not exist gets no line"
1456 );
1457 let key = crate::git::stdout_piped_in(
1458 &work,
1459 &["hash-object", "--stdin"],
1460 format!("amont-attest-input pre-push-run-tests-js {expected}\n").as_bytes(),
1461 )
1462 .unwrap();
1463 let under_key = git(&remote, &["notes", "--ref", INPUTS_REF, "show", &key]);
1464 assert_eq!(
1465 under_key, body,
1466 "the same block is filed under the fingerprint key"
1467 );
1468 in_repo(&work, || {
1470 attest_push(
1471 &test_settings(),
1472 "origin",
1473 &[push_ref_for(&work)],
1474 &["pre-push-run-tests-js".into(), "other".into()],
1475 );
1476 });
1477 assert_eq!(
1478 git(&remote, &["notes", "--ref", INPUTS_REF, "show", &key])
1479 .matches("BEGIN SSH SIGNATURE")
1480 .count(),
1481 1
1482 );
1483 let _ = std::fs::remove_dir_all(&d);
1484 let _ = std::fs::remove_dir_all(&work);
1485 }
1486
1487 #[test]
1490 fn without_a_valid_spec_the_block_is_tree_keyed_only() {
1491 let (d, work, remote, _) = remote_and_work("nospec");
1492 std::fs::create_dir_all(work.join(".github")).unwrap();
1493 std::fs::write(
1494 work.join(".github/attest-inputs"),
1495 "pre-push-run-tests-js src/*.ts\n",
1496 )
1497 .unwrap();
1498 git(&work, &["add", ".github/attest-inputs"]);
1499 git(&work, &["commit", "-qm", "chore: bad spec"]);
1500 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1501 in_repo(&work, || {
1502 attest_push(
1503 &test_settings(),
1504 "origin",
1505 &[push_ref_for(&work)],
1506 &["pre-push-run-tests-js".into()],
1507 );
1508 });
1509 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &tree]);
1510 assert!(!body.contains("\ninput "), "no input line:\n{body}");
1511 assert!(
1512 !crate::git::succeeds_in(
1513 &remote,
1514 &["rev-parse", "--verify", "--quiet", INPUTS_FULL_REF]
1515 ),
1516 "no inputs ref was created"
1517 );
1518 let _ = std::fs::remove_dir_all(&d);
1519 let _ = std::fs::remove_dir_all(&work);
1520 }
1521
1522 #[test]
1525 fn with_no_spec_the_block_carries_the_canonical_tree() {
1526 let (d, work, remote, _) = remote_and_work("canon");
1527 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1528 in_repo(&work, || {
1529 attest_push(
1530 &test_settings(),
1531 "origin",
1532 &[push_ref_for(&work)],
1533 &["pre-push-run-tests-js".into()],
1534 );
1535 });
1536 let c = in_repo(&work, || crate::attest_inputs::canon(&tree)).expect("canon");
1537 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &tree]);
1538 assert!(body.contains(&format!("\ncanon {c}\namont ")), "{body}");
1539 assert!(!body.contains("\ninput "), "nothing declared:\n{body}");
1540 let key = in_repo(&work, || crate::attest_inputs::canon_key(&c)).expect("key");
1541 assert_eq!(
1542 git(&remote, &["notes", "--ref", INPUTS_REF, "show", &key]),
1543 body,
1544 "the same block is filed under the canonical key"
1545 );
1546 in_repo(&work, || {
1548 attest_push(
1549 &test_settings(),
1550 "origin",
1551 &[push_ref_for(&work)],
1552 &["pre-push-run-tests-js".into()],
1553 );
1554 });
1555 assert_eq!(
1556 git(&remote, &["notes", "--ref", INPUTS_REF, "show", &key])
1557 .matches("BEGIN SSH SIGNATURE")
1558 .count(),
1559 1
1560 );
1561 let _ = std::fs::remove_dir_all(&d);
1562 let _ = std::fs::remove_dir_all(&work);
1563 }
1564
1565 #[test]
1567 fn a_valid_spec_also_carries_the_canonical_tree() {
1568 let (d, work, remote, _) = remote_and_work("canon-spec");
1569 std::fs::create_dir_all(work.join(".github")).unwrap();
1570 std::fs::write(
1571 work.join(".github/attest-inputs"),
1572 "pre-push-run-tests-js a.ts\n",
1573 )
1574 .unwrap();
1575 git(&work, &["add", ".github/attest-inputs"]);
1576 git(&work, &["commit", "-qm", "chore: spec"]);
1577 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1578 in_repo(&work, || {
1579 attest_push(
1580 &test_settings(),
1581 "origin",
1582 &[push_ref_for(&work)],
1583 &["pre-push-run-tests-js".into()],
1584 );
1585 });
1586 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &tree]);
1587 let input = body
1588 .find("\ninput pre-push-run-tests-js ")
1589 .expect("input line");
1590 let canon = body.find("\ncanon ").expect("canon line");
1591 let tool = body.find("\namont ").expect("amont line");
1592 assert!(
1593 input < canon && canon < tool,
1594 "input, canon, amont:\n{body}"
1595 );
1596 let _ = std::fs::remove_dir_all(&d);
1597 let _ = std::fs::remove_dir_all(&work);
1598 }
1599
1600 #[test]
1602 fn an_opt_out_or_an_unusable_spec_leaves_no_canon_line() {
1603 for (name, setup) in [
1604 ("canon-off", "off"),
1605 ("canon-bad-spec", "bad"),
1606 ("canon-unreadable-conf", "conf-dir"),
1607 ] {
1608 let (d, work, remote, _) = remote_and_work(name);
1609 match setup {
1610 "off" => {
1611 std::fs::write(work.join("amont.conf"), "set planExempt off\n").unwrap();
1612 git(&work, &["add", "amont.conf"]);
1613 }
1614 "bad" => {
1615 std::fs::create_dir_all(work.join(".github")).unwrap();
1616 std::fs::write(work.join(".github/attest-inputs"), "g src/*.ts\n").unwrap();
1617 git(&work, &["add", ".github/attest-inputs"]);
1618 }
1619 _ => {
1620 std::fs::create_dir_all(work.join("amont.conf")).unwrap();
1621 std::fs::write(work.join("amont.conf/x"), "x").unwrap();
1622 git(&work, &["add", "amont.conf/x"]);
1623 }
1624 }
1625 git(&work, &["commit", "-qm", "chore: setup"]);
1626 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1627 in_repo(&work, || {
1628 attest_push(
1629 &test_settings(),
1630 "origin",
1631 &[push_ref_for(&work)],
1632 &["pre-push-run-tests-js".into()],
1633 );
1634 });
1635 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &tree]);
1636 assert!(!body.contains("\ncanon "), "{name}: no canon line:\n{body}");
1637 assert!(
1638 !crate::git::succeeds_in(
1639 &remote,
1640 &["rev-parse", "--verify", "--quiet", INPUTS_FULL_REF]
1641 ),
1642 "{name}: no inputs ref was created"
1643 );
1644 let _ = std::fs::remove_dir_all(&d);
1645 let _ = std::fs::remove_dir_all(&work);
1646 }
1647 }
1648
1649 #[test]
1653 fn the_canonical_fingerprint_matches_attests_known_answers() {
1654 let work = repo("canon-vector");
1655 std::fs::create_dir_all(work.join("docs/plans")).unwrap();
1656 std::fs::create_dir_all(work.join("src")).unwrap();
1657 std::fs::write(work.join("a.ts"), "x\n").unwrap();
1658 std::fs::write(work.join("docs/plans/p.md"), "plan\n").unwrap();
1659 std::fs::write(work.join("src/lib.rs"), "fn f() {}\n").unwrap();
1660 std::fs::write(work.join("run.sh"), "#!/bin/sh\n").unwrap();
1661 git(&work, &["add", "-A"]);
1662 git(&work, &["update-index", "--chmod=+x", "run.sh"]);
1663 git(&work, &["commit", "-qm", "init"]);
1664 let canon = |w: &Path| {
1665 let t = git(w, &["rev-parse", "HEAD^{tree}"]);
1666 (t.clone(), in_repo(w, || crate::attest_inputs::canon(&t)))
1667 };
1668 let (t0, c0) = canon(&work);
1669 assert_eq!(t0, "fd4804a25fa9207c65410d3a57099643e14564eb");
1670 assert_eq!(
1671 c0.as_deref(),
1672 Some("e942ded734eec11936c0f65fd86c071f2ef505b0")
1673 );
1674 std::fs::write(work.join("docs/plans/p.md"), "plan2\n").unwrap();
1675 git(&work, &["add", "docs/plans/p.md"]);
1676 git(&work, &["commit", "-qm", "plan"]);
1677 let (t1, c1) = canon(&work);
1678 assert_ne!(t1, t0);
1679 assert_eq!(c1, c0, "a plan edit leaves the canonical tree unchanged");
1680 std::fs::write(work.join("src/lib.rs"), "fn g() {}\n").unwrap();
1681 git(&work, &["add", "src/lib.rs"]);
1682 git(&work, &["commit", "-qm", "code"]);
1683 assert_eq!(
1684 canon(&work).1.as_deref(),
1685 Some("57dabcd18b8b8fa35db59657892b2b8b34a1ae4f")
1686 );
1687 let _ = std::fs::remove_dir_all(&work);
1688 }
1689
1690 #[test]
1691 fn the_spec_grammar_is_attests() {
1692 assert!(parse_spec(b"# c\ng src Cargo.toml\n").is_some());
1693 for bad in [
1694 b"g src/*.rs\n".as_slice(),
1695 b"g :!x\n",
1696 b"g\n",
1697 b"g src\ng x\n",
1698 b"g src\r\n",
1699 b"g sr\xc3\xa9\n",
1700 b"-g src\n",
1701 b"g ./src\n",
1702 b"g src/\n",
1703 b"g a/../b\n",
1704 ] {
1705 assert!(parse_spec(bad).is_none(), "{bad:?}");
1706 }
1707 assert_eq!(
1708 implicit_inputs(&["crates/foo/src".into()]),
1709 [
1710 ".forgejo/attest-inputs",
1711 ".github/attest-inputs",
1712 ".gitmodules",
1713 ".gitattributes",
1714 "crates/.gitattributes",
1715 "crates/foo/.gitattributes"
1716 ]
1717 );
1718 }
1719
1720 #[test]
1723 fn no_opt_in_means_no_note() {
1724 let d = dir("optout");
1725 let (key, _) = keypair(&d);
1726 let remote = d.join("remote.git");
1727 std::fs::create_dir_all(&remote).unwrap();
1728 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
1729 let work = repo("optout-work");
1730 git(
1731 &work,
1732 &["remote", "add", "origin", remote.to_str().unwrap()],
1733 );
1734 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
1735 std::fs::write(work.join("a.ts"), "x").unwrap();
1736 git(&work, &["add", "a.ts"]);
1737 git(&work, &["commit", "-qm", "chore: a"]);
1738 let head = git(&work, &["rev-parse", "HEAD"]);
1739 let push_ref = PushRef {
1740 local_ref: "refs/heads/main".into(),
1741 local_oid: head.clone(),
1742 remote_ref: "refs/heads/main".into(),
1743 remote_oid: "0".repeat(40),
1744 };
1745 in_repo(&work, || {
1746 attest_push(
1747 &test_settings(),
1748 "origin",
1749 &[push_ref],
1750 &["pre-push-run-tests-js".into()],
1751 );
1752 });
1753 assert!(
1754 git(&remote, &["notes", "--ref", NOTES_REF, "list"]).is_empty(),
1755 "an un-opted-in repo attested something"
1756 );
1757 let _ = std::fs::remove_dir_all(&d);
1758 let _ = std::fs::remove_dir_all(&work);
1759 }
1760
1761 #[test]
1764 fn deletions_and_empty_gates_attest_nothing() {
1765 let d = dir("nothing");
1766 let (key, _) = keypair(&d);
1767 let remote = d.join("remote.git");
1768 std::fs::create_dir_all(&remote).unwrap();
1769 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
1770 let work = repo("nothing-work");
1771 git(
1772 &work,
1773 &["remote", "add", "origin", remote.to_str().unwrap()],
1774 );
1775 git(&work, &["config", "amont.attest", "true"]);
1776 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
1777 std::fs::write(work.join("a.ts"), "x").unwrap();
1778 git(&work, &["add", "a.ts"]);
1779 git(&work, &["commit", "-qm", "chore: a"]);
1780 let head = git(&work, &["rev-parse", "HEAD"]);
1781 let deletion = PushRef {
1782 local_ref: "(delete)".into(),
1783 local_oid: "0".repeat(40),
1784 remote_ref: "refs/heads/gone".into(),
1785 remote_oid: head.clone(),
1786 };
1787 let real = PushRef {
1788 local_ref: "refs/heads/main".into(),
1789 local_oid: head,
1790 remote_ref: "refs/heads/main".into(),
1791 remote_oid: "0".repeat(40),
1792 };
1793 in_repo(&work, || {
1794 attest_push(
1795 &test_settings(),
1796 "origin",
1797 &[deletion],
1798 &["pre-push-pytest".into()],
1799 );
1800 attest_push(&test_settings(), "origin", &[real], &[]);
1801 });
1802 assert!(git(&remote, &["notes", "--ref", NOTES_REF, "list"]).is_empty());
1803 let _ = std::fs::remove_dir_all(&d);
1804 let _ = std::fs::remove_dir_all(&work);
1805 }
1806
1807 #[test]
1812 fn covered_answers_in_a_fresh_clone_and_rejects_drift_and_forgery() {
1813 let d = dir("covered");
1814 let (key, signers) = keypair(&d);
1815 let remote = d.join("remote.git");
1816 std::fs::create_dir_all(&remote).unwrap();
1817 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
1818 git(&remote, &["symbolic-ref", "HEAD", "refs/heads/main"]);
1824 let work = repo("covered-work");
1825 git(
1826 &work,
1827 &["remote", "add", "origin", remote.to_str().unwrap()],
1828 );
1829 git(&work, &["config", "amont.attest", "true"]);
1830 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
1831 std::fs::write(work.join("a.ts"), "x").unwrap();
1832 git(&work, &["add", "a.ts"]);
1833 git(&work, &["commit", "-qm", "chore: a"]);
1834 git(&work, &["push", "-q", "origin", "HEAD:main"]);
1835 let head = git(&work, &["rev-parse", "HEAD"]);
1836 let push_ref = PushRef {
1837 local_ref: "refs/heads/main".into(),
1838 local_oid: head.clone(),
1839 remote_ref: "refs/heads/main".into(),
1840 remote_oid: "0".repeat(40),
1841 };
1842 in_repo(&work, || {
1843 attest_push(
1844 &test_settings(),
1845 "origin",
1846 &[push_ref],
1847 &["pre-push-pytest".into()],
1848 );
1849 });
1850 let clone = d.join("ci-checkout");
1851 git(
1852 &d,
1853 &[
1854 "clone",
1855 "-q",
1856 "--template=",
1857 remote.to_str().unwrap(),
1858 clone.to_str().unwrap(),
1859 ],
1860 );
1861 in_repo(&clone, || {
1862 assert_eq!(
1863 covered(&signers, "t@t.test", Some(&platform())).as_deref(),
1864 Some("pre-push-pytest"),
1865 "a fresh clone verifies the attestation and reads the gates"
1866 );
1867 assert_eq!(
1868 covered(&signers, "t@t.test", None).as_deref(),
1869 Some("pre-push-pytest"),
1870 "`any` covers a platform-independent suite"
1871 );
1872 assert_eq!(
1875 covered(&signers, "t@t.test", Some("s390x-aix")),
1876 None,
1877 "a pass on one platform is not evidence about another"
1878 );
1879 assert_eq!(
1880 covered(&signers, "someone@else.test", None),
1881 None,
1882 "an unlisted principal covers nothing"
1883 );
1884 });
1885 std::fs::write(clone.join("b.ts"), "y").unwrap();
1887 git(&clone, &["config", "user.email", "t@t.test"]);
1888 git(&clone, &["config", "user.name", "t"]);
1889 git(&clone, &["add", "b.ts"]);
1890 git(&clone, &["commit", "-qm", "chore: b"]);
1891 in_repo(&clone, || {
1892 assert_eq!(covered(&signers, "t@t.test", None), None, "drifted tree");
1893 });
1894 let head_tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1904 for object in [&head, &head_tree] {
1905 git(
1906 &work,
1907 &[
1908 "notes", "--ref", NOTES_REF, "add", "-f", "-m", "garbage", object,
1909 ],
1910 );
1911 }
1912 git(
1913 &work,
1914 &[
1915 "push",
1916 "-q",
1917 "origin",
1918 &format!("+{NOTES_FULL_REF}:{NOTES_FULL_REF}"),
1919 ],
1920 );
1921 git(&clone, &["reset", "-q", "--hard", &head]);
1922 in_repo(&clone, || {
1923 assert_eq!(
1924 covered(&signers, "t@t.test", None),
1925 None,
1926 "a foreign note is not a stamp"
1927 );
1928 });
1929 let _ = std::fs::remove_dir_all(&d);
1930 let _ = std::fs::remove_dir_all(&work);
1931 }
1932
1933 #[test]
1939 fn default_signers_is_found_from_a_subdirectory() {
1940 let work = repo("signers-subdir");
1941 std::fs::create_dir_all(work.join(".forgejo")).unwrap();
1942 std::fs::write(work.join(".forgejo/allowed_signers"), "t@t.test x\n").unwrap();
1943 let sub = work.join("packages").join("thing");
1944 std::fs::create_dir_all(&sub).unwrap();
1945 in_repo(&sub, || {
1946 let found = default_signers().expect("resolved from the repo root, not the cwd");
1947 assert!(found.ends_with(".forgejo/allowed_signers"));
1948 assert!(found.exists(), "the path it returns must be usable as-is");
1949 assert_eq!(
1950 first_principal(&found).as_deref(),
1951 Some("t@t.test"),
1952 "and readable from there"
1953 );
1954 });
1955 let _ = std::fs::remove_dir_all(&work);
1956 }
1957
1958 #[test]
1959 fn zero_oids_of_any_width_are_zero() {
1960 assert!(is_zero(&"0".repeat(40)));
1961 assert!(is_zero(&"0".repeat(64)));
1962 assert!(!is_zero("0a0000"));
1963 assert!(!is_zero(""));
1964 }
1965
1966 #[test]
1967 fn forget_removes_the_local_ref() {
1968 let work = repo("forget");
1969 std::fs::write(work.join("a.ts"), "x").unwrap();
1970 git(&work, &["add", "a.ts"]);
1971 git(&work, &["commit", "-qm", "chore: a"]);
1972 git(
1973 &work,
1974 &["notes", "--ref", NOTES_REF, "add", "-m", "x", "HEAD"],
1975 );
1976 in_repo(&work, forget);
1977 assert!(git(&work, &["notes", "--ref", NOTES_REF, "list"]).is_empty());
1978 let _ = std::fs::remove_dir_all(&work);
1979 }
1980
1981 #[test]
1996 fn an_attestation_survives_a_rewrite_that_keeps_the_tree() {
1997 let d = dir("rewritten");
1998 let (key, signers) = keypair(&d);
1999 let remote = d.join("remote.git");
2000 std::fs::create_dir_all(&remote).unwrap();
2001 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
2002 git(&remote, &["symbolic-ref", "HEAD", "refs/heads/main"]);
2003 let work = repo("rewritten-work");
2004 git(
2005 &work,
2006 &["remote", "add", "origin", remote.to_str().unwrap()],
2007 );
2008 git(&work, &["config", "amont.attest", "true"]);
2009 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
2010 std::fs::write(work.join("a.ts"), "x").unwrap();
2011 git(&work, &["add", "a.ts"]);
2012 git(&work, &["commit", "-qm", "feat: on a branch"]);
2013 git(&work, &["push", "-q", "origin", "HEAD:main"]);
2014 let branch_tip = git(&work, &["rev-parse", "HEAD"]);
2015 let push_ref = PushRef {
2016 local_ref: "refs/heads/main".into(),
2017 local_oid: branch_tip.clone(),
2018 remote_ref: "refs/heads/main".into(),
2019 remote_oid: "0".repeat(40),
2020 };
2021 in_repo(&work, || {
2022 attest_push(
2023 &test_settings(),
2024 "origin",
2025 &[push_ref],
2026 &["pre-push-pytest".into()],
2027 );
2028 });
2029
2030 git(
2034 &work,
2035 &[
2036 "commit",
2037 "-q",
2038 "--amend",
2039 "-m",
2040 "feat: squashed by the forge",
2041 ],
2042 );
2043 let rewritten = git(&work, &["rev-parse", "HEAD"]);
2044 assert_ne!(rewritten, branch_tip, "the fixture must rewrite the commit");
2045 assert_eq!(
2046 git(&work, &["rev-parse", "HEAD^{tree}"]),
2047 git(&work, &["rev-parse", &format!("{branch_tip}^{{tree}}")]),
2048 "…while preserving the tree, which is the premise"
2049 );
2050 git(&work, &["push", "-q", "-f", "origin", "HEAD:main"]);
2051
2052 let clone = d.join("ci-checkout");
2053 git(
2054 &d,
2055 &[
2056 "clone",
2057 "-q",
2058 "--template=",
2059 remote.to_str().unwrap(),
2060 clone.to_str().unwrap(),
2061 ],
2062 );
2063 in_repo(&clone, || {
2064 assert_eq!(
2065 covered(&signers, "t@t.test", None).as_deref(),
2066 Some("pre-push-pytest"),
2067 "the attestation must be found by the tree it signed"
2068 );
2069 });
2070 let _ = std::fs::remove_dir_all(&d);
2071 }
2072
2073 #[test]
2076 fn optional_paths_are_marked_once_and_stripped() {
2077 let spec = parse_spec(b"g src ?build.rs ?a/b/c\n").expect("valid");
2078 assert_eq!(
2079 spec[0].1,
2080 [
2081 PathTok {
2082 path: "src".into(),
2083 optional: false
2084 },
2085 PathTok {
2086 path: "build.rs".into(),
2087 optional: true
2088 },
2089 PathTok {
2090 path: "a/b/c".into(),
2091 optional: true
2092 },
2093 ]
2094 );
2095 assert!(
2096 parse_spec(b"g ?nope\n").is_some(),
2097 "an all-optional gate is a gate"
2098 );
2099 for bad in [
2100 "?", "??x", "?/x", "?./x", "?x/", "?:x", "x?", "?a//b", "?a/../b",
2101 ] {
2102 assert!(
2103 parse_spec(format!("g {bad}\n").as_bytes()).is_none(),
2104 "{bad} should be invalid"
2105 );
2106 }
2107 }
2108
2109 #[test]
2113 fn an_absent_optional_path_is_bound_by_its_absence() {
2114 let work = repo("optional-fp");
2115 std::fs::create_dir_all(work.join("src")).unwrap();
2116 std::fs::create_dir_all(work.join(".github")).unwrap();
2117 std::fs::write(work.join("src/main.rs"), "fn main() {}\n").unwrap();
2118 std::fs::write(work.join(".github/attest-inputs"), "g src ?nope\n").unwrap();
2119 git(&work, &["add", "-A"]);
2120 git(&work, &["commit", "-qm", "chore: spec"]);
2121 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
2122 let spec_oid = git(
2123 &work,
2124 &["rev-parse", &format!("{tree}:.github/attest-inputs")],
2125 );
2126 let main_oid = git(&work, &["rev-parse", &format!("{tree}:src/main.rs")]);
2127 let listing = format!(
2128 "100644 blob {spec_oid}\t.github/attest-inputs\x00100644 blob {main_oid}\tsrc/main.rs\x00"
2129 );
2130 let hand =
2131 crate::git::stdout_piped_in(&work, &["hash-object", "--stdin"], listing.as_bytes())
2132 .unwrap();
2133 let before = in_repo(&work, || {
2134 let spec = spec_at(&tree).expect("the spec parses");
2135 fingerprint(&tree, &spec[0].1)
2136 });
2137 assert_eq!(before.as_deref(), Some(hand.as_str()));
2138 std::fs::write(work.join("nope"), "now it exists\n").unwrap();
2139 git(&work, &["add", "nope"]);
2140 git(&work, &["commit", "-qm", "chore: nope"]);
2141 let tree2 = git(&work, &["rev-parse", "HEAD^{tree}"]);
2142 let after = in_repo(&work, || {
2143 let spec = spec_at(&tree2).expect("the spec parses");
2144 fingerprint(&tree2, &spec[0].1)
2145 });
2146 assert!(
2147 after.is_some() && after != before,
2148 "its appearance changes the fingerprint"
2149 );
2150 let _ = std::fs::remove_dir_all(&work);
2151 }
2152
2153 fn attested_clone(name: &str) -> (PathBuf, PathBuf, PathBuf, PathBuf) {
2158 let (d, work, remote, signers) = remote_and_work(name);
2159 git(&remote, &["symbolic-ref", "HEAD", "refs/heads/main"]);
2160 git(&work, &["push", "-q", "origin", "HEAD:main"]);
2161 in_repo(&work, || {
2162 attest_push(
2163 &test_settings(),
2164 "origin",
2165 &[push_ref_for(&work)],
2166 &["pre-push-pytest".into()],
2167 );
2168 });
2169 let clone = d.join("ci-checkout");
2170 git(
2171 &d,
2172 &[
2173 "clone",
2174 "-q",
2175 "--template=",
2176 remote.to_str().unwrap(),
2177 clone.to_str().unwrap(),
2178 ],
2179 );
2180 (d, remote, clone, signers)
2181 }
2182
2183 fn covers(clone: &Path, signers: &Path, budget: u64) -> Option<String> {
2184 covers_logged(clone, signers, budget).0
2185 }
2186
2187 fn covers_logged(clone: &Path, signers: &Path, budget: u64) -> (Option<String>, Vec<String>) {
2189 in_repo(clone, || {
2190 CAPTURE.with(|c| *c.borrow_mut() = Some(Vec::new()));
2191 let got = covered_within(signers, "t@t.test", None, budget);
2192 let lines = CAPTURE.with(|c| c.borrow_mut().take()).unwrap_or_default();
2193 (got, lines)
2194 })
2195 }
2196
2197 #[test]
2198 fn a_ref_revoked_on_origin_stops_covering_and_the_mirror_goes() {
2199 let (d, remote, clone, signers) = attested_clone("revoked");
2200 assert_eq!(
2201 covers(&clone, &signers, 15).as_deref(),
2202 Some("pre-push-pytest")
2203 );
2204 let oid = git(&clone, &["rev-parse", NOTES_FULL_REF]);
2205 git(&remote, &["update-ref", "-d", NOTES_FULL_REF]);
2206 let (got, lines) = covers_logged(&clone, &signers, 15);
2207 assert_eq!(got, None, "revoked on origin");
2208 assert_eq!(
2209 lines[0],
2210 format!("amont: origin has no {NOTES_FULL_REF}; deleted the local mirror (was {oid})")
2211 );
2212 assert!(
2213 in_repo(&clone, || crate::git::stdout(&[
2214 "rev-parse",
2215 "--verify",
2216 "--quiet",
2217 NOTES_FULL_REF
2218 ]))
2219 .is_none(),
2220 "the local mirror was deleted"
2221 );
2222 let undo = lines[1]
2224 .split("undo locally: git ")
2225 .nth(1)
2226 .and_then(|r| r.split(" (").next())
2227 .expect("an undo command");
2228 git(&clone, &undo.split(' ').collect::<Vec<_>>());
2229 assert_eq!(git(&clone, &["rev-parse", NOTES_FULL_REF]), oid);
2230 let _ = std::fs::remove_dir_all(&d);
2231 }
2232
2233 #[test]
2234 fn an_unreachable_origin_covers_nothing_and_keeps_the_mirror() {
2235 let (d, _remote, clone, signers) = attested_clone("unreachable");
2236 assert_eq!(
2237 covers(&clone, &signers, 15).as_deref(),
2238 Some("pre-push-pytest")
2239 );
2240 git(
2241 &clone,
2242 &[
2243 "remote",
2244 "set-url",
2245 "origin",
2246 "file:///nonexistent/amont-origin.git",
2247 ],
2248 );
2249 let (got, lines) = covers_logged(&clone, &signers, 15);
2250 assert_eq!(got, None, "a stale mirror is not judged");
2251 assert!(
2252 lines.iter().any(|l| l.starts_with(&format!(
2253 "amont: cannot fetch {NOTES_FULL_REF} from origin (exit "
2254 ))),
2255 "the reason is on stderr: {lines:?}"
2256 );
2257 assert!(
2258 !git(&clone, &["rev-parse", NOTES_FULL_REF]).is_empty(),
2259 "and it is kept"
2260 );
2261 let _ = std::fs::remove_dir_all(&d);
2262 }
2263
2264 #[test]
2267 fn without_an_origin_the_local_ref_is_judged() {
2268 let (d, _remote, clone, signers) = attested_clone("no-origin");
2269 assert_eq!(
2270 covers(&clone, &signers, 15).as_deref(),
2271 Some("pre-push-pytest")
2272 );
2273 git(&clone, &["remote", "remove", "origin"]);
2274 assert_eq!(
2275 covers(&clone, &signers, 15).as_deref(),
2276 Some("pre-push-pytest")
2277 );
2278 let _ = std::fs::remove_dir_all(&d);
2279 }
2280
2281 #[cfg(unix)]
2284 #[test]
2285 fn a_silent_origin_is_cut_off_and_leaves_nothing_behind() {
2286 use std::os::unix::fs::PermissionsExt;
2287 let (d, remote, clone, signers) = attested_clone("silent");
2288 let hang = d.join("hang-ssh");
2289 std::fs::write(&hang, "#!/bin/sh\nexec sleep 60\n").unwrap();
2290 std::fs::set_permissions(&hang, std::fs::Permissions::from_mode(0o755)).unwrap();
2291 git(
2293 &clone,
2294 &["config", "core.sshCommand", hang.to_str().unwrap()],
2295 );
2296 git(
2297 &clone,
2298 &["remote", "set-url", "origin", "ssh://example.invalid/x.git"],
2299 );
2300 let (got, took) = in_repo(&clone, || {
2303 let t0 = std::time::Instant::now();
2304 let got = covered_within(&signers, "t@t.test", None, 2);
2305 (got, t0.elapsed())
2306 });
2307 assert_eq!(got, None);
2308 assert!(took.as_secs() < 6, "took {took:?}");
2310 let lock = in_repo(&clone, || {
2311 crate::git::stdout(&["rev-parse", "--git-path", &format!("{NOTES_FULL_REF}.lock")])
2312 })
2313 .unwrap();
2314 assert!(
2315 !clone.join(&lock).exists() && !Path::new(&lock).exists(),
2316 "no lock left"
2317 );
2318 git(&clone, &["config", "--unset", "core.sshCommand"]);
2319 git(
2320 &clone,
2321 &["remote", "set-url", "origin", remote.to_str().unwrap()],
2322 );
2323 assert_eq!(
2324 covers(&clone, &signers, 15).as_deref(),
2325 Some("pre-push-pytest")
2326 );
2327 let _ = std::fs::remove_dir_all(&d);
2328 }
2329
2330 #[test]
2331 fn a_stale_lock_on_the_mirror_means_nothing_is_judged() {
2332 let (d, remote, clone, signers) = attested_clone("stale-lock");
2333 assert_eq!(
2334 covers(&clone, &signers, 15).as_deref(),
2335 Some("pre-push-pytest")
2336 );
2337 let lock = in_repo(&clone, || {
2338 crate::git::stdout(&["rev-parse", "--git-path", &format!("{NOTES_FULL_REF}.lock")])
2339 })
2340 .unwrap();
2341 let lock = if Path::new(&lock).is_absolute() {
2342 PathBuf::from(lock)
2343 } else {
2344 clone.join(lock)
2345 };
2346 std::fs::write(&lock, "").unwrap();
2347 assert_eq!(
2349 covers(&clone, &signers, 15).as_deref(),
2350 Some("pre-push-pytest"),
2351 "an unchanged origin: the copy is origin's"
2352 );
2353 let main = git(&remote, &["rev-parse", "main"]);
2356 git(
2358 &remote,
2359 &[
2360 "-c",
2361 "user.email=t@t.test",
2362 "-c",
2363 "user.name=t",
2364 "notes",
2365 "--ref",
2366 NOTES_REF,
2367 "add",
2368 "-f",
2369 "-m",
2370 "rewritten",
2371 &main,
2372 ],
2373 );
2374 let (got, lines) = covers_logged(&clone, &signers, 15);
2375 assert_eq!(got, None, "a pinned stale copy is never judged");
2376 assert!(
2377 lines.iter().any(|l| l.contains(&format!(
2378 "left a lock on {NOTES_FULL_REF}; if no git is running: rm "
2379 ))),
2380 "the lock is reported with its rm: {lines:?}"
2381 );
2382 std::fs::remove_file(&lock).unwrap();
2383 assert_eq!(
2384 covers(&clone, &signers, 15).as_deref(),
2385 Some("pre-push-pytest")
2386 );
2387 let _ = std::fs::remove_dir_all(&d);
2388 }
2389
2390 #[cfg(unix)]
2393 #[test]
2394 fn leftover_sync_refs_are_swept() {
2395 let (d, _remote, clone, signers) = attested_clone("sweep");
2396 let head = git(&clone, &["rev-parse", "HEAD"]);
2397 git(
2398 &clone,
2399 &["update-ref", "refs/amont-tmp/4194305/amont-attest", &head],
2401 );
2402 assert_eq!(
2403 covers(&clone, &signers, 15).as_deref(),
2404 Some("pre-push-pytest")
2405 );
2406 assert_eq!(git(&clone, &["for-each-ref", "refs/amont-tmp/"]), "");
2407 let _ = std::fs::remove_dir_all(&d);
2408 }
2409
2410 #[test]
2411 fn the_remote_environment_never_prompts() {
2412 let (args, env) = crate::git::remote_env(crate::git::Ssh::Batch);
2413 assert_eq!(
2414 args,
2415 [
2416 "-c",
2417 "http.lowSpeedLimit=1",
2418 "-c",
2419 "http.lowSpeedTime=10",
2420 "-c",
2421 "credential.interactive=never"
2422 ]
2423 );
2424 assert!(env.contains(&("GIT_TERMINAL_PROMPT", "0")));
2425 assert!(
2426 env.contains(&("GIT_ASKPASS", "")),
2427 "present and EMPTY disables every askpass"
2428 );
2429 assert!(env.contains(&("GCM_INTERACTIVE", "never")));
2430 assert!(env.contains(&(
2431 "GIT_SSH_COMMAND",
2432 "ssh -o BatchMode=yes -o ConnectTimeout=10"
2433 )));
2434 let (_, env) = crate::git::remote_env(crate::git::Ssh::User);
2435 assert!(
2436 !env.iter().any(|(k, _)| *k == "GIT_SSH_COMMAND"),
2437 "the user's own ssh command is left alone"
2438 );
2439 }
2440
2441 #[cfg(unix)]
2445 #[test]
2446 fn no_askpass_runs_against_an_origin_that_wants_credentials() {
2447 use std::io::{Read, Write};
2448 use std::os::unix::fs::PermissionsExt;
2449 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2450 let port = listener.local_addr().unwrap().port();
2451 std::thread::spawn(move || {
2452 for stream in listener.incoming().flatten() {
2453 let mut s = stream;
2454 let mut buf = [0u8; 4096];
2455 let _ = s.read(&mut buf);
2456 let _ = s.write_all(
2457 b"HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"x\"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
2458 );
2459 }
2460 });
2461 let work = repo("askpass");
2462 let marker = work.join("asked");
2463 let askpass = work.join("askpass.sh");
2464 std::fs::write(
2465 &askpass,
2466 format!("#!/bin/sh\ntouch '{}'\necho x\n", marker.display()),
2467 )
2468 .unwrap();
2469 std::fs::set_permissions(&askpass, std::fs::Permissions::from_mode(0o755)).unwrap();
2470 git(
2471 &work,
2472 &["config", "core.askPass", askpass.to_str().unwrap()],
2473 );
2474 git(&work, &["config", "credential.helper", ""]);
2475 let url = format!("http://127.0.0.1:{port}/x.git");
2476 in_repo(&work, || {
2477 let _ = crate::git::probe_remote(&["ls-remote", &url], 10);
2478 });
2479 assert!(
2480 !marker.exists(),
2481 "an askpass ran through the remote environment"
2482 );
2483 let _ = std::process::Command::new("git")
2487 .current_dir(&work)
2488 .args(["ls-remote", &url])
2489 .env("GIT_TERMINAL_PROMPT", "0")
2490 .env_remove("GIT_ASKPASS")
2491 .env_remove("SSH_ASKPASS")
2492 .stdin(std::process::Stdio::null())
2493 .output();
2494 assert!(
2495 marker.exists(),
2496 "negative control: without it the askpass does run"
2497 );
2498 let _ = std::fs::remove_dir_all(&work);
2499 }
2500}