Skip to main content

kranz_engine/
validator_integrity.rs

1//! Validator immutability: the TRIPWIRE half (ticket
2//! `validator-immutability-proof`, security review P1 #5; snapshot
3//! follow-up in [`crate::validator_snapshot`]; mandatory containment in
4//! `crate::sandbox::resolve_validator_containment`, ticket
5//! `validator-mandatory-containment`).
6//!
7//! "Read-only" validators were nominal: backends do not enforce
8//! `writable: false`, and the process sandbox allows writes to the session
9//! checkout — a prompt-injected or misbehaving validator can edit tests to
10//! manufacture a pass, `git add && git commit` its own changes, or leave
11//! unreviewed files in the deliverable. The layered posture that replaced
12//! that, outermost net first:
13//!
14//! 1. MANDATORY OS CONTAINMENT (ticket `validator-mandatory-containment`):
15//!    every validator session runs under a Seatbelt/bwrap wrap regardless
16//!    of `sandbox.enforce`, exposing only the snapshot — the real
17//!    checkout's source tree is read-denied, writes land nowhere but the
18//!    snapshot and the session scratch, and the shared git plumbing
19//!    (index, refs) is write-denied by deny-default. The snapshot alone
20//!    was physical separation, not containment: a hostile validator could
21//!    walk to the real checkout, modify tests, run them, and restore the
22//!    bytes — modify → use → restore leaves no drift for this module to
23//!    catch. Where the platform or backend cannot contain, the round now
24//!    FAILS CLOSED by default (ticket
25//!    `validator-containment-degrade-fail-closed`); only under the explicit
26//!    `validatorAllowUncontainedDegrade` opt-in does it carry the loud
27//!    degradation decision, with the layers below as what remains.
28//! 2. The copy-on-write immutable snapshot ([`crate::validator_snapshot`]):
29//!    each validator session runs in a THROWAWAY worktree copy of the
30//!    checkout and only its verdict crosses back, so writes are
31//!    structurally discarded rather than caught.
32//! 3. THIS MODULE — the tripwire, now explicitly defense-in-depth rather
33//!    than the net: an IDENTITY ASSERTION around every validator session,
34//!    still taken on the REAL checkout. Capture HEAD + porcelain status
35//!    before the spawn, re-capture after — with containment and snapshot
36//!    isolation in place the real checkout should be byte-identical, so
37//!    any drift means BOTH outer layers failed (a validator escaped its
38//!    containment and its snapshot, or moved shared git refs the snapshot
39//!    cannot isolate). The orchestrator then emits `validator.tamper` and
40//!    blocks the milestone, with no retry and no waivable finding.
41//!
42//! The assertion is precise: **no tracked file changed, HEAD unchanged,
43//! index unchanged** — plus no new non-ignored file (a dropped test file
44//! manufactures a pass just as an edit does), and **no `.git` metadata
45//! change**: the worktree can look identical while `config`
46//! (`core.fsmonitor`, `core.hooksPath`, aliases — all executed during the
47//! ENGINE's own git invocations), `hooks/`, or refs were weaponized, so the
48//! fingerprint covers them too (3rd-pass review). `git status --porcelain`
49//! respects .gitignore, so legitimate gate artifact churn (`target/`, the
50//! gitignored `.kranz` engine runtime) never trips it. The refs half is the
51//! one mutation class neither outer layer fully contains (worktrees share
52//! the common `.git`, and an uncontained platform keeps no write deny),
53//! which is exactly why the tripwire must stay.
54
55use crate::error::Result;
56use crate::git_ops::GitRepo;
57
58/// HEAD + `git status --porcelain` of a checkout at one instant — the
59/// identity a read-only validator session must preserve byte-for-byte.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct CheckoutFingerprint {
62    /// `git rev-parse HEAD`.
63    pub head: String,
64    /// `git status --porcelain --untracked-files=all`: index + worktree
65    /// status of tracked files plus EVERY untracked non-ignored path;
66    /// ignored paths (target/, .kranz runtime) never appear.
67    pub status: String,
68    /// Contents of `<git-common-dir>/config`: `core.fsmonitor`,
69    /// `core.hooksPath`, aliases, and external-diff drivers all execute
70    /// during the ENGINE's own git invocations — a validator that can write
71    /// only `.git/config` owns the host without touching the worktree.
72    pub git_config: String,
73    /// Sorted `name HASH` lines for non-`.sample` files in
74    /// `<git-common-dir>/hooks` — a planted hook executes on the engine's
75    /// next git operation that isn't hooks-disabled.
76    pub git_hooks: String,
77    /// `git for-each-ref` output: moving a ref retargets later merges
78    /// without touching HEAD or the worktree.
79    pub git_refs: String,
80    /// `git ls-files -v` output: `skip-worktree`/`assume-unchanged` flags
81    /// hide worktree modifications from `git status` (4th-pass review) —
82    /// the flags are part of the identity, so setting one is drift.
83    pub index_flags: String,
84    /// Contents of `<git-common-dir>/info/exclude`: the local exclude file
85    /// hides untracked files from `git status` without touching the tree.
86    pub info_exclude: String,
87}
88
89impl CheckoutFingerprint {
90    /// Capture the identity of `repo`'s checkout right now.
91    ///
92    /// All git invocations run on a VERIFICATION handle (hooks + fsmonitor
93    /// disabled): a poisoned `core.fsmonitor` in the checkout's config must
94    /// never get its payload executed by the detection itself (4th-pass
95    /// review — detection previously ran `git status` before comparing
96    /// config, so the payload ran first). The `.git` metadata itself is
97    /// read via the filesystem with BOUNDED, no-follow reads (5th-pass: a
98    /// replaced config or info/exclude that is a FIFO or an unbounded
99    /// source must not hang or exhaust the engine mid-detection — anything
100    /// unusual reads as a stable refusal marker, which is itself drift).
101    pub fn capture(repo: &GitRepo) -> Result<Self> {
102        let verification = repo.with_hooks_disabled()?;
103        let common = verification.git_common_dir()?;
104        Ok(CheckoutFingerprint {
105            head: verification.head_sha()?,
106            status: verification.porcelain_status()?,
107            git_config: bounded_metadata_read(&common.join("config")),
108            git_hooks: hook_listing(&common.join("hooks")),
109            git_refs: verification.for_each_ref()?,
110            index_flags: verification.ls_files_v()?,
111            info_exclude: bounded_metadata_read(&common.join("info").join("exclude")),
112        })
113    }
114
115    /// The drift between `self` (before the session) and `after` — `None`
116    /// when the checkout is byte-identical.
117    pub fn drift(&self, after: &Self) -> Option<CheckoutDrift> {
118        if self == after {
119            return None;
120        }
121        let before: std::collections::BTreeSet<&str> = self.status.lines().collect();
122        let later: std::collections::BTreeSet<&str> = after.status.lines().collect();
123        let mut metadata_fields: Vec<String> = Vec::new();
124        if self.git_config != after.git_config {
125            metadata_fields.push("config".to_string());
126        }
127        if self.git_hooks != after.git_hooks {
128            metadata_fields.push("hooks".to_string());
129        }
130        if self.git_refs != after.git_refs {
131            metadata_fields.push("refs".to_string());
132        }
133        if self.index_flags != after.index_flags {
134            metadata_fields.push("index-flags".to_string());
135        }
136        if self.info_exclude != after.info_exclude {
137            metadata_fields.push("info-exclude".to_string());
138        }
139        let git_metadata_changed = !metadata_fields.is_empty();
140        Some(CheckoutDrift {
141            head_before: self.head.clone(),
142            head_after: after.head.clone(),
143            appeared: later.difference(&before).map(|s| s.to_string()).collect(),
144            resolved: before.difference(&later).map(|s| s.to_string()).collect(),
145            git_metadata_changed,
146            git_metadata_fields: metadata_fields,
147        })
148    }
149}
150
151/// Bounded, no-follow read of a `.git` metadata file (config,
152/// info/exclude): regular files only, first 64 KiB (`.git` config files
153/// are kilobyte-scale; anything larger is itself suspicious). A symlink,
154/// FIFO, oversized, or unreadable path returns a STABLE refusal marker
155/// instead of being opened (5th-pass review: a validator that can replace
156/// the shared config with a FIFO or an unbounded source must not hang or
157/// exhaust the engine mid-capture — the marker is constant per shape, so
158/// it only registers as drift when it changes).
159fn bounded_metadata_read(path: &std::path::Path) -> String {
160    use std::io::Read as _;
161    const CAP: u64 = 64 * 1024;
162    let mut file = match open_regular_nofollow_nonblocking(path) {
163        Ok(file) => file,
164        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return String::new(),
165        Err(_) => return suspect_marker(path),
166    };
167    let Ok(metadata) = file.metadata() else {
168        return "SUSPECT:unreadable".to_string();
169    };
170    let file_type = metadata.file_type();
171    if !file_type.is_file() {
172        let kind = if file_type.is_dir() { "dir" } else { "special" };
173        return format!("SUSPECT:{kind}");
174    }
175    if metadata.len() > CAP {
176        return format!("SUSPECT:oversized:{}", metadata.len());
177    }
178    let mut buf = Vec::new();
179    match (&mut file).take(CAP + 1).read_to_end(&mut buf) {
180        Ok(_) if buf.len() as u64 <= CAP => String::from_utf8_lossy(&buf).into_owned(),
181        Ok(_) => format!("SUSPECT:oversized:{}+", CAP),
182        Err(_) => "SUSPECT:unreadable".to_string(),
183    }
184}
185
186/// Open a validator-controlled metadata entry without following its leaf and
187/// without blocking on a FIFO. The fd is verified after open, so swapping a
188/// regular entry for a symlink/device between `readdir` and `open` cannot
189/// escape the checks.
190fn open_regular_nofollow_nonblocking(path: &std::path::Path) -> std::io::Result<std::fs::File> {
191    let mut options = std::fs::OpenOptions::new();
192    options.read(true);
193    #[cfg(unix)]
194    {
195        use std::os::unix::fs::OpenOptionsExt as _;
196        options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
197    }
198    options.open(path)
199}
200
201fn suspect_marker(path: &std::path::Path) -> String {
202    let kind = match std::fs::symlink_metadata(path) {
203        Ok(metadata) if metadata.file_type().is_symlink() => "symlink",
204        Ok(metadata) if metadata.file_type().is_dir() => "dir",
205        Ok(metadata) if !metadata.file_type().is_file() => "special",
206        _ => "unreadable",
207    };
208    format!("SUSPECT:{kind}")
209}
210
211/// Sorted `name HASH` lines for the non-`.sample` hooks in `hooks_dir`
212/// (content-hashed: a same-length rewrite must not slip past). Reads are
213/// BOUNDED and never followed (4th-pass review): only regular files —
214/// hashed whole when ≤ 1 MiB (hooks are kilobyte-scale; the whole file
215/// closes the middle blind spot), and as first-32 KiB + last-32 KiB +
216/// length above 1 MiB (pathological size; the residual middle gap there is
217/// documented, not hidden). A symlink, FIFO, device, or other special
218/// entry is NEVER opened — a FIFO would block forever, a `/dev/zero`
219/// symlink would allocate without bound — and records a stable
220/// `name SUSPECT:<kind>` marker instead: the same marker on the next
221/// capture, so an unchanged oddity is not itself drift, but any change to
222/// it is. Absent or unreadable dirs list as empty.
223fn hook_listing(hooks_dir: &std::path::Path) -> String {
224    use std::hash::{Hash, Hasher};
225    const HOOK_FULL_READ_MAX: u64 = 1024 * 1024;
226    const HOOK_WINDOW: u64 = 32 * 1024;
227    let mut lines: Vec<String> = Vec::new();
228    if let Ok(entries) = std::fs::read_dir(hooks_dir) {
229        for entry in entries.flatten() {
230            let name = entry.file_name().to_string_lossy().into_owned();
231            if name.ends_with(".sample") {
232                continue;
233            }
234            let mut file = match open_regular_nofollow_nonblocking(&entry.path()) {
235                Ok(file) => file,
236                Err(_) => {
237                    lines.push(format!("{name} {}", suspect_marker(&entry.path())));
238                    continue;
239                }
240            };
241            let Ok(metadata) = file.metadata() else {
242                lines.push(format!("{name} SUSPECT:unreadable"));
243                continue;
244            };
245            let file_type = metadata.file_type();
246            if !file_type.is_file() {
247                let kind = if file_type.is_dir() { "dir" } else { "special" };
248                lines.push(format!("{name} SUSPECT:{kind}"));
249                continue;
250            }
251            use std::io::{Read as _, Seek as _, SeekFrom};
252            let len = metadata.len();
253            let mut hasher = std::collections::hash_map::DefaultHasher::new();
254            if len <= HOOK_FULL_READ_MAX {
255                let mut contents = Vec::new();
256                if (&mut file)
257                    .take(HOOK_FULL_READ_MAX + 1)
258                    .read_to_end(&mut contents)
259                    .is_err()
260                    || contents.len() as u64 > HOOK_FULL_READ_MAX
261                {
262                    lines.push(format!("{name} SUSPECT:oversized"));
263                    continue;
264                }
265                contents.hash(&mut hasher);
266            } else {
267                let mut head = vec![0u8; HOOK_WINDOW as usize];
268                let head_read = file.read(&mut head).unwrap_or(0);
269                head[..head_read].hash(&mut hasher);
270                let tail_start = len.saturating_sub(HOOK_WINDOW);
271                if file.seek(SeekFrom::Start(tail_start)).is_ok() {
272                    let mut tail = vec![0u8; HOOK_WINDOW as usize];
273                    let tail_read = file.read(&mut tail).unwrap_or(0);
274                    tail[..tail_read].hash(&mut hasher);
275                }
276                len.hash(&mut hasher);
277            }
278            lines.push(format!("{name} {:016x}", hasher.finish()));
279        }
280    }
281    lines.sort();
282    lines.join("\n")
283}
284
285/// What a validator session changed: HEAD movement plus the porcelain
286/// entries gained/lost across the session.
287#[derive(Debug, Clone, PartialEq, Eq)]
288pub struct CheckoutDrift {
289    pub head_before: String,
290    pub head_after: String,
291    /// Porcelain entries present after but not before (the session's
292    /// writes).
293    pub appeared: Vec<String>,
294    /// Porcelain entries present before but not after (the session reverted
295    /// or hid a pre-existing dirty state — equally a mutation).
296    pub resolved: Vec<String>,
297    /// `.git` config/hooks/refs changed — the checkout can look identical
298    /// while the plumbing was weaponized.
299    pub git_metadata_changed: bool,
300    /// WHICH metadata surfaces changed (`config`/`hooks`/`refs`/
301    /// `index-flags`/`info-exclude`) — recorded so a tripwire fire is
302    /// diagnosable without reconstructing the window (mission m-83d1ed
303    /// fired on metadata alone with no field named).
304    pub git_metadata_fields: Vec<String>,
305}
306
307impl CheckoutDrift {
308    /// One-line human summary for the `milestone.blocked` reason, capped so
309    /// a pathological drift (thousands of entries) stays readable.
310    pub fn summary(&self) -> String {
311        const MAX_ENTRIES: usize = 5;
312        let mut parts: Vec<String> = Vec::new();
313        if self.head_before != self.head_after {
314            parts.push(format!(
315                "HEAD moved {} -> {}",
316                short_sha(&self.head_before),
317                short_sha(&self.head_after)
318            ));
319        }
320        let entries = self.appeared.len() + self.resolved.len();
321        if entries > 0 {
322            let mut shown: Vec<&str> = self
323                .appeared
324                .iter()
325                .map(String::as_str)
326                .chain(self.resolved.iter().map(String::as_str))
327                .take(MAX_ENTRIES)
328                .collect();
329            if entries > MAX_ENTRIES {
330                shown.push("…");
331            }
332            parts.push(format!(
333                "{entries} status entr{} changed: {}",
334                if entries == 1 { "y" } else { "ies" },
335                shown.join(", ")
336            ));
337        }
338        if self.git_metadata_changed {
339            parts.push(format!(
340                ".git metadata changed ({})",
341                self.git_metadata_fields.join("/")
342            ));
343        }
344        parts.join("; ")
345    }
346}
347
348fn short_sha(sha: &str) -> &str {
349    sha.get(..7).unwrap_or(sha)
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    fn fp(head: &str, status: &str) -> CheckoutFingerprint {
357        CheckoutFingerprint {
358            head: head.to_string(),
359            status: status.to_string(),
360            git_config: String::new(),
361            git_hooks: String::new(),
362            git_refs: String::new(),
363            index_flags: String::new(),
364            info_exclude: String::new(),
365        }
366    }
367
368    #[test]
369    fn identical_fingerprints_have_no_drift() {
370        let before = fp("abc123", " M src/a.rs\n?? notes.txt\n");
371        assert_eq!(before.drift(&before.clone()), None);
372    }
373
374    #[test]
375    fn head_move_is_drift_even_with_identical_status() {
376        let before = fp("abc1234", "");
377        let after = fp("def5678", "");
378        let drift = before.drift(&after).expect("head move must be drift");
379        assert_eq!(drift.head_before, "abc1234");
380        assert_eq!(drift.head_after, "def5678");
381        assert!(drift.appeared.is_empty());
382        assert!(drift.resolved.is_empty());
383        assert!(drift.summary().contains("HEAD moved abc1234 -> def5678"));
384    }
385
386    #[test]
387    fn status_changes_split_into_appeared_and_resolved() {
388        let before = fp("abc1234", " M src/a.rs\n");
389        let after = fp("abc1234", " M src/b.rs\n?? dropped.rs\n");
390        let drift = before.drift(&after).expect("status change must be drift");
391        assert_eq!(drift.appeared, vec![" M src/b.rs", "?? dropped.rs"]);
392        assert_eq!(drift.resolved, vec![" M src/a.rs"]);
393        let summary = drift.summary();
394        assert!(summary.contains("3 status entries changed"), "{summary}");
395        assert!(summary.contains("?? dropped.rs"), "{summary}");
396    }
397
398    #[test]
399    fn summary_caps_long_entry_lists() {
400        let after_status: String = (0..20).map(|i| format!("?? f{i}.rs\n")).collect();
401        let drift = fp("h", "").drift(&fp("h", &after_status)).unwrap();
402        let summary = drift.summary();
403        assert!(summary.contains("20 status entries changed"), "{summary}");
404        assert!(summary.contains('…'), "{summary}");
405    }
406
407    /// 4th-pass review: skip-worktree/assume-unchanged flags and the local
408    /// exclude file hide modifications from porcelain — changing either is
409    /// metadata drift even with HEAD and status byte-identical.
410    #[test]
411    fn index_flags_and_info_exclude_changes_are_drift() {
412        let before = fp("abc1234", "");
413
414        let mut flagged = before.clone();
415        flagged.index_flags = "S src/hidden_test.rs\n".to_string();
416        let drift = before
417            .drift(&flagged)
418            .expect("a skip-worktree flag must be drift");
419        assert!(drift.git_metadata_changed);
420
421        let mut excluded = before.clone();
422        excluded.info_exclude = "secret-test.sh\n".to_string();
423        let drift = before
424            .drift(&excluded)
425            .expect("an info/exclude change must be drift");
426        assert!(drift.git_metadata_changed);
427    }
428
429    /// 4th-pass review: special entries (FIFO/symlink/dir) are never opened
430    /// — they record a stable SUSPECT marker instead, so the listing cannot
431    /// block or exhaust memory, and an unchanged oddity is not drift.
432    #[cfg(unix)]
433    #[test]
434    fn hook_listing_marks_special_entries_without_opening_them() {
435        use std::os::unix::fs::symlink;
436        let dir = tempfile::tempdir().unwrap();
437        let hooks = dir.path().join("hooks");
438        std::fs::create_dir(&hooks).unwrap();
439        // A FIFO: opening it for read would block forever.
440        let fifo_path = std::ffi::CString::new(hooks.join("evil-fifo").to_str().unwrap()).unwrap();
441        let rc = unsafe { libc::mkfifo(fifo_path.as_ptr(), 0o700) };
442        assert_eq!(rc, 0, "mkfifo failed");
443        // A symlink to an unbounded device: reading it would never fill.
444        symlink("/dev/zero", hooks.join("evil-link")).unwrap();
445        std::fs::create_dir(hooks.join("nested")).unwrap();
446        std::fs::write(hooks.join("good-hook"), b"echo ok").unwrap();
447
448        let listing = hook_listing(&hooks);
449        assert!(listing.contains("evil-fifo SUSPECT:special"), "{listing}");
450        assert!(listing.contains("evil-link SUSPECT:symlink"), "{listing}");
451        assert!(listing.contains("nested SUSPECT:dir"), "{listing}");
452        assert!(listing.contains("good-hook "), "{listing}");
453        // Stable: the same oddities list identically (no false drift).
454        assert_eq!(listing, hook_listing(&hooks));
455    }
456
457    #[cfg(unix)]
458    #[test]
459    fn metadata_reader_refuses_fifo_and_unbounded_symlink_without_opening_them() {
460        use std::os::unix::fs::symlink;
461        let dir = tempfile::tempdir().unwrap();
462        let fifo = dir.path().join("config-fifo");
463        let fifo_c = std::ffi::CString::new(fifo.to_str().unwrap()).unwrap();
464        assert_eq!(unsafe { libc::mkfifo(fifo_c.as_ptr(), 0o600) }, 0);
465        let link = dir.path().join("config-link");
466        symlink("/dev/zero", &link).unwrap();
467
468        assert_eq!(bounded_metadata_read(&fifo), "SUSPECT:special");
469        assert_eq!(bounded_metadata_read(&link), "SUSPECT:symlink");
470    }
471
472    #[cfg(unix)]
473    #[test]
474    fn capture_disables_validator_controlled_fsmonitor_before_running_git() {
475        use std::os::unix::fs::PermissionsExt as _;
476        use std::process::Command;
477        let dir = tempfile::tempdir().unwrap();
478        let git = |args: &[&str]| {
479            Command::new("git")
480                .args(args)
481                .current_dir(dir.path())
482                .output()
483                .expect("run git")
484        };
485        assert!(git(&["init", "-q"]).status.success());
486        std::fs::write(dir.path().join("tracked"), "one").unwrap();
487        assert!(git(&["add", "tracked"]).status.success());
488        assert!(git(&[
489            "-c",
490            "user.name=kranz-test",
491            "-c",
492            "user.email=kranz@test.invalid",
493            "commit",
494            "-qm",
495            "initial",
496        ])
497        .status
498        .success());
499
500        let marker = dir.path().join("fsmonitor-ran");
501        let monitor = dir.path().join("evil-fsmonitor");
502        std::fs::write(
503            &monitor,
504            format!(
505                "#!/bin/sh\nprintf invoked > '{}'\nexit 1\n",
506                marker.display()
507            ),
508        )
509        .unwrap();
510        std::fs::set_permissions(&monitor, std::fs::Permissions::from_mode(0o755)).unwrap();
511        assert!(
512            git(&["config", "core.fsmonitor", monitor.to_str().unwrap()])
513                .status
514                .success()
515        );
516
517        let _ = git(&["status", "--porcelain"]);
518        assert!(
519            marker.exists(),
520            "fixture: ordinary git status runs fsmonitor"
521        );
522        std::fs::remove_file(&marker).unwrap();
523
524        let repo = GitRepo::open(dir.path()).unwrap();
525        CheckoutFingerprint::capture(&repo).unwrap();
526        assert!(
527            !marker.exists(),
528            "fingerprint capture must disable fsmonitor before its first git invocation"
529        );
530    }
531
532    /// An over-cap hook still hashes deterministically, and a tail change
533    /// beyond the read cap still changes the line via the recorded length.
534    #[test]
535    fn hook_listing_bounds_large_hooks_but_still_notices_tail_changes() {
536        let dir = tempfile::tempdir().unwrap();
537        let hooks = dir.path().join("hooks");
538        std::fs::create_dir(&hooks).unwrap();
539        std::fs::write(hooks.join("big"), vec![b'a'; 128 * 1024]).unwrap();
540
541        let first = hook_listing(&hooks);
542        assert!(first.starts_with("big "), "{first}");
543        assert_eq!(first, hook_listing(&hooks), "listing is deterministic");
544
545        // Change ONLY bytes beyond the 64 KiB read cap: the length half of
546        // the hash still notices.
547        let mut contents = vec![b'a'; 128 * 1024];
548        contents[127 * 1024] = b'b';
549        std::fs::write(hooks.join("big"), &contents).unwrap();
550        assert_ne!(first, hook_listing(&hooks));
551    }
552
553    /// 3rd-pass review: the worktree can look identical while `.git` was
554    /// weaponized — config (`core.fsmonitor`), a planted hook, or a moved
555    /// ref must each be drift even with HEAD and status untouched.
556    #[test]
557    fn git_metadata_change_is_drift_with_identical_checkout() {
558        let before = fp("abc1234", "");
559
560        let mut config_tampered = before.clone();
561        config_tampered.git_config = "[core]\n\tfsmonitor = evil\n".to_string();
562        let drift = before
563            .drift(&config_tampered)
564            .expect("config tamper must be drift");
565        assert!(drift.git_metadata_changed);
566        assert!(
567            drift.summary().contains(".git metadata"),
568            "{}",
569            drift.summary()
570        );
571
572        let mut hook_planted = before.clone();
573        hook_planted.git_hooks = "post-checkout deadbeefdeadbeef\n".to_string();
574        let drift = before
575            .drift(&hook_planted)
576            .expect("planted hook must be drift");
577        assert!(drift.git_metadata_changed);
578
579        let mut ref_moved = before.clone();
580        ref_moved.git_refs = "refs/heads/main deadbeef\n".to_string();
581        let drift = before.drift(&ref_moved).expect("moved ref must be drift");
582        assert!(drift.git_metadata_changed);
583
584        // No false positive: identical metadata is not drift.
585        assert_eq!(before.drift(&before.clone()), None);
586    }
587
588    /// The hook listing ignores `.sample` files and hashes contents, so a
589    /// same-length rewrite still changes the line.
590    #[test]
591    fn hook_listing_skips_samples_and_hashes_contents() {
592        let dir = tempfile::tempdir().unwrap();
593        let hooks = dir.path().join("hooks");
594        std::fs::create_dir(&hooks).unwrap();
595        std::fs::write(hooks.join("pre-commit.sample"), "sample-a").unwrap();
596        std::fs::write(hooks.join("post-checkout"), b"echo one").unwrap();
597
598        let listing = hook_listing(&hooks);
599        assert!(!listing.contains("sample"), "{listing}");
600        assert!(listing.starts_with("post-checkout "), "{listing}");
601
602        // Same name, same length, different content: the hash must change.
603        std::fs::write(hooks.join("post-checkout"), b"echo two").unwrap();
604        let rewritten = hook_listing(&hooks);
605        assert_ne!(listing, rewritten);
606
607        // An absent dir lists empty (== an empty hooks dir).
608        assert_eq!(hook_listing(&dir.path().join("missing")), "");
609    }
610}