1use std::path::PathBuf;
48use std::process::{Command, Stdio};
49
50use crate::pushrefs::PushRef;
51
52pub const FORMAT: &str = "amont-attest-v1";
56
57pub const NOTES_REF: &str = "amont-attest";
59
60pub const NOTES_FULL_REF: &str = "refs/notes/amont-attest";
63
64pub const NAMESPACE: &str = "amont-attest";
69
70pub const PUSH_GUARD: &str = "AMONT_ATTEST_PUSH";
73
74const TOGGLE: &str = "amont.attest";
78
79const KEY_CONFIG: &str = "amont.attestKey";
81const KEY_DEFAULT: &str = ".ssh/amont-attest";
82
83pub fn push_guard_active() -> bool {
85 std::env::var_os(PUSH_GUARD).is_some()
86}
87
88pub fn enabled() -> bool {
90 crate::config::boolean_or(TOGGLE, false)
91}
92
93fn key_path() -> Option<PathBuf> {
99 if let Some(k) = crate::git::stdout(&["config", "--get", KEY_CONFIG]) {
100 if !k.is_empty() {
101 return Some(PathBuf::from(k));
102 }
103 }
104 let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
105 Some(PathBuf::from(home).join(KEY_DEFAULT))
106}
107
108pub fn payload(tree: &str, gates: &[String]) -> String {
112 format!(
113 "{FORMAT}\ntree {tree}\ngates {}\namont {}\n",
114 gates.join(" "),
115 env!("CARGO_PKG_VERSION")
116 )
117}
118
119fn sign(payload: &str, key: &std::path::Path) -> Option<String> {
123 use std::io::Write;
124 let mut child = Command::new("ssh-keygen")
125 .args(["-Y", "sign", "-n", NAMESPACE, "-f"])
126 .arg(key)
127 .stdin(Stdio::piped())
128 .stdout(Stdio::piped())
129 .stderr(Stdio::null())
130 .spawn()
131 .ok()?;
132 child.stdin.take()?.write_all(payload.as_bytes()).ok()?;
133 let out = child.wait_with_output().ok()?;
134 if !out.status.success() {
135 return None;
136 }
137 let sig = String::from_utf8_lossy(&out.stdout).trim().to_string();
138 sig.starts_with("-----BEGIN SSH SIGNATURE-----")
139 .then_some(sig)
140}
141
142pub fn verify(
149 payload: &str,
150 sig: &str,
151 allowed_signers: &std::path::Path,
152 principal: &str,
153) -> bool {
154 use std::io::Write;
155 let sig_file = std::env::temp_dir().join(format!(
157 "amont-attest-verify-{}-{:p}.sig",
158 std::process::id(),
159 &sig
160 ));
161 if std::fs::write(&sig_file, format!("{sig}\n")).is_err() {
162 return false;
163 }
164 let ok = (|| {
165 let mut child = Command::new("ssh-keygen")
166 .args(["-Y", "verify", "-n", NAMESPACE, "-I", principal, "-f"])
167 .arg(allowed_signers)
168 .arg("-s")
169 .arg(&sig_file)
170 .stdin(Stdio::piped())
171 .stdout(Stdio::null())
172 .stderr(Stdio::null())
173 .spawn()
174 .ok()?;
175 child.stdin.take()?.write_all(payload.as_bytes()).ok()?;
176 child.wait().ok().map(|s| s.success())
177 })()
178 .unwrap_or(false);
179 let _ = std::fs::remove_file(&sig_file);
180 ok
181}
182
183pub fn attest_push(remote: &str, refs: &[PushRef], gates: &[String]) {
195 if gates.is_empty() || remote.is_empty() || !enabled() {
196 return;
197 }
198 let Some(key) = key_path() else { return };
199 if !key.exists() {
200 crate::config::complain(
201 TOGGLE,
202 &format!("signing key {} does not exist", key.display()),
203 "no attestation (CI will run the tests)",
204 );
205 return;
206 }
207 let mut noted = false;
208 for r in refs {
209 if is_zero(&r.local_oid) {
210 continue; }
212 let spec = format!("{}^{{tree}}", r.local_oid);
213 let Some(tree) = crate::git::stdout(&["rev-parse", &spec]) else {
214 continue;
215 };
216 let body = match sign(&payload(&tree, gates), &key) {
217 Some(sig) => format!("{}\n{sig}", payload(&tree, gates)),
218 None => continue,
219 };
220 if crate::git::succeeds(&[
221 "notes",
222 "--ref",
223 NOTES_REF,
224 "add",
225 "-f",
226 "-m",
227 &body,
228 &r.local_oid,
229 ]) {
230 noted = true;
231 }
232 }
233 if noted && push_notes(remote) {
234 crate::say!(
235 "{} attested {} for CI ({})",
236 crate::ui::valid_sign(),
237 crate::ui::highlight(&gates.join(" ")),
238 NOTES_REF,
239 );
240 }
241}
242
243fn push_notes(remote: &str) -> bool {
248 let refspec = format!("{NOTES_FULL_REF}:{NOTES_FULL_REF}");
249 Command::new("git")
250 .args(["push", remote, &refspec])
251 .env(PUSH_GUARD, "1")
252 .stdin(Stdio::null())
253 .stdout(Stdio::null())
254 .stderr(Stdio::null())
255 .status()
256 .map(|s| s.success())
257 .unwrap_or(false)
258}
259
260fn is_zero(oid: &str) -> bool {
263 !oid.is_empty() && oid.bytes().all(|b| b == b'0')
264}
265
266pub fn forget() {
270 let _ = crate::git::succeeds(&["update-ref", "-d", NOTES_FULL_REF]);
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276 use std::path::Path;
277
278 fn dir(name: &str) -> PathBuf {
282 let d = std::env::temp_dir().join(format!("attest-{name}-{}", std::process::id()));
283 let _ = std::fs::remove_dir_all(&d);
284 std::fs::create_dir_all(&d).unwrap();
285 d
286 }
287
288 fn git(dir: &Path, args: &[&str]) -> String {
289 let out = std::process::Command::new("git")
290 .arg("-C")
291 .arg(dir)
292 .args(args)
293 .output()
294 .expect("git");
295 String::from_utf8_lossy(&out.stdout).trim().to_string()
296 }
297
298 fn repo(name: &str) -> PathBuf {
299 let d = dir(name);
300 git(&d, &["init", "-q", "--template=", "."]);
301 git(&d, &["config", "user.email", "t@t.test"]);
302 git(&d, &["config", "user.name", "t"]);
303 d
304 }
305
306 fn keypair(d: &Path) -> (PathBuf, PathBuf) {
309 let key = d.join("attest_key");
310 let ok = std::process::Command::new("ssh-keygen")
311 .args(["-q", "-t", "ed25519", "-N", "", "-C", "test", "-f"])
312 .arg(&key)
313 .status()
314 .expect("ssh-keygen must exist for these tests")
315 .success();
316 assert!(ok, "keygen failed");
317 let pubkey = std::fs::read_to_string(key.with_extension("pub")).unwrap();
318 let signers = d.join("allowed_signers");
319 std::fs::write(
320 &signers,
321 format!("t@t.test namespaces=\"{NAMESPACE}\" {pubkey}"),
322 )
323 .unwrap();
324 (key, signers)
325 }
326
327 fn in_repo<T>(dir: &Path, f: impl FnOnce() -> T) -> T {
330 let _guard = crate::TEST_CWD.lock().unwrap_or_else(|p| p.into_inner());
331 let prev = std::env::current_dir().unwrap();
332 std::env::set_current_dir(dir).unwrap();
333 let r = f();
334 std::env::set_current_dir(prev).unwrap();
335 r
336 }
337
338 #[test]
339 fn the_payload_is_the_documented_contract() {
340 let p = payload(
341 "abc123",
342 &["pre-push-pytest".into(), "pre-push-cargo-test".into()],
343 );
344 let lines: Vec<&str> = p.lines().collect();
345 assert_eq!(lines[0], FORMAT);
346 assert_eq!(lines[1], "tree abc123");
347 assert_eq!(lines[2], "gates pre-push-pytest pre-push-cargo-test");
348 assert_eq!(lines[3], format!("amont {}", env!("CARGO_PKG_VERSION")));
349 assert!(
350 p.ends_with('\n'),
351 "CI reconstructs these bytes; the trailing newline is part of them"
352 );
353 }
354
355 #[test]
356 fn sign_verify_roundtrip_and_tamper_rejection() {
357 let d = dir("roundtrip");
358 let (key, signers) = keypair(&d);
359 let p = payload("deadbeef", &["pre-push-pytest".into()]);
360 let sig = sign(&p, &key).expect("signing with a real key succeeds");
361 assert!(verify(&p, &sig, &signers, "t@t.test"));
362 let tampered = payload("deadbeee", &["pre-push-pytest".into()]);
365 assert!(!verify(&tampered, &sig, &signers, "t@t.test"));
366 assert!(!verify(&p, &sig, &signers, "someone@else.test"));
368 let _ = std::fs::remove_dir_all(&d);
369 }
370
371 #[test]
372 fn a_missing_key_signs_nothing() {
373 assert!(sign("anything", Path::new("/nonexistent/key")).is_none());
374 }
375
376 #[test]
380 fn an_enabled_push_leaves_a_verifiable_note_on_the_remote() {
381 let d = dir("e2e");
382 let (key, signers) = keypair(&d);
383 let remote = d.join("remote.git");
384 std::fs::create_dir_all(&remote).unwrap();
385 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
386 let work = repo("e2e-work");
387 git(
388 &work,
389 &["remote", "add", "origin", remote.to_str().unwrap()],
390 );
391 git(&work, &["config", "amont.attest", "true"]);
392 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
393 std::fs::write(work.join("a.ts"), "x").unwrap();
394 git(&work, &["add", "a.ts"]);
395 git(&work, &["commit", "-qm", "chore: a"]);
396 let head = git(&work, &["rev-parse", "HEAD"]);
397 let tree = git(&work, &["rev-parse", "HEAD^{tree}"]);
398 let push_ref = PushRef {
399 local_ref: "refs/heads/main".into(),
400 local_oid: head.clone(),
401 remote_ref: "refs/heads/main".into(),
402 remote_oid: "0".repeat(40),
403 };
404 in_repo(&work, || {
405 attest_push("origin", &[push_ref], &["pre-push-run-tests-js".into()]);
406 });
407 let body = git(&remote, &["notes", "--ref", NOTES_REF, "show", &head]);
409 assert!(!body.is_empty(), "no note reached the remote");
410 let (p, sig) = body
411 .split_once("\n\n")
412 .expect("payload, blank line, signature");
413 let p = format!("{p}\n"); assert!(p.starts_with(FORMAT));
415 assert!(
416 p.contains(&format!("tree {tree}")),
417 "attests the pushed tree"
418 );
419 assert!(
420 verify(&p, sig, &signers, "t@t.test"),
421 "the remote copy verifies"
422 );
423 let _ = std::fs::remove_dir_all(&d);
424 let _ = std::fs::remove_dir_all(&work);
425 }
426
427 #[test]
430 fn no_opt_in_means_no_note() {
431 let d = dir("optout");
432 let (key, _) = keypair(&d);
433 let remote = d.join("remote.git");
434 std::fs::create_dir_all(&remote).unwrap();
435 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
436 let work = repo("optout-work");
437 git(
438 &work,
439 &["remote", "add", "origin", remote.to_str().unwrap()],
440 );
441 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
442 std::fs::write(work.join("a.ts"), "x").unwrap();
443 git(&work, &["add", "a.ts"]);
444 git(&work, &["commit", "-qm", "chore: a"]);
445 let head = git(&work, &["rev-parse", "HEAD"]);
446 let push_ref = PushRef {
447 local_ref: "refs/heads/main".into(),
448 local_oid: head.clone(),
449 remote_ref: "refs/heads/main".into(),
450 remote_oid: "0".repeat(40),
451 };
452 in_repo(&work, || {
453 attest_push("origin", &[push_ref], &["pre-push-run-tests-js".into()]);
454 });
455 assert!(
456 git(&remote, &["notes", "--ref", NOTES_REF, "list"]).is_empty(),
457 "an un-opted-in repo attested something"
458 );
459 let _ = std::fs::remove_dir_all(&d);
460 let _ = std::fs::remove_dir_all(&work);
461 }
462
463 #[test]
466 fn deletions_and_empty_gates_attest_nothing() {
467 let d = dir("nothing");
468 let (key, _) = keypair(&d);
469 let remote = d.join("remote.git");
470 std::fs::create_dir_all(&remote).unwrap();
471 git(&remote, &["init", "-q", "--bare", "--template=", "."]);
472 let work = repo("nothing-work");
473 git(
474 &work,
475 &["remote", "add", "origin", remote.to_str().unwrap()],
476 );
477 git(&work, &["config", "amont.attest", "true"]);
478 git(&work, &["config", "amont.attestKey", key.to_str().unwrap()]);
479 std::fs::write(work.join("a.ts"), "x").unwrap();
480 git(&work, &["add", "a.ts"]);
481 git(&work, &["commit", "-qm", "chore: a"]);
482 let head = git(&work, &["rev-parse", "HEAD"]);
483 let deletion = PushRef {
484 local_ref: "(delete)".into(),
485 local_oid: "0".repeat(40),
486 remote_ref: "refs/heads/gone".into(),
487 remote_oid: head.clone(),
488 };
489 let real = PushRef {
490 local_ref: "refs/heads/main".into(),
491 local_oid: head,
492 remote_ref: "refs/heads/main".into(),
493 remote_oid: "0".repeat(40),
494 };
495 in_repo(&work, || {
496 attest_push("origin", &[deletion], &["pre-push-pytest".into()]);
497 attest_push("origin", &[real], &[]);
498 });
499 assert!(git(&remote, &["notes", "--ref", NOTES_REF, "list"]).is_empty());
500 let _ = std::fs::remove_dir_all(&d);
501 let _ = std::fs::remove_dir_all(&work);
502 }
503
504 #[test]
505 fn zero_oids_of_any_width_are_zero() {
506 assert!(is_zero(&"0".repeat(40)));
507 assert!(is_zero(&"0".repeat(64)));
508 assert!(!is_zero("0a0000"));
509 assert!(!is_zero(""));
510 }
511
512 #[test]
513 fn forget_removes_the_local_ref() {
514 let work = repo("forget");
515 std::fs::write(work.join("a.ts"), "x").unwrap();
516 git(&work, &["add", "a.ts"]);
517 git(&work, &["commit", "-qm", "chore: a"]);
518 git(
519 &work,
520 &["notes", "--ref", NOTES_REF, "add", "-m", "x", "HEAD"],
521 );
522 in_repo(&work, forget);
523 assert!(git(&work, &["notes", "--ref", NOTES_REF, "list"]).is_empty());
524 let _ = std::fs::remove_dir_all(&work);
525 }
526}