1use std::path::PathBuf;
72use std::process::{Command, Stdio};
73
74use crate::pushrefs::PushRef;
75
76pub const FORMAT: &str = "amont-attest-v2";
85
86pub const NOTES_REF: &str = "amont-attest";
88
89pub const NOTES_FULL_REF: &str = "refs/notes/amont-attest";
92
93pub const INPUTS_REF: &str = "amont-attest-inputs";
97pub const INPUTS_FULL_REF: &str = "refs/notes/amont-attest-inputs";
98
99const SPEC_PATHS: [&str; 2] = [".forgejo/attest-inputs", ".github/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
181const MAX_SPEC_BYTES: usize = 65536;
189const MAX_SPEC_GATES: usize = 64;
190const MAX_SPEC_PATHS: usize = 64;
191
192fn valid_gate(name: &str) -> bool {
194 let mut chars = name.chars();
195 match chars.next() {
196 Some(c) if c.is_ascii_alphanumeric() => {}
197 _ => return false,
198 }
199 name.len() <= 64 && chars.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
200}
201
202#[derive(Debug, Clone, PartialEq)]
207struct PathTok {
208 path: String,
209 optional: bool,
210}
211
212fn bad_path(tok: &str) -> bool {
217 let tok = match tok.strip_prefix('?') {
218 Some("") => return true,
219 Some(rest) => rest,
220 None => tok,
221 };
222 tok.starts_with(':')
223 || tok.starts_with('/')
224 || tok.starts_with("./")
225 || tok.starts_with("../")
226 || tok.ends_with('/')
227 || tok
228 .chars()
229 .any(|c| matches!(c, '*' | '?' | '[' | ']' | '\\'))
230 || tok
231 .split('/')
232 .any(|c| c.is_empty() || c == "." || c == "..")
233}
234
235fn parse_spec(bytes: &[u8]) -> Option<Vec<(String, Vec<PathTok>)>> {
237 if bytes.len() > MAX_SPEC_BYTES
238 || bytes
239 .iter()
240 .any(|&b| !(b == b' ' || b == b'\t' || b == b'\n' || (0x21..=0x7e).contains(&b)))
241 {
242 return None;
243 }
244 let text = std::str::from_utf8(bytes).ok()?;
245 let mut gates: Vec<(String, Vec<PathTok>)> = Vec::new();
246 for line in text.split('\n') {
247 let mut toks = line.split([' ', '\t']).filter(|t| !t.is_empty());
248 let Some(gate) = toks.next() else { continue };
249 if gate.starts_with('#') {
250 continue;
251 }
252 if !valid_gate(gate) || gates.iter().any(|(g, _)| g == gate) {
253 return None;
254 }
255 let paths: Vec<String> = toks.map(String::from).collect();
256 if paths.is_empty() || paths.len() > MAX_SPEC_PATHS || paths.iter().any(|p| bad_path(p)) {
257 return None;
258 }
259 let paths = paths
260 .into_iter()
261 .map(|p| match p.strip_prefix('?') {
262 Some(rest) => PathTok {
263 path: rest.to_string(),
264 optional: true,
265 },
266 None => PathTok {
267 path: p,
268 optional: false,
269 },
270 })
271 .collect();
272 gates.push((gate.to_string(), paths));
273 if gates.len() > MAX_SPEC_GATES {
274 return None;
275 }
276 }
277 Some(gates)
278}
279
280fn spec_at(tree: &str) -> Option<Vec<(String, Vec<PathTok>)>> {
284 let present: Vec<&str> = SPEC_PATHS
285 .iter()
286 .copied()
287 .filter(|p| crate::git::succeeds(&["cat-file", "-e", &format!("{tree}:{p}")]))
288 .collect();
289 let [path] = present.as_slice() else {
290 return None;
291 };
292 let bytes = crate::git::stdout_raw(&["cat-file", "blob", &format!("{tree}:{path}")])?;
293 parse_spec(&bytes)
294}
295
296fn implicit_inputs(tokens: &[String]) -> Vec<String> {
300 let mut attrs: Vec<String> = vec![".gitattributes".to_string()];
301 for t in tokens {
302 let comps: Vec<&str> = t.split('/').collect();
303 let mut prefix = String::new();
304 for c in &comps[..comps.len().saturating_sub(1)] {
305 if !prefix.is_empty() {
306 prefix.push('/');
307 }
308 prefix.push_str(c);
309 attrs.push(format!("{prefix}/.gitattributes"));
310 }
311 }
312 attrs.sort();
313 attrs.dedup();
314 let mut out: Vec<String> = SPEC_PATHS.iter().map(|s| s.to_string()).collect();
315 out.push(".gitmodules".to_string());
316 out.extend(attrs);
317 out
318}
319
320fn is_oid(s: &str) -> bool {
321 (s.len() == 40 || s.len() == 64)
322 && s.bytes()
323 .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
324}
325
326fn fingerprint(tree: &str, toks: &[PathTok]) -> Option<String> {
332 let required: Vec<&str> = toks
333 .iter()
334 .filter(|t| !t.optional)
335 .map(|t| t.path.as_str())
336 .collect();
337 if !required.is_empty() {
338 let names: String = required.iter().map(|p| format!("{tree}:{p}\n")).collect();
339 let answers = crate::git::stdout_piped(&["cat-file", "--batch-check"], &names)?;
340 if answers.lines().count() != required.len()
341 || answers.lines().any(|l| l.ends_with(" missing"))
342 {
343 return None;
344 }
345 }
346 let paths: Vec<String> = toks.iter().map(|t| t.path.clone()).collect();
347 let mut args: Vec<String> = vec![
348 "ls-tree".into(),
349 "-r".into(),
350 "-z".into(),
351 "--full-tree".into(),
352 tree.to_string(),
353 "--".into(),
354 ];
355 args.extend(implicit_inputs(&paths));
356 args.extend(paths.iter().cloned());
357 let argv: Vec<&str> = args.iter().map(String::as_str).collect();
358 let listing = crate::git::stdout_raw(&argv)?;
359 if listing.is_empty() {
360 return None;
361 }
362 crate::git::stdout_piped_in(
363 std::path::Path::new("."),
364 &["hash-object", "--stdin"],
365 &listing,
366 )
367 .filter(|s| is_oid(s))
368}
369
370fn inputs_for(tree: &str, gates: &[String]) -> Vec<(String, String)> {
373 let Some(spec) = spec_at(tree) else {
374 return Vec::new();
375 };
376 spec.iter()
377 .filter(|(g, _)| gates.iter().any(|x| x == g))
378 .filter_map(|(g, paths)| fingerprint(tree, paths).map(|fp| (g.clone(), fp)))
379 .collect()
380}
381
382fn input_key(gate: &str, fp: &str) -> Option<String> {
384 let pre = format!("amont-attest-input {gate} {fp}\n");
385 crate::git::stdout_piped_in(
386 std::path::Path::new("."),
387 &["hash-object", "--stdin"],
388 pre.as_bytes(),
389 )
390 .filter(|s| is_oid(s))
391}
392
393fn sign(payload: &str, key: &std::path::Path) -> Option<String> {
397 use std::io::Write;
398 let mut child = Command::new("ssh-keygen")
399 .args(["-Y", "sign", "-n", NAMESPACE, "-f"])
400 .arg(key)
401 .stdin(Stdio::piped())
402 .stdout(Stdio::piped())
403 .stderr(Stdio::null())
404 .spawn()
405 .ok()?;
406 child.stdin.take()?.write_all(payload.as_bytes()).ok()?;
407 let out = child.wait_with_output().ok()?;
408 if !out.status.success() {
409 return None;
410 }
411 let sig = String::from_utf8_lossy(&out.stdout).trim().to_string();
412 sig.starts_with("-----BEGIN SSH SIGNATURE-----")
413 .then_some(sig)
414}
415
416fn create_exclusive(path: &std::path::Path) -> Option<std::fs::File> {
434 std::fs::OpenOptions::new()
435 .write(true)
436 .create_new(true)
437 .open(path)
438 .ok()
439}
440
441pub fn verify(
442 payload: &str,
443 sig: &str,
444 allowed_signers: &std::path::Path,
445 principal: &str,
446) -> bool {
447 use std::io::Write;
448 let nonce = std::time::SystemTime::now()
455 .duration_since(std::time::UNIX_EPOCH)
456 .map(|d| d.subsec_nanos())
457 .unwrap_or(0);
458 let sig_file = std::env::temp_dir().join(format!(
459 "amont-attest-verify-{}-{:p}-{nonce}.sig",
460 std::process::id(),
461 &sig
462 ));
463 let Some(mut f) = create_exclusive(&sig_file) else {
464 return false;
465 };
466 if f.write_all(format!("{sig}\n").as_bytes()).is_err() {
467 let _ = std::fs::remove_file(&sig_file);
468 return false;
469 }
470 drop(f);
472 let ok = (|| {
473 let mut child = Command::new("ssh-keygen")
474 .args(["-Y", "verify", "-n", NAMESPACE, "-I", principal, "-f"])
475 .arg(allowed_signers)
476 .arg("-s")
477 .arg(&sig_file)
478 .stdin(Stdio::piped())
479 .stdout(Stdio::null())
480 .stderr(Stdio::null())
481 .spawn()
482 .ok()?;
483 child.stdin.take()?.write_all(payload.as_bytes()).ok()?;
484 child.wait().ok().map(|s| s.success())
485 })()
486 .unwrap_or(false);
487 let _ = std::fs::remove_file(&sig_file);
488 ok
489}
490
491pub fn attest_push(
503 settings: &crate::config::Settings,
504 remote: &str,
505 refs: &[PushRef],
506 gates: &[String],
507) {
508 if gates.is_empty() || remote.is_empty() || !enabled(settings) {
509 return;
510 }
511 let Some(key) = key_path() else { return };
512 if !key.exists() {
513 crate::config::complain(
514 TOGGLE,
515 &format!("signing key {} does not exist", key.display()),
516 "no attestation (CI will run the tests)",
517 );
518 return;
519 }
520 let mut blocks: Vec<(String, String)> = Vec::new();
521 let mut input_blocks: Vec<(String, String)> = Vec::new();
522 for r in refs {
523 if is_zero(&r.local_oid) {
524 continue; }
526 let spec = format!("{}^{{tree}}", r.local_oid);
527 let Some(tree) = crate::git::stdout(&["rev-parse", &spec]) else {
528 continue;
529 };
530 let inputs = inputs_for(&tree, gates);
533 let p = payload(&tree, gates, &inputs);
534 let body = match sign(&p, &key) {
535 Some(sig) => format!("{p}\n{sig}"),
536 None => continue,
537 };
538 for (gate, fp) in &inputs {
539 if let Some(k) = input_key(gate, fp) {
540 input_blocks.push((k, body.clone()));
541 }
542 }
543 blocks.push((tree, body.clone()));
561 blocks.push((r.local_oid.clone(), body));
562 }
563 if blocks.is_empty() {
564 return;
565 }
566 let main_ok = publish(remote, NOTES_FULL_REF, &blocks);
570 let inputs_ok = input_blocks.is_empty() || publish(remote, INPUTS_FULL_REF, &input_blocks);
571 if main_ok {
572 crate::say!(
573 "{} attested {} for CI ({}{})",
574 crate::ui::valid_sign(),
575 crate::ui::highlight(&gates.join(" ")),
576 NOTES_REF,
577 if input_blocks.is_empty() {
578 String::new()
579 } else if inputs_ok {
580 format!(", {} input fingerprints", input_blocks.len())
581 } else {
582 ", input fingerprints not published".to_string()
583 },
584 );
585 }
586}
587
588const PUSH_ATTEMPTS: u32 = 4;
590
591fn publish(remote: &str, notes_ref: &str, blocks: &[(String, String)]) -> bool {
612 let short = notes_ref.trim_start_matches("refs/notes/");
613 let tmp = format!("amont-attest-push-{}-{short}", std::process::id());
614 let tmp_full = format!("refs/notes/{tmp}");
615 let fetch_spec = format!("+{notes_ref}:{tmp_full}");
616 let push_spec = format!("{tmp_full}:{notes_ref}");
617 let mut published = false;
618 for _ in 0..PUSH_ATTEMPTS {
619 let _ = crate::git::succeeds(&["update-ref", "-d", &tmp_full]);
620 let _ = crate::git::succeeds(&["fetch", "--quiet", remote, &fetch_spec]);
623 let mut appended = false;
624 for (object, body) in blocks {
625 let existing =
626 crate::git::stdout(&["notes", "--ref", &tmp, "show", object]).unwrap_or_default();
627 if existing.contains(body.trim_end()) {
628 continue;
629 }
630 if crate::git::succeeds(&["notes", "--ref", &tmp, "append", "-m", body, object]) {
631 appended = true;
632 }
633 }
634 if !appended {
635 published = true;
637 break;
638 }
639 match push_notes(remote, &push_spec) {
640 Push::Done => {
641 published = true;
642 break;
643 }
644 Push::Raced => continue,
645 Push::Refused => break,
646 }
647 }
648 if published {
649 let _ = crate::git::succeeds(&["update-ref", notes_ref, &tmp_full]);
650 }
651 let _ = crate::git::succeeds(&["update-ref", "-d", &tmp_full]);
652 published
653}
654
655enum Push {
657 Done,
658 Raced,
661 Refused,
663}
664
665pub fn covered(
688 signers: &std::path::Path,
689 principal: &str,
690 require_platform: Option<&str>,
691) -> Option<String> {
692 covered_within(signers, principal, require_platform, REMOTE_BUDGET_SECS)
693}
694
695const REMOTE_BUDGET_SECS: u64 = 15;
697
698#[derive(Debug, PartialEq)]
700enum Sync {
701 Judge,
703 Skip,
705}
706
707fn sync_mirror(budget: u64) -> Sync {
720 if !crate::git::succeeds(&["remote", "get-url", "origin"]) {
721 return Sync::Judge;
723 }
724 sweep_sync_refs();
725 let old = crate::git::stdout(&["rev-parse", "--verify", "--quiet", NOTES_FULL_REF]);
726 let tmp = format!("{SYNC_NAMESPACE}{}/amont-attest", std::process::id());
727 let fetched = crate::git::probe_remote(
728 &[
729 "fetch",
730 "--quiet",
731 "origin",
732 &format!("+{NOTES_FULL_REF}:{tmp}"),
733 ],
734 budget,
735 );
736 let verdict = match fetched {
737 crate::git::Probe::Exit(0) => {
738 let new = crate::git::stdout(&["rev-parse", "--verify", "--quiet", &tmp]);
739 let expected = old.clone().unwrap_or_default();
742 match new {
743 None => {
744 say_skip(&format!(
746 "the fetch of {NOTES_FULL_REF} left nothing to read; local mirror not judged"
747 ));
748 Sync::Skip
749 }
750 Some(new)
751 if crate::git::succeeds(&["update-ref", NOTES_FULL_REF, &new, &expected]) =>
752 {
753 Sync::Judge
754 }
755 Some(new) => {
756 let now =
762 crate::git::stdout(&["rev-parse", "--verify", "--quiet", NOTES_FULL_REF]);
763 if now.as_deref() == Some(new.as_str()) {
764 Sync::Judge
765 } else {
766 say_skip(&format!(
767 "cannot update {NOTES_FULL_REF} to origin's copy; local mirror not judged"
768 ));
769 Sync::Skip
770 }
771 }
772 }
773 }
774 crate::git::Probe::TimedOut(secs) => {
775 say_skip(&format!(
777 "origin did not answer within {secs} s for {NOTES_FULL_REF}; local mirror not judged, running everything"
778 ));
779 Sync::Skip
780 }
781 _ => match crate::git::probe_remote(
782 &["ls-remote", "--exit-code", "origin", NOTES_FULL_REF],
783 budget,
784 ) {
785 crate::git::Probe::Exit(2) => drop_mirror(old.as_deref()),
786 crate::git::Probe::Exit(0) => {
787 say_skip(&format!(
788 "origin has {NOTES_FULL_REF} but fetching it failed; local mirror not judged, running everything"
789 ));
790 Sync::Skip
791 }
792 crate::git::Probe::Exit(code) => {
793 say_skip(&format!(
794 "cannot fetch {NOTES_FULL_REF} from origin (exit {code}; no credentials? persist-credentials: false?); local mirror not judged, running everything"
795 ));
796 Sync::Skip
797 }
798 crate::git::Probe::TimedOut(secs) => {
799 say_skip(&format!(
800 "origin did not answer within {secs} s for {NOTES_FULL_REF}; local mirror not judged, running everything"
801 ));
802 Sync::Skip
803 }
804 crate::git::Probe::Failed => {
805 say_skip(&format!(
806 "cannot fetch {NOTES_FULL_REF} from origin (git did not run); local mirror not judged, running everything"
807 ));
808 Sync::Skip
809 }
810 },
811 };
812 let _ = crate::git::succeeds(&["update-ref", "-d", &tmp]);
813 report_stale_lock();
814 verdict
815}
816
817fn drop_mirror(old: Option<&str>) -> Sync {
821 let Some(oid) = old else {
822 return Sync::Skip; };
824 if crate::git::succeeds(&["update-ref", "-d", NOTES_FULL_REF, oid]) {
825 say(format!(
826 "amont: origin has no {NOTES_FULL_REF}; deleted the local mirror (was {oid})"
827 ));
828 say(format!(
829 "amont: undo locally: git update-ref {NOTES_FULL_REF} {oid} (undo the revocation: git push origin {oid}:{NOTES_FULL_REF})"
830 ));
831 } else {
832 say_skip(&format!(
833 "origin has no {NOTES_FULL_REF} but the local mirror could not be deleted (read-only .git?); local mirror not judged"
834 ));
835 }
836 Sync::Skip
837}
838
839const SYNC_NAMESPACE: &str = "refs/amont-tmp/";
841
842fn sweep_sync_refs() {
847 let Some(refs) = crate::git::stdout(&["for-each-ref", "--format=%(refname)", SYNC_NAMESPACE])
848 else {
849 return;
850 };
851 for r in refs.lines() {
852 let Some(pid) = r
853 .strip_prefix(SYNC_NAMESPACE)
854 .and_then(|rest| rest.split('/').next())
855 .filter(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
856 else {
857 continue;
858 };
859 if pid == std::process::id().to_string() || process_alive(pid) != Some(false) {
860 continue;
861 }
862 let _ = crate::git::succeeds(&["update-ref", "-d", r]);
863 }
864}
865
866fn process_alive(pid: &str) -> Option<bool> {
868 if !cfg!(unix) {
869 return None;
870 }
871 let out = Command::new("kill")
873 .args(["-0", pid])
874 .env("LC_ALL", "C")
875 .stdin(Stdio::null())
876 .stdout(Stdio::null())
877 .stderr(Stdio::piped())
878 .output()
879 .ok()?;
880 if out.status.success() {
881 return Some(true);
882 }
883 let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
886 Some(!err.contains("no such process"))
887}
888
889fn report_stale_lock() {
892 let lock = format!("{NOTES_FULL_REF}.lock");
893 if let Some(path) = crate::git::stdout(&["rev-parse", "--git-path", &lock]) {
894 if std::path::Path::new(&path).exists() {
895 say_skip(&format!(
896 "a git that was killed left a lock on {NOTES_FULL_REF}; if no git is running: rm {path}"
897 ));
898 }
899 }
900}
901
902fn say_skip(why: &str) {
904 say(format!("amont: {why}"));
905}
906
907thread_local! {
908 static CAPTURE: std::cell::RefCell<Option<Vec<String>>> = const { std::cell::RefCell::new(None) };
911}
912
913fn say(line: String) {
915 CAPTURE.with(|c| {
916 if let Some(v) = c.borrow_mut().as_mut() {
917 v.push(line.clone());
918 }
919 });
920 eprintln!("{line}");
921}
922
923fn covered_within(
925 signers: &std::path::Path,
926 principal: &str,
927 require_platform: Option<&str>,
928 budget: u64,
929) -> Option<String> {
930 if sync_mirror(budget) == Sync::Skip {
931 return None;
932 }
933 let head_tree = crate::git::stdout(&["rev-parse", "HEAD^{tree}"])?;
934 for candidate in [head_tree.as_str(), "HEAD", "HEAD^2"] {
948 let Some(object) = crate::git::stdout(&["rev-parse", "--verify", candidate]) else {
949 continue;
950 };
951 let Some(body) = crate::git::stdout(&["notes", "--ref", NOTES_REF, "show", &object]) else {
952 continue;
953 };
954 let Some((payload, sig)) = split_note(&body) else {
955 continue;
956 };
957 let mut lines = payload.lines();
961 if lines.next() != Some(FORMAT) {
962 continue;
963 }
964 let field = |name: &str| {
965 payload
966 .lines()
967 .find_map(|l| l.strip_prefix(name).and_then(|r| r.strip_prefix(' ')))
968 .map(str::trim)
969 };
970 let (Some(tree), Some(gates), Some(ran_on)) =
971 (field("tree"), field("gates"), field("platform"))
972 else {
973 continue;
974 };
975 if tree != head_tree || gates.is_empty() {
976 continue; }
978 if require_platform.is_some_and(|want| want != ran_on) {
982 continue;
983 }
984 if verify(&payload, &sig, signers, principal) {
985 return Some(gates.to_string());
986 }
987 }
988 None
989}
990
991pub fn default_signers() -> Option<PathBuf> {
1002 let root = crate::git::stdout(&["rev-parse", "--show-toplevel"]).map(PathBuf::from);
1003 [".forgejo/allowed_signers", ".github/allowed_signers"]
1004 .into_iter()
1005 .map(|rel| match &root {
1006 Some(root) => root.join(rel),
1007 None => PathBuf::from(rel),
1008 })
1009 .find(|p| p.exists())
1010}
1011
1012pub fn first_principal(signers: &std::path::Path) -> Option<String> {
1017 let body = std::fs::read_to_string(signers).ok()?;
1018 body.lines()
1019 .map(str::trim)
1020 .find(|l| !l.is_empty() && !l.starts_with('#'))
1021 .and_then(|l| l.split_whitespace().next())
1022 .map(str::to_string)
1023}
1024
1025fn split_note(body: &str) -> Option<(String, String)> {
1029 let (payload, sig) = body.split_once("\n\n")?;
1030 if !sig.starts_with("-----BEGIN SSH SIGNATURE-----") {
1031 return None;
1032 }
1033 Some((format!("{payload}\n"), sig.to_string()))
1034}
1035
1036fn push_notes(remote: &str, refspec: &str) -> Push {
1042 let out = Command::new("git")
1043 .args(["push", "--quiet", remote, refspec])
1044 .env(PUSH_GUARD, "1")
1045 .stdin(Stdio::null())
1046 .stdout(Stdio::null())
1047 .output();
1048 let Ok(out) = out else { return Push::Refused };
1049 if out.status.success() {
1050 return Push::Done;
1051 }
1052 match classify_push_stderr(&String::from_utf8_lossy(&out.stderr)) {
1053 PushFailure::Raced => Push::Raced,
1054 PushFailure::Unreachable | PushFailure::Refused => Push::Refused,
1055 }
1056}
1057
1058#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1066pub enum PushFailure {
1067 Raced,
1068 Unreachable,
1069 Refused,
1070}
1071
1072pub fn classify_push_stderr(err: &str) -> PushFailure {
1075 const RACED: [&str; 5] = [
1076 "non-fast-forward",
1077 "fetch first",
1078 "stale info",
1079 "cannot lock ref",
1080 "failed to lock",
1081 ];
1082 const UNREACHABLE: [&str; 5] = [
1083 "Could not resolve host",
1084 "Connection refused",
1085 "Connection timed out",
1086 "unable to access",
1087 "Could not read from remote repository",
1088 ];
1089 let own = |l: &&str| {
1093 let l = l.trim_start();
1094 l.starts_with("fatal:") || l.starts_with("ssh:") || l.starts_with("error:")
1095 };
1096 if RACED.iter().any(|m| err.contains(m)) {
1097 PushFailure::Raced
1098 } else if err
1099 .lines()
1100 .filter(own)
1101 .any(|l| UNREACHABLE.iter().any(|m| l.contains(m)))
1102 {
1103 PushFailure::Unreachable
1104 } else {
1105 PushFailure::Refused
1106 }
1107}
1108
1109fn is_zero(oid: &str) -> bool {
1112 !oid.is_empty() && oid.bytes().all(|b| b == b'0')
1113}
1114
1115pub fn forget() -> bool {
1119 crate::git::succeeds(&["update-ref", "-d", NOTES_FULL_REF])
1120}
1121
1122pub fn forget_in(repo: &std::path::Path) -> bool {
1124 crate::git::succeeds_in(repo, &["update-ref", "-d", NOTES_FULL_REF])
1125}
1126
1127#[cfg(test)]
1128mod tests {
1129 use super::*;
1130
1131 #[test]
1132 fn push_stderr_is_sorted_into_raced_unreachable_and_refused() {
1133 assert_eq!(
1134 classify_push_stderr(" ! [rejected] feat -> feat (non-fast-forward)"),
1135 PushFailure::Raced
1136 );
1137 assert_eq!(
1138 classify_push_stderr("error: failed to lock ref"),
1139 PushFailure::Raced
1140 );
1141 for line in [
1142 "fatal: unable to access 'https://h/x.git/': Could not resolve host: h",
1143 "ssh: connect to host h port 22: Connection refused",
1144 "fatal: Could not read from remote repository.",
1145 "fatal: unable to access 'u': Connection timed out after 10001 ms",
1146 ] {
1147 assert_eq!(
1148 classify_push_stderr(line),
1149 PushFailure::Unreachable,
1150 "{line}"
1151 );
1152 }
1153 assert_eq!(
1154 classify_push_stderr("remote: permission denied"),
1155 PushFailure::Refused
1156 );
1157 assert_eq!(classify_push_stderr(""), PushFailure::Refused);
1158 }
1159
1160 #[test]
1161 fn a_hooks_own_words_do_not_make_the_remote_unreachable() {
1162 assert_eq!(
1164 classify_push_stderr("integration: Connection refused by the fixture\nhook failed"),
1165 PushFailure::Refused
1166 );
1167 }
1168
1169 fn test_settings() -> crate::config::Settings {
1172 crate::config::Settings::default()
1173 }
1174
1175 use std::path::Path;
1176
1177 fn dir(name: &str) -> PathBuf {
1181 let d = std::env::temp_dir().join(format!("attest-{name}-{}", std::process::id()));
1182 let _ = std::fs::remove_dir_all(&d);
1183 std::fs::create_dir_all(&d).unwrap();
1184 d
1185 }
1186
1187 fn git(dir: &Path, args: &[&str]) -> String {
1196 let out = std::process::Command::new("git")
1197 .arg("-C")
1198 .arg(dir)
1199 .args(args)
1200 .output()
1201 .expect("git");
1202 assert!(
1203 out.status.success(),
1204 "fixture: git {args:?} in {} exited {:?}: {}",
1205 dir.display(),
1206 out.status.code(),
1207 String::from_utf8_lossy(&out.stderr).trim()
1208 );
1209 String::from_utf8_lossy(&out.stdout).trim().to_string()
1210 }
1211
1212 fn repo(name: &str) -> PathBuf {
1213 let d = dir(name);
1214 git(&d, &["init", "-q", "--template=", "."]);
1215 git(&d, &["config", "user.email", "t@t.test"]);
1216 git(&d, &["config", "user.name", "t"]);
1217 d
1218 }
1219
1220 fn keypair(d: &Path) -> (PathBuf, PathBuf) {
1223 let key = d.join("attest_key");
1224 let ok = std::process::Command::new("ssh-keygen")
1225 .args(["-q", "-t", "ed25519", "-N", "", "-C", "test", "-f"])
1226 .arg(&key)
1227 .status()
1228 .expect("ssh-keygen must exist for these tests")
1229 .success();
1230 assert!(ok, "keygen failed");
1231 let pubkey = std::fs::read_to_string(key.with_extension("pub")).unwrap();
1232 let signers = d.join("allowed_signers");
1233 std::fs::write(
1234 &signers,
1235 format!("t@t.test namespaces=\"{NAMESPACE}\" {pubkey}"),
1236 )
1237 .unwrap();
1238 (key, signers)
1239 }
1240
1241 fn in_repo<T>(dir: &Path, f: impl FnOnce() -> T) -> T {
1244 let _guard = crate::TEST_CWD.lock().unwrap_or_else(|p| p.into_inner());
1245 let prev = std::env::current_dir().unwrap();
1246 std::env::set_current_dir(dir).unwrap();
1247 let r = f();
1248 std::env::set_current_dir(prev).unwrap();
1249 r
1250 }
1251
1252 #[test]
1257 fn the_signature_file_refuses_to_follow_what_is_already_there() {
1258 let dir = std::env::temp_dir().join(format!("amont-excl-{}", std::process::id()));
1259 let _ = std::fs::remove_dir_all(&dir);
1260 std::fs::create_dir_all(&dir).expect("temp dir");
1261
1262 let taken = dir.join("taken");
1264 std::fs::write(&taken, "original").expect("seed");
1265 assert!(create_exclusive(&taken).is_none());
1266 assert_eq!(std::fs::read_to_string(&taken).unwrap(), "original");
1267
1268 #[cfg(unix)]
1270 {
1271 let victim = dir.join("victim");
1272 std::fs::write(&victim, "precious").expect("seed");
1273 let link = dir.join("link");
1274 std::os::unix::fs::symlink(&victim, &link).expect("symlink");
1275 assert!(
1276 create_exclusive(&link).is_none(),
1277 "a symlink must be refused, not followed"
1278 );
1279 assert_eq!(
1280 std::fs::read_to_string(&victim).unwrap(),
1281 "precious",
1282 "the link target was written through"
1283 );
1284 }
1285
1286 let fresh = dir.join("fresh");
1288 assert!(create_exclusive(&fresh).is_some());
1289
1290 let _ = std::fs::remove_dir_all(&dir);
1291 }
1292
1293 #[test]
1294 fn the_payload_is_the_documented_contract() {
1295 let p = payload(
1296 "abc123",
1297 &["pre-push-pytest".into(), "pre-push-cargo-test".into()],
1298 &[],
1299 );
1300 let lines: Vec<&str> = p.lines().collect();
1301 assert_eq!(lines[0], FORMAT);
1302 assert_eq!(lines[1], "tree abc123");
1303 assert_eq!(lines[2], "gates pre-push-pytest pre-push-cargo-test");
1304 assert_eq!(lines[3], format!("platform {}", platform()));
1305 assert_eq!(lines[4], format!("amont {}", env!("CARGO_PKG_VERSION")));
1306 assert!(
1307 p.ends_with('\n'),
1308 "CI reconstructs these bytes; the trailing newline is part of them"
1309 );
1310 }
1311
1312 #[test]
1313 fn sign_verify_roundtrip_and_tamper_rejection() {
1314 let d = dir("roundtrip");
1315 let (key, signers) = keypair(&d);
1316 let p = payload("deadbeef", &["pre-push-pytest".into()], &[]);
1317 let sig = sign(&p, &key).expect("signing with a real key succeeds");
1318 assert!(verify(&p, &sig, &signers, "t@t.test"));
1319 let tampered = payload("deadbeee", &["pre-push-pytest".into()], &[]);
1322 assert!(!verify(&tampered, &sig, &signers, "t@t.test"));
1323 assert!(!verify(&p, &sig, &signers, "someone@else.test"));
1325 let _ = std::fs::remove_dir_all(&d);
1326 }
1327
1328 #[test]
1329 fn a_missing_key_signs_nothing() {
1330 assert!(sign("anything", Path::new("/nonexistent/key")).is_none());
1331 }
1332
1333 #[test]
1337 fn an_enabled_push_leaves_a_verifiable_note_on_the_remote() {
1338 let d = dir("e2e");
1339 let (key, signers) = keypair(&d);
1340 let remote = d.join("remote.git");
1341 std::fs::create_dir_all(&remote).unwrap();
1342 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
1343 let work = repo("e2e-work");
1344 git(
1345 &work,
1346 &["remote", "add", "origin", remote.to_str().unwrap()],
1347 );
1348 git(&work, &["config", "amont.attest", "true"]);
1349 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
1350 std::fs::write(work.join("a.ts"), "x").unwrap();
1351 git(&work, &["add", "a.ts"]);
1352 git(&work, &["commit", "-qm", "chore: a"]);
1353 let head = git(&work, &["rev-parse", "HEAD"]);
1354 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1355 let push_ref = PushRef {
1356 local_ref: "refs/heads/main".into(),
1357 local_oid: head.clone(),
1358 remote_ref: "refs/heads/main".into(),
1359 remote_oid: "0".repeat(40),
1360 };
1361 in_repo(&work, || {
1362 attest_push(
1363 &test_settings(),
1364 "origin",
1365 &[push_ref],
1366 &["pre-push-run-tests-js".into()],
1367 );
1368 });
1369 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &head]);
1371 assert!(!body.is_empty(), "no note reached the remote");
1372 let (p, sig) = body
1373 .split_once("\n\n")
1374 .expect("payload, blank line, signature");
1375 let p = format!("{p}\n"); assert!(p.starts_with(FORMAT));
1377 assert!(
1378 p.contains(&format!("tree {tree}")),
1379 "attests the pushed tree"
1380 );
1381 assert!(
1382 verify(&p, sig, &signers, "t@t.test"),
1383 "the remote copy verifies"
1384 );
1385 let _ = std::fs::remove_dir_all(&d);
1386 let _ = std::fs::remove_dir_all(&work);
1387 }
1388
1389 fn remote_and_work(name: &str) -> (PathBuf, PathBuf, PathBuf, PathBuf) {
1391 let d = dir(name);
1392 let (key, signers) = keypair(&d);
1393 let remote = d.join("remote.git");
1394 std::fs::create_dir_all(&remote).unwrap();
1395 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
1396 let work = repo(&format!("{name}-work"));
1397 git(
1398 &work,
1399 &["remote", "add", "origin", remote.to_str().unwrap()],
1400 );
1401 git(&work, &["config", "amont.attest", "true"]);
1402 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
1403 std::fs::write(work.join("a.ts"), "x").unwrap();
1404 git(&work, &["add", "a.ts"]);
1405 git(&work, &["commit", "-qm", "chore: a"]);
1406 (d, work, remote, signers)
1407 }
1408
1409 fn push_ref_for(work: &Path) -> PushRef {
1410 PushRef {
1411 local_ref: "refs/heads/main".into(),
1412 local_oid: git(work, &["rev-parse", "HEAD"]),
1413 remote_ref: "refs/heads/main".into(),
1414 remote_oid: "0".repeat(40),
1415 }
1416 }
1417
1418 fn blocks_in(body: &str) -> usize {
1419 body.matches("-----BEGIN SSH SIGNATURE-----").count()
1420 }
1421
1422 #[test]
1426 fn a_block_already_on_the_remote_survives_and_ours_is_appended() {
1427 let (d, work, remote, _) = remote_and_work("append");
1428 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1429 let seed = repo("append-seed");
1431 git(
1432 &seed,
1433 &["remote", "add", "origin", remote.to_str().unwrap()],
1434 );
1435 git(&seed, &["fetch", "-q", "origin"]);
1436 std::fs::write(seed.join("a.ts"), "x").unwrap();
1437 git(&seed, &["add", "a.ts"]);
1438 git(&seed, &["commit", "-qm", "chore: a"]);
1439 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-----";
1440 git(
1441 &seed,
1442 &["notes", "--ref", NOTES_REF, "add", "-m", foreign, &tree],
1443 );
1444 git(
1445 &seed,
1446 &[
1447 "push",
1448 "-q",
1449 "origin",
1450 &format!("{NOTES_FULL_REF}:{NOTES_FULL_REF}"),
1451 ],
1452 );
1453 in_repo(&work, || {
1454 attest_push(
1455 &test_settings(),
1456 "origin",
1457 &[push_ref_for(&work)],
1458 &["pre-push-run-tests-js".into()],
1459 );
1460 });
1461 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &tree]);
1462 assert_eq!(blocks_in(&body), 2, "both blocks on the remote:\n{body}");
1463 assert!(body.contains("gates ci-fmt"), "the foreign block survives");
1464 assert!(
1465 body.contains("gates pre-push-run-tests-js"),
1466 "ours was appended"
1467 );
1468 let _ = std::fs::remove_dir_all(&d);
1469 let _ = std::fs::remove_dir_all(&work);
1470 let _ = std::fs::remove_dir_all(&seed);
1471 }
1472
1473 #[test]
1478 fn a_stale_local_notes_ref_no_longer_loses_the_push() {
1479 let (d, work, remote, _) = remote_and_work("stale");
1480 let head = git(&work, &["rev-parse", "HEAD"]);
1481 git(
1484 &work,
1485 &[
1486 "notes",
1487 "--ref",
1488 NOTES_REF,
1489 "add",
1490 "-m",
1491 "stale local",
1492 &head,
1493 ],
1494 );
1495 let stale = git(&work, &["rev-parse", NOTES_FULL_REF]);
1496 let seed = repo("stale-seed");
1498 std::fs::write(seed.join("b.ts"), "y").unwrap();
1499 git(&seed, &["add", "b.ts"]);
1500 git(&seed, &["commit", "-qm", "chore: b"]);
1501 git(
1502 &seed,
1503 &["remote", "add", "origin", remote.to_str().unwrap()],
1504 );
1505 git(
1506 &seed,
1507 &[
1508 "notes",
1509 "--ref",
1510 NOTES_REF,
1511 "add",
1512 "-m",
1513 "remote first",
1514 "HEAD",
1515 ],
1516 );
1517 git(&seed, &["push", "-q", "origin", "HEAD:refs/heads/other"]);
1518 git(
1519 &seed,
1520 &[
1521 "push",
1522 "-q",
1523 "origin",
1524 &format!("{NOTES_FULL_REF}:{NOTES_FULL_REF}"),
1525 ],
1526 );
1527 in_repo(&work, || {
1528 attest_push(
1529 &test_settings(),
1530 "origin",
1531 &[push_ref_for(&work)],
1532 &["pre-push-run-tests-js".into()],
1533 );
1534 });
1535 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &head]);
1536 assert!(
1537 body.contains("gates pre-push-run-tests-js"),
1538 "the push landed:\n{body}"
1539 );
1540 assert_ne!(
1541 git(&work, &["rev-parse", NOTES_FULL_REF]),
1542 stale,
1543 "the local ref followed the published state"
1544 );
1545 assert!(
1546 git(&work, &["for-each-ref", "refs/notes/amont-attest-push-*"]).is_empty(),
1547 "no temporary ref left behind"
1548 );
1549 let _ = std::fs::remove_dir_all(&d);
1550 let _ = std::fs::remove_dir_all(&work);
1551 let _ = std::fs::remove_dir_all(&seed);
1552 }
1553
1554 #[test]
1557 fn a_second_push_of_the_same_tree_appends_nothing() {
1558 let (d, work, remote, _) = remote_and_work("twice");
1559 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1560 for _ in 0..2 {
1561 in_repo(&work, || {
1562 attest_push(
1563 &test_settings(),
1564 "origin",
1565 &[push_ref_for(&work)],
1566 &["pre-push-run-tests-js".into()],
1567 );
1568 });
1569 }
1570 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &tree]);
1571 assert_eq!(blocks_in(&body), 1, "one block, not two:\n{body}");
1572 let _ = std::fs::remove_dir_all(&d);
1573 let _ = std::fs::remove_dir_all(&work);
1574 }
1575
1576 #[test]
1580 fn a_spec_yields_input_lines_and_fingerprint_keyed_notes() {
1581 let (d, work, remote, _) = remote_and_work("inputs");
1582 std::fs::create_dir_all(work.join(".github")).unwrap();
1583 std::fs::write(
1584 work.join(".github/attest-inputs"),
1585 "# what each gate reads\npre-push-run-tests-js a.ts\nother nope\n",
1586 )
1587 .unwrap();
1588 git(&work, &["add", ".github/attest-inputs"]);
1589 git(&work, &["commit", "-qm", "chore: spec"]);
1590 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1591 let listing = std::process::Command::new("git")
1593 .args([
1594 "-C",
1595 work.to_str().unwrap(),
1596 "ls-tree",
1597 "-r",
1598 "-z",
1599 "--full-tree",
1600 &tree,
1601 "--",
1602 ".forgejo/attest-inputs",
1603 ".github/attest-inputs",
1604 ".gitmodules",
1605 ".gitattributes",
1606 "a.ts",
1607 ])
1608 .output()
1609 .unwrap()
1610 .stdout;
1611 let expected =
1612 crate::git::stdout_piped_in(&work, &["hash-object", "--stdin"], &listing).unwrap();
1613 in_repo(&work, || {
1614 attest_push(
1615 &test_settings(),
1616 "origin",
1617 &[push_ref_for(&work)],
1618 &["pre-push-run-tests-js".into(), "other".into()],
1619 );
1620 });
1621 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &tree]);
1622 assert!(
1623 body.contains(&format!("input pre-push-run-tests-js {expected}\n")),
1624 "the input line carries the reference fingerprint:\n{body}"
1625 );
1626 assert!(
1627 !body.contains("input other "),
1628 "a gate whose path does not exist gets no line"
1629 );
1630 let key = crate::git::stdout_piped_in(
1631 &work,
1632 &["hash-object", "--stdin"],
1633 format!("amont-attest-input pre-push-run-tests-js {expected}\n").as_bytes(),
1634 )
1635 .unwrap();
1636 let under_key = git(&remote, &["notes", "--ref", INPUTS_REF, "show", &key]);
1637 assert_eq!(
1638 under_key, body,
1639 "the same block is filed under the fingerprint key"
1640 );
1641 in_repo(&work, || {
1643 attest_push(
1644 &test_settings(),
1645 "origin",
1646 &[push_ref_for(&work)],
1647 &["pre-push-run-tests-js".into(), "other".into()],
1648 );
1649 });
1650 assert_eq!(
1651 git(&remote, &["notes", "--ref", INPUTS_REF, "show", &key])
1652 .matches("BEGIN SSH SIGNATURE")
1653 .count(),
1654 1
1655 );
1656 let _ = std::fs::remove_dir_all(&d);
1657 let _ = std::fs::remove_dir_all(&work);
1658 }
1659
1660 #[test]
1663 fn without_a_valid_spec_the_block_is_tree_keyed_only() {
1664 let (d, work, remote, _) = remote_and_work("nospec");
1665 std::fs::create_dir_all(work.join(".github")).unwrap();
1666 std::fs::write(
1667 work.join(".github/attest-inputs"),
1668 "pre-push-run-tests-js src/*.ts\n",
1669 )
1670 .unwrap();
1671 git(&work, &["add", ".github/attest-inputs"]);
1672 git(&work, &["commit", "-qm", "chore: bad spec"]);
1673 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1674 in_repo(&work, || {
1675 attest_push(
1676 &test_settings(),
1677 "origin",
1678 &[push_ref_for(&work)],
1679 &["pre-push-run-tests-js".into()],
1680 );
1681 });
1682 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &tree]);
1683 assert!(!body.contains("\ninput "), "no input line:\n{body}");
1684 assert!(
1685 !crate::git::succeeds_in(
1686 &remote,
1687 &["rev-parse", "--verify", "--quiet", INPUTS_FULL_REF]
1688 ),
1689 "no inputs ref was created"
1690 );
1691 let _ = std::fs::remove_dir_all(&d);
1692 let _ = std::fs::remove_dir_all(&work);
1693 }
1694
1695 #[test]
1696 fn the_spec_grammar_is_attests() {
1697 assert!(parse_spec(b"# c\ng src Cargo.toml\n").is_some());
1698 for bad in [
1699 b"g src/*.rs\n".as_slice(),
1700 b"g :!x\n",
1701 b"g\n",
1702 b"g src\ng x\n",
1703 b"g src\r\n",
1704 b"g sr\xc3\xa9\n",
1705 b"-g src\n",
1706 b"g ./src\n",
1707 b"g src/\n",
1708 b"g a/../b\n",
1709 ] {
1710 assert!(parse_spec(bad).is_none(), "{bad:?}");
1711 }
1712 assert_eq!(
1713 implicit_inputs(&["crates/foo/src".into()]),
1714 [
1715 ".forgejo/attest-inputs",
1716 ".github/attest-inputs",
1717 ".gitmodules",
1718 ".gitattributes",
1719 "crates/.gitattributes",
1720 "crates/foo/.gitattributes"
1721 ]
1722 );
1723 }
1724
1725 #[test]
1728 fn no_opt_in_means_no_note() {
1729 let d = dir("optout");
1730 let (key, _) = keypair(&d);
1731 let remote = d.join("remote.git");
1732 std::fs::create_dir_all(&remote).unwrap();
1733 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
1734 let work = repo("optout-work");
1735 git(
1736 &work,
1737 &["remote", "add", "origin", remote.to_str().unwrap()],
1738 );
1739 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
1740 std::fs::write(work.join("a.ts"), "x").unwrap();
1741 git(&work, &["add", "a.ts"]);
1742 git(&work, &["commit", "-qm", "chore: a"]);
1743 let head = git(&work, &["rev-parse", "HEAD"]);
1744 let push_ref = PushRef {
1745 local_ref: "refs/heads/main".into(),
1746 local_oid: head.clone(),
1747 remote_ref: "refs/heads/main".into(),
1748 remote_oid: "0".repeat(40),
1749 };
1750 in_repo(&work, || {
1751 attest_push(
1752 &test_settings(),
1753 "origin",
1754 &[push_ref],
1755 &["pre-push-run-tests-js".into()],
1756 );
1757 });
1758 assert!(
1759 git(&remote, &["notes", "--ref", NOTES_REF, "list"]).is_empty(),
1760 "an un-opted-in repo attested something"
1761 );
1762 let _ = std::fs::remove_dir_all(&d);
1763 let _ = std::fs::remove_dir_all(&work);
1764 }
1765
1766 #[test]
1769 fn deletions_and_empty_gates_attest_nothing() {
1770 let d = dir("nothing");
1771 let (key, _) = keypair(&d);
1772 let remote = d.join("remote.git");
1773 std::fs::create_dir_all(&remote).unwrap();
1774 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
1775 let work = repo("nothing-work");
1776 git(
1777 &work,
1778 &["remote", "add", "origin", remote.to_str().unwrap()],
1779 );
1780 git(&work, &["config", "amont.attest", "true"]);
1781 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
1782 std::fs::write(work.join("a.ts"), "x").unwrap();
1783 git(&work, &["add", "a.ts"]);
1784 git(&work, &["commit", "-qm", "chore: a"]);
1785 let head = git(&work, &["rev-parse", "HEAD"]);
1786 let deletion = PushRef {
1787 local_ref: "(delete)".into(),
1788 local_oid: "0".repeat(40),
1789 remote_ref: "refs/heads/gone".into(),
1790 remote_oid: head.clone(),
1791 };
1792 let real = PushRef {
1793 local_ref: "refs/heads/main".into(),
1794 local_oid: head,
1795 remote_ref: "refs/heads/main".into(),
1796 remote_oid: "0".repeat(40),
1797 };
1798 in_repo(&work, || {
1799 attest_push(
1800 &test_settings(),
1801 "origin",
1802 &[deletion],
1803 &["pre-push-pytest".into()],
1804 );
1805 attest_push(&test_settings(), "origin", &[real], &[]);
1806 });
1807 assert!(git(&remote, &["notes", "--ref", NOTES_REF, "list"]).is_empty());
1808 let _ = std::fs::remove_dir_all(&d);
1809 let _ = std::fs::remove_dir_all(&work);
1810 }
1811
1812 #[test]
1817 fn covered_answers_in_a_fresh_clone_and_rejects_drift_and_forgery() {
1818 let d = dir("covered");
1819 let (key, signers) = keypair(&d);
1820 let remote = d.join("remote.git");
1821 std::fs::create_dir_all(&remote).unwrap();
1822 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
1823 git(&remote, &["symbolic-ref", "HEAD", "refs/heads/main"]);
1829 let work = repo("covered-work");
1830 git(
1831 &work,
1832 &["remote", "add", "origin", remote.to_str().unwrap()],
1833 );
1834 git(&work, &["config", "amont.attest", "true"]);
1835 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
1836 std::fs::write(work.join("a.ts"), "x").unwrap();
1837 git(&work, &["add", "a.ts"]);
1838 git(&work, &["commit", "-qm", "chore: a"]);
1839 git(&work, &["push", "-q", "origin", "HEAD:main"]);
1840 let head = git(&work, &["rev-parse", "HEAD"]);
1841 let push_ref = PushRef {
1842 local_ref: "refs/heads/main".into(),
1843 local_oid: head.clone(),
1844 remote_ref: "refs/heads/main".into(),
1845 remote_oid: "0".repeat(40),
1846 };
1847 in_repo(&work, || {
1848 attest_push(
1849 &test_settings(),
1850 "origin",
1851 &[push_ref],
1852 &["pre-push-pytest".into()],
1853 );
1854 });
1855 let clone = d.join("ci-checkout");
1856 git(
1857 &d,
1858 &[
1859 "clone",
1860 "-q",
1861 "--template=",
1862 remote.to_str().unwrap(),
1863 clone.to_str().unwrap(),
1864 ],
1865 );
1866 in_repo(&clone, || {
1867 assert_eq!(
1868 covered(&signers, "t@t.test", Some(&platform())).as_deref(),
1869 Some("pre-push-pytest"),
1870 "a fresh clone verifies the attestation and reads the gates"
1871 );
1872 assert_eq!(
1873 covered(&signers, "t@t.test", None).as_deref(),
1874 Some("pre-push-pytest"),
1875 "`any` covers a platform-independent suite"
1876 );
1877 assert_eq!(
1880 covered(&signers, "t@t.test", Some("s390x-aix")),
1881 None,
1882 "a pass on one platform is not evidence about another"
1883 );
1884 assert_eq!(
1885 covered(&signers, "someone@else.test", None),
1886 None,
1887 "an unlisted principal covers nothing"
1888 );
1889 });
1890 std::fs::write(clone.join("b.ts"), "y").unwrap();
1892 git(&clone, &["config", "user.email", "t@t.test"]);
1893 git(&clone, &["config", "user.name", "t"]);
1894 git(&clone, &["add", "b.ts"]);
1895 git(&clone, &["commit", "-qm", "chore: b"]);
1896 in_repo(&clone, || {
1897 assert_eq!(covered(&signers, "t@t.test", None), None, "drifted tree");
1898 });
1899 let head_tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1909 for object in [&head, &head_tree] {
1910 git(
1911 &work,
1912 &[
1913 "notes", "--ref", NOTES_REF, "add", "-f", "-m", "garbage", object,
1914 ],
1915 );
1916 }
1917 git(
1918 &work,
1919 &[
1920 "push",
1921 "-q",
1922 "origin",
1923 &format!("+{NOTES_FULL_REF}:{NOTES_FULL_REF}"),
1924 ],
1925 );
1926 git(&clone, &["reset", "-q", "--hard", &head]);
1927 in_repo(&clone, || {
1928 assert_eq!(
1929 covered(&signers, "t@t.test", None),
1930 None,
1931 "a foreign note is not a stamp"
1932 );
1933 });
1934 let _ = std::fs::remove_dir_all(&d);
1935 let _ = std::fs::remove_dir_all(&work);
1936 }
1937
1938 #[test]
1944 fn default_signers_is_found_from_a_subdirectory() {
1945 let work = repo("signers-subdir");
1946 std::fs::create_dir_all(work.join(".forgejo")).unwrap();
1947 std::fs::write(work.join(".forgejo/allowed_signers"), "t@t.test x\n").unwrap();
1948 let sub = work.join("packages").join("thing");
1949 std::fs::create_dir_all(&sub).unwrap();
1950 in_repo(&sub, || {
1951 let found = default_signers().expect("resolved from the repo root, not the cwd");
1952 assert!(found.ends_with(".forgejo/allowed_signers"));
1953 assert!(found.exists(), "the path it returns must be usable as-is");
1954 assert_eq!(
1955 first_principal(&found).as_deref(),
1956 Some("t@t.test"),
1957 "and readable from there"
1958 );
1959 });
1960 let _ = std::fs::remove_dir_all(&work);
1961 }
1962
1963 #[test]
1964 fn zero_oids_of_any_width_are_zero() {
1965 assert!(is_zero(&"0".repeat(40)));
1966 assert!(is_zero(&"0".repeat(64)));
1967 assert!(!is_zero("0a0000"));
1968 assert!(!is_zero(""));
1969 }
1970
1971 #[test]
1972 fn forget_removes_the_local_ref() {
1973 let work = repo("forget");
1974 std::fs::write(work.join("a.ts"), "x").unwrap();
1975 git(&work, &["add", "a.ts"]);
1976 git(&work, &["commit", "-qm", "chore: a"]);
1977 git(
1978 &work,
1979 &["notes", "--ref", NOTES_REF, "add", "-m", "x", "HEAD"],
1980 );
1981 in_repo(&work, forget);
1982 assert!(git(&work, &["notes", "--ref", NOTES_REF, "list"]).is_empty());
1983 let _ = std::fs::remove_dir_all(&work);
1984 }
1985
1986 #[test]
2001 fn an_attestation_survives_a_rewrite_that_keeps_the_tree() {
2002 let d = dir("rewritten");
2003 let (key, signers) = keypair(&d);
2004 let remote = d.join("remote.git");
2005 std::fs::create_dir_all(&remote).unwrap();
2006 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
2007 git(&remote, &["symbolic-ref", "HEAD", "refs/heads/main"]);
2008 let work = repo("rewritten-work");
2009 git(
2010 &work,
2011 &["remote", "add", "origin", remote.to_str().unwrap()],
2012 );
2013 git(&work, &["config", "amont.attest", "true"]);
2014 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
2015 std::fs::write(work.join("a.ts"), "x").unwrap();
2016 git(&work, &["add", "a.ts"]);
2017 git(&work, &["commit", "-qm", "feat: on a branch"]);
2018 git(&work, &["push", "-q", "origin", "HEAD:main"]);
2019 let branch_tip = git(&work, &["rev-parse", "HEAD"]);
2020 let push_ref = PushRef {
2021 local_ref: "refs/heads/main".into(),
2022 local_oid: branch_tip.clone(),
2023 remote_ref: "refs/heads/main".into(),
2024 remote_oid: "0".repeat(40),
2025 };
2026 in_repo(&work, || {
2027 attest_push(
2028 &test_settings(),
2029 "origin",
2030 &[push_ref],
2031 &["pre-push-pytest".into()],
2032 );
2033 });
2034
2035 git(
2039 &work,
2040 &[
2041 "commit",
2042 "-q",
2043 "--amend",
2044 "-m",
2045 "feat: squashed by the forge",
2046 ],
2047 );
2048 let rewritten = git(&work, &["rev-parse", "HEAD"]);
2049 assert_ne!(rewritten, branch_tip, "the fixture must rewrite the commit");
2050 assert_eq!(
2051 git(&work, &["rev-parse", "HEAD^{tree}"]),
2052 git(&work, &["rev-parse", &format!("{branch_tip}^{{tree}}")]),
2053 "…while preserving the tree, which is the premise"
2054 );
2055 git(&work, &["push", "-q", "-f", "origin", "HEAD:main"]);
2056
2057 let clone = d.join("ci-checkout");
2058 git(
2059 &d,
2060 &[
2061 "clone",
2062 "-q",
2063 "--template=",
2064 remote.to_str().unwrap(),
2065 clone.to_str().unwrap(),
2066 ],
2067 );
2068 in_repo(&clone, || {
2069 assert_eq!(
2070 covered(&signers, "t@t.test", None).as_deref(),
2071 Some("pre-push-pytest"),
2072 "the attestation must be found by the tree it signed"
2073 );
2074 });
2075 let _ = std::fs::remove_dir_all(&d);
2076 }
2077
2078 #[test]
2081 fn optional_paths_are_marked_once_and_stripped() {
2082 let spec = parse_spec(b"g src ?build.rs ?a/b/c\n").expect("valid");
2083 assert_eq!(
2084 spec[0].1,
2085 [
2086 PathTok {
2087 path: "src".into(),
2088 optional: false
2089 },
2090 PathTok {
2091 path: "build.rs".into(),
2092 optional: true
2093 },
2094 PathTok {
2095 path: "a/b/c".into(),
2096 optional: true
2097 },
2098 ]
2099 );
2100 assert!(
2101 parse_spec(b"g ?nope\n").is_some(),
2102 "an all-optional gate is a gate"
2103 );
2104 for bad in [
2105 "?", "??x", "?/x", "?./x", "?x/", "?:x", "x?", "?a//b", "?a/../b",
2106 ] {
2107 assert!(
2108 parse_spec(format!("g {bad}\n").as_bytes()).is_none(),
2109 "{bad} should be invalid"
2110 );
2111 }
2112 }
2113
2114 #[test]
2118 fn an_absent_optional_path_is_bound_by_its_absence() {
2119 let work = repo("optional-fp");
2120 std::fs::create_dir_all(work.join("src")).unwrap();
2121 std::fs::create_dir_all(work.join(".github")).unwrap();
2122 std::fs::write(work.join("src/main.rs"), "fn main() {}\n").unwrap();
2123 std::fs::write(work.join(".github/attest-inputs"), "g src ?nope\n").unwrap();
2124 git(&work, &["add", "-A"]);
2125 git(&work, &["commit", "-qm", "chore: spec"]);
2126 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
2127 let spec_oid = git(
2128 &work,
2129 &["rev-parse", &format!("{tree}:.github/attest-inputs")],
2130 );
2131 let main_oid = git(&work, &["rev-parse", &format!("{tree}:src/main.rs")]);
2132 let listing = format!(
2133 "100644 blob {spec_oid}\t.github/attest-inputs\x00100644 blob {main_oid}\tsrc/main.rs\x00"
2134 );
2135 let hand =
2136 crate::git::stdout_piped_in(&work, &["hash-object", "--stdin"], listing.as_bytes())
2137 .unwrap();
2138 let before = in_repo(&work, || {
2139 let spec = spec_at(&tree).expect("the spec parses");
2140 fingerprint(&tree, &spec[0].1)
2141 });
2142 assert_eq!(before.as_deref(), Some(hand.as_str()));
2143 std::fs::write(work.join("nope"), "now it exists\n").unwrap();
2144 git(&work, &["add", "nope"]);
2145 git(&work, &["commit", "-qm", "chore: nope"]);
2146 let tree2 = git(&work, &["rev-parse", "HEAD^{tree}"]);
2147 let after = in_repo(&work, || {
2148 let spec = spec_at(&tree2).expect("the spec parses");
2149 fingerprint(&tree2, &spec[0].1)
2150 });
2151 assert!(
2152 after.is_some() && after != before,
2153 "its appearance changes the fingerprint"
2154 );
2155 let _ = std::fs::remove_dir_all(&work);
2156 }
2157
2158 fn attested_clone(name: &str) -> (PathBuf, PathBuf, PathBuf, PathBuf) {
2163 let (d, work, remote, signers) = remote_and_work(name);
2164 git(&remote, &["symbolic-ref", "HEAD", "refs/heads/main"]);
2165 git(&work, &["push", "-q", "origin", "HEAD:main"]);
2166 in_repo(&work, || {
2167 attest_push(
2168 &test_settings(),
2169 "origin",
2170 &[push_ref_for(&work)],
2171 &["pre-push-pytest".into()],
2172 );
2173 });
2174 let clone = d.join("ci-checkout");
2175 git(
2176 &d,
2177 &[
2178 "clone",
2179 "-q",
2180 "--template=",
2181 remote.to_str().unwrap(),
2182 clone.to_str().unwrap(),
2183 ],
2184 );
2185 (d, remote, clone, signers)
2186 }
2187
2188 fn covers(clone: &Path, signers: &Path, budget: u64) -> Option<String> {
2189 covers_logged(clone, signers, budget).0
2190 }
2191
2192 fn covers_logged(clone: &Path, signers: &Path, budget: u64) -> (Option<String>, Vec<String>) {
2194 in_repo(clone, || {
2195 CAPTURE.with(|c| *c.borrow_mut() = Some(Vec::new()));
2196 let got = covered_within(signers, "t@t.test", None, budget);
2197 let lines = CAPTURE.with(|c| c.borrow_mut().take()).unwrap_or_default();
2198 (got, lines)
2199 })
2200 }
2201
2202 #[test]
2203 fn a_ref_revoked_on_origin_stops_covering_and_the_mirror_goes() {
2204 let (d, remote, clone, signers) = attested_clone("revoked");
2205 assert_eq!(
2206 covers(&clone, &signers, 15).as_deref(),
2207 Some("pre-push-pytest")
2208 );
2209 let oid = git(&clone, &["rev-parse", NOTES_FULL_REF]);
2210 git(&remote, &["update-ref", "-d", NOTES_FULL_REF]);
2211 let (got, lines) = covers_logged(&clone, &signers, 15);
2212 assert_eq!(got, None, "revoked on origin");
2213 assert_eq!(
2214 lines[0],
2215 format!("amont: origin has no {NOTES_FULL_REF}; deleted the local mirror (was {oid})")
2216 );
2217 assert!(
2218 in_repo(&clone, || crate::git::stdout(&[
2219 "rev-parse",
2220 "--verify",
2221 "--quiet",
2222 NOTES_FULL_REF
2223 ]))
2224 .is_none(),
2225 "the local mirror was deleted"
2226 );
2227 let undo = lines[1]
2229 .split("undo locally: git ")
2230 .nth(1)
2231 .and_then(|r| r.split(" (").next())
2232 .expect("an undo command");
2233 git(&clone, &undo.split(' ').collect::<Vec<_>>());
2234 assert_eq!(git(&clone, &["rev-parse", NOTES_FULL_REF]), oid);
2235 let _ = std::fs::remove_dir_all(&d);
2236 }
2237
2238 #[test]
2239 fn an_unreachable_origin_covers_nothing_and_keeps_the_mirror() {
2240 let (d, _remote, clone, signers) = attested_clone("unreachable");
2241 assert_eq!(
2242 covers(&clone, &signers, 15).as_deref(),
2243 Some("pre-push-pytest")
2244 );
2245 git(
2246 &clone,
2247 &[
2248 "remote",
2249 "set-url",
2250 "origin",
2251 "file:///nonexistent/amont-origin.git",
2252 ],
2253 );
2254 let (got, lines) = covers_logged(&clone, &signers, 15);
2255 assert_eq!(got, None, "a stale mirror is not judged");
2256 assert!(
2257 lines.iter().any(|l| l.starts_with(&format!(
2258 "amont: cannot fetch {NOTES_FULL_REF} from origin (exit "
2259 ))),
2260 "the reason is on stderr: {lines:?}"
2261 );
2262 assert!(
2263 !git(&clone, &["rev-parse", NOTES_FULL_REF]).is_empty(),
2264 "and it is kept"
2265 );
2266 let _ = std::fs::remove_dir_all(&d);
2267 }
2268
2269 #[test]
2272 fn without_an_origin_the_local_ref_is_judged() {
2273 let (d, _remote, clone, signers) = attested_clone("no-origin");
2274 assert_eq!(
2275 covers(&clone, &signers, 15).as_deref(),
2276 Some("pre-push-pytest")
2277 );
2278 git(&clone, &["remote", "remove", "origin"]);
2279 assert_eq!(
2280 covers(&clone, &signers, 15).as_deref(),
2281 Some("pre-push-pytest")
2282 );
2283 let _ = std::fs::remove_dir_all(&d);
2284 }
2285
2286 #[cfg(unix)]
2289 #[test]
2290 fn a_silent_origin_is_cut_off_and_leaves_nothing_behind() {
2291 use std::os::unix::fs::PermissionsExt;
2292 let (d, remote, clone, signers) = attested_clone("silent");
2293 let hang = d.join("hang-ssh");
2294 std::fs::write(&hang, "#!/bin/sh\nexec sleep 60\n").unwrap();
2295 std::fs::set_permissions(&hang, std::fs::Permissions::from_mode(0o755)).unwrap();
2296 git(
2298 &clone,
2299 &["config", "core.sshCommand", hang.to_str().unwrap()],
2300 );
2301 git(
2302 &clone,
2303 &["remote", "set-url", "origin", "ssh://example.invalid/x.git"],
2304 );
2305 let (got, took) = in_repo(&clone, || {
2308 let t0 = std::time::Instant::now();
2309 let got = covered_within(&signers, "t@t.test", None, 2);
2310 (got, t0.elapsed())
2311 });
2312 assert_eq!(got, None);
2313 assert!(took.as_secs() < 6, "took {took:?}");
2315 let lock = in_repo(&clone, || {
2316 crate::git::stdout(&["rev-parse", "--git-path", &format!("{NOTES_FULL_REF}.lock")])
2317 })
2318 .unwrap();
2319 assert!(
2320 !clone.join(&lock).exists() && !Path::new(&lock).exists(),
2321 "no lock left"
2322 );
2323 git(&clone, &["config", "--unset", "core.sshCommand"]);
2324 git(
2325 &clone,
2326 &["remote", "set-url", "origin", remote.to_str().unwrap()],
2327 );
2328 assert_eq!(
2329 covers(&clone, &signers, 15).as_deref(),
2330 Some("pre-push-pytest")
2331 );
2332 let _ = std::fs::remove_dir_all(&d);
2333 }
2334
2335 #[test]
2336 fn a_stale_lock_on_the_mirror_means_nothing_is_judged() {
2337 let (d, remote, clone, signers) = attested_clone("stale-lock");
2338 assert_eq!(
2339 covers(&clone, &signers, 15).as_deref(),
2340 Some("pre-push-pytest")
2341 );
2342 let lock = in_repo(&clone, || {
2343 crate::git::stdout(&["rev-parse", "--git-path", &format!("{NOTES_FULL_REF}.lock")])
2344 })
2345 .unwrap();
2346 let lock = if Path::new(&lock).is_absolute() {
2347 PathBuf::from(lock)
2348 } else {
2349 clone.join(lock)
2350 };
2351 std::fs::write(&lock, "").unwrap();
2352 assert_eq!(
2354 covers(&clone, &signers, 15).as_deref(),
2355 Some("pre-push-pytest"),
2356 "an unchanged origin: the copy is origin's"
2357 );
2358 let main = git(&remote, &["rev-parse", "main"]);
2361 git(
2363 &remote,
2364 &[
2365 "-c",
2366 "user.email=t@t.test",
2367 "-c",
2368 "user.name=t",
2369 "notes",
2370 "--ref",
2371 NOTES_REF,
2372 "add",
2373 "-f",
2374 "-m",
2375 "rewritten",
2376 &main,
2377 ],
2378 );
2379 let (got, lines) = covers_logged(&clone, &signers, 15);
2380 assert_eq!(got, None, "a pinned stale copy is never judged");
2381 assert!(
2382 lines.iter().any(|l| l.contains(&format!(
2383 "left a lock on {NOTES_FULL_REF}; if no git is running: rm "
2384 ))),
2385 "the lock is reported with its rm: {lines:?}"
2386 );
2387 std::fs::remove_file(&lock).unwrap();
2388 assert_eq!(
2389 covers(&clone, &signers, 15).as_deref(),
2390 Some("pre-push-pytest")
2391 );
2392 let _ = std::fs::remove_dir_all(&d);
2393 }
2394
2395 #[cfg(unix)]
2398 #[test]
2399 fn leftover_sync_refs_are_swept() {
2400 let (d, _remote, clone, signers) = attested_clone("sweep");
2401 let head = git(&clone, &["rev-parse", "HEAD"]);
2402 git(
2403 &clone,
2404 &["update-ref", "refs/amont-tmp/4194305/amont-attest", &head],
2406 );
2407 assert_eq!(
2408 covers(&clone, &signers, 15).as_deref(),
2409 Some("pre-push-pytest")
2410 );
2411 assert_eq!(git(&clone, &["for-each-ref", "refs/amont-tmp/"]), "");
2412 let _ = std::fs::remove_dir_all(&d);
2413 }
2414
2415 #[test]
2416 fn the_remote_environment_never_prompts() {
2417 let (args, env) = crate::git::remote_env(crate::git::Ssh::Batch);
2418 assert_eq!(
2419 args,
2420 [
2421 "-c",
2422 "http.lowSpeedLimit=1",
2423 "-c",
2424 "http.lowSpeedTime=10",
2425 "-c",
2426 "credential.interactive=never"
2427 ]
2428 );
2429 assert!(env.contains(&("GIT_TERMINAL_PROMPT", "0")));
2430 assert!(
2431 env.contains(&("GIT_ASKPASS", "")),
2432 "present and EMPTY disables every askpass"
2433 );
2434 assert!(env.contains(&("GCM_INTERACTIVE", "never")));
2435 assert!(env.contains(&(
2436 "GIT_SSH_COMMAND",
2437 "ssh -o BatchMode=yes -o ConnectTimeout=10"
2438 )));
2439 let (_, env) = crate::git::remote_env(crate::git::Ssh::User);
2440 assert!(
2441 !env.iter().any(|(k, _)| *k == "GIT_SSH_COMMAND"),
2442 "the user's own ssh command is left alone"
2443 );
2444 }
2445
2446 #[cfg(unix)]
2450 #[test]
2451 fn no_askpass_runs_against_an_origin_that_wants_credentials() {
2452 use std::io::{Read, Write};
2453 use std::os::unix::fs::PermissionsExt;
2454 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2455 let port = listener.local_addr().unwrap().port();
2456 std::thread::spawn(move || {
2457 for stream in listener.incoming().flatten() {
2458 let mut s = stream;
2459 let mut buf = [0u8; 4096];
2460 let _ = s.read(&mut buf);
2461 let _ = s.write_all(
2462 b"HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"x\"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
2463 );
2464 }
2465 });
2466 let work = repo("askpass");
2467 let marker = work.join("asked");
2468 let askpass = work.join("askpass.sh");
2469 std::fs::write(
2470 &askpass,
2471 format!("#!/bin/sh\ntouch '{}'\necho x\n", marker.display()),
2472 )
2473 .unwrap();
2474 std::fs::set_permissions(&askpass, std::fs::Permissions::from_mode(0o755)).unwrap();
2475 git(
2476 &work,
2477 &["config", "core.askPass", askpass.to_str().unwrap()],
2478 );
2479 git(&work, &["config", "credential.helper", ""]);
2480 let url = format!("http://127.0.0.1:{port}/x.git");
2481 in_repo(&work, || {
2482 let _ = crate::git::probe_remote(&["ls-remote", &url], 10);
2483 });
2484 assert!(
2485 !marker.exists(),
2486 "an askpass ran through the remote environment"
2487 );
2488 let _ = std::process::Command::new("git")
2492 .current_dir(&work)
2493 .args(["ls-remote", &url])
2494 .env("GIT_TERMINAL_PROMPT", "0")
2495 .env_remove("GIT_ASKPASS")
2496 .env_remove("SSH_ASKPASS")
2497 .stdin(std::process::Stdio::null())
2498 .output();
2499 assert!(
2500 marker.exists(),
2501 "negative control: without it the askpass does run"
2502 );
2503 let _ = std::fs::remove_dir_all(&work);
2504 }
2505}