use crate::error::Result;
use crate::git_ops::GitRepo;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckoutFingerprint {
pub head: String,
pub status: String,
pub git_config: String,
pub git_hooks: String,
pub git_refs: String,
pub index_flags: String,
pub info_exclude: String,
}
impl CheckoutFingerprint {
pub fn capture(repo: &GitRepo) -> Result<Self> {
let verification = repo.with_hooks_disabled()?;
let common = verification.git_common_dir()?;
Ok(CheckoutFingerprint {
head: verification.head_sha()?,
status: verification.porcelain_status()?,
git_config: bounded_metadata_read(&common.join("config")),
git_hooks: hook_listing(&common.join("hooks")),
git_refs: verification.for_each_ref()?,
index_flags: verification.ls_files_v()?,
info_exclude: bounded_metadata_read(&common.join("info").join("exclude")),
})
}
pub fn drift(&self, after: &Self) -> Option<CheckoutDrift> {
if self == after {
return None;
}
let before: std::collections::BTreeSet<&str> = self.status.lines().collect();
let later: std::collections::BTreeSet<&str> = after.status.lines().collect();
let mut metadata_fields: Vec<String> = Vec::new();
if self.git_config != after.git_config {
metadata_fields.push("config".to_string());
}
if self.git_hooks != after.git_hooks {
metadata_fields.push("hooks".to_string());
}
if self.git_refs != after.git_refs {
metadata_fields.push("refs".to_string());
}
if self.index_flags != after.index_flags {
metadata_fields.push("index-flags".to_string());
}
if self.info_exclude != after.info_exclude {
metadata_fields.push("info-exclude".to_string());
}
let git_metadata_changed = !metadata_fields.is_empty();
Some(CheckoutDrift {
head_before: self.head.clone(),
head_after: after.head.clone(),
appeared: later.difference(&before).map(|s| s.to_string()).collect(),
resolved: before.difference(&later).map(|s| s.to_string()).collect(),
git_metadata_changed,
git_metadata_fields: metadata_fields,
})
}
}
fn bounded_metadata_read(path: &std::path::Path) -> String {
use std::io::Read as _;
const CAP: u64 = 64 * 1024;
let mut file = match open_regular_nofollow_nonblocking(path) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return String::new(),
Err(_) => return suspect_marker(path),
};
let Ok(metadata) = file.metadata() else {
return "SUSPECT:unreadable".to_string();
};
let file_type = metadata.file_type();
if !file_type.is_file() {
let kind = if file_type.is_dir() { "dir" } else { "special" };
return format!("SUSPECT:{kind}");
}
if metadata.len() > CAP {
return format!("SUSPECT:oversized:{}", metadata.len());
}
let mut buf = Vec::new();
match (&mut file).take(CAP + 1).read_to_end(&mut buf) {
Ok(_) if buf.len() as u64 <= CAP => String::from_utf8_lossy(&buf).into_owned(),
Ok(_) => format!("SUSPECT:oversized:{}+", CAP),
Err(_) => "SUSPECT:unreadable".to_string(),
}
}
fn open_regular_nofollow_nonblocking(path: &std::path::Path) -> std::io::Result<std::fs::File> {
let mut options = std::fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
}
options.open(path)
}
fn suspect_marker(path: &std::path::Path) -> String {
let kind = match std::fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => "symlink",
Ok(metadata) if metadata.file_type().is_dir() => "dir",
Ok(metadata) if !metadata.file_type().is_file() => "special",
_ => "unreadable",
};
format!("SUSPECT:{kind}")
}
fn hook_listing(hooks_dir: &std::path::Path) -> String {
use std::hash::{Hash, Hasher};
const HOOK_FULL_READ_MAX: u64 = 1024 * 1024;
const HOOK_WINDOW: u64 = 32 * 1024;
let mut lines: Vec<String> = Vec::new();
if let Ok(entries) = std::fs::read_dir(hooks_dir) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if name.ends_with(".sample") {
continue;
}
let mut file = match open_regular_nofollow_nonblocking(&entry.path()) {
Ok(file) => file,
Err(_) => {
lines.push(format!("{name} {}", suspect_marker(&entry.path())));
continue;
}
};
let Ok(metadata) = file.metadata() else {
lines.push(format!("{name} SUSPECT:unreadable"));
continue;
};
let file_type = metadata.file_type();
if !file_type.is_file() {
let kind = if file_type.is_dir() { "dir" } else { "special" };
lines.push(format!("{name} SUSPECT:{kind}"));
continue;
}
use std::io::{Read as _, Seek as _, SeekFrom};
let len = metadata.len();
let mut hasher = std::collections::hash_map::DefaultHasher::new();
if len <= HOOK_FULL_READ_MAX {
let mut contents = Vec::new();
if (&mut file)
.take(HOOK_FULL_READ_MAX + 1)
.read_to_end(&mut contents)
.is_err()
|| contents.len() as u64 > HOOK_FULL_READ_MAX
{
lines.push(format!("{name} SUSPECT:oversized"));
continue;
}
contents.hash(&mut hasher);
} else {
let mut head = vec![0u8; HOOK_WINDOW as usize];
let head_read = file.read(&mut head).unwrap_or(0);
head[..head_read].hash(&mut hasher);
let tail_start = len.saturating_sub(HOOK_WINDOW);
if file.seek(SeekFrom::Start(tail_start)).is_ok() {
let mut tail = vec![0u8; HOOK_WINDOW as usize];
let tail_read = file.read(&mut tail).unwrap_or(0);
tail[..tail_read].hash(&mut hasher);
}
len.hash(&mut hasher);
}
lines.push(format!("{name} {:016x}", hasher.finish()));
}
}
lines.sort();
lines.join("\n")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckoutDrift {
pub head_before: String,
pub head_after: String,
pub appeared: Vec<String>,
pub resolved: Vec<String>,
pub git_metadata_changed: bool,
pub git_metadata_fields: Vec<String>,
}
impl CheckoutDrift {
pub fn summary(&self) -> String {
const MAX_ENTRIES: usize = 5;
let mut parts: Vec<String> = Vec::new();
if self.head_before != self.head_after {
parts.push(format!(
"HEAD moved {} -> {}",
short_sha(&self.head_before),
short_sha(&self.head_after)
));
}
let entries = self.appeared.len() + self.resolved.len();
if entries > 0 {
let mut shown: Vec<&str> = self
.appeared
.iter()
.map(String::as_str)
.chain(self.resolved.iter().map(String::as_str))
.take(MAX_ENTRIES)
.collect();
if entries > MAX_ENTRIES {
shown.push("…");
}
parts.push(format!(
"{entries} status entr{} changed: {}",
if entries == 1 { "y" } else { "ies" },
shown.join(", ")
));
}
if self.git_metadata_changed {
parts.push(format!(
".git metadata changed ({})",
self.git_metadata_fields.join("/")
));
}
parts.join("; ")
}
}
fn short_sha(sha: &str) -> &str {
sha.get(..7).unwrap_or(sha)
}
#[cfg(test)]
mod tests {
use super::*;
fn fp(head: &str, status: &str) -> CheckoutFingerprint {
CheckoutFingerprint {
head: head.to_string(),
status: status.to_string(),
git_config: String::new(),
git_hooks: String::new(),
git_refs: String::new(),
index_flags: String::new(),
info_exclude: String::new(),
}
}
#[test]
fn identical_fingerprints_have_no_drift() {
let before = fp("abc123", " M src/a.rs\n?? notes.txt\n");
assert_eq!(before.drift(&before.clone()), None);
}
#[test]
fn head_move_is_drift_even_with_identical_status() {
let before = fp("abc1234", "");
let after = fp("def5678", "");
let drift = before.drift(&after).expect("head move must be drift");
assert_eq!(drift.head_before, "abc1234");
assert_eq!(drift.head_after, "def5678");
assert!(drift.appeared.is_empty());
assert!(drift.resolved.is_empty());
assert!(drift.summary().contains("HEAD moved abc1234 -> def5678"));
}
#[test]
fn status_changes_split_into_appeared_and_resolved() {
let before = fp("abc1234", " M src/a.rs\n");
let after = fp("abc1234", " M src/b.rs\n?? dropped.rs\n");
let drift = before.drift(&after).expect("status change must be drift");
assert_eq!(drift.appeared, vec![" M src/b.rs", "?? dropped.rs"]);
assert_eq!(drift.resolved, vec![" M src/a.rs"]);
let summary = drift.summary();
assert!(summary.contains("3 status entries changed"), "{summary}");
assert!(summary.contains("?? dropped.rs"), "{summary}");
}
#[test]
fn summary_caps_long_entry_lists() {
let after_status: String = (0..20).map(|i| format!("?? f{i}.rs\n")).collect();
let drift = fp("h", "").drift(&fp("h", &after_status)).unwrap();
let summary = drift.summary();
assert!(summary.contains("20 status entries changed"), "{summary}");
assert!(summary.contains('…'), "{summary}");
}
#[test]
fn index_flags_and_info_exclude_changes_are_drift() {
let before = fp("abc1234", "");
let mut flagged = before.clone();
flagged.index_flags = "S src/hidden_test.rs\n".to_string();
let drift = before
.drift(&flagged)
.expect("a skip-worktree flag must be drift");
assert!(drift.git_metadata_changed);
let mut excluded = before.clone();
excluded.info_exclude = "secret-test.sh\n".to_string();
let drift = before
.drift(&excluded)
.expect("an info/exclude change must be drift");
assert!(drift.git_metadata_changed);
}
#[cfg(unix)]
#[test]
fn hook_listing_marks_special_entries_without_opening_them() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let hooks = dir.path().join("hooks");
std::fs::create_dir(&hooks).unwrap();
let fifo_path = std::ffi::CString::new(hooks.join("evil-fifo").to_str().unwrap()).unwrap();
let rc = unsafe { libc::mkfifo(fifo_path.as_ptr(), 0o700) };
assert_eq!(rc, 0, "mkfifo failed");
symlink("/dev/zero", hooks.join("evil-link")).unwrap();
std::fs::create_dir(hooks.join("nested")).unwrap();
std::fs::write(hooks.join("good-hook"), b"echo ok").unwrap();
let listing = hook_listing(&hooks);
assert!(listing.contains("evil-fifo SUSPECT:special"), "{listing}");
assert!(listing.contains("evil-link SUSPECT:symlink"), "{listing}");
assert!(listing.contains("nested SUSPECT:dir"), "{listing}");
assert!(listing.contains("good-hook "), "{listing}");
assert_eq!(listing, hook_listing(&hooks));
}
#[cfg(unix)]
#[test]
fn metadata_reader_refuses_fifo_and_unbounded_symlink_without_opening_them() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let fifo = dir.path().join("config-fifo");
let fifo_c = std::ffi::CString::new(fifo.to_str().unwrap()).unwrap();
assert_eq!(unsafe { libc::mkfifo(fifo_c.as_ptr(), 0o600) }, 0);
let link = dir.path().join("config-link");
symlink("/dev/zero", &link).unwrap();
assert_eq!(bounded_metadata_read(&fifo), "SUSPECT:special");
assert_eq!(bounded_metadata_read(&link), "SUSPECT:symlink");
}
#[cfg(unix)]
#[test]
fn capture_disables_validator_controlled_fsmonitor_before_running_git() {
use std::os::unix::fs::PermissionsExt as _;
use std::process::Command;
let dir = tempfile::tempdir().unwrap();
let git = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(dir.path())
.output()
.expect("run git")
};
assert!(git(&["init", "-q"]).status.success());
std::fs::write(dir.path().join("tracked"), "one").unwrap();
assert!(git(&["add", "tracked"]).status.success());
assert!(git(&[
"-c",
"user.name=kranz-test",
"-c",
"user.email=kranz@test.invalid",
"commit",
"-qm",
"initial",
])
.status
.success());
let marker = dir.path().join("fsmonitor-ran");
let monitor = dir.path().join("evil-fsmonitor");
std::fs::write(
&monitor,
format!(
"#!/bin/sh\nprintf invoked > '{}'\nexit 1\n",
marker.display()
),
)
.unwrap();
std::fs::set_permissions(&monitor, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(
git(&["config", "core.fsmonitor", monitor.to_str().unwrap()])
.status
.success()
);
let _ = git(&["status", "--porcelain"]);
assert!(
marker.exists(),
"fixture: ordinary git status runs fsmonitor"
);
std::fs::remove_file(&marker).unwrap();
let repo = GitRepo::open(dir.path()).unwrap();
CheckoutFingerprint::capture(&repo).unwrap();
assert!(
!marker.exists(),
"fingerprint capture must disable fsmonitor before its first git invocation"
);
}
#[test]
fn hook_listing_bounds_large_hooks_but_still_notices_tail_changes() {
let dir = tempfile::tempdir().unwrap();
let hooks = dir.path().join("hooks");
std::fs::create_dir(&hooks).unwrap();
std::fs::write(hooks.join("big"), vec![b'a'; 128 * 1024]).unwrap();
let first = hook_listing(&hooks);
assert!(first.starts_with("big "), "{first}");
assert_eq!(first, hook_listing(&hooks), "listing is deterministic");
let mut contents = vec![b'a'; 128 * 1024];
contents[127 * 1024] = b'b';
std::fs::write(hooks.join("big"), &contents).unwrap();
assert_ne!(first, hook_listing(&hooks));
}
#[test]
fn git_metadata_change_is_drift_with_identical_checkout() {
let before = fp("abc1234", "");
let mut config_tampered = before.clone();
config_tampered.git_config = "[core]\n\tfsmonitor = evil\n".to_string();
let drift = before
.drift(&config_tampered)
.expect("config tamper must be drift");
assert!(drift.git_metadata_changed);
assert!(
drift.summary().contains(".git metadata"),
"{}",
drift.summary()
);
let mut hook_planted = before.clone();
hook_planted.git_hooks = "post-checkout deadbeefdeadbeef\n".to_string();
let drift = before
.drift(&hook_planted)
.expect("planted hook must be drift");
assert!(drift.git_metadata_changed);
let mut ref_moved = before.clone();
ref_moved.git_refs = "refs/heads/main deadbeef\n".to_string();
let drift = before.drift(&ref_moved).expect("moved ref must be drift");
assert!(drift.git_metadata_changed);
assert_eq!(before.drift(&before.clone()), None);
}
#[test]
fn hook_listing_skips_samples_and_hashes_contents() {
let dir = tempfile::tempdir().unwrap();
let hooks = dir.path().join("hooks");
std::fs::create_dir(&hooks).unwrap();
std::fs::write(hooks.join("pre-commit.sample"), "sample-a").unwrap();
std::fs::write(hooks.join("post-checkout"), b"echo one").unwrap();
let listing = hook_listing(&hooks);
assert!(!listing.contains("sample"), "{listing}");
assert!(listing.starts_with("post-checkout "), "{listing}");
std::fs::write(hooks.join("post-checkout"), b"echo two").unwrap();
let rewritten = hook_listing(&hooks);
assert_ne!(listing, rewritten);
assert_eq!(hook_listing(&dir.path().join("missing")), "");
}
}