use std::path::{Path, PathBuf};
use std::process::Command;
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_amont-fleet")
}
fn template() -> String {
std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../templates/hooks/pre-commit"
))
.expect("template")
}
fn shim_for(binary: &str) -> String {
template().replace("__AMONT_BIN__", binary)
}
const DISPATCHERS: [&str; 4] = ["commit-msg", "pre-commit", "pre-push", "prepare-commit-msg"];
struct Tree(PathBuf);
impl Tree {
fn new(name: &str) -> Self {
let d =
std::env::temp_dir().join(format!("fleet-writesafety-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).expect("mkdir");
Tree(d)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for Tree {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn git(dir: &Path, args: &[&str]) {
let out = Command::new("git")
.args(["-c", "init.templateDir="])
.args(args)
.current_dir(dir)
.output()
.expect("git");
assert!(
out.status.success(),
"git {args:?} in {}: {}",
dir.display(),
String::from_utf8_lossy(&out.stderr)
);
}
#[cfg(unix)]
fn repo_with_symlinked_dispatchers(t: &Tree, rel: &str, binary: &str, link: &[&str]) -> PathBuf {
let repo = t.path().join(rel);
std::fs::create_dir_all(repo.join("shared")).expect("mkdir");
git(&repo, &["init", "-q", "--template=", "."]);
git(&repo, &["config", "user.email", "t@t"]);
git(&repo, &["config", "user.name", "t"]);
for n in DISPATCHERS {
std::fs::write(repo.join("shared").join(n), shim_for(binary)).expect("write");
}
git(&repo, &["add", "shared"]);
git(&repo, &["commit", "-q", "--no-verify", "-m", "chore: seed"]);
let hooks = repo.join(".git/hooks");
std::fs::create_dir_all(&hooks).expect("mkdir");
for n in DISPATCHERS {
if link.contains(&n) {
std::os::unix::fs::symlink(repo.join("shared").join(n), hooks.join(n))
.expect("symlink");
} else {
std::fs::write(hooks.join(n), shim_for(binary)).expect("write");
}
}
repo
}
fn fake_binary(t: &Tree) -> PathBuf {
let b = t.path().join("fake-amont");
std::fs::write(&b, "#!/bin/sh\nexit 0\n").expect("write");
b
}
#[cfg(unix)]
#[test]
fn apply_never_writes_through_a_symlinked_dispatcher() {
let t = Tree::new("symlink");
let binary = fake_binary(&t);
let bin_str = binary.to_str().unwrap();
let all = repo_with_symlinked_dispatchers(&t, "all", "/opt/other-amont", &DISPATCHERS);
let before: Vec<String> = DISPATCHERS
.iter()
.map(|n| std::fs::read_to_string(all.join("shared").join(n)).expect("read"))
.collect();
let out = Command::new(bin())
.args(["fix", "--apply", "--root"])
.arg(t.path())
.arg("--binary")
.arg(&binary)
.output()
.expect("apply");
for (i, n) in DISPATCHERS.iter().enumerate() {
assert_eq!(
std::fs::read_to_string(all.join("shared").join(n))
.ok()
.as_deref(),
Some(before[i].as_str()),
"apply wrote THROUGH the link and rewrote tracked {n}:\n{}",
String::from_utf8_lossy(&out.stdout)
);
}
let one = repo_with_symlinked_dispatchers(&t, "one", "/opt/other-amont", &["commit-msg"]);
let target = one.join("shared/commit-msg");
let before = std::fs::read_to_string(&target).expect("read");
let out = Command::new(bin())
.args(["fix", "--apply", "--json", "--root"])
.arg(t.path())
.arg("--binary")
.arg(&binary)
.output()
.expect("apply");
let reports: serde_json::Value = serde_json::from_slice(&out.stdout).expect("json");
let report = reports
.as_array()
.expect("array")
.iter()
.find(|r| r["repo"] == "one")
.expect("planned");
assert_eq!(
std::fs::read_to_string(&target).ok().as_deref(),
Some(before.as_str()),
"a tracked file behind a link was rewritten: {report}"
);
assert_eq!(report["outcome"]["outcome"], "failed", "{report}");
let error = report["outcome"]["error"].as_str().unwrap_or_default();
assert!(
error.contains("symlink") && error.contains("shared/commit-msg"),
"the refusal must name the link AND what it points at: {error:?}"
);
assert!(
std::fs::symlink_metadata(one.join(".git/hooks/commit-msg"))
.expect("stat")
.file_type()
.is_symlink(),
"the link was replaced by a refusal that claimed to touch nothing"
);
let _ = bin_str;
}
#[cfg(unix)]
#[test]
fn apply_refuses_when_git_cannot_answer_whether_a_path_is_tracked() {
use std::os::unix::fs::PermissionsExt;
let real_git = Command::new("sh")
.args(["-c", "command -v git"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.expect("a real git to delegate to");
let t = Tree::new("dubious");
let binary = fake_binary(&t);
let hooks = t.path().join("r/.git/hooks");
std::fs::create_dir_all(&hooks).expect("mkdir");
for n in DISPATCHERS {
std::fs::write(hooks.join(n), shim_for(binary.to_str().unwrap())).expect("write");
}
std::fs::remove_file(hooks.join("pre-push")).expect("rm");
let shimdir = t.path().join("gitshim");
std::fs::create_dir_all(&shimdir).expect("mkdir");
std::fs::write(
shimdir.join("git"),
format!(
"#!/bin/sh\n\
for a in \"$@\"; do\n\
\x20 if [ \"$a\" = \"--error-unmatch\" ]; then\n\
\x20 echo 'fatal: detected dubious ownership in repository' >&2\n\
\x20 exit 128\n\
\x20 fi\n\
done\n\
exec {real_git} \"$@\"\n"
),
)
.expect("write shim");
std::fs::set_permissions(shimdir.join("git"), std::fs::Permissions::from_mode(0o755))
.expect("chmod");
let path = format!(
"{}:{}",
shimdir.display(),
std::env::var("PATH").unwrap_or_default()
);
let out = Command::new(bin())
.args(["fix", "--apply", "--json", "--root"])
.arg(t.path())
.arg("--binary")
.arg(&binary)
.env("PATH", &path)
.output()
.expect("apply");
assert!(
!hooks.join("pre-push").exists(),
"a write went ahead while git could not say whether the path is tracked:\n{}",
String::from_utf8_lossy(&out.stdout)
);
let reports: serde_json::Value = serde_json::from_slice(&out.stdout).expect("json");
let report = &reports.as_array().expect("array")[0];
assert_eq!(report["outcome"]["outcome"], "refused", "{report}");
let human = Command::new(bin())
.args(["fix", "--apply", "--root"])
.arg(t.path())
.arg("--binary")
.arg(&binary)
.env("PATH", &path)
.output()
.expect("apply");
let text = String::from_utf8_lossy(&human.stdout);
assert!(
text.contains("cannot tell whether") && text.contains("safe.directory"),
"a refusal must name the reason and the fix:\n{text}"
);
}