use std::path::Path;
use crate::error::{MindError, Result};
const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
struct Fnv(u64);
impl Fnv {
fn new() -> Self {
Fnv(FNV_OFFSET)
}
fn write(&mut self, bytes: &[u8]) {
for &b in bytes {
self.0 ^= b as u64;
self.0 = self.0.wrapping_mul(FNV_PRIME);
}
}
fn finish_hex(self) -> String {
format!("{:016x}", self.0)
}
fn value(self) -> u64 {
self.0
}
}
pub(crate) fn hash_str(s: &str) -> String {
let mut h = Fnv::new();
h.write(s.as_bytes());
h.finish_hex()
}
pub fn hash_path(path: &Path) -> Result<String> {
let mut h = Fnv::new();
let meta = std::fs::symlink_metadata(path).map_err(|e| MindError::io(path, e))?;
if meta.file_type().is_symlink() {
let target = std::fs::read_link(path).map_err(|e| MindError::io(path, e))?;
let target_bytes = target.to_string_lossy();
h.write(b"S");
h.write(&(target_bytes.len() as u64).to_le_bytes());
h.write(target_bytes.as_bytes());
} else if meta.is_dir() {
let mut files = Vec::new();
collect_files(path, path, &mut files)?;
files.sort();
for (tag, rel, bytes) in files {
h.write(&[tag]);
h.write(&(rel.len() as u64).to_le_bytes());
h.write(rel.as_bytes());
h.write(&(bytes.len() as u64).to_le_bytes());
h.write(&bytes);
}
} else {
let bytes = std::fs::read(path).map_err(|e| MindError::io(path, e))?;
h.write(b"F");
h.write(&bytes);
}
Ok(h.finish_hex())
}
pub(crate) fn stat_fingerprint(path: &Path) -> Result<String> {
let mut h = Fnv::new();
let meta = std::fs::symlink_metadata(path).map_err(|e| MindError::io(path, e))?;
if meta.file_type().is_symlink() {
let target = std::fs::read_link(path).map_err(|e| MindError::io(path, e))?;
let target_bytes = target.to_string_lossy();
h.write(b"S");
h.write(&(target_bytes.len() as u64).to_le_bytes());
h.write(target_bytes.as_bytes());
} else if meta.is_dir() {
let mut entries = Vec::new();
collect_stats(path, path, &mut entries)?;
entries.sort();
for (tag, rel, mtime, size, ino, ctime) in entries {
h.write(&[tag]);
h.write(&(rel.len() as u64).to_le_bytes());
h.write(rel.as_bytes());
h.write(&mtime.to_le_bytes());
h.write(&size.to_le_bytes());
h.write(&ino.to_le_bytes());
h.write(&ctime.to_le_bytes());
}
} else {
let (ino, ctime) = unix_stat_fields(&meta);
h.write(b"F");
h.write(&mtime_nanos(&meta, path)?.to_le_bytes());
h.write(&meta.len().to_le_bytes());
h.write(&ino.to_le_bytes());
h.write(&ctime.to_le_bytes());
}
Ok(h.finish_hex())
}
#[cfg(unix)]
fn unix_stat_fields(meta: &std::fs::Metadata) -> (u64, i128) {
use std::os::unix::fs::MetadataExt;
let ctime_nanos = meta.ctime() as i128 * 1_000_000_000 + meta.ctime_nsec() as i128;
(meta.ino(), ctime_nanos)
}
#[cfg(not(unix))]
fn unix_stat_fields(_meta: &std::fs::Metadata) -> (u64, i128) {
(0, 0)
}
fn mtime_nanos(meta: &std::fs::Metadata, path: &Path) -> Result<i128> {
let t = meta.modified().map_err(|e| MindError::io(path, e))?;
Ok(match t.duration_since(std::time::UNIX_EPOCH) {
Ok(d) => d.as_nanos() as i128,
Err(e) => -(e.duration().as_nanos() as i128),
})
}
fn collect_stats(
root: &Path,
dir: &Path,
out: &mut Vec<(u8, String, i128, u64, u64, i128)>,
) -> Result<()> {
let rd = std::fs::read_dir(dir).map_err(|e| MindError::io(dir, e))?;
for entry in rd {
let entry = entry.map_err(|e| MindError::io(dir, e))?;
let path = entry.path();
let meta = std::fs::symlink_metadata(&path).map_err(|e| MindError::io(&path, e))?;
let ft = meta.file_type();
let rel = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.into_owned();
if ft.is_symlink() {
let target = std::fs::read_link(&path).map_err(|e| MindError::io(&path, e))?;
let target_bytes = target.to_string_lossy();
let mut th = Fnv::new();
th.write(target_bytes.as_bytes());
out.push((b'S', rel, 0, th.value(), 0, 0));
} else if ft.is_dir() {
collect_stats(root, &path, out)?;
} else {
let (ino, ctime) = unix_stat_fields(&meta);
out.push((
b'F',
rel,
mtime_nanos(&meta, &path)?,
meta.len(),
ino,
ctime,
));
}
}
Ok(())
}
fn collect_files(root: &Path, dir: &Path, out: &mut Vec<(u8, String, Vec<u8>)>) -> Result<()> {
collect_files_at(root, dir, out, 0)
}
fn collect_files_at(
root: &Path,
dir: &Path,
out: &mut Vec<(u8, String, Vec<u8>)>,
depth: usize,
) -> Result<()> {
if depth > crate::install::MAX_ITEM_TREE_DEPTH {
return Err(crate::install::depth_exceeded(dir));
}
let rd = std::fs::read_dir(dir).map_err(|e| MindError::io(dir, e))?;
for entry in rd {
let entry = entry.map_err(|e| MindError::io(dir, e))?;
let path = entry.path();
let meta = std::fs::symlink_metadata(&path).map_err(|e| MindError::io(&path, e))?;
let ft = meta.file_type();
if ft.is_symlink() {
let target = std::fs::read_link(&path).map_err(|e| MindError::io(&path, e))?;
let rel = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.into_owned();
out.push((
b'S',
rel,
target.to_string_lossy().into_owned().into_bytes(),
));
} else if ft.is_dir() {
collect_files_at(root, &path, out, depth + 1)?;
} else {
let rel = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.into_owned();
let bytes = std::fs::read(&path).map_err(|e| MindError::io(&path, e))?;
out.push((b'F', rel, bytes));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
struct TmpDir(std::path::PathBuf);
impl Drop for TmpDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
impl std::ops::Deref for TmpDir {
type Target = std::path::Path;
fn deref(&self) -> &std::path::Path {
&self.0
}
}
fn tmp(name: &str) -> TmpDir {
let dir = std::env::temp_dir().join(format!("mind-hashtest-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
TmpDir(dir)
}
#[test]
fn hash_str_is_stable_16_hex_and_distinguishes_input() {
let a = hash_str("skills/foo");
let b = hash_str("skills/foo");
let c = hash_str("skills/bar");
assert_eq!(a, b, "hash_str must be deterministic for the same input");
assert_ne!(a, c, "distinct inputs must (in practice) hash differently");
assert_eq!(a.len(), 16, "hash_str must return a 16-hex-digit digest");
assert!(
a.chars().all(|ch| ch.is_ascii_hexdigit()),
"hash_str digest must be pure hex: {a}"
);
}
#[test]
fn stat_fingerprint_is_stable_and_changes_with_content() {
let dir = tmp("fingerprint");
std::fs::write(dir.join("a.txt"), b"hello").unwrap();
let f1 = stat_fingerprint(&dir).unwrap();
let f2 = stat_fingerprint(&dir).unwrap();
assert_eq!(f1, f2, "fingerprint must be stable for an untouched tree");
std::fs::write(dir.join("a.txt"), b"hello world, longer").unwrap();
let f3 = stat_fingerprint(&dir).unwrap();
assert_ne!(f1, f3, "a content+size change must change the fingerprint");
std::fs::write(dir.join("b.txt"), b"x").unwrap();
let f4 = stat_fingerprint(&dir).unwrap();
assert_ne!(f3, f4, "adding a file must change the fingerprint");
std::fs::remove_file(dir.join("b.txt")).unwrap();
let f5 = stat_fingerprint(&dir).unwrap();
assert_ne!(f4, f5, "removing a file must change the fingerprint");
}
#[test]
fn stat_fingerprint_covers_nested_files_and_symlink_targets() {
let dir = tmp("fingerprint-nested");
std::fs::create_dir_all(dir.join("sub")).unwrap();
std::fs::write(dir.join("sub/deep.txt"), b"one").unwrap();
let before = stat_fingerprint(&dir).unwrap();
std::fs::write(dir.join("sub/deep.txt"), b"two different").unwrap();
assert_ne!(
before,
stat_fingerprint(&dir).unwrap(),
"a nested file edit must change the fingerprint"
);
#[cfg(unix)]
{
let link = dir.join("link");
std::os::unix::fs::symlink("first-target", &link).unwrap();
let with_first = stat_fingerprint(&dir).unwrap();
std::fs::remove_file(&link).unwrap();
std::os::unix::fs::symlink("second-target", &link).unwrap();
assert_ne!(
with_first,
stat_fingerprint(&dir).unwrap(),
"a symlink retarget must change the fingerprint"
);
}
}
#[test]
fn stat_fingerprint_detects_same_size_content_change() {
let dir = tmp("fingerprint-same-size");
std::fs::write(dir.join("a.txt"), b"aaaaa").unwrap();
let f1 = stat_fingerprint(&dir).unwrap();
std::fs::write(dir.join("a.txt"), b"bbbbb").unwrap();
let f2 = stat_fingerprint(&dir).unwrap();
assert_ne!(
f1, f2,
"a same-size content rewrite must change the fingerprint"
);
}
#[cfg(unix)]
fn force_mtime(path: &std::path::Path, seconds: i64, nanos: i64) {
use std::ffi::CString;
let c_path = CString::new(path.to_str().unwrap()).unwrap();
let times = [
libc::timespec {
tv_sec: seconds,
tv_nsec: nanos,
},
libc::timespec {
tv_sec: seconds,
tv_nsec: nanos,
},
];
let rc = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) };
assert_eq!(rc, 0, "utimensat must succeed to force mtime for this test");
}
#[cfg(unix)]
#[test]
fn stat_fingerprint_same_size_mtime_preserving_rewrite_still_changes() {
let dir = tmp("fingerprint-mtime-preserving");
let f = dir.join("a.txt");
std::fs::write(&f, b"aaaaa").unwrap();
let meta_before = std::fs::metadata(&f).unwrap();
let mtime_before = meta_before.modified().unwrap();
let dur = mtime_before.duration_since(std::time::UNIX_EPOCH).unwrap();
let before = stat_fingerprint(&dir).unwrap();
std::fs::write(&f, b"bbbbb").unwrap();
force_mtime(&f, dur.as_secs() as i64, dur.subsec_nanos() as i64);
let mtime_after = std::fs::metadata(&f).unwrap().modified().unwrap();
assert_eq!(
mtime_after, mtime_before,
"mtime must be forced back to its original value for this test to be meaningful"
);
let after = stat_fingerprint(&dir).unwrap();
assert_ne!(
before, after,
"a same-size, mtime-preserving content change must still move the \
fingerprint (ctime/inode must catch what mtime/size cannot)"
);
}
#[test]
fn hash_is_stable_for_same_content() {
let dir = tmp("stable");
std::fs::write(dir.join("a.txt"), b"hello").unwrap();
let h1 = hash_path(&dir).unwrap();
let h2 = hash_path(&dir).unwrap();
assert_eq!(h1, h2);
}
#[test]
fn hash_changes_when_content_changes() {
let dir = tmp("change");
let f = dir.join("a.txt");
std::fs::write(&f, b"hello").unwrap();
let before = hash_path(&dir).unwrap();
std::fs::write(&f, b"hello!").unwrap();
let after = hash_path(&dir).unwrap();
assert_ne!(before, after);
}
#[test]
fn single_file_and_dir_both_hash() {
let dir = tmp("file");
let f = dir.join("only.md");
std::fs::write(&f, b"x").unwrap();
assert!(!hash_path(&f).unwrap().is_empty());
assert!(!hash_path(&dir).unwrap().is_empty());
}
#[cfg(unix)]
#[test]
fn hash_path_symlink_cycle_does_not_overflow() {
let dir = tmp("symlink-cycle");
std::os::unix::fs::symlink(&*dir, dir.join("loop")).unwrap();
let result = hash_path(&dir);
assert!(
result.is_ok(),
"symlink cycle must not overflow: {result:?}"
);
}
#[test]
fn hash_path_depth_caps_a_pathological_tree() {
let dir = tmp("deep-tree");
let mut deep = dir.to_path_buf();
for _ in 0..(crate::install::MAX_ITEM_TREE_DEPTH + 2) {
deep.push("d");
}
std::fs::create_dir_all(&deep).unwrap();
let result = hash_path(&dir);
assert!(
result.is_err(),
"hash_path must refuse a tree deeper than the cap: {result:?}"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("nested directories"),
"error message must name the depth cap: {msg}"
);
}
#[cfg(unix)]
#[test]
fn hash_path_symlink_target_affects_hash() {
let dir = tmp("symlink-hash");
std::fs::write(dir.join("file.txt"), b"content").unwrap();
std::os::unix::fs::symlink("/target/a", dir.join("link")).unwrap();
let h_a = hash_path(&dir).unwrap();
std::fs::remove_file(dir.join("link")).unwrap();
std::os::unix::fs::symlink("/target/b", dir.join("link")).unwrap();
let h_b = hash_path(&dir).unwrap();
assert_ne!(h_a, h_b, "retargeting a symlink must change the hash");
std::fs::remove_file(dir.join("link")).unwrap();
let h_none = hash_path(&dir).unwrap();
assert_ne!(h_a, h_none, "removing a symlink must change the hash");
}
#[test]
fn hash_length_framing_prevents_path_content_split_collision() {
let dir_ab_c = tmp("framing-ab-c");
std::fs::create_dir_all(dir_ab_c.join("ab")).unwrap();
std::fs::write(dir_ab_c.join("ab").join("x"), b"c").unwrap();
let dir1 = tmp("framing-flat-ab-c");
std::fs::write(dir1.join("ab"), b"c").unwrap();
let dir2 = tmp("framing-flat-a-bc");
std::fs::write(dir2.join("a"), b"bc").unwrap();
let h1 = hash_path(&dir1).unwrap();
let h2 = hash_path(&dir2).unwrap();
assert_ne!(
h1, h2,
"entry ('ab','c') must not collide with ('a','bc') under length framing"
);
}
#[cfg(unix)]
#[test]
fn hash_type_tag_prevents_file_symlink_collision() {
let dir1 = tmp("tag-file");
std::fs::write(dir1.join("foo"), b"/target").unwrap();
let dir2 = tmp("tag-symlink");
std::os::unix::fs::symlink("/target", dir2.join("foo")).unwrap();
let h1 = hash_path(&dir1).unwrap();
let h2 = hash_path(&dir2).unwrap();
assert_ne!(
h1, h2,
"a file and a symlink with matching name/content must not collide"
);
}
#[cfg(unix)]
#[test]
fn hash_single_file_vs_single_symlink_distinct() {
let dir = tmp("single-vs-sym");
let file_path = dir.join("f");
std::fs::write(&file_path, b"/target").unwrap();
let sym_path = dir.join("s");
std::os::unix::fs::symlink("/target", &sym_path).unwrap();
let h_file = hash_path(&file_path).unwrap();
let h_sym = hash_path(&sym_path).unwrap();
assert_ne!(
h_file, h_sym,
"file with content '/target' must not collide with symlink -> '/target'"
);
}
#[test]
fn hash_length_framing_empty_content_vs_suffix() {
let dir1 = tmp("framing-abc-empty");
std::fs::write(dir1.join("abc"), b"").unwrap();
let dir2 = tmp("framing-ab-c2");
std::fs::write(dir2.join("ab"), b"c").unwrap();
let h1 = hash_path(&dir1).unwrap();
let h2 = hash_path(&dir2).unwrap();
assert_ne!(
h1, h2,
"entry ('abc','') must not collide with ('ab','c') under length framing"
);
}
}