1use crate::error::Result;
56use crate::git_ops::GitRepo;
57
58#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct CheckoutFingerprint {
62 pub head: String,
64 pub status: String,
68 pub git_config: String,
73 pub git_hooks: String,
77 pub git_refs: String,
80 pub index_flags: String,
84 pub info_exclude: String,
87}
88
89impl CheckoutFingerprint {
90 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 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
151fn 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
186fn 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
211fn 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#[derive(Debug, Clone, PartialEq, Eq)]
288pub struct CheckoutDrift {
289 pub head_before: String,
290 pub head_after: String,
291 pub appeared: Vec<String>,
294 pub resolved: Vec<String>,
297 pub git_metadata_changed: bool,
300 pub git_metadata_fields: Vec<String>,
305}
306
307impl CheckoutDrift {
308 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 #[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 #[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 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 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 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 #[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 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 #[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 assert_eq!(before.drift(&before.clone()), None);
586 }
587
588 #[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 std::fs::write(hooks.join("post-checkout"), b"echo two").unwrap();
604 let rewritten = hook_listing(&hooks);
605 assert_ne!(listing, rewritten);
606
607 assert_eq!(hook_listing(&dir.path().join("missing")), "");
609 }
610}