Skip to main content

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}