amont_runtime/hookfile.rs
1//! The single owner of every "is this file ours, and may we touch it?" answer.
2//!
3//! Three separate places used to answer that question, each with its own
4//! one-liner, and every one of them failed OPEN — an error, an odd file type or
5//! a git that would not answer all collapsed into `false`, and `false` meant
6//! "not foreign", which meant "go ahead and overwrite it". The three:
7//!
8//! ```text
9//! install::foreign_hooks read_to_string(..).map(|t| !is_our_shim(&t)).unwrap_or(false)
10//! fleet::scan::is_ours read_to_string(..).map(|s| is_our_shim(&s)).unwrap_or(false)
11//! fleet::fix::plan read_to_string(..).map(|t| !is_our_shim(&t)).unwrap_or(false)
12//! ```
13//!
14//! `read_to_string` fails on any file that is not valid UTF-8. A compiled hook
15//! — somebody's Go binary at `.git/hooks/pre-commit`, which is a perfectly
16//! ordinary thing to have — reads back `Err(InvalidData)`, and `unwrap_or(false)`
17//! turned that into "not foreign". `amont install` then wrote a shim straight
18//! over it, with no `--force`, no refusal, and no message. That is the same
19//! class of failure as the two incidents that overwrote tracked source files,
20//! and it had no guard at all.
21//!
22//! So everything here fails CLOSED. When this module cannot establish that a
23//! path is ours and safe, it says so with a reason, and the caller refuses.
24//! Refusing is cheap; the alternative has destroyed somebody's work three times.
25//!
26//! ## Why the LINK is the thing, never its target
27//!
28//! `std::fs::write` and `std::fs::OpenOptions` FOLLOW symlinks: opening
29//! `.git/hooks/pre-commit` when that is a link to `../../devhooks/pre-commit`
30//! truncates and rewrites `devhooks/pre-commit`, a tracked file in the working
31//! tree. That is the verified bug this module exists to end: the guard checked
32//! the link path — untracked, in `.git`, nothing alarming about it — and the
33//! write landed somewhere else entirely.
34//!
35//! `std::fs::rename` does NOT follow symlinks; it replaces the link. So every
36//! write in this module is staged to a sibling temporary file and renamed into
37//! place. `--force` on a symlinked hook therefore means "replace the link", and
38//! the file it pointed at is never opened at all. There is no code path here
39//! that writes through a link, which is a stronger statement than "we check
40//! first" — a check can be raced, and this cannot.
41//!
42//! ## Windows
43//!
44//! - A junction and a directory symlink both report `is_symlink()` from
45//! `symlink_metadata`, so the symlink refusal covers them. Ordinary users
46//! cannot create file symlinks without Developer Mode, which is why the
47//! symlink tests are `#[cfg(unix)]` — the CODE is not.
48//! - `fs::rename` over a file another process has open fails on Windows where
49//! it would succeed on unix. That failure is REPORTED (`SwapFailure` names
50//! the path and the io error) rather than swallowed, because a hook that was
51//! not written is exactly what the caller must be told about.
52//! - `nlink` is unix-only. The multiply-linked refusal below is `#[cfg(unix)]`;
53//! Windows hard links exist but std exposes no count, so that particular
54//! check simply is not made there. Every other guard applies on both.
55
56use std::io;
57use std::path::{Component, Path, PathBuf};
58use std::process::Command;
59
60/// A line every shim carries and nothing else does.
61///
62/// `uninstall` needs to answer "is this file ours to delete?" and the answer
63/// must not be "it is named pre-commit". A colleague's own `pre-commit` lives at
64/// the same path and deleting it would be the third time this project destroyed
65/// somebody's file. Marker-based on purpose rather than byte-comparing against
66/// `bake(SHIM, path)`: a shim somebody hand-edited is still ours, and uninstall
67/// should still take it.
68pub const SHIM_MARKER: &str = "git-templates hook shim";
69
70/// How far into a file the marker may appear. Every shim this project has ever
71/// baked carries it on line 2 — verified against every revision of every file
72/// under `templates/hooks/` in this repository's history, all 36 of them, from
73/// the first `sh` shim to the current one. Ten lines is that fact with room to
74/// spare, not a guess.
75const MARKER_WINDOW: usize = 10;
76
77/// Whether a file in `.git/hooks` is one of ours.
78///
79/// ANCHORED, and that is the whole point. The predicate used to be
80/// `text.contains(SHIM_MARKER)` over the entire file, which claimed as ours any
81/// hook that so much as mentioned the phrase — a colleague's `pre-commit`
82/// carrying `# replaces the git-templates hook shim` in its header was "ours",
83/// and `install --force` overwrote it while `uninstall` deleted it outright.
84/// Prose about this project is not a claim of ownership over the file it is
85/// written in.
86///
87/// Three conditions, all necessary:
88///
89/// - the line is a `#` COMMENT, so a heredoc, a quoted string or a `grep`
90/// pattern mentioning the phrase does not count;
91/// - the marker OPENS that comment — `# git-templates hook shim…` — rather than
92/// appearing somewhere inside a sentence. This is the condition that actually
93/// separates a shim from prose, and it is the reason the rule is stated in
94/// terms of position rather than presence: `# I removed the git-templates
95/// hook shim on purpose` is a note ABOUT us, and a note about us is not a
96/// signature;
97/// - within the first [`MARKER_WINDOW`] lines, so it is a header rather than a
98/// remark somewhere in a 200-line script.
99///
100/// Backward compatibility is a hard requirement here and not a nicety: an
101/// `uninstall` that stops recognising a shim baked two years ago leaves it
102/// installed, running, and now unremovable by the tool that put it there. Every
103/// one of the 36 shim revisions in this repository's history opens line 2 with
104/// exactly `# git-templates hook shim`, so all three conditions hold for all of
105/// them; `every_shim_form_this_project_has_ever_baked_is_still_ours` keeps that
106/// true rather than assuming it.
107pub fn is_our_shim(text: &str) -> bool {
108 text.lines().take(MARKER_WINDOW).any(|line| {
109 line.trim_start()
110 .strip_prefix('#')
111 .is_some_and(|body| body.trim_start().starts_with(SHIM_MARKER))
112 })
113}
114
115/// Why a file at a hook path is not ours to write.
116///
117/// Every variant is a REASON, carried so the refusal can name it. "not ours"
118/// alone sends somebody to look at a file for a difference they cannot see;
119/// "not valid UTF-8" tells them it is a compiled hook in one word.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum ForeignWhy {
122 /// Readable text without our marker. Somebody wrote this on purpose.
123 HandWritten,
124 /// Not valid UTF-8 — a compiled binary, or text in another encoding. The
125 /// case the old `unwrap_or(false)` silently overwrote.
126 NotUtf8,
127 /// It exists and we could not read it. Permissions, a bad mount, a
128 /// disappearing network share. Never "therefore ours".
129 Unreadable { why: String },
130 /// A hard link with other names pointing at the same inode. Truncating it
131 /// would rewrite whatever else refers to it, and we cannot see from here
132 /// what that is.
133 #[cfg(unix)]
134 MultiplyLinked { links: u64 },
135}
136
137impl ForeignWhy {
138 /// A fragment for the middle of a sentence: "… is {}".
139 pub fn describe(&self) -> String {
140 match self {
141 ForeignWhy::HandWritten => "not one of our shims".to_string(),
142 ForeignWhy::NotUtf8 => "not valid UTF-8 — a compiled hook, probably".to_string(),
143 ForeignWhy::Unreadable { why } => format!("unreadable ({why})"),
144 #[cfg(unix)]
145 ForeignWhy::MultiplyLinked { links } => {
146 format!("a hard link with {links} names — rewriting it rewrites the others")
147 }
148 }
149 }
150}
151
152/// What is at a hook path, decided once, by [`classify`].
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub enum HookFile {
155 /// Nothing there. The only state in which writing is unambiguously fine.
156 Absent,
157 /// A regular file carrying our marker.
158 Ours,
159 Foreign(ForeignWhy),
160 /// A symlink, of any kind, pointing anywhere — including at one of our own
161 /// shims. `target` is `None` when the link itself could not be read.
162 Symlink {
163 target: Option<PathBuf>,
164 },
165 /// A directory, a fifo, a socket, a device. Not something to `write` to.
166 NotARegularFile,
167 /// `symlink_metadata` failed with something other than "not found". We do
168 /// not know what is there, which is not the same as nothing being there.
169 Unknown {
170 why: String,
171 },
172}
173
174impl HookFile {
175 /// A fragment for the middle of a sentence: "… is {}".
176 pub fn describe(&self) -> String {
177 match self {
178 HookFile::Absent => "absent".to_string(),
179 HookFile::Ours => "one of our shims".to_string(),
180 HookFile::Foreign(why) => why.describe(),
181 HookFile::Symlink { target: Some(t) } => format!("a symlink to {}", t.display()),
182 HookFile::Symlink { target: None } => "a symlink we could not read".to_string(),
183 HookFile::NotARegularFile => "not a regular file".to_string(),
184 HookFile::Unknown { why } => format!("unstattable ({why})"),
185 }
186 }
187}
188
189/// What is at `path`. Never mutates, never follows a link, never guesses.
190///
191/// The order of the tests is load-bearing:
192///
193/// 1. `symlink_metadata` — NOT `metadata`, which follows links and would report
194/// a symlink-to-a-shim as an ordinary file of ours. `NotFound` is the one
195/// error that means "nothing is there"; every other error means we could not
196/// look, which is [`HookFile::Unknown`] and refuses.
197/// 2. symlink before file type, because a link to a regular file passes
198/// `is_file()` on a `metadata` call and would sail through.
199/// 3. link count before reading, because a hard-linked file is foreign whatever
200/// its contents say.
201/// 4. read, then UTF-8, then the marker — each failure its own named reason
202/// rather than a shared `false`.
203pub fn classify(path: &Path) -> HookFile {
204 let meta = match std::fs::symlink_metadata(path) {
205 Ok(m) => m,
206 Err(e) if e.kind() == io::ErrorKind::NotFound => return HookFile::Absent,
207 Err(e) => return HookFile::Unknown { why: e.to_string() },
208 };
209 if meta.file_type().is_symlink() {
210 return HookFile::Symlink {
211 target: std::fs::read_link(path).ok(),
212 };
213 }
214 if !meta.is_file() {
215 return HookFile::NotARegularFile;
216 }
217 #[cfg(unix)]
218 {
219 use std::os::unix::fs::MetadataExt;
220 if meta.nlink() > 1 {
221 return HookFile::Foreign(ForeignWhy::MultiplyLinked {
222 links: meta.nlink(),
223 });
224 }
225 }
226 // `read`, not `read_to_string`: the bytes decide, and a decode failure is a
227 // classification (`NotUtf8`) rather than an error to swallow.
228 let bytes = match std::fs::read(path) {
229 Ok(b) => b,
230 Err(e) => {
231 return HookFile::Foreign(ForeignWhy::Unreadable { why: e.to_string() });
232 }
233 };
234 let Ok(text) = std::str::from_utf8(&bytes) else {
235 return HookFile::Foreign(ForeignWhy::NotUtf8);
236 };
237 if is_our_shim(text) {
238 HookFile::Ours
239 } else {
240 HookFile::Foreign(ForeignWhy::HandWritten)
241 }
242}
243
244/// Whether git tracks a path — with "could not ask" kept distinct from "no".
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub enum Tracked {
247 Yes,
248 No,
249 /// git could not answer. NOT "no": this is the state in which every caller
250 /// must refuse, because "we could not tell whether this file is somebody's
251 /// tracked source" is the exact ignorance the two overwrite incidents were
252 /// made of.
253 Unknown {
254 why: String,
255 },
256}
257
258/// Ask git whether it tracks `path`.
259///
260/// Asked as `git -C <parent> ls-files --error-unmatch -- <file-name>`, and the
261/// two halves of that are both deliberate.
262///
263/// **The BASENAME, not the absolute path.** git normalises a pathspec
264/// LEXICALLY, but resolves its working directory PHYSICALLY. When `.git/hooks`
265/// is a symlink into the working tree — the setup at the centre of both
266/// incidents — handing git the absolute path `<repo>/.git/hooks/pre-commit`
267/// gets `exit 1, pathspec did not match`, because no such path exists in the
268/// index under that spelling. Handing it `pre-commit` from `-C
269/// <repo>/.git/hooks` resolves through the link to `<repo>/devhooks/pre-commit`
270/// and gets `exit 0`. Same file, same question, opposite answers: the
271/// absolute-path form silently reports every hook reached through a symlinked
272/// directory as untracked. (`amont-fleet`'s own `fix::is_tracked` still asks
273/// the absolute-path way, and has this hole.)
274///
275/// **The exit code, not `status.success()`.** git distinguishes them and the
276/// distinction is the whole point:
277///
278/// - `0` — tracked.
279/// - `1` — the pathspec matched nothing in the index. Untracked. This is also
280/// what a hook under a plain `.git/hooks` returns, since `.git` is outside
281/// the working tree.
282/// - `128` + "not a git repository" — no repository at all. Treated as
283/// untracked: the test fixtures throughout this suite build hook directories
284/// in bare temp dirs, and refusing those would make every one of them fail
285/// for a reason unrelated to what it tests.
286/// - anything else — spawn failure, `detected dubious ownership`, a broken
287/// `.git`, a permissions error. [`Tracked::Unknown`], which refuses.
288///
289/// The `dubious ownership` case is the one worth naming out loud, because it is
290/// common (a repo owned by another uid, which is every repo inside a container
291/// bind mount) and because it produces a `fatal:` that a `success()` check reads
292/// as a clean "no". Under the old predicate, that meant `install --force` was
293/// free to write over tracked files in exactly the environments where the user
294/// cannot see what happened. The refusal message names
295/// `git config --global --add safe.directory <path>` because that is the fix,
296/// and a refusal without one is just an obstacle.
297pub fn tracked(path: &Path) -> Tracked {
298 tracked_with(Path::new("git"), path)
299}
300
301/// [`tracked`], with the `git` to run named explicitly.
302///
303/// Split out for the tests, and for one specific reason: the branch that
304/// matters most here is the one where git answers with a `fatal:` — and there
305/// is no way to provoke that from a test except by owning a repository as
306/// another uid or by putting a fake `git` first on `$PATH`. `std::env::set_var`
307/// in a `#[test]` changes the environment of the whole PROCESS, and rust's test
308/// harness runs every test in that one process, in parallel. The first attempt
309/// at this test did exactly that and took eleven unrelated tests down with it,
310/// all of them ones that shell out to git. Passing the program in costs one
311/// parameter and cannot race anything.
312fn tracked_with(git: &Path, path: &Path) -> Tracked {
313 let Some(name) = path.file_name() else {
314 return Tracked::Unknown {
315 why: format!("{} has no file name", path.display()),
316 };
317 };
318 let dir = match path.parent() {
319 Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
320 _ => PathBuf::from("."),
321 };
322 let out = match Command::new(git)
323 .arg("-C")
324 .arg(&dir)
325 .args(["ls-files", "--error-unmatch", "--"])
326 .arg(name)
327 .output()
328 {
329 Ok(o) => o,
330 Err(e) => {
331 return Tracked::Unknown {
332 why: format!("could not run git: {e}"),
333 };
334 }
335 };
336 let stderr = String::from_utf8_lossy(&out.stderr);
337 match out.status.code() {
338 Some(0) => Tracked::Yes,
339 Some(1) => Tracked::No,
340 Some(128) if stderr.to_lowercase().contains("not a git repository") => Tracked::No,
341 Some(code) => Tracked::Unknown {
342 why: format!("git ls-files exited {code}: {}", first_line(&stderr)),
343 },
344 // Killed by a signal. No exit code at all, and certainly no answer.
345 None => Tracked::Unknown {
346 why: "git ls-files was killed before it answered".to_string(),
347 },
348 }
349}
350
351fn first_line(s: &str) -> String {
352 s.lines()
353 .find(|l| !l.trim().is_empty())
354 .unwrap_or("no output")
355 .trim()
356 .to_string()
357}
358
359/// A refusal to touch a path, with the reason attached.
360///
361/// Carries the path rather than a name, because "commit-msg" is not enough to
362/// go and look at when four directories could hold one.
363#[derive(Debug, Clone, PartialEq, Eq)]
364pub enum Refuse {
365 Foreign {
366 path: PathBuf,
367 why: ForeignWhy,
368 },
369 Symlink {
370 path: PathBuf,
371 target: Option<PathBuf>,
372 },
373 NotARegularFile {
374 path: PathBuf,
375 },
376 Tracked {
377 path: PathBuf,
378 },
379 TrackedUnknown {
380 path: PathBuf,
381 why: String,
382 },
383 Unstattable {
384 path: PathBuf,
385 why: String,
386 },
387}
388
389impl Refuse {
390 pub fn path(&self) -> &Path {
391 match self {
392 Refuse::Foreign { path, .. }
393 | Refuse::Symlink { path, .. }
394 | Refuse::NotARegularFile { path }
395 | Refuse::Tracked { path }
396 | Refuse::TrackedUnknown { path, .. }
397 | Refuse::Unstattable { path, .. } => path,
398 }
399 }
400
401 /// One line, naming the path, the reason, and — where there is one — the
402 /// command that resolves it. A refusal that does not say what to do next is
403 /// indistinguishable from a bug.
404 pub fn explain(&self) -> String {
405 match self {
406 Refuse::Foreign { path, why } => {
407 format!(
408 "{} is {} — leaving it alone",
409 path.display(),
410 why.describe()
411 )
412 }
413 Refuse::Symlink {
414 path,
415 target: Some(t),
416 } => format!(
417 "{} is a symlink to {} — writing through it would rewrite that file",
418 path.display(),
419 t.display()
420 ),
421 Refuse::Symlink { path, target: None } => format!(
422 "{} is a symlink we could not read — refusing to write through it",
423 path.display()
424 ),
425 Refuse::NotARegularFile { path } => format!(
426 "{} is not a regular file (a directory, a fifo, a device) — refusing",
427 path.display()
428 ),
429 Refuse::Tracked { path } => format!(
430 "{} is TRACKED by git — that is somebody's source, not our hook",
431 path.display()
432 ),
433 Refuse::TrackedUnknown { path, why } => format!(
434 "cannot tell whether {} is tracked ({why})\n \
435 If this is a repository you own: \
436 git config --global --add safe.directory {}",
437 path.display(),
438 path.parent().unwrap_or(path).display()
439 ),
440 Refuse::Unstattable { path, why } => {
441 format!("cannot look at {} ({why}) — refusing", path.display())
442 }
443 }
444 }
445}
446
447/// May we write a hook at `path`, and what is there now?
448///
449/// `force` is the user saying "I looked at it". It relaxes exactly two
450/// refusals — a foreign file, and a symlink — and it relaxes them into
451/// REPLACING THE LINK, never into writing through it (see the module doc).
452///
453/// `force` does NOT relax the tracked guard, and this is the one rule in the
454/// module with no exception. `--force` is a statement about `.git/hooks`, which
455/// is machine-local scratch space; it is not consent to rewrite a file in the
456/// working tree that git is watching. The user who typed it was thinking about
457/// a hook they wrote last month, not about `devhooks/pre-commit` being on the
458/// other end of a link they forgot they made. Nor does it relax
459/// `TrackedUnknown`: an override that fires when we could not even ask the
460/// question is an override of the guard rather than of the finding.
461///
462/// The tracked check runs FIRST so its refusal survives everything else — a
463/// tracked symlink refuses as `Tracked`, not as a forceable `Symlink`.
464pub fn guard_write(path: &Path, force: bool) -> Result<HookFile, Refuse> {
465 guard_write_with(Path::new("git"), path, force)
466}
467
468/// [`guard_write`], with the `git` to ask named — see [`tracked_with`] for why
469/// the seam exists at all.
470fn guard_write_with(git: &Path, path: &Path, force: bool) -> Result<HookFile, Refuse> {
471 match tracked_with(git, path) {
472 Tracked::No => {}
473 Tracked::Yes => {
474 return Err(Refuse::Tracked {
475 path: path.to_path_buf(),
476 });
477 }
478 Tracked::Unknown { why } => {
479 return Err(Refuse::TrackedUnknown {
480 path: path.to_path_buf(),
481 why,
482 });
483 }
484 }
485 let what = classify(path);
486 match &what {
487 HookFile::Absent | HookFile::Ours => Ok(what),
488 HookFile::Foreign(why) => {
489 if force {
490 Ok(what)
491 } else {
492 Err(Refuse::Foreign {
493 path: path.to_path_buf(),
494 why: why.clone(),
495 })
496 }
497 }
498 HookFile::Symlink { target } => {
499 if force {
500 Ok(what)
501 } else {
502 Err(Refuse::Symlink {
503 path: path.to_path_buf(),
504 target: target.clone(),
505 })
506 }
507 }
508 // Neither of these is forceable, deliberately. `--force` means "replace
509 // the hook that is there"; a directory or a device at a hook path is
510 // not a hook that is there, it is a sign that something else is going
511 // on, and renaming over it either fails or destroys something we cannot
512 // describe. Refusing costs one `rm`.
513 HookFile::NotARegularFile => Err(Refuse::NotARegularFile {
514 path: path.to_path_buf(),
515 }),
516 HookFile::Unknown { why } => Err(Refuse::Unstattable {
517 path: path.to_path_buf(),
518 why: why.clone(),
519 }),
520 }
521}
522
523/// May we remove the hook at `path`?
524///
525/// `expect_ours` is what separates `uninstall` (which removes only files
526/// carrying our marker) from a caller that has already established ownership
527/// some other way. With it set, anything that is not [`HookFile::Ours`] is
528/// refused with its reason — including a symlink that points AT one of our
529/// shims, because deleting the link and deleting the shim are different acts
530/// and only one of them was asked for.
531///
532/// [`HookFile::Absent`] is `Ok`: there is nothing to remove and nothing to
533/// refuse, and `uninstall` run twice must not be an error.
534///
535/// The tracked guard applies here exactly as it does to writes, and for a
536/// sharper reason: `remove_file` on a tracked path deletes work.
537pub fn guard_remove(path: &Path, expect_ours: bool) -> Result<(), Refuse> {
538 let what = classify(path);
539 if what == HookFile::Absent {
540 return Ok(());
541 }
542 match tracked(path) {
543 Tracked::No => {}
544 Tracked::Yes => {
545 return Err(Refuse::Tracked {
546 path: path.to_path_buf(),
547 });
548 }
549 Tracked::Unknown { why } => {
550 return Err(Refuse::TrackedUnknown {
551 path: path.to_path_buf(),
552 why,
553 });
554 }
555 }
556 if !expect_ours {
557 return Ok(());
558 }
559 match what {
560 HookFile::Ours | HookFile::Absent => Ok(()),
561 HookFile::Foreign(why) => Err(Refuse::Foreign {
562 path: path.to_path_buf(),
563 why,
564 }),
565 HookFile::Symlink { target } => Err(Refuse::Symlink {
566 path: path.to_path_buf(),
567 target,
568 }),
569 HookFile::NotARegularFile => Err(Refuse::NotARegularFile {
570 path: path.to_path_buf(),
571 }),
572 HookFile::Unknown { why } => Err(Refuse::Unstattable {
573 path: path.to_path_buf(),
574 why,
575 }),
576 }
577}
578
579/// A body written to a sibling temporary file, not yet in place.
580///
581/// The two-phase shape exists so a multi-file install is all-or-nothing at the
582/// only point where that is achievable. Guarding four paths and then writing
583/// four files leaves a window in which the third write fails and the repository
584/// holds two new dispatchers and two old ones — the state `bake_repo_hooks`'s
585/// own comment claims to prevent and did not. Staging all four first moves
586/// every failure that can be anticipated (no space, no permission, a read-only
587/// directory) BEFORE the first destination is touched.
588///
589/// It is not a transaction and does not pretend to be: `commit_all` can still
590/// fail on its third rename. What it gives is that the failure is reported with
591/// the exact list of what landed and what did not, instead of a count.
592#[derive(Debug)]
593pub struct Staged {
594 dest: PathBuf,
595 tmp: PathBuf,
596 committed: bool,
597}
598
599impl Staged {
600 pub fn dest(&self) -> &Path {
601 &self.dest
602 }
603 pub fn tmp(&self) -> &Path {
604 &self.tmp
605 }
606}
607
608/// A staged file that never landed is litter in somebody's `.git/hooks`, where
609/// it is both confusing and — being executable and named like a hook — worth
610/// not leaving. `Drop` runs on the early return from `commit_all` and on any
611/// `?` between `stage` and `commit_all`.
612impl Drop for Staged {
613 fn drop(&mut self) {
614 if !self.committed {
615 let _ = std::fs::remove_file(&self.tmp);
616 }
617 }
618}
619
620/// Write `body` to a sibling of `dest`, ready to be renamed into place.
621///
622/// A SIBLING, in the destination's own directory, which is not a detail:
623/// `fs::rename` across filesystems fails with `EXDEV`, and `.git` is a mount
624/// point often enough (a bind mount, a container volume, a separate partition
625/// for a large repo) that staging in `/tmp` would turn a rename that cannot
626/// fail into one that fails on exactly the machines hardest to reproduce.
627///
628/// The temporary name carries the pid so two installs racing in the same
629/// repository do not stage over each other, and starts with a dot so it does
630/// not look like a hook to anyone reading the directory.
631pub fn stage(dest: &Path, body: &str, exec: bool) -> io::Result<Staged> {
632 let dir = match dest.parent() {
633 Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
634 _ => PathBuf::from("."),
635 };
636 let name = dest
637 .file_name()
638 .map(|n| n.to_string_lossy().into_owned())
639 .unwrap_or_else(|| "hook".to_string());
640 let tmp = dir.join(format!(".amont-tmp-{}-{name}", std::process::id()));
641 // Remove first: a leftover from a killed run could be a symlink, and
642 // `fs::write` would follow it.
643 let _ = std::fs::remove_file(&tmp);
644 std::fs::write(&tmp, body)?;
645 set_mode(&tmp, exec)?;
646 Ok(Staged {
647 dest: dest.to_path_buf(),
648 tmp,
649 committed: false,
650 })
651}
652
653/// Everything that did and did not happen when a swap went wrong.
654///
655/// `landed` and `not_written` are both present because either alone is a lie by
656/// omission. "3 of 4 written" tells somebody they have a problem; naming which
657/// three tells them what their repository currently does on commit.
658#[derive(Debug)]
659pub struct SwapFailure {
660 pub landed: Vec<PathBuf>,
661 pub not_written: Vec<PathBuf>,
662 pub at: PathBuf,
663 pub error: io::Error,
664}
665
666impl std::fmt::Display for SwapFailure {
667 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
668 writeln!(
669 f,
670 "cannot put {} in place: {}",
671 self.at.display(),
672 self.error
673 )?;
674 writeln!(f, " written: {}", show(&self.landed))?;
675 write!(f, " NOT written: {}", show(&self.not_written))
676 }
677}
678
679fn show(paths: &[PathBuf]) -> String {
680 if paths.is_empty() {
681 return "(none)".to_string();
682 }
683 paths
684 .iter()
685 .map(|p| p.display().to_string())
686 .collect::<Vec<_>>()
687 .join(", ")
688}
689
690/// Rename every staged file into place, stopping at the first failure.
691///
692/// `fs::rename` REPLACES a symlink at the destination rather than following it,
693/// which is the property the whole staging dance is for. Same directory, so
694/// `EXDEV` cannot arise.
695pub fn commit_all(mut staged: Vec<Staged>) -> Result<Vec<PathBuf>, SwapFailure> {
696 let mut landed: Vec<PathBuf> = Vec::new();
697 for i in 0..staged.len() {
698 match std::fs::rename(&staged[i].tmp, &staged[i].dest) {
699 Ok(()) => {
700 staged[i].committed = true;
701 landed.push(staged[i].dest.clone());
702 }
703 Err(error) => {
704 return Err(SwapFailure {
705 landed,
706 not_written: staged[i..].iter().map(|s| s.dest.clone()).collect(),
707 at: staged[i].dest.clone(),
708 error,
709 });
710 }
711 }
712 }
713 Ok(landed)
714}
715
716/// Remove a file, treating "already gone" as success.
717///
718/// `remove_file` on unix unlinks a symlink rather than its target, which is the
719/// behaviour `uninstall --force` needs and the reason this is not `remove_dir_all`
720/// or anything cleverer.
721pub fn remove_regular(path: &Path) -> io::Result<()> {
722 match std::fs::remove_file(path) {
723 Ok(()) => Ok(()),
724 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
725 Err(e) => Err(e),
726 }
727}
728
729#[cfg(unix)]
730fn set_mode(p: &Path, exec: bool) -> io::Result<()> {
731 use std::os::unix::fs::PermissionsExt;
732 let mode = if exec { 0o755 } else { 0o644 };
733 std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode))
734}
735
736#[cfg(not(unix))]
737fn set_mode(_p: &Path, _exec: bool) -> io::Result<()> {
738 Ok(()) // Windows has no execute bit; git runs the shim through sh regardless.
739}
740
741/// `.` and `..` resolved textually, without asking the filesystem.
742///
743/// Deliberately NOT `canonicalize`: that requires every component to exist, it
744/// resolves symlinks (so a containment test built on it would answer about the
745/// TARGET, which is the opposite of what a link refusal wants to know), and on
746/// Windows it returns an extended-length `\\?\C:\…` path that compares equal to
747/// nothing anybody wrote down.
748pub fn resolve_lexical(path: &Path) -> PathBuf {
749 let mut out = PathBuf::new();
750 for c in path.components() {
751 match c {
752 Component::CurDir => {}
753 Component::ParentDir => match out.components().next_back() {
754 Some(Component::Normal(_)) => {
755 out.pop();
756 }
757 // The parent of a root is the root. Anything else (an empty
758 // buffer, a leading `..`) keeps the `..`, because dropping it
759 // would silently change which directory the path names.
760 Some(Component::RootDir) | Some(Component::Prefix(_)) => {}
761 _ => out.push(Component::ParentDir),
762 },
763 other => out.push(other),
764 }
765 }
766 out
767}
768
769/// Whether `child` is `parent` or lives under it, lexically.
770///
771/// Used for containment questions about paths that may not exist yet, where
772/// `canonicalize` cannot be asked.
773pub fn is_within(child: &Path, parent: &Path) -> bool {
774 resolve_lexical(child).starts_with(resolve_lexical(parent))
775}
776
777#[cfg(test)]
778mod tests {
779 use super::*;
780
781 fn tmpdir(name: &str) -> PathBuf {
782 let d = std::env::temp_dir().join(format!("gh-hookfile-{name}-{}", std::process::id()));
783 let _ = std::fs::remove_dir_all(&d);
784 std::fs::create_dir_all(&d).expect("mkdir");
785 d
786 }
787
788 /// root ignores permission bits, so a test built on them proves nothing
789 /// there. Same guard, and the same reason, as `tests/install.rs`.
790 #[cfg(unix)]
791 fn running_as_root() -> bool {
792 extern "C" {
793 fn geteuid() -> u32;
794 }
795 unsafe { geteuid() == 0 }
796 }
797
798 /// The bug, exactly: a compiled hook is not valid UTF-8, `read_to_string`
799 /// returned `Err`, `unwrap_or(false)` said "not foreign", and the installer
800 /// wrote over somebody's binary without a word.
801 #[test]
802 fn a_non_utf8_hook_is_foreign_not_ours() {
803 let d = tmpdir("notutf8");
804 let p = d.join("pre-commit");
805 // A Mach-O/ELF-ish header: bytes no UTF-8 decoder accepts.
806 std::fs::write(&p, [0x7f, b'E', b'L', b'F', 0x02, 0x01, 0xff, 0xfe]).expect("write");
807 assert_eq!(classify(&p), HookFile::Foreign(ForeignWhy::NotUtf8));
808 let _ = std::fs::remove_dir_all(&d);
809 }
810
811 /// Unreadable is FOREIGN, never ours. The old predicate turned every io
812 /// error into "not foreign", which is the failing-open half of the same
813 /// line.
814 #[cfg(unix)]
815 #[test]
816 fn an_unreadable_hook_is_foreign_not_ours() {
817 use std::os::unix::fs::PermissionsExt;
818 if running_as_root() {
819 return;
820 }
821 let d = tmpdir("unreadable");
822 let p = d.join("pre-commit");
823 std::fs::write(&p, "#!/bin/sh\n# git-templates hook shim.\n").expect("write");
824 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o000)).expect("chmod");
825
826 let got = classify(&p);
827 let _ = std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644));
828 assert!(
829 matches!(got, HookFile::Foreign(ForeignWhy::Unreadable { .. })),
830 "an unreadable hook classified as {got:?}"
831 );
832 let _ = std::fs::remove_dir_all(&d);
833 }
834
835 /// A link to one of OUR OWN shims is still a link. The content is ours; the
836 /// path is not, and `fs::write` here rewrites whatever is on the other end.
837 #[cfg(unix)]
838 #[test]
839 fn a_symlink_to_our_own_shim_is_still_a_symlink() {
840 let d = tmpdir("symlink-ours");
841 let real = d.join("real-shim");
842 std::fs::write(&real, "#!/bin/sh\n# git-templates hook shim.\nexec x\n").expect("write");
843 let link = d.join("pre-commit");
844 std::os::unix::fs::symlink(&real, &link).expect("symlink");
845
846 match classify(&link) {
847 HookFile::Symlink { target } => {
848 assert_eq!(target.as_deref(), Some(real.as_path()));
849 }
850 other => panic!("a symlink classified as {other:?}"),
851 }
852 let _ = std::fs::remove_dir_all(&d);
853 }
854
855 /// Mentioning this project is not a claim of ownership. The unanchored
856 /// `text.contains(SHIM_MARKER)` said otherwise, and `--force` then
857 /// overwrote a hook whose author had written a courteous note about why
858 /// they replaced ours.
859 #[test]
860 fn a_hook_that_merely_mentions_the_marker_is_not_ours() {
861 let prose = "#!/bin/sh\n\
862 # My own commit-msg. I removed the git-templates hook shim on purpose:\n\
863 # it disagreed with our house rules. Do not put it back.\n\
864 exec my-linter \"$@\"\n";
865 assert!(!is_our_shim(prose), "prose about the shim claimed the file");
866
867 // Deep in the body counts for nothing either — this is the `grep`-in-a
868 // -script shape.
869 let deep = format!("#!/bin/sh\n{}\n# {SHIM_MARKER}\n", "echo x\n".repeat(20));
870 assert!(!is_our_shim(&deep), "a line 20 deep claimed the file");
871
872 // And it must be a COMMENT, not a string a script happens to carry.
873 let quoted = "#!/bin/sh\ngrep -q \"git-templates hook shim\" \"$0\" && exit 0\n";
874 assert!(!is_our_shim(quoted), "a quoted mention claimed the file");
875 }
876
877 /// Backward compatibility, checked against the actual history rather than
878 /// asserted. Every revision of every file under `templates/hooks/` that has
879 /// ever carried the marker put it on line 2, as a `#` comment — the `sh`
880 /// shims of the Phase 0 port, the `.zsh`/`.js` suffixed generation, and the
881 /// current one. An `uninstall` that stops recognising any of them leaves a
882 /// shim installed and running with no tool willing to remove it.
883 #[test]
884 fn every_shim_form_this_project_has_ever_baked_is_still_ours() {
885 let historical = [
886 // The current template, and the one `include_str!` embeds.
887 crate::install::SHIM,
888 // The Phase 0 shim, and the shape both fleet test suites still
889 // build fixtures from.
890 "#!/bin/sh\n# git-templates hook shim.\nexec \"$BIN\" --hooks-dir \"$(dirname \"$0\")\" pre-commit \"$@\"\n",
891 // The generation that dispatched by suffix.
892 "#!/bin/sh\n# git-templates hook shim → the amont binary.\nexec x --hooks-dir y pre-commit-ruff\n",
893 // Hand-edited, which uninstall must still take: the marker is the
894 // claim, not byte equality with the template.
895 "#!/bin/sh\n# git-templates hook shim → the amont binary.\n# edited by me\nexec /usr/local/bin/amont \"$@\"\n",
896 ];
897 for shim in historical {
898 assert!(
899 is_our_shim(shim),
900 "a shim this project baked is no longer recognised:\n{}",
901 shim.lines().take(3).collect::<Vec<_>>().join("\n")
902 );
903 }
904 }
905
906 /// The verified bug, at the level of one function: a write must land on the
907 /// LINK and leave the file it pointed at exactly as it was.
908 #[cfg(unix)]
909 #[test]
910 fn a_staged_write_replaces_the_link_and_never_its_target() {
911 let d = tmpdir("write-through");
912 let target = d.join("tracked-source");
913 std::fs::write(&target, "PRECIOUS\n").expect("write");
914 let link = d.join("pre-commit");
915 std::os::unix::fs::symlink(&target, &link).expect("symlink");
916
917 let s = stage(&link, "#!/bin/sh\n# git-templates hook shim.\n", true).expect("stage");
918 commit_all(vec![s]).expect("commit");
919
920 assert_eq!(
921 std::fs::read_to_string(&target).expect("read"),
922 "PRECIOUS\n",
923 "THE WRITE WENT THROUGH THE LINK"
924 );
925 assert!(
926 !std::fs::symlink_metadata(&link)
927 .expect("stat")
928 .file_type()
929 .is_symlink(),
930 "the link survived the write"
931 );
932 assert!(matches!(classify(&link), HookFile::Ours));
933 let _ = std::fs::remove_dir_all(&d);
934 }
935
936 /// A git that cannot answer must not read as "not tracked". Shimmed with a
937 /// `git` that exits 128 saying something other than "not a git repository"
938 /// — the shape of `detected dubious ownership`, which is what a repository
939 /// owned by another uid produces on every call, in every container that
940 /// bind-mounts a checkout.
941 #[cfg(unix)]
942 #[test]
943 fn a_git_that_cannot_answer_is_unknown_not_untracked() {
944 use std::os::unix::fs::PermissionsExt;
945 let d = tmpdir("git-shim");
946 let fake = d.join("fake-git");
947 std::fs::write(
948 &fake,
949 "#!/bin/sh\necho \"fatal: detected dubious ownership in repository\" >&2\nexit 128\n",
950 )
951 .expect("write");
952 std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).expect("chmod");
953
954 let hook = d.join("pre-commit");
955 std::fs::write(&hook, "#!/bin/sh\n").expect("write");
956
957 let got = tracked_with(&fake, &hook);
958 assert!(
959 matches!(got, Tracked::Unknown { .. }),
960 "a fatal git reported as {got:?}"
961 );
962 let err = guard_write_with(&fake, &hook, true)
963 .expect_err("--force must not override an unanswerable tracked check");
964 assert!(
965 matches!(err, Refuse::TrackedUnknown { .. }),
966 "refused as {err:?}"
967 );
968 assert!(
969 err.explain().contains("safe.directory"),
970 "the refusal must name the fix:\n{}",
971 err.explain()
972 );
973 let _ = std::fs::remove_dir_all(&d);
974 }
975
976 /// Staging is the all-or-nothing point. If any body cannot be written, no
977 /// destination has been touched — there is nothing to undo, because nothing
978 /// was done.
979 #[cfg(unix)]
980 #[test]
981 fn a_failed_stage_leaves_every_destination_untouched() {
982 use std::os::unix::fs::PermissionsExt;
983 if running_as_root() {
984 return;
985 }
986 let d = tmpdir("stage-fail");
987 let existing = d.join("pre-commit");
988 std::fs::write(&existing, "OLD\n").expect("write");
989 std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o555)).expect("chmod");
990
991 let err = stage(&existing, "NEW\n", true).expect_err("a read-only dir must fail");
992 let _ = std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o755));
993
994 assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
995 assert_eq!(
996 std::fs::read_to_string(&existing).expect("read"),
997 "OLD\n",
998 "a failed stage changed the destination"
999 );
1000 let _ = std::fs::remove_dir_all(&d);
1001 }
1002
1003 /// An uncommitted stage must not leave an executable file named like a hook
1004 /// lying in `.git/hooks`.
1005 #[test]
1006 fn dropping_an_uncommitted_stage_removes_its_temporary() {
1007 let d = tmpdir("drop");
1008 let dest = d.join("pre-commit");
1009 let tmp = {
1010 let s = stage(&dest, "body\n", true).expect("stage");
1011 let t = s.tmp().to_path_buf();
1012 assert!(t.is_file(), "stage wrote nothing");
1013 t
1014 };
1015 assert!(!tmp.exists(), "an uncommitted temporary survived");
1016 assert!(!dest.exists(), "staging touched the destination");
1017 let _ = std::fs::remove_dir_all(&d);
1018 }
1019
1020 /// Absent is the only unambiguous "yes", and it has to be one — an install
1021 /// into an empty hooks dir is the common case.
1022 #[test]
1023 fn an_absent_hook_is_absent_and_writable() {
1024 let d = tmpdir("absent");
1025 let p = d.join("pre-commit");
1026 assert_eq!(classify(&p), HookFile::Absent);
1027 assert_eq!(guard_write(&p, false), Ok(HookFile::Absent));
1028 assert_eq!(guard_remove(&p, true), Ok(()));
1029 let _ = std::fs::remove_dir_all(&d);
1030 }
1031
1032 /// A directory named `pre-commit` is not a hook to replace, and `--force`
1033 /// does not make it one.
1034 #[test]
1035 fn a_directory_at_a_hook_path_is_refused_even_with_force() {
1036 let d = tmpdir("notfile");
1037 let p = d.join("pre-commit");
1038 std::fs::create_dir_all(&p).expect("mkdir");
1039 assert_eq!(classify(&p), HookFile::NotARegularFile);
1040 for force in [false, true] {
1041 assert!(matches!(
1042 guard_write(&p, force),
1043 Err(Refuse::NotARegularFile { .. })
1044 ));
1045 }
1046 let _ = std::fs::remove_dir_all(&d);
1047 }
1048
1049 /// `--force` is a statement about a hook, and its exact reach is worth
1050 /// pinning: a foreign file and a symlink yield, and nothing else does.
1051 #[cfg(unix)]
1052 #[test]
1053 fn force_reaches_a_foreign_file_and_a_symlink_and_stops_there() {
1054 let d = tmpdir("force-reach");
1055 let foreign = d.join("commit-msg");
1056 std::fs::write(&foreign, "#!/bin/sh\necho mine\n").expect("write");
1057 assert!(matches!(
1058 guard_write(&foreign, false),
1059 Err(Refuse::Foreign { .. })
1060 ));
1061 assert!(guard_write(&foreign, true).is_ok());
1062
1063 let link = d.join("pre-commit");
1064 std::os::unix::fs::symlink(&foreign, &link).expect("symlink");
1065 assert!(matches!(
1066 guard_write(&link, false),
1067 Err(Refuse::Symlink { .. })
1068 ));
1069 assert!(guard_write(&link, true).is_ok());
1070 let _ = std::fs::remove_dir_all(&d);
1071 }
1072
1073 /// A symlink pointing at one of our shims is refused by `uninstall` too:
1074 /// deleting the link and deleting the shim are different acts.
1075 #[cfg(unix)]
1076 #[test]
1077 fn uninstall_refuses_a_symlink_even_when_it_points_at_our_shim() {
1078 let d = tmpdir("rm-symlink");
1079 let real = d.join("real");
1080 std::fs::write(&real, "#!/bin/sh\n# git-templates hook shim.\n").expect("write");
1081 let link = d.join("pre-commit");
1082 std::os::unix::fs::symlink(&real, &link).expect("symlink");
1083
1084 assert!(matches!(
1085 guard_remove(&link, true),
1086 Err(Refuse::Symlink { .. })
1087 ));
1088 assert!(real.is_file(), "the target was removed");
1089 let _ = std::fs::remove_dir_all(&d);
1090 }
1091
1092 /// git tracks it ⇒ nobody writes it, whatever `--force` says.
1093 #[test]
1094 fn a_tracked_hook_is_refused_and_force_does_not_reach_it() {
1095 let d = tmpdir("tracked");
1096 let run = |args: &[&str]| {
1097 Command::new("git")
1098 .arg("-C")
1099 .arg(&d)
1100 .args(args)
1101 .output()
1102 .expect("git");
1103 };
1104 run(&["init", "-q", "--template=", "."]);
1105 run(&["config", "user.email", "t@t.test"]);
1106 run(&["config", "user.name", "t"]);
1107 let p = d.join("pre-commit");
1108 std::fs::write(&p, "tracked source\n").expect("write");
1109 run(&["add", "-A"]);
1110 run(&["commit", "-qm", "seed"]);
1111
1112 assert_eq!(tracked(&p), Tracked::Yes);
1113 for force in [false, true] {
1114 assert_eq!(
1115 guard_write(&p, force),
1116 Err(Refuse::Tracked { path: p.clone() }),
1117 "force={force} reached a tracked file"
1118 );
1119 }
1120 assert_eq!(
1121 guard_remove(&p, false),
1122 Err(Refuse::Tracked { path: p.clone() })
1123 );
1124 let _ = std::fs::remove_dir_all(&d);
1125 }
1126
1127 /// The fixtures throughout this suite build hook directories in bare temp
1128 /// dirs with no repository anywhere above them. "No repository" is a clean
1129 /// "not tracked", not an unanswerable question — refuse it and every
1130 /// synthetic fixture in the workspace starts failing for a reason unrelated
1131 /// to what it tests.
1132 ///
1133 /// Relies on `std::env::temp_dir()` not sitting inside a checkout, the same
1134 /// assumption `install::tests::an_ordinary_directory_is_safe` already
1135 /// makes. Not asserted with `GIT_CEILING_DIRECTORIES`, because setting it
1136 /// would mean `set_var` — process-wide, and this harness runs every test in
1137 /// one process in parallel.
1138 #[test]
1139 fn no_repository_at_all_is_untracked_rather_than_unknown() {
1140 let d = tmpdir("norepo");
1141 let p = d.join("pre-commit");
1142 std::fs::write(&p, "x\n").expect("write");
1143 assert_eq!(tracked(&p), Tracked::No);
1144 let _ = std::fs::remove_dir_all(&d);
1145 }
1146
1147 #[test]
1148 fn lexical_resolution_never_touches_the_filesystem() {
1149 assert_eq!(
1150 resolve_lexical(Path::new("/a/b/../c/./d")),
1151 PathBuf::from("/a/c/d")
1152 );
1153 assert_eq!(resolve_lexical(Path::new("a/../..")), PathBuf::from(".."));
1154 // The parent of the root is the root, not `/..`.
1155 assert_eq!(resolve_lexical(Path::new("/..")), PathBuf::from("/"));
1156 assert!(is_within(Path::new("/a/b/c"), Path::new("/a/b")));
1157 assert!(is_within(Path::new("/a/b"), Path::new("/a/b")));
1158 assert!(!is_within(Path::new("/a/bc"), Path::new("/a/b")));
1159 assert!(!is_within(Path::new("/a/b/../../x"), Path::new("/a/b")));
1160 }
1161}