use std::io;
use std::path::{Component, Path, PathBuf};
use std::process::Command;
pub const SHIM_MARKER: &str = "git-templates hook shim";
const MARKER_WINDOW: usize = 10;
pub fn is_our_shim(text: &str) -> bool {
text.lines().take(MARKER_WINDOW).any(|line| {
line.trim_start()
.strip_prefix('#')
.is_some_and(|body| body.trim_start().starts_with(SHIM_MARKER))
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ForeignWhy {
HandWritten,
NotUtf8,
Unreadable { why: String },
#[cfg(unix)]
MultiplyLinked { links: u64 },
}
impl ForeignWhy {
pub fn describe(&self) -> String {
match self {
ForeignWhy::HandWritten => "not one of our shims".to_string(),
ForeignWhy::NotUtf8 => "not valid UTF-8 — a compiled hook, probably".to_string(),
ForeignWhy::Unreadable { why } => format!("unreadable ({why})"),
#[cfg(unix)]
ForeignWhy::MultiplyLinked { links } => {
format!("a hard link with {links} names — rewriting it rewrites the others")
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookFile {
Absent,
Ours,
Foreign(ForeignWhy),
Symlink {
target: Option<PathBuf>,
},
NotARegularFile,
Unknown {
why: String,
},
}
impl HookFile {
pub fn describe(&self) -> String {
match self {
HookFile::Absent => "absent".to_string(),
HookFile::Ours => "one of our shims".to_string(),
HookFile::Foreign(why) => why.describe(),
HookFile::Symlink { target: Some(t) } => format!("a symlink to {}", t.display()),
HookFile::Symlink { target: None } => "a symlink we could not read".to_string(),
HookFile::NotARegularFile => "not a regular file".to_string(),
HookFile::Unknown { why } => format!("unstattable ({why})"),
}
}
}
pub fn classify(path: &Path) -> HookFile {
let meta = match std::fs::symlink_metadata(path) {
Ok(m) => m,
Err(e) if e.kind() == io::ErrorKind::NotFound => return HookFile::Absent,
Err(e) => return HookFile::Unknown { why: e.to_string() },
};
if meta.file_type().is_symlink() {
return HookFile::Symlink {
target: std::fs::read_link(path).ok(),
};
}
if !meta.is_file() {
return HookFile::NotARegularFile;
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if meta.nlink() > 1 {
return HookFile::Foreign(ForeignWhy::MultiplyLinked {
links: meta.nlink(),
});
}
}
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) => {
return HookFile::Foreign(ForeignWhy::Unreadable { why: e.to_string() });
}
};
let Ok(text) = std::str::from_utf8(&bytes) else {
return HookFile::Foreign(ForeignWhy::NotUtf8);
};
if is_our_shim(text) {
HookFile::Ours
} else {
HookFile::Foreign(ForeignWhy::HandWritten)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Tracked {
Yes,
No,
Unknown {
why: String,
},
}
pub fn tracked(path: &Path) -> Tracked {
tracked_with(Path::new("git"), path)
}
fn tracked_with(git: &Path, path: &Path) -> Tracked {
let Some(name) = path.file_name() else {
return Tracked::Unknown {
why: format!("{} has no file name", path.display()),
};
};
let dir = match path.parent() {
Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
_ => PathBuf::from("."),
};
let out = match Command::new(git)
.arg("-C")
.arg(&dir)
.args(["ls-files", "--error-unmatch", "--"])
.arg(name)
.output()
{
Ok(o) => o,
Err(e) => {
return Tracked::Unknown {
why: format!("could not run git: {e}"),
};
}
};
let stderr = String::from_utf8_lossy(&out.stderr);
match out.status.code() {
Some(0) => Tracked::Yes,
Some(1) => Tracked::No,
Some(128) if stderr.to_lowercase().contains("not a git repository") => Tracked::No,
Some(code) => Tracked::Unknown {
why: format!("git ls-files exited {code}: {}", first_line(&stderr)),
},
None => Tracked::Unknown {
why: "git ls-files was killed before it answered".to_string(),
},
}
}
fn first_line(s: &str) -> String {
s.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("no output")
.trim()
.to_string()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refuse {
Foreign {
path: PathBuf,
why: ForeignWhy,
},
Symlink {
path: PathBuf,
target: Option<PathBuf>,
},
NotARegularFile {
path: PathBuf,
},
Tracked {
path: PathBuf,
},
TrackedUnknown {
path: PathBuf,
why: String,
},
Unstattable {
path: PathBuf,
why: String,
},
}
impl Refuse {
pub fn path(&self) -> &Path {
match self {
Refuse::Foreign { path, .. }
| Refuse::Symlink { path, .. }
| Refuse::NotARegularFile { path }
| Refuse::Tracked { path }
| Refuse::TrackedUnknown { path, .. }
| Refuse::Unstattable { path, .. } => path,
}
}
pub fn explain(&self) -> String {
match self {
Refuse::Foreign { path, why } => {
format!(
"{} is {} — leaving it alone",
path.display(),
why.describe()
)
}
Refuse::Symlink {
path,
target: Some(t),
} => format!(
"{} is a symlink to {} — writing through it would rewrite that file",
path.display(),
t.display()
),
Refuse::Symlink { path, target: None } => format!(
"{} is a symlink we could not read — refusing to write through it",
path.display()
),
Refuse::NotARegularFile { path } => format!(
"{} is not a regular file (a directory, a fifo, a device) — refusing",
path.display()
),
Refuse::Tracked { path } => format!(
"{} is TRACKED by git — that is somebody's source, not our hook",
path.display()
),
Refuse::TrackedUnknown { path, why } => format!(
"cannot tell whether {} is tracked ({why})\n \
If this is a repository you own: \
git config --global --add safe.directory {}",
path.display(),
path.parent().unwrap_or(path).display()
),
Refuse::Unstattable { path, why } => {
format!("cannot look at {} ({why}) — refusing", path.display())
}
}
}
}
pub fn guard_write(path: &Path, force: bool) -> Result<HookFile, Refuse> {
guard_write_with(Path::new("git"), path, force)
}
fn guard_write_with(git: &Path, path: &Path, force: bool) -> Result<HookFile, Refuse> {
match tracked_with(git, path) {
Tracked::No => {}
Tracked::Yes => {
return Err(Refuse::Tracked {
path: path.to_path_buf(),
});
}
Tracked::Unknown { why } => {
return Err(Refuse::TrackedUnknown {
path: path.to_path_buf(),
why,
});
}
}
let what = classify(path);
match &what {
HookFile::Absent | HookFile::Ours => Ok(what),
HookFile::Foreign(why) => {
if force {
Ok(what)
} else {
Err(Refuse::Foreign {
path: path.to_path_buf(),
why: why.clone(),
})
}
}
HookFile::Symlink { target } => {
if force {
Ok(what)
} else {
Err(Refuse::Symlink {
path: path.to_path_buf(),
target: target.clone(),
})
}
}
HookFile::NotARegularFile => Err(Refuse::NotARegularFile {
path: path.to_path_buf(),
}),
HookFile::Unknown { why } => Err(Refuse::Unstattable {
path: path.to_path_buf(),
why: why.clone(),
}),
}
}
pub fn guard_remove(path: &Path, expect_ours: bool) -> Result<(), Refuse> {
let what = classify(path);
if what == HookFile::Absent {
return Ok(());
}
match tracked(path) {
Tracked::No => {}
Tracked::Yes => {
return Err(Refuse::Tracked {
path: path.to_path_buf(),
});
}
Tracked::Unknown { why } => {
return Err(Refuse::TrackedUnknown {
path: path.to_path_buf(),
why,
});
}
}
if !expect_ours {
return Ok(());
}
match what {
HookFile::Ours | HookFile::Absent => Ok(()),
HookFile::Foreign(why) => Err(Refuse::Foreign {
path: path.to_path_buf(),
why,
}),
HookFile::Symlink { target } => Err(Refuse::Symlink {
path: path.to_path_buf(),
target,
}),
HookFile::NotARegularFile => Err(Refuse::NotARegularFile {
path: path.to_path_buf(),
}),
HookFile::Unknown { why } => Err(Refuse::Unstattable {
path: path.to_path_buf(),
why,
}),
}
}
#[derive(Debug)]
pub struct Staged {
dest: PathBuf,
tmp: PathBuf,
committed: bool,
}
impl Staged {
pub fn dest(&self) -> &Path {
&self.dest
}
pub fn tmp(&self) -> &Path {
&self.tmp
}
}
impl Drop for Staged {
fn drop(&mut self) {
if !self.committed {
let _ = std::fs::remove_file(&self.tmp);
}
}
}
pub fn stage(dest: &Path, body: &str, exec: bool) -> io::Result<Staged> {
let dir = match dest.parent() {
Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
_ => PathBuf::from("."),
};
let name = dest
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "hook".to_string());
let tmp = dir.join(format!(".amont-tmp-{}-{name}", std::process::id()));
let _ = std::fs::remove_file(&tmp);
std::fs::write(&tmp, body)?;
set_mode(&tmp, exec)?;
Ok(Staged {
dest: dest.to_path_buf(),
tmp,
committed: false,
})
}
#[derive(Debug)]
pub struct SwapFailure {
pub landed: Vec<PathBuf>,
pub not_written: Vec<PathBuf>,
pub at: PathBuf,
pub error: io::Error,
}
impl std::fmt::Display for SwapFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(
f,
"cannot put {} in place: {}",
self.at.display(),
self.error
)?;
writeln!(f, " written: {}", show(&self.landed))?;
write!(f, " NOT written: {}", show(&self.not_written))
}
}
fn show(paths: &[PathBuf]) -> String {
if paths.is_empty() {
return "(none)".to_string();
}
paths
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ")
}
pub fn commit_all(mut staged: Vec<Staged>) -> Result<Vec<PathBuf>, SwapFailure> {
let mut landed: Vec<PathBuf> = Vec::new();
for i in 0..staged.len() {
match std::fs::rename(&staged[i].tmp, &staged[i].dest) {
Ok(()) => {
staged[i].committed = true;
landed.push(staged[i].dest.clone());
}
Err(error) => {
return Err(SwapFailure {
landed,
not_written: staged[i..].iter().map(|s| s.dest.clone()).collect(),
at: staged[i].dest.clone(),
error,
});
}
}
}
Ok(landed)
}
pub fn remove_regular(path: &Path) -> io::Result<()> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
#[cfg(unix)]
fn set_mode(p: &Path, exec: bool) -> io::Result<()> {
use std::os::unix::fs::PermissionsExt;
let mode = if exec { 0o755 } else { 0o644 };
std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode))
}
#[cfg(not(unix))]
fn set_mode(_p: &Path, _exec: bool) -> io::Result<()> {
Ok(()) }
pub fn resolve_lexical(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for c in path.components() {
match c {
Component::CurDir => {}
Component::ParentDir => match out.components().next_back() {
Some(Component::Normal(_)) => {
out.pop();
}
Some(Component::RootDir) | Some(Component::Prefix(_)) => {}
_ => out.push(Component::ParentDir),
},
other => out.push(other),
}
}
out
}
pub fn is_within(child: &Path, parent: &Path) -> bool {
resolve_lexical(child).starts_with(resolve_lexical(parent))
}
#[cfg(test)]
mod tests {
use super::*;
fn tmpdir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("gh-hookfile-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).expect("mkdir");
d
}
#[cfg(unix)]
fn running_as_root() -> bool {
extern "C" {
fn geteuid() -> u32;
}
unsafe { geteuid() == 0 }
}
#[test]
fn a_non_utf8_hook_is_foreign_not_ours() {
let d = tmpdir("notutf8");
let p = d.join("pre-commit");
std::fs::write(&p, [0x7f, b'E', b'L', b'F', 0x02, 0x01, 0xff, 0xfe]).expect("write");
assert_eq!(classify(&p), HookFile::Foreign(ForeignWhy::NotUtf8));
let _ = std::fs::remove_dir_all(&d);
}
#[cfg(unix)]
#[test]
fn an_unreadable_hook_is_foreign_not_ours() {
use std::os::unix::fs::PermissionsExt;
if running_as_root() {
return;
}
let d = tmpdir("unreadable");
let p = d.join("pre-commit");
std::fs::write(&p, "#!/bin/sh\n# git-templates hook shim.\n").expect("write");
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o000)).expect("chmod");
let got = classify(&p);
let _ = std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644));
assert!(
matches!(got, HookFile::Foreign(ForeignWhy::Unreadable { .. })),
"an unreadable hook classified as {got:?}"
);
let _ = std::fs::remove_dir_all(&d);
}
#[cfg(unix)]
#[test]
fn a_symlink_to_our_own_shim_is_still_a_symlink() {
let d = tmpdir("symlink-ours");
let real = d.join("real-shim");
std::fs::write(&real, "#!/bin/sh\n# git-templates hook shim.\nexec x\n").expect("write");
let link = d.join("pre-commit");
std::os::unix::fs::symlink(&real, &link).expect("symlink");
match classify(&link) {
HookFile::Symlink { target } => {
assert_eq!(target.as_deref(), Some(real.as_path()));
}
other => panic!("a symlink classified as {other:?}"),
}
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_hook_that_merely_mentions_the_marker_is_not_ours() {
let prose = "#!/bin/sh\n\
# My own commit-msg. I removed the git-templates hook shim on purpose:\n\
# it disagreed with our house rules. Do not put it back.\n\
exec my-linter \"$@\"\n";
assert!(!is_our_shim(prose), "prose about the shim claimed the file");
let deep = format!("#!/bin/sh\n{}\n# {SHIM_MARKER}\n", "echo x\n".repeat(20));
assert!(!is_our_shim(&deep), "a line 20 deep claimed the file");
let quoted = "#!/bin/sh\ngrep -q \"git-templates hook shim\" \"$0\" && exit 0\n";
assert!(!is_our_shim(quoted), "a quoted mention claimed the file");
}
#[test]
fn every_shim_form_this_project_has_ever_baked_is_still_ours() {
let historical = [
crate::install::SHIM,
"#!/bin/sh\n# git-templates hook shim.\nexec \"$BIN\" --hooks-dir \"$(dirname \"$0\")\" pre-commit \"$@\"\n",
"#!/bin/sh\n# git-templates hook shim → the amont binary.\nexec x --hooks-dir y pre-commit-ruff\n",
"#!/bin/sh\n# git-templates hook shim → the amont binary.\n# edited by me\nexec /usr/local/bin/amont \"$@\"\n",
];
for shim in historical {
assert!(
is_our_shim(shim),
"a shim this project baked is no longer recognised:\n{}",
shim.lines().take(3).collect::<Vec<_>>().join("\n")
);
}
}
#[cfg(unix)]
#[test]
fn a_staged_write_replaces_the_link_and_never_its_target() {
let d = tmpdir("write-through");
let target = d.join("tracked-source");
std::fs::write(&target, "PRECIOUS\n").expect("write");
let link = d.join("pre-commit");
std::os::unix::fs::symlink(&target, &link).expect("symlink");
let s = stage(&link, "#!/bin/sh\n# git-templates hook shim.\n", true).expect("stage");
commit_all(vec![s]).expect("commit");
assert_eq!(
std::fs::read_to_string(&target).expect("read"),
"PRECIOUS\n",
"THE WRITE WENT THROUGH THE LINK"
);
assert!(
!std::fs::symlink_metadata(&link)
.expect("stat")
.file_type()
.is_symlink(),
"the link survived the write"
);
assert!(matches!(classify(&link), HookFile::Ours));
let _ = std::fs::remove_dir_all(&d);
}
#[cfg(unix)]
#[test]
fn a_git_that_cannot_answer_is_unknown_not_untracked() {
use std::os::unix::fs::PermissionsExt;
let d = tmpdir("git-shim");
let fake = d.join("fake-git");
std::fs::write(
&fake,
"#!/bin/sh\necho \"fatal: detected dubious ownership in repository\" >&2\nexit 128\n",
)
.expect("write");
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).expect("chmod");
let hook = d.join("pre-commit");
std::fs::write(&hook, "#!/bin/sh\n").expect("write");
let got = tracked_with(&fake, &hook);
assert!(
matches!(got, Tracked::Unknown { .. }),
"a fatal git reported as {got:?}"
);
let err = guard_write_with(&fake, &hook, true)
.expect_err("--force must not override an unanswerable tracked check");
assert!(
matches!(err, Refuse::TrackedUnknown { .. }),
"refused as {err:?}"
);
assert!(
err.explain().contains("safe.directory"),
"the refusal must name the fix:\n{}",
err.explain()
);
let _ = std::fs::remove_dir_all(&d);
}
#[cfg(unix)]
#[test]
fn a_failed_stage_leaves_every_destination_untouched() {
use std::os::unix::fs::PermissionsExt;
if running_as_root() {
return;
}
let d = tmpdir("stage-fail");
let existing = d.join("pre-commit");
std::fs::write(&existing, "OLD\n").expect("write");
std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o555)).expect("chmod");
let err = stage(&existing, "NEW\n", true).expect_err("a read-only dir must fail");
let _ = std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o755));
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
assert_eq!(
std::fs::read_to_string(&existing).expect("read"),
"OLD\n",
"a failed stage changed the destination"
);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn dropping_an_uncommitted_stage_removes_its_temporary() {
let d = tmpdir("drop");
let dest = d.join("pre-commit");
let tmp = {
let s = stage(&dest, "body\n", true).expect("stage");
let t = s.tmp().to_path_buf();
assert!(t.is_file(), "stage wrote nothing");
t
};
assert!(!tmp.exists(), "an uncommitted temporary survived");
assert!(!dest.exists(), "staging touched the destination");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn an_absent_hook_is_absent_and_writable() {
let d = tmpdir("absent");
let p = d.join("pre-commit");
assert_eq!(classify(&p), HookFile::Absent);
assert_eq!(guard_write(&p, false), Ok(HookFile::Absent));
assert_eq!(guard_remove(&p, true), Ok(()));
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_directory_at_a_hook_path_is_refused_even_with_force() {
let d = tmpdir("notfile");
let p = d.join("pre-commit");
std::fs::create_dir_all(&p).expect("mkdir");
assert_eq!(classify(&p), HookFile::NotARegularFile);
for force in [false, true] {
assert!(matches!(
guard_write(&p, force),
Err(Refuse::NotARegularFile { .. })
));
}
let _ = std::fs::remove_dir_all(&d);
}
#[cfg(unix)]
#[test]
fn force_reaches_a_foreign_file_and_a_symlink_and_stops_there() {
let d = tmpdir("force-reach");
let foreign = d.join("commit-msg");
std::fs::write(&foreign, "#!/bin/sh\necho mine\n").expect("write");
assert!(matches!(
guard_write(&foreign, false),
Err(Refuse::Foreign { .. })
));
assert!(guard_write(&foreign, true).is_ok());
let link = d.join("pre-commit");
std::os::unix::fs::symlink(&foreign, &link).expect("symlink");
assert!(matches!(
guard_write(&link, false),
Err(Refuse::Symlink { .. })
));
assert!(guard_write(&link, true).is_ok());
let _ = std::fs::remove_dir_all(&d);
}
#[cfg(unix)]
#[test]
fn uninstall_refuses_a_symlink_even_when_it_points_at_our_shim() {
let d = tmpdir("rm-symlink");
let real = d.join("real");
std::fs::write(&real, "#!/bin/sh\n# git-templates hook shim.\n").expect("write");
let link = d.join("pre-commit");
std::os::unix::fs::symlink(&real, &link).expect("symlink");
assert!(matches!(
guard_remove(&link, true),
Err(Refuse::Symlink { .. })
));
assert!(real.is_file(), "the target was removed");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_tracked_hook_is_refused_and_force_does_not_reach_it() {
let d = tmpdir("tracked");
let run = |args: &[&str]| {
Command::new("git")
.arg("-C")
.arg(&d)
.args(args)
.output()
.expect("git");
};
run(&["init", "-q", "--template=", "."]);
run(&["config", "user.email", "t@t.test"]);
run(&["config", "user.name", "t"]);
let p = d.join("pre-commit");
std::fs::write(&p, "tracked source\n").expect("write");
run(&["add", "-A"]);
run(&["commit", "-qm", "seed"]);
assert_eq!(tracked(&p), Tracked::Yes);
for force in [false, true] {
assert_eq!(
guard_write(&p, force),
Err(Refuse::Tracked { path: p.clone() }),
"force={force} reached a tracked file"
);
}
assert_eq!(
guard_remove(&p, false),
Err(Refuse::Tracked { path: p.clone() })
);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn no_repository_at_all_is_untracked_rather_than_unknown() {
let d = tmpdir("norepo");
let p = d.join("pre-commit");
std::fs::write(&p, "x\n").expect("write");
assert_eq!(tracked(&p), Tracked::No);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn lexical_resolution_never_touches_the_filesystem() {
assert_eq!(
resolve_lexical(Path::new("/a/b/../c/./d")),
PathBuf::from("/a/c/d")
);
assert_eq!(resolve_lexical(Path::new("a/../..")), PathBuf::from(".."));
assert_eq!(resolve_lexical(Path::new("/..")), PathBuf::from("/"));
assert!(is_within(Path::new("/a/b/c"), Path::new("/a/b")));
assert!(is_within(Path::new("/a/b"), Path::new("/a/b")));
assert!(!is_within(Path::new("/a/bc"), Path::new("/a/b")));
assert!(!is_within(Path::new("/a/b/../../x"), Path::new("/a/b")));
}
}