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 PROVENANCE_REF: &str = "amont-provenance";
114pub const PROVENANCE_FULL_REF: &str = "refs/notes/amont-provenance";
115pub const PROVENANCE_NAMESPACE: &str = "amont-provenance";
116pub const PROVENANCE_FORMAT: &str = "amont-provenance-v1";
117
118pub const PUSH_GUARD: &str = "AMONT_ATTEST_PUSH";
121
122const TOGGLE: &str = "amont.attest";
126
127const KEY_CONFIG: &str = "amont.attestKey";
129const KEY_DEFAULT: &str = ".ssh/amont-attest";
130
131pub fn push_guard_active() -> bool {
133 std::env::var_os(PUSH_GUARD).is_some()
134}
135
136pub fn enabled(settings: &crate::config::Settings) -> bool {
138 crate::config::boolean_or(settings, TOGGLE, false)
139}
140
141fn key_path() -> Option<PathBuf> {
147 if let Some(k) = crate::git::stdout(&["config", "--get", KEY_CONFIG]) {
148 if !k.is_empty() {
149 return Some(PathBuf::from(k));
150 }
151 }
152 let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
153 Some(PathBuf::from(home).join(KEY_DEFAULT))
154}
155
156pub fn platform() -> String {
163 format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS)
164}
165
166pub fn payload(
181 tree: &str,
182 gates: &[String],
183 inputs: &[(String, String)],
184 canon: Option<&str>,
185) -> String {
186 let mut p = format!(
187 "{FORMAT}\ntree {tree}\ngates {}\nplatform {}\n",
188 gates.join(" "),
189 platform()
190 );
191 for (gate, fp) in inputs {
192 p.push_str(&format!("input {gate} {fp}\n"));
193 }
194 if let Some(c) = canon {
195 p.push_str(&format!("canon {c}\n"));
196 }
197 p.push_str(&format!("amont {}\n", env!("CARGO_PKG_VERSION")));
198 p
199}
200
201fn inputs_for(tree: &str, gates: &[String]) -> Vec<(String, String)> {
204 let Some(spec) = spec_at(tree) else {
205 return Vec::new();
206 };
207 spec.iter()
208 .filter(|(g, _)| gates.iter().any(|x| x == g))
209 .filter_map(|(g, paths)| fingerprint(tree, paths).map(|fp| (g.clone(), fp)))
210 .collect()
211}
212
213fn sign(payload: &str, key: &std::path::Path) -> Option<String> {
217 sign_in(NAMESPACE, payload, key)
218}
219
220fn sign_in(namespace: &str, payload: &str, key: &std::path::Path) -> Option<String> {
222 use std::io::Write;
223 let mut child = Command::new("ssh-keygen")
224 .args(["-Y", "sign", "-n", namespace, "-f"])
225 .arg(key)
226 .stdin(Stdio::piped())
227 .stdout(Stdio::piped())
228 .stderr(Stdio::null())
229 .spawn()
230 .ok()?;
231 child.stdin.take()?.write_all(payload.as_bytes()).ok()?;
232 let out = child.wait_with_output().ok()?;
233 if !out.status.success() {
234 return None;
235 }
236 let sig = String::from_utf8_lossy(&out.stdout).trim().to_string();
237 sig.starts_with("-----BEGIN SSH SIGNATURE-----")
238 .then_some(sig)
239}
240
241fn create_exclusive(path: &std::path::Path) -> Option<std::fs::File> {
259 std::fs::OpenOptions::new()
260 .write(true)
261 .create_new(true)
262 .open(path)
263 .ok()
264}
265
266pub fn verify(
267 payload: &str,
268 sig: &str,
269 allowed_signers: &std::path::Path,
270 principal: &str,
271) -> bool {
272 verify_in(NAMESPACE, payload, sig, allowed_signers, principal)
273}
274
275fn verify_in(
277 namespace: &str,
278 payload: &str,
279 sig: &str,
280 allowed_signers: &std::path::Path,
281 principal: &str,
282) -> bool {
283 use std::io::Write;
284 let nonce = std::time::SystemTime::now()
291 .duration_since(std::time::UNIX_EPOCH)
292 .map(|d| d.subsec_nanos())
293 .unwrap_or(0);
294 let sig_file = std::env::temp_dir().join(format!(
295 "amont-attest-verify-{}-{:p}-{nonce}.sig",
296 std::process::id(),
297 &sig
298 ));
299 let Some(mut f) = create_exclusive(&sig_file) else {
300 return false;
301 };
302 if f.write_all(format!("{sig}\n").as_bytes()).is_err() {
303 let _ = std::fs::remove_file(&sig_file);
304 return false;
305 }
306 drop(f);
308 let ok = (|| {
309 let mut child = Command::new("ssh-keygen")
310 .args(["-Y", "verify", "-n", namespace, "-I", principal, "-f"])
311 .arg(allowed_signers)
312 .arg("-s")
313 .arg(&sig_file)
314 .stdin(Stdio::piped())
315 .stdout(Stdio::null())
316 .stderr(Stdio::null())
317 .spawn()
318 .ok()?;
319 child.stdin.take()?.write_all(payload.as_bytes()).ok()?;
320 child.wait().ok().map(|s| s.success())
321 })()
322 .unwrap_or(false);
323 let _ = std::fs::remove_file(&sig_file);
324 ok
325}
326
327pub fn attest_push(
339 settings: &crate::config::Settings,
340 remote: &str,
341 refs: &[PushRef],
342 gates: &[String],
343) {
344 if gates.is_empty() || remote.is_empty() || !enabled(settings) {
345 return;
346 }
347 let Some(key) = key_path() else { return };
348 if !key.exists() {
349 crate::config::complain(
350 TOGGLE,
351 &format!("signing key {} does not exist", key.display()),
352 "no attestation (CI will run the tests)",
353 );
354 return;
355 }
356 let mut blocks: Vec<(String, String)> = Vec::new();
357 let mut input_blocks: Vec<(String, String)> = Vec::new();
358 let mut canon_blocks: Vec<(String, String)> = Vec::new();
359 for r in refs {
360 if is_zero(&r.local_oid) {
361 continue; }
363 let spec = format!("{}^{{tree}}", r.local_oid);
364 let Some(tree) = crate::git::stdout(&["rev-parse", &spec]) else {
365 continue;
366 };
367 let inputs = inputs_for(&tree, gates);
370 let canon = canon_for_attest(&tree);
375 let p = payload(&tree, gates, &inputs, canon.as_deref());
376 let body = match sign(&p, &key) {
377 Some(sig) => format!("{p}\n{sig}"),
378 None => continue,
379 };
380 for (gate, fp) in &inputs {
381 if let Some(k) = input_key(gate, fp) {
382 input_blocks.push((k, body.clone()));
383 }
384 }
385 if let Some(k) = canon.as_deref().and_then(canon_key) {
386 canon_blocks.push((k, body.clone()));
387 }
388 blocks.push((tree, body.clone()));
406 blocks.push((r.local_oid.clone(), body));
407 }
408 if blocks.is_empty() {
409 return;
410 }
411 let main_ok = publish(remote, NOTES_FULL_REF, &blocks);
415 let n_inputs = input_blocks.len();
416 input_blocks.extend(canon_blocks);
417 let inputs_ok = input_blocks.is_empty() || publish(remote, INPUTS_FULL_REF, &input_blocks);
418 if main_ok {
419 crate::say!(
420 "{} attested {} for CI ({}{})",
421 crate::ui::valid_sign(),
422 crate::ui::highlight(&gates.join(" ")),
423 NOTES_REF,
424 if input_blocks.is_empty() {
425 String::new()
426 } else if inputs_ok {
427 let canonical = input_blocks.len() - n_inputs;
428 match (n_inputs, canonical) {
429 (0, _) => ", canonical tree".to_string(),
430 (n, 0) => format!(", {n} input fingerprints"),
431 (n, _) => format!(", {n} input fingerprints, canonical tree"),
432 }
433 } else {
434 ", input fingerprints not published".to_string()
435 },
436 );
437 }
438}
439
440fn provenance_dir(env: impl Fn(&str) -> Option<std::ffi::OsString>) -> Option<PathBuf> {
449 let config = match env("CLAUDE_CONFIG_DIR") {
450 Some(d) => PathBuf::from(d),
451 None => PathBuf::from(env("HOME").or_else(|| env("USERPROFILE"))?).join(".claude"),
452 };
453 Some(config.join("amont-agent").join("provenance"))
454}
455
456fn repo_name() -> Option<String> {
461 let common = crate::git::stdout(&["rev-parse", "--git-common-dir"])?;
462 if common.is_empty() {
463 return None;
464 }
465 let common = PathBuf::from(common);
466 let common = if common.is_absolute() {
467 common
468 } else {
469 std::env::current_dir().ok()?.join(common)
470 };
471 let common = std::fs::canonicalize(&common).unwrap_or(common);
472 common
473 .parent()
474 .and_then(std::path::Path::file_name)
475 .map(|n| n.to_string_lossy().into_owned())
476 .filter(|n| !n.is_empty())
477}
478
479pub fn provenance_push(settings: &crate::config::Settings, remote: &str, refs: &[PushRef]) {
484 if remote.is_empty() || !enabled(settings) {
485 return;
486 }
487 let (Some(dir), Some(name)) = (provenance_dir(|k| std::env::var_os(k)), repo_name()) else {
488 return;
489 };
490 provenance_push_from(&dir.join(name), remote, refs);
491}
492
493fn provenance_push_from(dir: &std::path::Path, remote: &str, refs: &[PushRef]) {
495 let mut found: Vec<(PathBuf, String, String, String)> = Vec::new();
496 for r in refs {
497 if is_zero(&r.local_oid) {
498 continue;
499 }
500 let file = dir.join(&r.local_oid);
501 let Ok(payload) = std::fs::read_to_string(&file) else {
502 continue; };
504 let spec = format!("{}^{{tree}}", r.local_oid);
505 let Some(tree) = crate::git::stdout(&["rev-parse", &spec]) else {
506 continue;
507 };
508 let first = payload.lines().next().unwrap_or_default();
509 let claimed = payload.lines().find_map(|l| l.strip_prefix("tree "));
510 if first != PROVENANCE_FORMAT || claimed != Some(tree.as_str()) {
511 crate::say!(
512 " provenance for {} not signed: its payload is not an {PROVENANCE_FORMAT} record of this tree",
513 &r.local_oid[..r.local_oid.len().min(7)]
514 );
515 continue;
516 }
517 if payload
521 .lines()
522 .any(|l| l.split_ascii_whitespace().next() == Some("canon"))
523 {
524 for line in canon_refusal(&r.local_oid, &file) {
525 crate::say!("{line}");
526 }
527 continue;
528 }
529 let Some(canon) = crate::attest_inputs::canon(&tree) else {
532 crate::say!(
533 " provenance for {} not signed: its canonical tree cannot be computed;",
534 &r.local_oid[..r.local_oid.len().min(7)]
535 );
536 crate::say!(" the payload is kept for the next push");
537 continue;
538 };
539 let payload = with_canon(&payload, &canon);
540 found.push((file, r.local_oid.clone(), tree, payload));
541 }
542 if found.is_empty() {
543 return;
544 }
545 let Some(key) = key_path().filter(|k| k.exists()) else {
546 crate::say!(" provenance not signed: no signing key");
547 return;
548 };
549 let mut blocks: Vec<(String, String)> = Vec::new();
550 let mut signed: Vec<PathBuf> = Vec::new();
551 for (file, commit, tree, payload) in found {
552 let Some(sig) = sign_in(PROVENANCE_NAMESPACE, &payload, &key) else {
553 continue;
554 };
555 let body = format!("{payload}\n{sig}");
556 blocks.push((tree, body.clone()));
557 blocks.push((commit, body));
558 signed.push(file);
559 }
560 if blocks.is_empty() {
561 crate::say!(" provenance not signed: the signer refused");
562 return;
563 }
564 if publish(remote, PROVENANCE_FULL_REF, &blocks) {
565 for file in &signed {
566 if std::fs::remove_file(file).is_err() {
567 crate::say!(
568 " provenance published, but its payload {} could not be removed",
569 file.display()
570 );
571 }
572 }
573 crate::say!(
574 "{} provenance published ({PROVENANCE_REF})",
575 crate::ui::valid_sign()
576 );
577 } else {
578 crate::say!(" provenance not published: kept for the next push");
579 }
580}
581
582fn with_canon(payload: &str, canon: &str) -> String {
585 let mut out = String::with_capacity(payload.len() + canon.len() + 7);
586 let mut done = false;
587 for line in payload.split_inclusive('\n') {
588 out.push_str(line);
589 if !done && line.starts_with("tree ") {
590 if !line.ends_with('\n') {
591 out.push('\n');
592 }
593 out.push_str(&format!("canon {canon}\n"));
594 done = true;
595 }
596 }
597 out
598}
599
600fn canon_refusal(commit: &str, file: &std::path::Path) -> Vec<String> {
604 let dir = file.parent().unwrap_or(file).display().to_string();
605 let name = file
606 .file_name()
607 .map(|n| n.to_string_lossy().into_owned())
608 .unwrap_or_default();
609 vec![
610 format!(
611 " provenance for {} not signed: its payload carries a canon line",
612 &commit[..commit.len().min(7)]
613 ),
614 " (a pre-release amont-agent wrote it); delete the file".to_string(),
615 format!(" {name} in"),
616 format!(" {dir}"),
617 " and upgrade amont-agent".to_string(),
618 ]
619}
620
621const PUSH_ATTEMPTS: u32 = 4;
623
624fn publish(remote: &str, notes_ref: &str, blocks: &[(String, String)]) -> bool {
645 let short = notes_ref.trim_start_matches("refs/notes/");
646 let tmp = format!("amont-attest-push-{}-{short}", std::process::id());
647 let tmp_full = format!("refs/notes/{tmp}");
648 let fetch_spec = format!("+{notes_ref}:{tmp_full}");
649 let push_spec = format!("{tmp_full}:{notes_ref}");
650 let mut published = false;
651 for _ in 0..PUSH_ATTEMPTS {
652 let _ = crate::git::succeeds(&["update-ref", "-d", &tmp_full]);
653 let _ = crate::git::succeeds(&["fetch", "--quiet", remote, &fetch_spec]);
656 let mut appended = false;
657 for (object, body) in blocks {
658 let existing =
659 crate::git::stdout(&["notes", "--ref", &tmp, "show", object]).unwrap_or_default();
660 if existing.contains(body.trim_end()) {
661 continue;
662 }
663 if crate::git::succeeds(&["notes", "--ref", &tmp, "append", "-m", body, object]) {
664 appended = true;
665 }
666 }
667 if !appended {
668 published = true;
670 break;
671 }
672 match push_notes(remote, &push_spec) {
673 Push::Done => {
674 published = true;
675 break;
676 }
677 Push::Raced => continue,
678 Push::Refused => break,
679 }
680 }
681 if published {
682 let _ = crate::git::succeeds(&["update-ref", notes_ref, &tmp_full]);
683 }
684 let _ = crate::git::succeeds(&["update-ref", "-d", &tmp_full]);
685 published
686}
687
688enum Push {
690 Done,
691 Raced,
694 Refused,
696}
697
698pub fn covered(
721 signers: &std::path::Path,
722 principal: &str,
723 require_platform: Option<&str>,
724) -> Option<String> {
725 covered_within(signers, principal, require_platform, REMOTE_BUDGET_SECS)
726}
727
728const REMOTE_BUDGET_SECS: u64 = 15;
730
731#[derive(Debug, PartialEq)]
733enum Sync {
734 Judge,
736 Skip,
738}
739
740fn sync_mirror(budget: u64) -> Sync {
753 if !crate::git::succeeds(&["remote", "get-url", "origin"]) {
754 return Sync::Judge;
756 }
757 sweep_sync_refs();
758 let old = crate::git::stdout(&["rev-parse", "--verify", "--quiet", NOTES_FULL_REF]);
759 let tmp = format!("{SYNC_NAMESPACE}{}/amont-attest", std::process::id());
760 let fetched = crate::git::probe_remote(
761 &[
762 "fetch",
763 "--quiet",
764 "origin",
765 &format!("+{NOTES_FULL_REF}:{tmp}"),
766 ],
767 budget,
768 );
769 let verdict = match fetched {
770 crate::git::Probe::Exit(0) => {
771 let new = crate::git::stdout(&["rev-parse", "--verify", "--quiet", &tmp]);
772 let expected = old.clone().unwrap_or_default();
775 match new {
776 None => {
777 say_skip(&format!(
779 "the fetch of {NOTES_FULL_REF} left nothing to read; local mirror not judged"
780 ));
781 Sync::Skip
782 }
783 Some(new)
784 if crate::git::succeeds(&["update-ref", NOTES_FULL_REF, &new, &expected]) =>
785 {
786 Sync::Judge
787 }
788 Some(new) => {
789 let now =
795 crate::git::stdout(&["rev-parse", "--verify", "--quiet", NOTES_FULL_REF]);
796 if now.as_deref() == Some(new.as_str()) {
797 Sync::Judge
798 } else {
799 say_skip(&format!(
800 "cannot update {NOTES_FULL_REF} to origin's copy; local mirror not judged"
801 ));
802 Sync::Skip
803 }
804 }
805 }
806 }
807 crate::git::Probe::TimedOut(secs) => {
808 say_skip(&format!(
810 "origin did not answer within {secs} s for {NOTES_FULL_REF}; local mirror not judged, running everything"
811 ));
812 Sync::Skip
813 }
814 _ => match crate::git::probe_remote(
815 &["ls-remote", "--exit-code", "origin", NOTES_FULL_REF],
816 budget,
817 ) {
818 crate::git::Probe::Exit(2) => drop_mirror(old.as_deref()),
819 crate::git::Probe::Exit(0) => {
820 say_skip(&format!(
821 "origin has {NOTES_FULL_REF} but fetching it failed; local mirror not judged, running everything"
822 ));
823 Sync::Skip
824 }
825 crate::git::Probe::Exit(code) => {
826 say_skip(&format!(
827 "cannot fetch {NOTES_FULL_REF} from origin (exit {code}; no credentials? persist-credentials: false?); local mirror not judged, running everything"
828 ));
829 Sync::Skip
830 }
831 crate::git::Probe::TimedOut(secs) => {
832 say_skip(&format!(
833 "origin did not answer within {secs} s for {NOTES_FULL_REF}; local mirror not judged, running everything"
834 ));
835 Sync::Skip
836 }
837 crate::git::Probe::Failed => {
838 say_skip(&format!(
839 "cannot fetch {NOTES_FULL_REF} from origin (git did not run); local mirror not judged, running everything"
840 ));
841 Sync::Skip
842 }
843 },
844 };
845 let _ = crate::git::succeeds(&["update-ref", "-d", &tmp]);
846 report_stale_lock();
847 verdict
848}
849
850fn drop_mirror(old: Option<&str>) -> Sync {
854 let Some(oid) = old else {
855 return Sync::Skip; };
857 if crate::git::succeeds(&["update-ref", "-d", NOTES_FULL_REF, oid]) {
858 say(format!(
859 "amont: origin has no {NOTES_FULL_REF}; deleted the local mirror (was {oid})"
860 ));
861 say(format!(
862 "amont: undo locally: git update-ref {NOTES_FULL_REF} {oid} (undo the revocation: git push origin {oid}:{NOTES_FULL_REF})"
863 ));
864 } else {
865 say_skip(&format!(
866 "origin has no {NOTES_FULL_REF} but the local mirror could not be deleted (read-only .git?); local mirror not judged"
867 ));
868 }
869 Sync::Skip
870}
871
872const SYNC_NAMESPACE: &str = "refs/amont-tmp/";
874
875fn sweep_sync_refs() {
880 let Some(refs) = crate::git::stdout(&["for-each-ref", "--format=%(refname)", SYNC_NAMESPACE])
881 else {
882 return;
883 };
884 for r in refs.lines() {
885 let Some(pid) = r
886 .strip_prefix(SYNC_NAMESPACE)
887 .and_then(|rest| rest.split('/').next())
888 .filter(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
889 else {
890 continue;
891 };
892 if pid == std::process::id().to_string() || process_alive(pid) != Some(false) {
893 continue;
894 }
895 let _ = crate::git::succeeds(&["update-ref", "-d", r]);
896 }
897}
898
899fn process_alive(pid: &str) -> Option<bool> {
901 if !cfg!(unix) {
902 return None;
903 }
904 let out = Command::new("kill")
906 .args(["-0", pid])
907 .env("LC_ALL", "C")
908 .stdin(Stdio::null())
909 .stdout(Stdio::null())
910 .stderr(Stdio::piped())
911 .output()
912 .ok()?;
913 if out.status.success() {
914 return Some(true);
915 }
916 let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
919 Some(!err.contains("no such process"))
920}
921
922fn report_stale_lock() {
925 let lock = format!("{NOTES_FULL_REF}.lock");
926 if let Some(path) = crate::git::stdout(&["rev-parse", "--git-path", &lock]) {
927 if std::path::Path::new(&path).exists() {
928 say_skip(&format!(
929 "a git that was killed left a lock on {NOTES_FULL_REF}; if no git is running: rm {path}"
930 ));
931 }
932 }
933}
934
935fn say_skip(why: &str) {
937 say(format!("amont: {why}"));
938}
939
940thread_local! {
941 static CAPTURE: std::cell::RefCell<Option<Vec<String>>> = const { std::cell::RefCell::new(None) };
944}
945
946fn say(line: String) {
948 CAPTURE.with(|c| {
949 if let Some(v) = c.borrow_mut().as_mut() {
950 v.push(line.clone());
951 }
952 });
953 eprintln!("{line}");
954}
955
956fn covered_within(
958 signers: &std::path::Path,
959 principal: &str,
960 require_platform: Option<&str>,
961 budget: u64,
962) -> Option<String> {
963 if sync_mirror(budget) == Sync::Skip {
964 return None;
965 }
966 let head_tree = crate::git::stdout(&["rev-parse", "HEAD^{tree}"])?;
967 for candidate in [head_tree.as_str(), "HEAD", "HEAD^2"] {
981 let Some(object) = crate::git::stdout(&["rev-parse", "--verify", candidate]) else {
982 continue;
983 };
984 let Some(body) = crate::git::stdout(&["notes", "--ref", NOTES_REF, "show", &object]) else {
985 continue;
986 };
987 let Some((payload, sig)) = split_note(&body) else {
988 continue;
989 };
990 let mut lines = payload.lines();
994 if lines.next() != Some(FORMAT) {
995 continue;
996 }
997 let field = |name: &str| {
998 payload
999 .lines()
1000 .find_map(|l| l.strip_prefix(name).and_then(|r| r.strip_prefix(' ')))
1001 .map(str::trim)
1002 };
1003 let (Some(tree), Some(gates), Some(ran_on)) =
1004 (field("tree"), field("gates"), field("platform"))
1005 else {
1006 continue;
1007 };
1008 if tree != head_tree || gates.is_empty() {
1009 continue; }
1011 if require_platform.is_some_and(|want| want != ran_on) {
1015 continue;
1016 }
1017 if verify(&payload, &sig, signers, principal) {
1018 return Some(gates.to_string());
1019 }
1020 }
1021 None
1022}
1023
1024pub fn default_signers() -> Option<PathBuf> {
1035 let root = crate::git::stdout(&["rev-parse", "--show-toplevel"]).map(PathBuf::from);
1036 [".forgejo/allowed_signers", ".github/allowed_signers"]
1037 .into_iter()
1038 .map(|rel| match &root {
1039 Some(root) => root.join(rel),
1040 None => PathBuf::from(rel),
1041 })
1042 .find(|p| p.exists())
1043}
1044
1045pub fn first_principal(signers: &std::path::Path) -> Option<String> {
1050 let body = std::fs::read_to_string(signers).ok()?;
1051 body.lines()
1052 .map(str::trim)
1053 .find(|l| !l.is_empty() && !l.starts_with('#'))
1054 .and_then(|l| l.split_whitespace().next())
1055 .map(str::to_string)
1056}
1057
1058fn split_note(body: &str) -> Option<(String, String)> {
1062 let (payload, sig) = body.split_once("\n\n")?;
1063 if !sig.starts_with("-----BEGIN SSH SIGNATURE-----") {
1064 return None;
1065 }
1066 Some((format!("{payload}\n"), sig.to_string()))
1067}
1068
1069fn push_notes(remote: &str, refspec: &str) -> Push {
1075 let out = Command::new("git")
1076 .args(["push", "--quiet", remote, refspec])
1077 .env(PUSH_GUARD, "1")
1078 .stdin(Stdio::null())
1079 .stdout(Stdio::null())
1080 .output();
1081 let Ok(out) = out else { return Push::Refused };
1082 if out.status.success() {
1083 return Push::Done;
1084 }
1085 match classify_push_stderr(&String::from_utf8_lossy(&out.stderr)) {
1086 PushFailure::Raced => Push::Raced,
1087 PushFailure::Unreachable | PushFailure::Refused => Push::Refused,
1088 }
1089}
1090
1091#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1099pub enum PushFailure {
1100 Raced,
1101 Unreachable,
1102 Refused,
1103}
1104
1105pub fn classify_push_stderr(err: &str) -> PushFailure {
1108 const RACED: [&str; 5] = [
1109 "non-fast-forward",
1110 "fetch first",
1111 "stale info",
1112 "cannot lock ref",
1113 "failed to lock",
1114 ];
1115 const UNREACHABLE: [&str; 5] = [
1116 "Could not resolve host",
1117 "Connection refused",
1118 "Connection timed out",
1119 "unable to access",
1120 "Could not read from remote repository",
1121 ];
1122 let own = |l: &&str| {
1126 let l = l.trim_start();
1127 l.starts_with("fatal:") || l.starts_with("ssh:") || l.starts_with("error:")
1128 };
1129 if RACED.iter().any(|m| err.contains(m)) {
1130 PushFailure::Raced
1131 } else if err
1132 .lines()
1133 .filter(own)
1134 .any(|l| UNREACHABLE.iter().any(|m| l.contains(m)))
1135 {
1136 PushFailure::Unreachable
1137 } else {
1138 PushFailure::Refused
1139 }
1140}
1141
1142fn is_zero(oid: &str) -> bool {
1145 !oid.is_empty() && oid.bytes().all(|b| b == b'0')
1146}
1147
1148pub fn forget() -> bool {
1152 crate::git::succeeds(&["update-ref", "-d", NOTES_FULL_REF])
1153}
1154
1155pub fn forget_in(repo: &std::path::Path) -> bool {
1157 crate::git::succeeds_in(repo, &["update-ref", "-d", NOTES_FULL_REF])
1158}
1159
1160#[cfg(test)]
1161mod tests {
1162 use super::*;
1163
1164 #[test]
1165 fn push_stderr_is_sorted_into_raced_unreachable_and_refused() {
1166 assert_eq!(
1167 classify_push_stderr(" ! [rejected] feat -> feat (non-fast-forward)"),
1168 PushFailure::Raced
1169 );
1170 assert_eq!(
1171 classify_push_stderr("error: failed to lock ref"),
1172 PushFailure::Raced
1173 );
1174 for line in [
1175 "fatal: unable to access 'https://h/x.git/': Could not resolve host: h",
1176 "ssh: connect to host h port 22: Connection refused",
1177 "fatal: Could not read from remote repository.",
1178 "fatal: unable to access 'u': Connection timed out after 10001 ms",
1179 ] {
1180 assert_eq!(
1181 classify_push_stderr(line),
1182 PushFailure::Unreachable,
1183 "{line}"
1184 );
1185 }
1186 assert_eq!(
1187 classify_push_stderr("remote: permission denied"),
1188 PushFailure::Refused
1189 );
1190 assert_eq!(classify_push_stderr(""), PushFailure::Refused);
1191 }
1192
1193 #[test]
1194 fn a_hooks_own_words_do_not_make_the_remote_unreachable() {
1195 assert_eq!(
1197 classify_push_stderr("integration: Connection refused by the fixture\nhook failed"),
1198 PushFailure::Refused
1199 );
1200 }
1201
1202 fn test_settings() -> crate::config::Settings {
1205 crate::config::Settings::default()
1206 }
1207
1208 use std::io::Write;
1209 use std::path::Path;
1210
1211 fn dir(name: &str) -> PathBuf {
1215 let d = std::env::temp_dir().join(format!("attest-{name}-{}", std::process::id()));
1216 let _ = std::fs::remove_dir_all(&d);
1217 std::fs::create_dir_all(&d).unwrap();
1218 d
1219 }
1220
1221 fn git(dir: &Path, args: &[&str]) -> String {
1230 let out = std::process::Command::new("git")
1231 .arg("-C")
1232 .arg(dir)
1233 .args(args)
1234 .output()
1235 .expect("git");
1236 assert!(
1237 out.status.success(),
1238 "fixture: git {args:?} in {} exited {:?}: {}",
1239 dir.display(),
1240 out.status.code(),
1241 String::from_utf8_lossy(&out.stderr).trim()
1242 );
1243 String::from_utf8_lossy(&out.stdout).trim().to_string()
1244 }
1245
1246 fn repo(name: &str) -> PathBuf {
1247 let d = dir(name);
1248 git(&d, &["init", "-q", "--template=", "."]);
1249 git(&d, &["config", "user.email", "t@t.test"]);
1250 git(&d, &["config", "user.name", "t"]);
1251 d
1252 }
1253
1254 fn keypair(d: &Path) -> (PathBuf, PathBuf) {
1257 let key = d.join("attest_key");
1258 let ok = std::process::Command::new("ssh-keygen")
1259 .args(["-q", "-t", "ed25519", "-N", "", "-C", "test", "-f"])
1260 .arg(&key)
1261 .status()
1262 .expect("ssh-keygen must exist for these tests")
1263 .success();
1264 assert!(ok, "keygen failed");
1265 let pubkey = std::fs::read_to_string(key.with_extension("pub")).unwrap();
1266 let signers = d.join("allowed_signers");
1267 std::fs::write(
1268 &signers,
1269 format!("t@t.test namespaces=\"{NAMESPACE}\" {pubkey}"),
1270 )
1271 .unwrap();
1272 (key, signers)
1273 }
1274
1275 fn in_repo<T>(dir: &Path, f: impl FnOnce() -> T) -> T {
1278 let _guard = crate::TEST_CWD.lock().unwrap_or_else(|p| p.into_inner());
1279 let prev = std::env::current_dir().unwrap();
1280 std::env::set_current_dir(dir).unwrap();
1281 let r = f();
1282 std::env::set_current_dir(prev).unwrap();
1283 r
1284 }
1285
1286 #[test]
1291 fn the_signature_file_refuses_to_follow_what_is_already_there() {
1292 let dir = std::env::temp_dir().join(format!("amont-excl-{}", std::process::id()));
1293 let _ = std::fs::remove_dir_all(&dir);
1294 std::fs::create_dir_all(&dir).expect("temp dir");
1295
1296 let taken = dir.join("taken");
1298 std::fs::write(&taken, "original").expect("seed");
1299 assert!(create_exclusive(&taken).is_none());
1300 assert_eq!(std::fs::read_to_string(&taken).unwrap(), "original");
1301
1302 #[cfg(unix)]
1304 {
1305 let victim = dir.join("victim");
1306 std::fs::write(&victim, "precious").expect("seed");
1307 let link = dir.join("link");
1308 std::os::unix::fs::symlink(&victim, &link).expect("symlink");
1309 assert!(
1310 create_exclusive(&link).is_none(),
1311 "a symlink must be refused, not followed"
1312 );
1313 assert_eq!(
1314 std::fs::read_to_string(&victim).unwrap(),
1315 "precious",
1316 "the link target was written through"
1317 );
1318 }
1319
1320 let fresh = dir.join("fresh");
1322 assert!(create_exclusive(&fresh).is_some());
1323
1324 let _ = std::fs::remove_dir_all(&dir);
1325 }
1326
1327 #[test]
1328 fn the_payload_is_the_documented_contract() {
1329 let p = payload(
1330 "abc123",
1331 &["pre-push-pytest".into(), "pre-push-cargo-test".into()],
1332 &[],
1333 None,
1334 );
1335 let lines: Vec<&str> = p.lines().collect();
1336 assert_eq!(lines[0], FORMAT);
1337 assert_eq!(lines[1], "tree abc123");
1338 assert_eq!(lines[2], "gates pre-push-pytest pre-push-cargo-test");
1339 assert_eq!(lines[3], format!("platform {}", platform()));
1340 assert_eq!(lines[4], format!("amont {}", env!("CARGO_PKG_VERSION")));
1341 assert!(
1342 p.ends_with('\n'),
1343 "CI reconstructs these bytes; the trailing newline is part of them"
1344 );
1345 }
1346
1347 #[test]
1348 fn sign_verify_roundtrip_and_tamper_rejection() {
1349 let d = dir("roundtrip");
1350 let (key, signers) = keypair(&d);
1351 let p = payload("deadbeef", &["pre-push-pytest".into()], &[], None);
1352 let sig = sign(&p, &key).expect("signing with a real key succeeds");
1353 assert!(verify(&p, &sig, &signers, "t@t.test"));
1354 let tampered = payload("deadbeee", &["pre-push-pytest".into()], &[], None);
1357 assert!(!verify(&tampered, &sig, &signers, "t@t.test"));
1358 assert!(!verify(&p, &sig, &signers, "someone@else.test"));
1360 let _ = std::fs::remove_dir_all(&d);
1361 }
1362
1363 #[test]
1364 fn the_provenance_dir_is_resolved_like_amont_agents_config_dir() {
1365 use std::ffi::OsString;
1366 let env = |pairs: &'static [(&'static str, &'static str)]| {
1367 move |k: &str| {
1368 pairs
1369 .iter()
1370 .find(|(n, _)| *n == k)
1371 .map(|(_, v)| OsString::from(v))
1372 }
1373 };
1374 assert_eq!(
1375 provenance_dir(env(&[("CLAUDE_CONFIG_DIR", "/c"), ("HOME", "/h")])),
1376 Some(PathBuf::from("/c/amont-agent/provenance"))
1377 );
1378 assert_eq!(
1379 provenance_dir(env(&[("HOME", "/h"), ("USERPROFILE", "/u")])),
1380 Some(PathBuf::from("/h/.claude/amont-agent/provenance"))
1381 );
1382 assert_eq!(
1383 provenance_dir(env(&[("USERPROFILE", "/u")])),
1384 Some(PathBuf::from("/u/.claude/amont-agent/provenance"))
1385 );
1386 assert_eq!(provenance_dir(env(&[])), None);
1387 }
1388
1389 fn provenance_world(name: &str) -> (PathBuf, PathBuf, PathBuf, String, String, PathBuf) {
1393 provenance_world_in(name, None)
1394 }
1395
1396 fn provenance_world_in(
1398 name: &str,
1399 object_format: Option<&str>,
1400 ) -> (PathBuf, PathBuf, PathBuf, String, String, PathBuf) {
1401 let d = dir(name);
1402 let (key, _) = keypair(&d);
1403 let pubkey = std::fs::read_to_string(key.with_extension("pub")).unwrap();
1404 let signers = d.join("allowed_signers_provenance");
1405 std::fs::write(
1406 &signers,
1407 format!("t@t.test namespaces=\"{NAMESPACE},{PROVENANCE_NAMESPACE}\" {pubkey}"),
1408 )
1409 .unwrap();
1410 let remote = d.join("remote.git");
1411 std::fs::create_dir_all(&remote).unwrap();
1412 let fmt = format!("--object-format={}", object_format.unwrap_or("sha1"));
1413 git(&remote, &["init", "-q", "--bare", "--template=", &fmt, "."]);
1414 let work = match object_format {
1415 None => repo(&format!("{name}-work")),
1416 Some(f) => {
1417 let w = dir(&format!("{name}-work"));
1418 let fmt = format!("--object-format={f}");
1419 git(&w, &["init", "-q", "--template=", &fmt, "."]);
1420 git(&w, &["config", "user.email", "t@t.test"]);
1421 git(&w, &["config", "user.name", "t"]);
1422 w
1423 }
1424 };
1425 git(
1426 &work,
1427 &["remote", "add", "origin", remote.to_str().unwrap()],
1428 );
1429 git(&work, &["config", "amont.attest", "true"]);
1430 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
1431 std::fs::write(work.join("README.md"), "words").unwrap();
1432 std::fs::create_dir_all(work.join("docs/plans")).unwrap();
1433 std::fs::write(work.join("docs/plans/p.md"), "# P\n").unwrap();
1434 git(&work, &["add", "README.md", "docs/plans/p.md"]);
1435 git(&work, &["commit", "-qm", "docs: words"]);
1436 let head = git(&work, &["rev-parse", "HEAD"]);
1437 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1438 (d, work, remote, head, tree, signers)
1439 }
1440
1441 fn record(head: &str, tree: &str) -> String {
1442 format!(
1443 "{PROVENANCE_FORMAT}\ncommit {head}\ntree {tree}\n\
1444 plan docs/plans/p.md {}\nreview reviewed {}\npreview not-required\n\
1445 producer amont-agent 0.0.0\n",
1446 "a".repeat(64),
1447 "c".repeat(64)
1448 )
1449 }
1450
1451 fn canon_by_hand(work: &Path, tree: &str) -> String {
1455 let listing = git(
1456 work,
1457 &[
1458 "-c",
1459 "core.quotePath=true",
1460 "ls-tree",
1461 "-r",
1462 "--full-tree",
1463 tree,
1464 ],
1465 );
1466 let mut text = String::new();
1467 for line in listing.lines() {
1468 let (head, path) = line.split_once('\t').unwrap();
1469 let f: Vec<&str> = head.split(' ').collect();
1470 let plan = matches!(f[0], "100644" | "100755")
1471 && (path.starts_with("docs/plans/") || path.starts_with("\"docs/plans/"));
1472 if plan {
1473 text.push_str(&format!(
1474 "{} {} {}\t{path}\n",
1475 f[0],
1476 f[1],
1477 "0".repeat(f[2].len())
1478 ));
1479 } else {
1480 text.push_str(&format!("{line}\n"));
1481 }
1482 }
1483 let mut child = std::process::Command::new("git")
1484 .arg("-C")
1485 .arg(work)
1486 .args(["hash-object", "--stdin"])
1487 .stdin(Stdio::piped())
1488 .stdout(Stdio::piped())
1489 .spawn()
1490 .unwrap();
1491 child
1492 .stdin
1493 .take()
1494 .unwrap()
1495 .write_all(text.as_bytes())
1496 .unwrap();
1497 let out = child.wait_with_output().unwrap();
1498 String::from_utf8_lossy(&out.stdout).trim().to_string()
1499 }
1500
1501 fn provenance_payload(remote: &Path, object: &str) -> (String, String) {
1503 let note = git(remote, &["notes", "--ref", PROVENANCE_REF, "show", object]);
1504 let (p, sig) = note
1505 .split_once("\n\n")
1506 .expect("payload, blank line, signature");
1507 (format!("{p}\n"), sig.to_string())
1508 }
1509
1510 fn push_of(head: &str) -> PushRef {
1511 PushRef {
1512 local_ref: "refs/heads/feat/x".into(),
1513 local_oid: head.to_string(),
1514 remote_ref: "refs/heads/feat/x".into(),
1515 remote_oid: "0".repeat(40),
1516 }
1517 }
1518
1519 #[test]
1523 fn a_provenance_payload_is_signed_and_published_by_tree_and_commit() {
1524 let (d, work, remote, head, tree, signers) = provenance_world("prov-e2e");
1525 let payloads = d.join("payloads");
1526 std::fs::create_dir_all(&payloads).unwrap();
1527 let file = payloads.join(&head);
1528 let body = record(&head, &tree);
1529 std::fs::write(&file, &body).unwrap();
1530 let gates = ["pre-push-cargo-test".to_string()];
1531 in_repo(&work, || {
1532 attest_push(&test_settings(), "origin", &[push_of(&head)], &gates)
1533 });
1534 let attested = git(&remote, &["notes", "--ref", NOTES_REF, "show", &head]);
1535 in_repo(&work, || {
1536 provenance_push_from(&payloads, "origin", &[push_of(&head)])
1537 });
1538 let canon = canon_by_hand(&work, &tree);
1541 assert_ne!(
1542 canon, tree,
1543 "the tree carries a plan, so canon(T) is not its oid"
1544 );
1545 let signed = body.replacen(
1546 &format!("tree {tree}\n"),
1547 &format!("tree {tree}\ncanon {canon}\n"),
1548 1,
1549 );
1550 for object in [&head, &tree] {
1551 let (p, sig) = provenance_payload(&remote, object);
1552 assert_eq!(p, signed, "the payload, with canon(T) inserted, is signed");
1553 assert_eq!(p.lines().nth(3), Some(format!("canon {canon}").as_str()));
1554 assert!(verify_in(
1555 PROVENANCE_NAMESPACE,
1556 &p,
1557 &sig,
1558 &signers,
1559 "t@t.test"
1560 ));
1561 assert!(
1562 !verify_in(NAMESPACE, &p, &sig, &signers, "t@t.test"),
1563 "a provenance signature is never an attestation"
1564 );
1565 }
1566 assert!(!file.exists(), "a published payload is deleted");
1567 assert_eq!(
1568 git(&remote, &["notes", "--ref", NOTES_REF, "show", &head]),
1569 attested,
1570 "the attestation note is byte-identical with a provenance payload present"
1571 );
1572 let _ = std::fs::remove_dir_all(&d);
1573 let _ = std::fs::remove_dir_all(&work);
1574 }
1575
1576 #[test]
1577 fn a_payload_for_another_tree_is_neither_signed_nor_deleted() {
1578 let (d, work, remote, head, _tree, _) = provenance_world("prov-mismatch");
1579 let payloads = d.join("payloads");
1580 std::fs::create_dir_all(&payloads).unwrap();
1581 let file = payloads.join(&head);
1582 std::fs::write(&file, record(&head, &"b".repeat(40))).unwrap();
1583 in_repo(&work, || {
1584 provenance_push_from(&payloads, "origin", &[push_of(&head)])
1585 });
1586 assert!(file.exists(), "a mismatched payload is kept");
1587 let refs = git(&remote, &["for-each-ref", PROVENANCE_FULL_REF]);
1588 assert!(refs.is_empty(), "nothing was published: {refs}");
1589 let _ = std::fs::remove_dir_all(&d);
1590 let _ = std::fs::remove_dir_all(&work);
1591 }
1592
1593 #[test]
1596 fn a_payload_with_a_canon_line_is_refused_and_kept() {
1597 let (d, work, remote, head, tree, _) = provenance_world("prov-canon");
1598 let payloads = d.join("payloads");
1599 std::fs::create_dir_all(&payloads).unwrap();
1600 let file = payloads.join(&head);
1601 let old = record(&head, &tree).replacen(
1602 &format!("tree {tree}\n"),
1603 &format!("tree {tree}\ncanon {}\n", "c".repeat(64)),
1604 1,
1605 );
1606 std::fs::write(&file, &old).unwrap();
1607 in_repo(&work, || {
1608 provenance_push_from(&payloads, "origin", &[push_of(&head)])
1609 });
1610 assert_eq!(
1611 std::fs::read_to_string(&file).unwrap(),
1612 old,
1613 "kept as it was"
1614 );
1615 let refs = git(&remote, &["for-each-ref", PROVENANCE_FULL_REF]);
1616 assert!(refs.is_empty(), "nothing was published: {refs}");
1617 let _ = std::fs::remove_dir_all(&d);
1618 let _ = std::fs::remove_dir_all(&work);
1619 }
1620
1621 #[test]
1624 fn the_canon_refusal_fits_in_80_columns() {
1625 for commit in [
1626 "9f1c2ab0d6e4c1b2a3f405e6d7c8b9a0f1e2d3c4".to_string(),
1627 "e".repeat(64),
1628 ] {
1629 let file = PathBuf::from("/Users/someone/.claude/amont-agent/provenance/amont-agent")
1630 .join(&commit);
1631 let lines = canon_refusal(&commit, &file);
1632 assert!(lines[0].starts_with(" provenance for "), "{lines:?}");
1633 assert!(lines[0].contains(&commit[..7]), "{lines:?}");
1634 let all = lines.join("\n");
1635 assert!(all.contains("canon line") && all.contains("pre-release amont-agent"));
1636 assert!(all.contains(&format!(" {commit} in\n")), "{all}");
1637 assert!(all.contains("upgrade amont-agent"), "{all}");
1638 for l in &lines {
1639 assert!(
1640 l.chars().count() <= 80,
1641 "{} columns: {l}",
1642 l.chars().count()
1643 );
1644 }
1645 }
1646 }
1647
1648 #[test]
1650 fn a_failed_canon_keeps_the_payload() {
1651 let (d, work, remote, head, tree, _) = provenance_world("prov-nocanon");
1652 let docs = git(&work, &["rev-parse", &format!("{tree}:docs")]);
1654 let objects = PathBuf::from(git(&work, &["rev-parse", "--git-path", "objects"]));
1655 let objects = if objects.is_absolute() {
1656 objects
1657 } else {
1658 work.join(objects)
1659 };
1660 std::fs::remove_file(objects.join(&docs[..2]).join(&docs[2..])).unwrap();
1661 let payloads = d.join("payloads");
1662 std::fs::create_dir_all(&payloads).unwrap();
1663 let file = payloads.join(&head);
1664 std::fs::write(&file, record(&head, &tree)).unwrap();
1665 in_repo(&work, || {
1666 assert_eq!(crate::attest_inputs::canon(&tree), None);
1667 provenance_push_from(&payloads, "origin", &[push_of(&head)])
1668 });
1669 assert!(file.exists(), "a payload without a canon is kept");
1670 let refs = git(&remote, &["for-each-ref", PROVENANCE_FULL_REF]);
1671 assert!(refs.is_empty(), "nothing was published: {refs}");
1672 let _ = std::fs::remove_dir_all(&d);
1673 let _ = std::fs::remove_dir_all(&work);
1674 }
1675
1676 #[test]
1679 fn a_sha256_repository_signs_a_64_hex_canon() {
1680 let (d, work, remote, head, tree, _) = provenance_world_in("prov-sha256", Some("sha256"));
1681 assert_eq!(tree.len(), 64);
1682 let payloads = d.join("payloads");
1683 std::fs::create_dir_all(&payloads).unwrap();
1684 std::fs::write(payloads.join(&head), record(&head, &tree)).unwrap();
1685 in_repo(&work, || {
1686 provenance_push_from(&payloads, "origin", &[push_of(&head)])
1687 });
1688 let (p, _) = provenance_payload(&remote, &head);
1689 let canon = canon_by_hand(&work, &tree);
1690 assert_eq!(canon.len(), 64);
1691 assert_eq!(
1692 p.lines().nth(3),
1693 Some(format!("canon {canon}").as_str()),
1694 "{p}"
1695 );
1696 let _ = std::fs::remove_dir_all(&d);
1697 let _ = std::fs::remove_dir_all(&work);
1698 }
1699
1700 #[test]
1705 fn the_cross_check_tree_has_the_hand_computed_canon() {
1706 for format in ["sha1", "sha256"] {
1707 let w = dir(&format!("canon-cross-{format}"));
1708 git(
1709 &w,
1710 &[
1711 "init",
1712 "-q",
1713 "--template=",
1714 &format!("--object-format={format}"),
1715 ".",
1716 ],
1717 );
1718 std::fs::create_dir_all(w.join("docs/plans")).unwrap();
1719 std::fs::create_dir_all(w.join("x/docs/plans")).unwrap();
1720 std::fs::write(w.join("README.md"), "words\n").unwrap();
1721 std::fs::write(w.join("docs/plans/p.md"), "# P\n").unwrap();
1722 std::fs::write(w.join("x/docs/plans/n.md"), "nested\n").unwrap();
1723 let target = {
1724 let mut c = std::process::Command::new("git")
1725 .arg("-C")
1726 .arg(&w)
1727 .args(["hash-object", "-w", "--stdin"])
1728 .stdin(Stdio::piped())
1729 .stdout(Stdio::piped())
1730 .spawn()
1731 .unwrap();
1732 c.stdin.take().unwrap().write_all(b"p.md").unwrap();
1733 String::from_utf8_lossy(&c.wait_with_output().unwrap().stdout)
1734 .trim()
1735 .to_string()
1736 };
1737 git(
1738 &w,
1739 &["add", "README.md", "docs/plans/p.md", "x/docs/plans/n.md"],
1740 );
1741 git(
1742 &w,
1743 &[
1744 "update-index",
1745 "--add",
1746 "--cacheinfo",
1747 &format!("120000,{target},docs/plans/link"),
1748 ],
1749 );
1750 let tree = git(&w, &["write-tree"]);
1751 let canon = in_repo(&w, || crate::attest_inputs::canon(&tree)).unwrap();
1752 println!("cross-check {format} tree {tree} canon {canon}");
1753 assert_eq!(canon, canon_by_hand(&w, &tree), "{format}");
1754 let _ = std::fs::remove_dir_all(&w);
1755 }
1756 }
1757
1758 #[test]
1759 fn a_failed_publish_keeps_the_payload_for_the_next_push() {
1760 let (d, work, _remote, head, tree, _) = provenance_world("prov-fail");
1761 git(
1762 &work,
1763 &[
1764 "remote",
1765 "set-url",
1766 "origin",
1767 d.join("gone.git").to_str().unwrap(),
1768 ],
1769 );
1770 let payloads = d.join("payloads");
1771 std::fs::create_dir_all(&payloads).unwrap();
1772 let file = payloads.join(&head);
1773 std::fs::write(&file, record(&head, &tree)).unwrap();
1774 in_repo(&work, || {
1775 provenance_push_from(&payloads, "origin", &[push_of(&head)])
1776 });
1777 assert!(file.exists(), "an unpublished payload is kept");
1778 let _ = std::fs::remove_dir_all(&d);
1779 let _ = std::fs::remove_dir_all(&work);
1780 }
1781
1782 #[test]
1785 fn a_config_key_cannot_point_amont_at_other_payloads() {
1786 let (d, work, remote, head, tree, _) = provenance_world("prov-config");
1787 let elsewhere = d.join("elsewhere");
1788 let name = work.file_name().unwrap().to_string_lossy().into_owned();
1789 std::fs::create_dir_all(elsewhere.join(&name)).unwrap();
1790 std::fs::write(elsewhere.join(&name).join(&head), record(&head, &tree)).unwrap();
1791 for key in ["amont.provenanceDir", "amont.agent.provenanceDir"] {
1792 git(&work, &["config", key, elsewhere.to_str().unwrap()]);
1793 }
1794 in_repo(&work, || {
1795 provenance_push(&test_settings(), "origin", &[push_of(&head)])
1796 });
1797 let refs = git(&remote, &["for-each-ref", PROVENANCE_FULL_REF]);
1798 assert!(refs.is_empty(), "a configured directory was read: {refs}");
1799 let _ = std::fs::remove_dir_all(&d);
1800 let _ = std::fs::remove_dir_all(&work);
1801 }
1802
1803 #[test]
1804 fn no_payload_publishes_nothing() {
1805 let (d, work, remote, head, _, _) = provenance_world("prov-none");
1806 let payloads = d.join("payloads");
1807 std::fs::create_dir_all(&payloads).unwrap();
1808 in_repo(&work, || {
1809 provenance_push_from(&payloads, "origin", &[push_of(&head)])
1810 });
1811 let refs = git(&remote, &["for-each-ref", PROVENANCE_FULL_REF]);
1812 assert!(refs.is_empty(), "{refs}");
1813 let _ = std::fs::remove_dir_all(&d);
1814 let _ = std::fs::remove_dir_all(&work);
1815 }
1816
1817 #[test]
1818 fn a_missing_key_signs_nothing() {
1819 assert!(sign("anything", Path::new("/nonexistent/key")).is_none());
1820 }
1821
1822 #[test]
1826 fn an_enabled_push_leaves_a_verifiable_note_on_the_remote() {
1827 let d = dir("e2e");
1828 let (key, signers) = keypair(&d);
1829 let remote = d.join("remote.git");
1830 std::fs::create_dir_all(&remote).unwrap();
1831 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
1832 let work = repo("e2e-work");
1833 git(
1834 &work,
1835 &["remote", "add", "origin", remote.to_str().unwrap()],
1836 );
1837 git(&work, &["config", "amont.attest", "true"]);
1838 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
1839 std::fs::write(work.join("a.ts"), "x").unwrap();
1840 git(&work, &["add", "a.ts"]);
1841 git(&work, &["commit", "-qm", "chore: a"]);
1842 let head = git(&work, &["rev-parse", "HEAD"]);
1843 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1844 let push_ref = PushRef {
1845 local_ref: "refs/heads/main".into(),
1846 local_oid: head.clone(),
1847 remote_ref: "refs/heads/main".into(),
1848 remote_oid: "0".repeat(40),
1849 };
1850 in_repo(&work, || {
1851 attest_push(
1852 &test_settings(),
1853 "origin",
1854 &[push_ref],
1855 &["pre-push-run-tests-js".into()],
1856 );
1857 });
1858 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &head]);
1860 assert!(!body.is_empty(), "no note reached the remote");
1861 let (p, sig) = body
1862 .split_once("\n\n")
1863 .expect("payload, blank line, signature");
1864 let p = format!("{p}\n"); assert!(p.starts_with(FORMAT));
1866 assert!(
1867 p.contains(&format!("tree {tree}")),
1868 "attests the pushed tree"
1869 );
1870 assert!(
1871 verify(&p, sig, &signers, "t@t.test"),
1872 "the remote copy verifies"
1873 );
1874 let _ = std::fs::remove_dir_all(&d);
1875 let _ = std::fs::remove_dir_all(&work);
1876 }
1877
1878 fn remote_and_work(name: &str) -> (PathBuf, PathBuf, PathBuf, PathBuf) {
1880 let d = dir(name);
1881 let (key, signers) = keypair(&d);
1882 let remote = d.join("remote.git");
1883 std::fs::create_dir_all(&remote).unwrap();
1884 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
1885 let work = repo(&format!("{name}-work"));
1886 git(
1887 &work,
1888 &["remote", "add", "origin", remote.to_str().unwrap()],
1889 );
1890 git(&work, &["config", "amont.attest", "true"]);
1891 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
1892 std::fs::write(work.join("a.ts"), "x").unwrap();
1893 git(&work, &["add", "a.ts"]);
1894 git(&work, &["commit", "-qm", "chore: a"]);
1895 (d, work, remote, signers)
1896 }
1897
1898 fn push_ref_for(work: &Path) -> PushRef {
1899 PushRef {
1900 local_ref: "refs/heads/main".into(),
1901 local_oid: git(work, &["rev-parse", "HEAD"]),
1902 remote_ref: "refs/heads/main".into(),
1903 remote_oid: "0".repeat(40),
1904 }
1905 }
1906
1907 fn blocks_in(body: &str) -> usize {
1908 body.matches("-----BEGIN SSH SIGNATURE-----").count()
1909 }
1910
1911 #[test]
1915 fn a_block_already_on_the_remote_survives_and_ours_is_appended() {
1916 let (d, work, remote, _) = remote_and_work("append");
1917 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
1918 let seed = repo("append-seed");
1920 git(
1921 &seed,
1922 &["remote", "add", "origin", remote.to_str().unwrap()],
1923 );
1924 git(&seed, &["fetch", "-q", "origin"]);
1925 std::fs::write(seed.join("a.ts"), "x").unwrap();
1926 git(&seed, &["add", "a.ts"]);
1927 git(&seed, &["commit", "-qm", "chore: a"]);
1928 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-----";
1929 git(
1930 &seed,
1931 &["notes", "--ref", NOTES_REF, "add", "-m", foreign, &tree],
1932 );
1933 git(
1934 &seed,
1935 &[
1936 "push",
1937 "-q",
1938 "origin",
1939 &format!("{NOTES_FULL_REF}:{NOTES_FULL_REF}"),
1940 ],
1941 );
1942 in_repo(&work, || {
1943 attest_push(
1944 &test_settings(),
1945 "origin",
1946 &[push_ref_for(&work)],
1947 &["pre-push-run-tests-js".into()],
1948 );
1949 });
1950 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &tree]);
1951 assert_eq!(blocks_in(&body), 2, "both blocks on the remote:\n{body}");
1952 assert!(body.contains("gates ci-fmt"), "the foreign block survives");
1953 assert!(
1954 body.contains("gates pre-push-run-tests-js"),
1955 "ours was appended"
1956 );
1957 let _ = std::fs::remove_dir_all(&d);
1958 let _ = std::fs::remove_dir_all(&work);
1959 let _ = std::fs::remove_dir_all(&seed);
1960 }
1961
1962 #[test]
1967 fn a_stale_local_notes_ref_no_longer_loses_the_push() {
1968 let (d, work, remote, _) = remote_and_work("stale");
1969 let head = git(&work, &["rev-parse", "HEAD"]);
1970 git(
1973 &work,
1974 &[
1975 "notes",
1976 "--ref",
1977 NOTES_REF,
1978 "add",
1979 "-m",
1980 "stale local",
1981 &head,
1982 ],
1983 );
1984 let stale = git(&work, &["rev-parse", NOTES_FULL_REF]);
1985 let seed = repo("stale-seed");
1987 std::fs::write(seed.join("b.ts"), "y").unwrap();
1988 git(&seed, &["add", "b.ts"]);
1989 git(&seed, &["commit", "-qm", "chore: b"]);
1990 git(
1991 &seed,
1992 &["remote", "add", "origin", remote.to_str().unwrap()],
1993 );
1994 git(
1995 &seed,
1996 &[
1997 "notes",
1998 "--ref",
1999 NOTES_REF,
2000 "add",
2001 "-m",
2002 "remote first",
2003 "HEAD",
2004 ],
2005 );
2006 git(&seed, &["push", "-q", "origin", "HEAD:refs/heads/other"]);
2007 git(
2008 &seed,
2009 &[
2010 "push",
2011 "-q",
2012 "origin",
2013 &format!("{NOTES_FULL_REF}:{NOTES_FULL_REF}"),
2014 ],
2015 );
2016 in_repo(&work, || {
2017 attest_push(
2018 &test_settings(),
2019 "origin",
2020 &[push_ref_for(&work)],
2021 &["pre-push-run-tests-js".into()],
2022 );
2023 });
2024 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &head]);
2025 assert!(
2026 body.contains("gates pre-push-run-tests-js"),
2027 "the push landed:\n{body}"
2028 );
2029 assert_ne!(
2030 git(&work, &["rev-parse", NOTES_FULL_REF]),
2031 stale,
2032 "the local ref followed the published state"
2033 );
2034 assert!(
2035 git(&work, &["for-each-ref", "refs/notes/amont-attest-push-*"]).is_empty(),
2036 "no temporary ref left behind"
2037 );
2038 let _ = std::fs::remove_dir_all(&d);
2039 let _ = std::fs::remove_dir_all(&work);
2040 let _ = std::fs::remove_dir_all(&seed);
2041 }
2042
2043 #[test]
2046 fn a_second_push_of_the_same_tree_appends_nothing() {
2047 let (d, work, remote, _) = remote_and_work("twice");
2048 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
2049 for _ in 0..2 {
2050 in_repo(&work, || {
2051 attest_push(
2052 &test_settings(),
2053 "origin",
2054 &[push_ref_for(&work)],
2055 &["pre-push-run-tests-js".into()],
2056 );
2057 });
2058 }
2059 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &tree]);
2060 assert_eq!(blocks_in(&body), 1, "one block, not two:\n{body}");
2061 let _ = std::fs::remove_dir_all(&d);
2062 let _ = std::fs::remove_dir_all(&work);
2063 }
2064
2065 #[test]
2069 fn a_spec_yields_input_lines_and_fingerprint_keyed_notes() {
2070 let (d, work, remote, _) = remote_and_work("inputs");
2071 std::fs::create_dir_all(work.join(".github")).unwrap();
2072 std::fs::write(
2073 work.join(".github/attest-inputs"),
2074 "# what each gate reads\npre-push-run-tests-js a.ts\nother nope\n",
2075 )
2076 .unwrap();
2077 git(&work, &["add", ".github/attest-inputs"]);
2078 git(&work, &["commit", "-qm", "chore: spec"]);
2079 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
2080 let listing = std::process::Command::new("git")
2082 .args([
2083 "-C",
2084 work.to_str().unwrap(),
2085 "ls-tree",
2086 "-r",
2087 "-z",
2088 "--full-tree",
2089 &tree,
2090 "--",
2091 ".forgejo/attest-inputs",
2092 ".github/attest-inputs",
2093 ".gitmodules",
2094 ".gitattributes",
2095 "a.ts",
2096 ])
2097 .output()
2098 .unwrap()
2099 .stdout;
2100 let expected =
2101 crate::git::stdout_piped_in(&work, &["hash-object", "--stdin"], &listing).unwrap();
2102 in_repo(&work, || {
2103 attest_push(
2104 &test_settings(),
2105 "origin",
2106 &[push_ref_for(&work)],
2107 &["pre-push-run-tests-js".into(), "other".into()],
2108 );
2109 });
2110 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &tree]);
2111 assert!(
2112 body.contains(&format!("input pre-push-run-tests-js {expected}\n")),
2113 "the input line carries the reference fingerprint:\n{body}"
2114 );
2115 assert!(
2116 !body.contains("input other "),
2117 "a gate whose path does not exist gets no line"
2118 );
2119 let key = crate::git::stdout_piped_in(
2120 &work,
2121 &["hash-object", "--stdin"],
2122 format!("amont-attest-input pre-push-run-tests-js {expected}\n").as_bytes(),
2123 )
2124 .unwrap();
2125 let under_key = git(&remote, &["notes", "--ref", INPUTS_REF, "show", &key]);
2126 assert_eq!(
2127 under_key, body,
2128 "the same block is filed under the fingerprint key"
2129 );
2130 in_repo(&work, || {
2132 attest_push(
2133 &test_settings(),
2134 "origin",
2135 &[push_ref_for(&work)],
2136 &["pre-push-run-tests-js".into(), "other".into()],
2137 );
2138 });
2139 assert_eq!(
2140 git(&remote, &["notes", "--ref", INPUTS_REF, "show", &key])
2141 .matches("BEGIN SSH SIGNATURE")
2142 .count(),
2143 1
2144 );
2145 let _ = std::fs::remove_dir_all(&d);
2146 let _ = std::fs::remove_dir_all(&work);
2147 }
2148
2149 #[test]
2152 fn without_a_valid_spec_the_block_is_tree_keyed_only() {
2153 let (d, work, remote, _) = remote_and_work("nospec");
2154 std::fs::create_dir_all(work.join(".github")).unwrap();
2155 std::fs::write(
2156 work.join(".github/attest-inputs"),
2157 "pre-push-run-tests-js src/*.ts\n",
2158 )
2159 .unwrap();
2160 git(&work, &["add", ".github/attest-inputs"]);
2161 git(&work, &["commit", "-qm", "chore: bad spec"]);
2162 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
2163 in_repo(&work, || {
2164 attest_push(
2165 &test_settings(),
2166 "origin",
2167 &[push_ref_for(&work)],
2168 &["pre-push-run-tests-js".into()],
2169 );
2170 });
2171 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &tree]);
2172 assert!(!body.contains("\ninput "), "no input line:\n{body}");
2173 assert!(
2174 !crate::git::succeeds_in(
2175 &remote,
2176 &["rev-parse", "--verify", "--quiet", INPUTS_FULL_REF]
2177 ),
2178 "no inputs ref was created"
2179 );
2180 let _ = std::fs::remove_dir_all(&d);
2181 let _ = std::fs::remove_dir_all(&work);
2182 }
2183
2184 #[test]
2187 fn with_no_spec_the_block_carries_the_canonical_tree() {
2188 let (d, work, remote, _) = remote_and_work("canon");
2189 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
2190 in_repo(&work, || {
2191 attest_push(
2192 &test_settings(),
2193 "origin",
2194 &[push_ref_for(&work)],
2195 &["pre-push-run-tests-js".into()],
2196 );
2197 });
2198 let c = in_repo(&work, || crate::attest_inputs::canon(&tree)).expect("canon");
2199 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &tree]);
2200 assert!(body.contains(&format!("\ncanon {c}\namont ")), "{body}");
2201 assert!(!body.contains("\ninput "), "nothing declared:\n{body}");
2202 let key = in_repo(&work, || crate::attest_inputs::canon_key(&c)).expect("key");
2203 assert_eq!(
2204 git(&remote, &["notes", "--ref", INPUTS_REF, "show", &key]),
2205 body,
2206 "the same block is filed under the canonical key"
2207 );
2208 in_repo(&work, || {
2210 attest_push(
2211 &test_settings(),
2212 "origin",
2213 &[push_ref_for(&work)],
2214 &["pre-push-run-tests-js".into()],
2215 );
2216 });
2217 assert_eq!(
2218 git(&remote, &["notes", "--ref", INPUTS_REF, "show", &key])
2219 .matches("BEGIN SSH SIGNATURE")
2220 .count(),
2221 1
2222 );
2223 let _ = std::fs::remove_dir_all(&d);
2224 let _ = std::fs::remove_dir_all(&work);
2225 }
2226
2227 #[test]
2229 fn a_valid_spec_also_carries_the_canonical_tree() {
2230 let (d, work, remote, _) = remote_and_work("canon-spec");
2231 std::fs::create_dir_all(work.join(".github")).unwrap();
2232 std::fs::write(
2233 work.join(".github/attest-inputs"),
2234 "pre-push-run-tests-js a.ts\n",
2235 )
2236 .unwrap();
2237 git(&work, &["add", ".github/attest-inputs"]);
2238 git(&work, &["commit", "-qm", "chore: spec"]);
2239 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
2240 in_repo(&work, || {
2241 attest_push(
2242 &test_settings(),
2243 "origin",
2244 &[push_ref_for(&work)],
2245 &["pre-push-run-tests-js".into()],
2246 );
2247 });
2248 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &tree]);
2249 let input = body
2250 .find("\ninput pre-push-run-tests-js ")
2251 .expect("input line");
2252 let canon = body.find("\ncanon ").expect("canon line");
2253 let tool = body.find("\namont ").expect("amont line");
2254 assert!(
2255 input < canon && canon < tool,
2256 "input, canon, amont:\n{body}"
2257 );
2258 let _ = std::fs::remove_dir_all(&d);
2259 let _ = std::fs::remove_dir_all(&work);
2260 }
2261
2262 #[test]
2264 fn an_opt_out_or_an_unusable_spec_leaves_no_canon_line() {
2265 for (name, setup) in [
2266 ("canon-off", "off"),
2267 ("canon-bad-spec", "bad"),
2268 ("canon-unreadable-conf", "conf-dir"),
2269 ] {
2270 let (d, work, remote, _) = remote_and_work(name);
2271 match setup {
2272 "off" => {
2273 std::fs::write(work.join("amont.conf"), "set planExempt off\n").unwrap();
2274 git(&work, &["add", "amont.conf"]);
2275 }
2276 "bad" => {
2277 std::fs::create_dir_all(work.join(".github")).unwrap();
2278 std::fs::write(work.join(".github/attest-inputs"), "g src/*.ts\n").unwrap();
2279 git(&work, &["add", ".github/attest-inputs"]);
2280 }
2281 _ => {
2282 std::fs::create_dir_all(work.join("amont.conf")).unwrap();
2283 std::fs::write(work.join("amont.conf/x"), "x").unwrap();
2284 git(&work, &["add", "amont.conf/x"]);
2285 }
2286 }
2287 git(&work, &["commit", "-qm", "chore: setup"]);
2288 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
2289 in_repo(&work, || {
2290 attest_push(
2291 &test_settings(),
2292 "origin",
2293 &[push_ref_for(&work)],
2294 &["pre-push-run-tests-js".into()],
2295 );
2296 });
2297 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &tree]);
2298 assert!(!body.contains("\ncanon "), "{name}: no canon line:\n{body}");
2299 assert!(
2300 !crate::git::succeeds_in(
2301 &remote,
2302 &["rev-parse", "--verify", "--quiet", INPUTS_FULL_REF]
2303 ),
2304 "{name}: no inputs ref was created"
2305 );
2306 let _ = std::fs::remove_dir_all(&d);
2307 let _ = std::fs::remove_dir_all(&work);
2308 }
2309 }
2310
2311 #[test]
2315 fn the_canonical_fingerprint_matches_attests_known_answers() {
2316 let work = repo("canon-vector");
2317 std::fs::create_dir_all(work.join("docs/plans")).unwrap();
2318 std::fs::create_dir_all(work.join("src")).unwrap();
2319 std::fs::write(work.join("a.ts"), "x\n").unwrap();
2320 std::fs::write(work.join("docs/plans/p.md"), "plan\n").unwrap();
2321 std::fs::write(work.join("src/lib.rs"), "fn f() {}\n").unwrap();
2322 std::fs::write(work.join("run.sh"), "#!/bin/sh\n").unwrap();
2323 git(&work, &["add", "-A"]);
2324 git(&work, &["update-index", "--chmod=+x", "run.sh"]);
2325 git(&work, &["commit", "-qm", "init"]);
2326 let canon = |w: &Path| {
2327 let t = git(w, &["rev-parse", "HEAD^{tree}"]);
2328 (t.clone(), in_repo(w, || crate::attest_inputs::canon(&t)))
2329 };
2330 let (t0, c0) = canon(&work);
2331 assert_eq!(t0, "fd4804a25fa9207c65410d3a57099643e14564eb");
2332 assert_eq!(
2333 c0.as_deref(),
2334 Some("e942ded734eec11936c0f65fd86c071f2ef505b0")
2335 );
2336 std::fs::write(work.join("docs/plans/p.md"), "plan2\n").unwrap();
2337 git(&work, &["add", "docs/plans/p.md"]);
2338 git(&work, &["commit", "-qm", "plan"]);
2339 let (t1, c1) = canon(&work);
2340 assert_ne!(t1, t0);
2341 assert_eq!(c1, c0, "a plan edit leaves the canonical tree unchanged");
2342 std::fs::write(work.join("src/lib.rs"), "fn g() {}\n").unwrap();
2343 git(&work, &["add", "src/lib.rs"]);
2344 git(&work, &["commit", "-qm", "code"]);
2345 assert_eq!(
2346 canon(&work).1.as_deref(),
2347 Some("57dabcd18b8b8fa35db59657892b2b8b34a1ae4f")
2348 );
2349 let _ = std::fs::remove_dir_all(&work);
2350 }
2351
2352 #[test]
2353 fn the_spec_grammar_is_attests() {
2354 assert!(parse_spec(b"# c\ng src Cargo.toml\n").is_some());
2355 for bad in [
2356 b"g src/*.rs\n".as_slice(),
2357 b"g :!x\n",
2358 b"g\n",
2359 b"g src\ng x\n",
2360 b"g src\r\n",
2361 b"g sr\xc3\xa9\n",
2362 b"-g src\n",
2363 b"g ./src\n",
2364 b"g src/\n",
2365 b"g a/../b\n",
2366 ] {
2367 assert!(parse_spec(bad).is_none(), "{bad:?}");
2368 }
2369 assert_eq!(
2370 implicit_inputs(&["crates/foo/src".into()]),
2371 [
2372 ".forgejo/attest-inputs",
2373 ".github/attest-inputs",
2374 ".gitmodules",
2375 ".gitattributes",
2376 "crates/.gitattributes",
2377 "crates/foo/.gitattributes"
2378 ]
2379 );
2380 }
2381
2382 #[test]
2385 fn no_opt_in_means_no_note() {
2386 let d = dir("optout");
2387 let (key, _) = keypair(&d);
2388 let remote = d.join("remote.git");
2389 std::fs::create_dir_all(&remote).unwrap();
2390 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
2391 let work = repo("optout-work");
2392 git(
2393 &work,
2394 &["remote", "add", "origin", remote.to_str().unwrap()],
2395 );
2396 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
2397 std::fs::write(work.join("a.ts"), "x").unwrap();
2398 git(&work, &["add", "a.ts"]);
2399 git(&work, &["commit", "-qm", "chore: a"]);
2400 let head = git(&work, &["rev-parse", "HEAD"]);
2401 let push_ref = PushRef {
2402 local_ref: "refs/heads/main".into(),
2403 local_oid: head.clone(),
2404 remote_ref: "refs/heads/main".into(),
2405 remote_oid: "0".repeat(40),
2406 };
2407 in_repo(&work, || {
2408 attest_push(
2409 &test_settings(),
2410 "origin",
2411 &[push_ref],
2412 &["pre-push-run-tests-js".into()],
2413 );
2414 });
2415 assert!(
2416 git(&remote, &["notes", "--ref", NOTES_REF, "list"]).is_empty(),
2417 "an un-opted-in repo attested something"
2418 );
2419 let _ = std::fs::remove_dir_all(&d);
2420 let _ = std::fs::remove_dir_all(&work);
2421 }
2422
2423 #[test]
2426 fn deletions_and_empty_gates_attest_nothing() {
2427 let d = dir("nothing");
2428 let (key, _) = keypair(&d);
2429 let remote = d.join("remote.git");
2430 std::fs::create_dir_all(&remote).unwrap();
2431 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
2432 let work = repo("nothing-work");
2433 git(
2434 &work,
2435 &["remote", "add", "origin", remote.to_str().unwrap()],
2436 );
2437 git(&work, &["config", "amont.attest", "true"]);
2438 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
2439 std::fs::write(work.join("a.ts"), "x").unwrap();
2440 git(&work, &["add", "a.ts"]);
2441 git(&work, &["commit", "-qm", "chore: a"]);
2442 let head = git(&work, &["rev-parse", "HEAD"]);
2443 let deletion = PushRef {
2444 local_ref: "(delete)".into(),
2445 local_oid: "0".repeat(40),
2446 remote_ref: "refs/heads/gone".into(),
2447 remote_oid: head.clone(),
2448 };
2449 let real = PushRef {
2450 local_ref: "refs/heads/main".into(),
2451 local_oid: head,
2452 remote_ref: "refs/heads/main".into(),
2453 remote_oid: "0".repeat(40),
2454 };
2455 in_repo(&work, || {
2456 attest_push(
2457 &test_settings(),
2458 "origin",
2459 &[deletion],
2460 &["pre-push-pytest".into()],
2461 );
2462 attest_push(&test_settings(), "origin", &[real], &[]);
2463 });
2464 assert!(git(&remote, &["notes", "--ref", NOTES_REF, "list"]).is_empty());
2465 let _ = std::fs::remove_dir_all(&d);
2466 let _ = std::fs::remove_dir_all(&work);
2467 }
2468
2469 #[test]
2474 fn covered_answers_in_a_fresh_clone_and_rejects_drift_and_forgery() {
2475 let d = dir("covered");
2476 let (key, signers) = keypair(&d);
2477 let remote = d.join("remote.git");
2478 std::fs::create_dir_all(&remote).unwrap();
2479 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
2480 git(&remote, &["symbolic-ref", "HEAD", "refs/heads/main"]);
2486 let work = repo("covered-work");
2487 git(
2488 &work,
2489 &["remote", "add", "origin", remote.to_str().unwrap()],
2490 );
2491 git(&work, &["config", "amont.attest", "true"]);
2492 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
2493 std::fs::write(work.join("a.ts"), "x").unwrap();
2494 git(&work, &["add", "a.ts"]);
2495 git(&work, &["commit", "-qm", "chore: a"]);
2496 git(&work, &["push", "-q", "origin", "HEAD:main"]);
2497 let head = git(&work, &["rev-parse", "HEAD"]);
2498 let push_ref = PushRef {
2499 local_ref: "refs/heads/main".into(),
2500 local_oid: head.clone(),
2501 remote_ref: "refs/heads/main".into(),
2502 remote_oid: "0".repeat(40),
2503 };
2504 in_repo(&work, || {
2505 attest_push(
2506 &test_settings(),
2507 "origin",
2508 &[push_ref],
2509 &["pre-push-pytest".into()],
2510 );
2511 });
2512 let clone = d.join("ci-checkout");
2513 git(
2514 &d,
2515 &[
2516 "clone",
2517 "-q",
2518 "--template=",
2519 remote.to_str().unwrap(),
2520 clone.to_str().unwrap(),
2521 ],
2522 );
2523 in_repo(&clone, || {
2524 assert_eq!(
2525 covered(&signers, "t@t.test", Some(&platform())).as_deref(),
2526 Some("pre-push-pytest"),
2527 "a fresh clone verifies the attestation and reads the gates"
2528 );
2529 assert_eq!(
2530 covered(&signers, "t@t.test", None).as_deref(),
2531 Some("pre-push-pytest"),
2532 "`any` covers a platform-independent suite"
2533 );
2534 assert_eq!(
2537 covered(&signers, "t@t.test", Some("s390x-aix")),
2538 None,
2539 "a pass on one platform is not evidence about another"
2540 );
2541 assert_eq!(
2542 covered(&signers, "someone@else.test", None),
2543 None,
2544 "an unlisted principal covers nothing"
2545 );
2546 });
2547 std::fs::write(clone.join("b.ts"), "y").unwrap();
2549 git(&clone, &["config", "user.email", "t@t.test"]);
2550 git(&clone, &["config", "user.name", "t"]);
2551 git(&clone, &["add", "b.ts"]);
2552 git(&clone, &["commit", "-qm", "chore: b"]);
2553 in_repo(&clone, || {
2554 assert_eq!(covered(&signers, "t@t.test", None), None, "drifted tree");
2555 });
2556 let head_tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
2566 for object in [&head, &head_tree] {
2567 git(
2568 &work,
2569 &[
2570 "notes", "--ref", NOTES_REF, "add", "-f", "-m", "garbage", object,
2571 ],
2572 );
2573 }
2574 git(
2575 &work,
2576 &[
2577 "push",
2578 "-q",
2579 "origin",
2580 &format!("+{NOTES_FULL_REF}:{NOTES_FULL_REF}"),
2581 ],
2582 );
2583 git(&clone, &["reset", "-q", "--hard", &head]);
2584 in_repo(&clone, || {
2585 assert_eq!(
2586 covered(&signers, "t@t.test", None),
2587 None,
2588 "a foreign note is not a stamp"
2589 );
2590 });
2591 let _ = std::fs::remove_dir_all(&d);
2592 let _ = std::fs::remove_dir_all(&work);
2593 }
2594
2595 #[test]
2601 fn default_signers_is_found_from_a_subdirectory() {
2602 let work = repo("signers-subdir");
2603 std::fs::create_dir_all(work.join(".forgejo")).unwrap();
2604 std::fs::write(work.join(".forgejo/allowed_signers"), "t@t.test x\n").unwrap();
2605 let sub = work.join("packages").join("thing");
2606 std::fs::create_dir_all(&sub).unwrap();
2607 in_repo(&sub, || {
2608 let found = default_signers().expect("resolved from the repo root, not the cwd");
2609 assert!(found.ends_with(".forgejo/allowed_signers"));
2610 assert!(found.exists(), "the path it returns must be usable as-is");
2611 assert_eq!(
2612 first_principal(&found).as_deref(),
2613 Some("t@t.test"),
2614 "and readable from there"
2615 );
2616 });
2617 let _ = std::fs::remove_dir_all(&work);
2618 }
2619
2620 #[test]
2621 fn zero_oids_of_any_width_are_zero() {
2622 assert!(is_zero(&"0".repeat(40)));
2623 assert!(is_zero(&"0".repeat(64)));
2624 assert!(!is_zero("0a0000"));
2625 assert!(!is_zero(""));
2626 }
2627
2628 #[test]
2629 fn forget_removes_the_local_ref() {
2630 let work = repo("forget");
2631 std::fs::write(work.join("a.ts"), "x").unwrap();
2632 git(&work, &["add", "a.ts"]);
2633 git(&work, &["commit", "-qm", "chore: a"]);
2634 git(
2635 &work,
2636 &["notes", "--ref", NOTES_REF, "add", "-m", "x", "HEAD"],
2637 );
2638 in_repo(&work, forget);
2639 assert!(git(&work, &["notes", "--ref", NOTES_REF, "list"]).is_empty());
2640 let _ = std::fs::remove_dir_all(&work);
2641 }
2642
2643 #[test]
2658 fn an_attestation_survives_a_rewrite_that_keeps_the_tree() {
2659 let d = dir("rewritten");
2660 let (key, signers) = keypair(&d);
2661 let remote = d.join("remote.git");
2662 std::fs::create_dir_all(&remote).unwrap();
2663 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
2664 git(&remote, &["symbolic-ref", "HEAD", "refs/heads/main"]);
2665 let work = repo("rewritten-work");
2666 git(
2667 &work,
2668 &["remote", "add", "origin", remote.to_str().unwrap()],
2669 );
2670 git(&work, &["config", "amont.attest", "true"]);
2671 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
2672 std::fs::write(work.join("a.ts"), "x").unwrap();
2673 git(&work, &["add", "a.ts"]);
2674 git(&work, &["commit", "-qm", "feat: on a branch"]);
2675 git(&work, &["push", "-q", "origin", "HEAD:main"]);
2676 let branch_tip = git(&work, &["rev-parse", "HEAD"]);
2677 let push_ref = PushRef {
2678 local_ref: "refs/heads/main".into(),
2679 local_oid: branch_tip.clone(),
2680 remote_ref: "refs/heads/main".into(),
2681 remote_oid: "0".repeat(40),
2682 };
2683 in_repo(&work, || {
2684 attest_push(
2685 &test_settings(),
2686 "origin",
2687 &[push_ref],
2688 &["pre-push-pytest".into()],
2689 );
2690 });
2691
2692 git(
2696 &work,
2697 &[
2698 "commit",
2699 "-q",
2700 "--amend",
2701 "-m",
2702 "feat: squashed by the forge",
2703 ],
2704 );
2705 let rewritten = git(&work, &["rev-parse", "HEAD"]);
2706 assert_ne!(rewritten, branch_tip, "the fixture must rewrite the commit");
2707 assert_eq!(
2708 git(&work, &["rev-parse", "HEAD^{tree}"]),
2709 git(&work, &["rev-parse", &format!("{branch_tip}^{{tree}}")]),
2710 "…while preserving the tree, which is the premise"
2711 );
2712 git(&work, &["push", "-q", "-f", "origin", "HEAD:main"]);
2713
2714 let clone = d.join("ci-checkout");
2715 git(
2716 &d,
2717 &[
2718 "clone",
2719 "-q",
2720 "--template=",
2721 remote.to_str().unwrap(),
2722 clone.to_str().unwrap(),
2723 ],
2724 );
2725 in_repo(&clone, || {
2726 assert_eq!(
2727 covered(&signers, "t@t.test", None).as_deref(),
2728 Some("pre-push-pytest"),
2729 "the attestation must be found by the tree it signed"
2730 );
2731 });
2732 let _ = std::fs::remove_dir_all(&d);
2733 }
2734
2735 #[test]
2738 fn optional_paths_are_marked_once_and_stripped() {
2739 let spec = parse_spec(b"g src ?build.rs ?a/b/c\n").expect("valid");
2740 assert_eq!(
2741 spec[0].1,
2742 [
2743 PathTok {
2744 path: "src".into(),
2745 optional: false
2746 },
2747 PathTok {
2748 path: "build.rs".into(),
2749 optional: true
2750 },
2751 PathTok {
2752 path: "a/b/c".into(),
2753 optional: true
2754 },
2755 ]
2756 );
2757 assert!(
2758 parse_spec(b"g ?nope\n").is_some(),
2759 "an all-optional gate is a gate"
2760 );
2761 for bad in [
2762 "?", "??x", "?/x", "?./x", "?x/", "?:x", "x?", "?a//b", "?a/../b",
2763 ] {
2764 assert!(
2765 parse_spec(format!("g {bad}\n").as_bytes()).is_none(),
2766 "{bad} should be invalid"
2767 );
2768 }
2769 }
2770
2771 #[test]
2775 fn an_absent_optional_path_is_bound_by_its_absence() {
2776 let work = repo("optional-fp");
2777 std::fs::create_dir_all(work.join("src")).unwrap();
2778 std::fs::create_dir_all(work.join(".github")).unwrap();
2779 std::fs::write(work.join("src/main.rs"), "fn main() {}\n").unwrap();
2780 std::fs::write(work.join(".github/attest-inputs"), "g src ?nope\n").unwrap();
2781 git(&work, &["add", "-A"]);
2782 git(&work, &["commit", "-qm", "chore: spec"]);
2783 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
2784 let spec_oid = git(
2785 &work,
2786 &["rev-parse", &format!("{tree}:.github/attest-inputs")],
2787 );
2788 let main_oid = git(&work, &["rev-parse", &format!("{tree}:src/main.rs")]);
2789 let listing = format!(
2790 "100644 blob {spec_oid}\t.github/attest-inputs\x00100644 blob {main_oid}\tsrc/main.rs\x00"
2791 );
2792 let hand =
2793 crate::git::stdout_piped_in(&work, &["hash-object", "--stdin"], listing.as_bytes())
2794 .unwrap();
2795 let before = in_repo(&work, || {
2796 let spec = spec_at(&tree).expect("the spec parses");
2797 fingerprint(&tree, &spec[0].1)
2798 });
2799 assert_eq!(before.as_deref(), Some(hand.as_str()));
2800 std::fs::write(work.join("nope"), "now it exists\n").unwrap();
2801 git(&work, &["add", "nope"]);
2802 git(&work, &["commit", "-qm", "chore: nope"]);
2803 let tree2 = git(&work, &["rev-parse", "HEAD^{tree}"]);
2804 let after = in_repo(&work, || {
2805 let spec = spec_at(&tree2).expect("the spec parses");
2806 fingerprint(&tree2, &spec[0].1)
2807 });
2808 assert!(
2809 after.is_some() && after != before,
2810 "its appearance changes the fingerprint"
2811 );
2812 let _ = std::fs::remove_dir_all(&work);
2813 }
2814
2815 fn attested_clone(name: &str) -> (PathBuf, PathBuf, PathBuf, PathBuf) {
2820 let (d, work, remote, signers) = remote_and_work(name);
2821 git(&remote, &["symbolic-ref", "HEAD", "refs/heads/main"]);
2822 git(&work, &["push", "-q", "origin", "HEAD:main"]);
2823 in_repo(&work, || {
2824 attest_push(
2825 &test_settings(),
2826 "origin",
2827 &[push_ref_for(&work)],
2828 &["pre-push-pytest".into()],
2829 );
2830 });
2831 let clone = d.join("ci-checkout");
2832 git(
2833 &d,
2834 &[
2835 "clone",
2836 "-q",
2837 "--template=",
2838 remote.to_str().unwrap(),
2839 clone.to_str().unwrap(),
2840 ],
2841 );
2842 (d, remote, clone, signers)
2843 }
2844
2845 fn covers(clone: &Path, signers: &Path, budget: u64) -> Option<String> {
2846 covers_logged(clone, signers, budget).0
2847 }
2848
2849 fn covers_logged(clone: &Path, signers: &Path, budget: u64) -> (Option<String>, Vec<String>) {
2851 in_repo(clone, || {
2852 CAPTURE.with(|c| *c.borrow_mut() = Some(Vec::new()));
2853 let got = covered_within(signers, "t@t.test", None, budget);
2854 let lines = CAPTURE.with(|c| c.borrow_mut().take()).unwrap_or_default();
2855 (got, lines)
2856 })
2857 }
2858
2859 #[test]
2860 fn a_ref_revoked_on_origin_stops_covering_and_the_mirror_goes() {
2861 let (d, remote, clone, signers) = attested_clone("revoked");
2862 assert_eq!(
2863 covers(&clone, &signers, 15).as_deref(),
2864 Some("pre-push-pytest")
2865 );
2866 let oid = git(&clone, &["rev-parse", NOTES_FULL_REF]);
2867 git(&remote, &["update-ref", "-d", NOTES_FULL_REF]);
2868 let (got, lines) = covers_logged(&clone, &signers, 15);
2869 assert_eq!(got, None, "revoked on origin");
2870 assert_eq!(
2871 lines[0],
2872 format!("amont: origin has no {NOTES_FULL_REF}; deleted the local mirror (was {oid})")
2873 );
2874 assert!(
2875 in_repo(&clone, || crate::git::stdout(&[
2876 "rev-parse",
2877 "--verify",
2878 "--quiet",
2879 NOTES_FULL_REF
2880 ]))
2881 .is_none(),
2882 "the local mirror was deleted"
2883 );
2884 let undo = lines[1]
2886 .split("undo locally: git ")
2887 .nth(1)
2888 .and_then(|r| r.split(" (").next())
2889 .expect("an undo command");
2890 git(&clone, &undo.split(' ').collect::<Vec<_>>());
2891 assert_eq!(git(&clone, &["rev-parse", NOTES_FULL_REF]), oid);
2892 let _ = std::fs::remove_dir_all(&d);
2893 }
2894
2895 #[test]
2896 fn an_unreachable_origin_covers_nothing_and_keeps_the_mirror() {
2897 let (d, _remote, clone, signers) = attested_clone("unreachable");
2898 assert_eq!(
2899 covers(&clone, &signers, 15).as_deref(),
2900 Some("pre-push-pytest")
2901 );
2902 git(
2903 &clone,
2904 &[
2905 "remote",
2906 "set-url",
2907 "origin",
2908 "file:///nonexistent/amont-origin.git",
2909 ],
2910 );
2911 let (got, lines) = covers_logged(&clone, &signers, 15);
2912 assert_eq!(got, None, "a stale mirror is not judged");
2913 assert!(
2914 lines.iter().any(|l| l.starts_with(&format!(
2915 "amont: cannot fetch {NOTES_FULL_REF} from origin (exit "
2916 ))),
2917 "the reason is on stderr: {lines:?}"
2918 );
2919 assert!(
2920 !git(&clone, &["rev-parse", NOTES_FULL_REF]).is_empty(),
2921 "and it is kept"
2922 );
2923 let _ = std::fs::remove_dir_all(&d);
2924 }
2925
2926 #[test]
2929 fn without_an_origin_the_local_ref_is_judged() {
2930 let (d, _remote, clone, signers) = attested_clone("no-origin");
2931 assert_eq!(
2932 covers(&clone, &signers, 15).as_deref(),
2933 Some("pre-push-pytest")
2934 );
2935 git(&clone, &["remote", "remove", "origin"]);
2936 assert_eq!(
2937 covers(&clone, &signers, 15).as_deref(),
2938 Some("pre-push-pytest")
2939 );
2940 let _ = std::fs::remove_dir_all(&d);
2941 }
2942
2943 #[cfg(unix)]
2946 #[test]
2947 fn a_silent_origin_is_cut_off_and_leaves_nothing_behind() {
2948 use std::os::unix::fs::PermissionsExt;
2949 let (d, remote, clone, signers) = attested_clone("silent");
2950 let hang = d.join("hang-ssh");
2951 std::fs::write(&hang, "#!/bin/sh\nexec sleep 60\n").unwrap();
2952 std::fs::set_permissions(&hang, std::fs::Permissions::from_mode(0o755)).unwrap();
2953 git(
2955 &clone,
2956 &["config", "core.sshCommand", hang.to_str().unwrap()],
2957 );
2958 git(
2959 &clone,
2960 &["remote", "set-url", "origin", "ssh://example.invalid/x.git"],
2961 );
2962 let (got, took) = in_repo(&clone, || {
2965 let t0 = std::time::Instant::now();
2966 let got = covered_within(&signers, "t@t.test", None, 2);
2967 (got, t0.elapsed())
2968 });
2969 assert_eq!(got, None);
2970 assert!(took.as_secs() < 6, "took {took:?}");
2972 let lock = in_repo(&clone, || {
2973 crate::git::stdout(&["rev-parse", "--git-path", &format!("{NOTES_FULL_REF}.lock")])
2974 })
2975 .unwrap();
2976 assert!(
2977 !clone.join(&lock).exists() && !Path::new(&lock).exists(),
2978 "no lock left"
2979 );
2980 git(&clone, &["config", "--unset", "core.sshCommand"]);
2981 git(
2982 &clone,
2983 &["remote", "set-url", "origin", remote.to_str().unwrap()],
2984 );
2985 assert_eq!(
2986 covers(&clone, &signers, 15).as_deref(),
2987 Some("pre-push-pytest")
2988 );
2989 let _ = std::fs::remove_dir_all(&d);
2990 }
2991
2992 #[test]
2993 fn a_stale_lock_on_the_mirror_means_nothing_is_judged() {
2994 let (d, remote, clone, signers) = attested_clone("stale-lock");
2995 assert_eq!(
2996 covers(&clone, &signers, 15).as_deref(),
2997 Some("pre-push-pytest")
2998 );
2999 let lock = in_repo(&clone, || {
3000 crate::git::stdout(&["rev-parse", "--git-path", &format!("{NOTES_FULL_REF}.lock")])
3001 })
3002 .unwrap();
3003 let lock = if Path::new(&lock).is_absolute() {
3004 PathBuf::from(lock)
3005 } else {
3006 clone.join(lock)
3007 };
3008 std::fs::write(&lock, "").unwrap();
3009 assert_eq!(
3011 covers(&clone, &signers, 15).as_deref(),
3012 Some("pre-push-pytest"),
3013 "an unchanged origin: the copy is origin's"
3014 );
3015 let main = git(&remote, &["rev-parse", "main"]);
3018 git(
3020 &remote,
3021 &[
3022 "-c",
3023 "user.email=t@t.test",
3024 "-c",
3025 "user.name=t",
3026 "notes",
3027 "--ref",
3028 NOTES_REF,
3029 "add",
3030 "-f",
3031 "-m",
3032 "rewritten",
3033 &main,
3034 ],
3035 );
3036 let (got, lines) = covers_logged(&clone, &signers, 15);
3037 assert_eq!(got, None, "a pinned stale copy is never judged");
3038 assert!(
3039 lines.iter().any(|l| l.contains(&format!(
3040 "left a lock on {NOTES_FULL_REF}; if no git is running: rm "
3041 ))),
3042 "the lock is reported with its rm: {lines:?}"
3043 );
3044 std::fs::remove_file(&lock).unwrap();
3045 assert_eq!(
3046 covers(&clone, &signers, 15).as_deref(),
3047 Some("pre-push-pytest")
3048 );
3049 let _ = std::fs::remove_dir_all(&d);
3050 }
3051
3052 #[cfg(unix)]
3055 #[test]
3056 fn leftover_sync_refs_are_swept() {
3057 let (d, _remote, clone, signers) = attested_clone("sweep");
3058 let head = git(&clone, &["rev-parse", "HEAD"]);
3059 git(
3060 &clone,
3061 &["update-ref", "refs/amont-tmp/4194305/amont-attest", &head],
3063 );
3064 assert_eq!(
3065 covers(&clone, &signers, 15).as_deref(),
3066 Some("pre-push-pytest")
3067 );
3068 assert_eq!(git(&clone, &["for-each-ref", "refs/amont-tmp/"]), "");
3069 let _ = std::fs::remove_dir_all(&d);
3070 }
3071
3072 #[test]
3073 fn the_remote_environment_never_prompts() {
3074 let (args, env) = crate::git::remote_env(crate::git::Ssh::Batch);
3075 assert_eq!(
3076 args,
3077 [
3078 "-c",
3079 "http.lowSpeedLimit=1",
3080 "-c",
3081 "http.lowSpeedTime=10",
3082 "-c",
3083 "credential.interactive=never"
3084 ]
3085 );
3086 assert!(env.contains(&("GIT_TERMINAL_PROMPT", "0")));
3087 assert!(
3088 env.contains(&("GIT_ASKPASS", "")),
3089 "present and EMPTY disables every askpass"
3090 );
3091 assert!(env.contains(&("GCM_INTERACTIVE", "never")));
3092 assert!(env.contains(&(
3093 "GIT_SSH_COMMAND",
3094 "ssh -o BatchMode=yes -o ConnectTimeout=10"
3095 )));
3096 let (_, env) = crate::git::remote_env(crate::git::Ssh::User);
3097 assert!(
3098 !env.iter().any(|(k, _)| *k == "GIT_SSH_COMMAND"),
3099 "the user's own ssh command is left alone"
3100 );
3101 }
3102
3103 #[cfg(unix)]
3107 #[test]
3108 fn no_askpass_runs_against_an_origin_that_wants_credentials() {
3109 use std::io::{Read, Write};
3110 use std::os::unix::fs::PermissionsExt;
3111 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3112 let port = listener.local_addr().unwrap().port();
3113 std::thread::spawn(move || {
3114 for stream in listener.incoming().flatten() {
3115 let mut s = stream;
3116 let mut buf = [0u8; 4096];
3117 let _ = s.read(&mut buf);
3118 let _ = s.write_all(
3119 b"HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"x\"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
3120 );
3121 }
3122 });
3123 let work = repo("askpass");
3124 let marker = work.join("asked");
3125 let askpass = work.join("askpass.sh");
3126 std::fs::write(
3127 &askpass,
3128 format!("#!/bin/sh\ntouch '{}'\necho x\n", marker.display()),
3129 )
3130 .unwrap();
3131 std::fs::set_permissions(&askpass, std::fs::Permissions::from_mode(0o755)).unwrap();
3132 git(
3133 &work,
3134 &["config", "core.askPass", askpass.to_str().unwrap()],
3135 );
3136 git(&work, &["config", "credential.helper", ""]);
3137 let url = format!("http://127.0.0.1:{port}/x.git");
3138 in_repo(&work, || {
3139 let _ = crate::git::probe_remote(&["ls-remote", &url], 10);
3140 });
3141 assert!(
3142 !marker.exists(),
3143 "an askpass ran through the remote environment"
3144 );
3145 let _ = std::process::Command::new("git")
3149 .current_dir(&work)
3150 .args(["ls-remote", &url])
3151 .env("GIT_TERMINAL_PROMPT", "0")
3152 .env_remove("GIT_ASKPASS")
3153 .env_remove("SSH_ASKPASS")
3154 .stdin(std::process::Stdio::null())
3155 .output();
3156 assert!(
3157 marker.exists(),
3158 "negative control: without it the askpass does run"
3159 );
3160 let _ = std::fs::remove_dir_all(&work);
3161 }
3162}