use std::path::Path;
#[cfg(feature = "cache")]
use crate::cache::CacheStore;
pub(crate) const MIN_CHUNK_SIZE: usize = 2 * 1024;
pub(crate) const AVG_CHUNK_SIZE: usize = 4 * 1024;
pub(crate) const MAX_CHUNK_SIZE: usize = 8 * 1024;
const GEAR_SEED: u64 = 0x0123_4567_89AB_CDEF;
const FASTCDC_BITS: u32 = 12;
const FASTCDC_MASK_S: u64 = ((1u64 << (FASTCDC_BITS + 1)) - 1) << 1;
const FASTCDC_MASK_L: u64 = (1u64 << FASTCDC_BITS) - 1;
const GEAR_TABLE: [u64; 256] = generate_gear_table();
pub const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
fn validate_file_for_hashing(path: &Path) -> Result<(), std::io::Error> {
let metadata = std::fs::symlink_metadata(path)?;
if metadata.file_type().is_symlink() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"refusing to hash symlink {}; resolve the target explicitly \
before calling the hashing API",
path.display()
),
));
}
let size = metadata.len();
if size > MAX_FILE_SIZE {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"file size {size} bytes exceeds MAX_FILE_SIZE ({MAX_FILE_SIZE} bytes); \
refusing to hash in-memory to prevent OOM (C5-followup: streaming I/O)"
),
));
}
Ok(())
}
const fn splitmix64(state: &mut u64) -> u64 {
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
const fn generate_gear_table() -> [u64; 256] {
let mut table = [0u64; 256];
let mut state = GEAR_SEED;
let mut i = 0;
while i < 256 {
table[i] = splitmix64(&mut state);
i += 1;
}
table
}
pub fn compute_file_hash(path: &Path) -> Result<String, std::io::Error> {
validate_file_for_hashing(path)?;
let content = std::fs::read(path)?;
Ok(compute_content_hash(&content))
}
#[cfg(feature = "cache")]
pub fn compute_file_hash_cached(
path: &Path,
cache: Option<&dyn CacheStore>,
) -> Result<String, std::io::Error> {
let cache = match cache {
Some(c) => c,
None => return compute_file_hash(path),
};
let key = match build_cache_key(path) {
Some(k) => k,
None => return compute_file_hash(path),
};
if let Some(cached) = cache.get(&key) {
if let Ok(s) = String::from_utf8(cached) {
return Ok(s);
}
}
let hash = compute_file_hash(path)?;
cache.set(&key, hash.as_bytes().to_vec());
Ok(hash)
}
#[cfg(feature = "cache")]
fn build_cache_key(path: &Path) -> Option<String> {
let metadata = std::fs::metadata(path).ok()?;
let mtime = metadata.modified().ok()?;
let nanos = mtime.duration_since(std::time::UNIX_EPOCH).ok()?.as_nanos();
let path_hash = compute_content_hash(path.to_string_lossy().as_bytes());
Some(format!("hash:file:{path_hash}:{nanos}"))
}
#[must_use]
pub fn compute_content_hash(content: &[u8]) -> String {
let digest = blake3::hash(content);
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(64);
for byte in digest.as_bytes() {
out.push(HEX[(byte >> 4) as usize] as char);
out.push(HEX[(byte & 0x0f) as usize] as char);
}
out
}
pub fn chunked_hash(file_path: &Path) -> Result<Vec<(u64, String)>, std::io::Error> {
validate_file_for_hashing(file_path)?;
let content = std::fs::read(file_path)?;
Ok(chunk_content(&content))
}
fn chunk_content(content: &[u8]) -> Vec<(u64, String)> {
let offsets = fastcdc_offsets(content);
let mut chunks = Vec::with_capacity(offsets.len().saturating_sub(1));
for window in offsets.windows(2) {
let start = window[0];
let end = window[1];
let hash = compute_content_hash(&content[start..end]);
chunks.push((start as u64, hash));
}
chunks
}
fn fastcdc_offsets(data: &[u8]) -> Vec<usize> {
let n = data.len();
if n == 0 {
return Vec::new();
}
let mut offsets = Vec::with_capacity(n / AVG_CHUNK_SIZE + 2);
offsets.push(0);
let mut start = 0usize;
while start < n {
let remaining = n - start;
let chunk_len = if remaining <= MIN_CHUNK_SIZE {
remaining
} else {
find_cut_point(&data[start..n])
};
start += chunk_len;
offsets.push(start);
}
offsets
}
fn find_cut_point(data: &[u8]) -> usize {
let n = data.len();
debug_assert!(n > MIN_CHUNK_SIZE, "find_cut_point requires n > MIN");
let max_scan = n.min(MAX_CHUNK_SIZE);
let mut fp: u64 = 0;
let phase1_end = max_scan.min(AVG_CHUNK_SIZE);
let mut i = MIN_CHUNK_SIZE;
while i < phase1_end {
fp = (fp << 1).wrapping_add(GEAR_TABLE[data[i] as usize]);
if (fp & FASTCDC_MASK_S) == 0 {
return i + 1;
}
i += 1;
}
while i < max_scan {
fp = (fp << 1).wrapping_add(GEAR_TABLE[data[i] as usize]);
if (fp & FASTCDC_MASK_L) == 0 {
return i + 1;
}
i += 1;
}
max_scan
}
pub fn compute_file_hash_incremental(
file_path: &Path,
old_chunks: &[(u64, String)],
) -> Result<(String, Vec<(u64, String)>), std::io::Error> {
validate_file_for_hashing(file_path)?;
let content = std::fs::read(file_path)?;
if content.is_empty() {
return Ok((compute_content_hash(b""), Vec::new()));
}
let new_chunks = chunk_content(&content);
let file_hash = aggregate_chunk_hashes(&new_chunks);
let _ = old_chunks;
Ok((file_hash, new_chunks))
}
fn aggregate_chunk_hashes(chunks: &[(u64, String)]) -> String {
let mut hashes: Vec<&str> = chunks.iter().map(|(_, h)| h.as_str()).collect();
hashes.sort_unstable();
let mut concat: Vec<u8> = Vec::with_capacity(hashes.len() * 64);
for h in &hashes {
concat.extend_from_slice(h.as_bytes());
}
compute_content_hash(&concat)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::NamedTempFile;
#[test]
fn compute_content_hash_returns_64_char_hex_string() {
let hash = compute_content_hash(b"hello world");
assert_eq!(hash.len(), 64, "BLAKE3 hex digest must be 64 chars");
assert!(
hash.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"hash must be lowercase hex: {hash}"
);
}
#[test]
fn compute_content_hash_known_value() {
let hash = compute_content_hash(b"hello world");
assert_eq!(
hash,
"d74981efa70a0c880b8d8c1985d075dbcbf679b99a5f9914e5aaf96b831a9e24" );
}
#[test]
fn compute_content_hash_empty_input() {
let hash = compute_content_hash(b"");
assert_eq!(
hash,
"af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262" );
assert_eq!(hash.len(), 64);
}
#[test]
fn compute_content_hash_same_content_same_hash() {
let a = compute_content_hash(b"fn main() {}");
let b = compute_content_hash(b"fn main() {}");
assert_eq!(a, b, "identical content must produce identical hashes");
}
#[test]
fn compute_content_hash_different_content_different_hash() {
let a = compute_content_hash(b"fn main() {}");
let b = compute_content_hash(b"fn main() { }");
assert_ne!(a, b, "different content must produce different hashes");
}
#[test]
fn compute_content_hash_handles_binary_content() {
let binary: Vec<u8> = (0..=255u8).collect();
let hash = compute_content_hash(&binary);
assert_eq!(hash.len(), 64);
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn compute_content_hash_handles_unicode() {
let hash = compute_content_hash("fn 你好() {}".as_bytes());
assert_eq!(hash.len(), 64);
}
#[test]
fn compute_file_hash_returns_64_char_hex_string() {
let tmp = NamedTempFile::new().unwrap();
fs::write(tmp.path(), b"fn main() {}").unwrap();
let hash = compute_file_hash(tmp.path()).expect("hash should succeed");
assert_eq!(hash.len(), 64);
assert!(
hash.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"hash must be lowercase hex: {hash}"
);
}
#[test]
fn compute_file_hash_matches_content_hash() {
let tmp = NamedTempFile::new().unwrap();
let content = b"int main(void) { return 0; }";
fs::write(tmp.path(), content).unwrap();
let file_hash = compute_file_hash(tmp.path()).unwrap();
let content_hash = compute_content_hash(content);
assert_eq!(file_hash, content_hash);
}
#[test]
fn compute_file_hash_same_file_same_hash() {
let tmp = NamedTempFile::new().unwrap();
fs::write(tmp.path(), b"fn foo() {}").unwrap();
let a = compute_file_hash(tmp.path()).unwrap();
let b = compute_file_hash(tmp.path()).unwrap();
assert_eq!(a, b);
}
#[test]
fn compute_file_hash_changes_when_content_changes() {
let tmp = NamedTempFile::new().unwrap();
fs::write(tmp.path(), b"fn foo() {}").unwrap();
let before = compute_file_hash(tmp.path()).unwrap();
fs::write(tmp.path(), b"fn bar() {}").unwrap();
let after = compute_file_hash(tmp.path()).unwrap();
assert_ne!(before, after);
}
#[test]
fn compute_file_hash_nonexistent_file_returns_error() {
let result = compute_file_hash(Path::new("/nonexistent/path/does_not_exist.rs"));
assert!(result.is_err(), "nonexistent file should error");
let err = result.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
}
#[test]
fn compute_file_hash_rejects_symlink() {
let target = NamedTempFile::new().unwrap();
fs::write(target.path(), b"sensitive content").unwrap();
let dir = tempfile::TempDir::new().unwrap();
let link_path = dir.path().join("link.rs");
std::os::unix::fs::symlink(target.path(), &link_path).expect("symlink");
let result = compute_file_hash(&link_path);
assert!(result.is_err(), "symlink should be rejected");
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidInput);
}
#[test]
fn compute_file_hash_rejects_oversized_file() {
let tmp = NamedTempFile::new().unwrap();
let mut file = fs::OpenOptions::new().write(true).open(tmp.path()).unwrap();
let offset = std::io::Seek::seek(
&mut file.try_clone().unwrap(),
std::io::SeekFrom::Start(MAX_FILE_SIZE + 1),
)
.unwrap();
use std::io::Write;
let _ = file.write(&[0u8]);
drop(file);
assert!(offset >= MAX_FILE_SIZE);
let result = compute_file_hash(tmp.path());
assert!(result.is_err(), "oversized file should be rejected");
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidInput);
}
#[test]
fn compute_file_hash_empty_file() {
let tmp = NamedTempFile::new().unwrap();
fs::write(tmp.path(), b"").unwrap();
let hash = compute_file_hash(tmp.path()).unwrap();
assert_eq!(
hash,
"af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262" );
}
fn fill_pseudo_random(dst: &mut [u8], seed: u64) {
let mut state = seed;
for byte in dst.iter_mut() {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
*byte = (state & 0xFF) as u8;
}
}
#[test]
fn test_fastcdc_chunks_local_modification_only_rehashes_changed_chunks() {
let mut content = vec![0u8; 1024 * 1024];
fill_pseudo_random(&mut content, 0x1234_5678_9ABC_DEF0);
let tmp = NamedTempFile::new().unwrap();
fs::write(tmp.path(), &content).unwrap();
let chunks_before = chunked_hash(tmp.path()).expect("chunked_hash should succeed");
assert!(
chunks_before.len() > 100,
"expected > 100 chunks for 1MB file, got {}",
chunks_before.len()
);
let modify_offset = 409_600usize;
for i in 0..100 {
content[modify_offset + i] ^= 0xFF;
}
fs::write(tmp.path(), &content).unwrap();
let chunks_after =
chunked_hash(tmp.path()).expect("chunked_hash should succeed after edit");
let mut identical_before = 0usize;
let mut drifted_before = 0usize;
for (off, hash) in &chunks_before {
if (*off as usize) >= modify_offset {
break;
}
let matched = chunks_after
.iter()
.take_while(|(o, _)| (*o as usize) < modify_offset)
.any(|(o, h)| o == off && h == hash);
if matched {
identical_before += 1;
} else {
drifted_before += 1;
}
}
assert!(
drifted_before <= 1,
"expected <= 1 drifted chunk before modification (the spanning chunk), \
got {drifted_before} (identical_before={identical_before})"
);
assert!(
identical_before > 0,
"expected at least some chunks before modification, got 0"
);
let before_map: std::collections::HashMap<u64, &String> =
chunks_before.iter().map(|(o, h)| (*o, h)).collect();
let changed: usize = chunks_after
.iter()
.filter(|(o, h)| before_map.get(o).is_none_or(|bh| *bh != h))
.count();
assert!(
changed <= 5,
"expected <= 5 changed chunks after local modification, got {changed} \
(total before={}, total after={})",
chunks_before.len(),
chunks_after.len()
);
}
#[test]
fn test_fastcdc_chunk_size_bounds() {
let mut content = vec![0u8; 1024 * 1024];
fill_pseudo_random(&mut content, 0x0C0F_FEEB_EEF1_2345);
let tmp = NamedTempFile::new().unwrap();
fs::write(tmp.path(), &content).unwrap();
let chunks = chunked_hash(tmp.path()).expect("chunked_hash should succeed");
let mut sizes: Vec<usize> = Vec::with_capacity(chunks.len());
for (i, (off, _)) in chunks.iter().enumerate() {
let start = *off as usize;
let end = if i + 1 < chunks.len() {
chunks[i + 1].0 as usize
} else {
content.len()
};
sizes.push(end.saturating_sub(start));
}
assert!(!sizes.is_empty(), "should produce at least one chunk");
for (i, &size) in sizes.iter().enumerate() {
let is_final = i + 1 == sizes.len();
assert!(
size >= MIN_CHUNK_SIZE || is_final,
"chunk {i} size {size} < MIN_CHUNK_SIZE {} (non-final)",
MIN_CHUNK_SIZE
);
assert!(
size <= MAX_CHUNK_SIZE,
"chunk {i} size {size} > MAX_CHUNK_SIZE {}",
MAX_CHUNK_SIZE
);
}
let total: usize = sizes.iter().sum();
let avg = total as f64 / sizes.len() as f64;
let tolerance = (AVG_CHUNK_SIZE as f64) * 0.5;
assert!(
(avg - AVG_CHUNK_SIZE as f64).abs() <= tolerance,
"average chunk size {avg:.1} not within ±{tolerance:.1} of AVG_CHUNK_SIZE {AVG_CHUNK_SIZE}"
);
}
#[test]
fn test_fastcdc_empty_file_returns_empty() {
let tmp = NamedTempFile::new().unwrap();
fs::write(tmp.path(), b"").unwrap();
let chunks = chunked_hash(tmp.path()).expect("empty file should not error");
assert!(
chunks.is_empty(),
"empty file should produce no chunks, got {chunks:?}"
);
}
#[test]
fn test_fastcdc_small_file_returns_single_chunk() {
let tmp = NamedTempFile::new().unwrap();
let content = vec![0x42u8; 1024];
fs::write(tmp.path(), &content).unwrap();
let chunks = chunked_hash(tmp.path()).expect("small file should not error");
assert_eq!(
chunks.len(),
1,
"small file should produce exactly 1 chunk, got {}",
chunks.len()
);
assert_eq!(chunks[0].0, 0u64, "single chunk should start at offset 0");
assert_eq!(chunks[0].1.len(), 64, "chunk hash should be 64-char hex");
}
#[test]
fn test_chunked_hash_returns_valid_offsets_and_hashes() {
let mut content = vec![0u8; 64 * 1024];
fill_pseudo_random(&mut content, 0x0BAD_CAFE_1234_5678);
let tmp = NamedTempFile::new().unwrap();
fs::write(tmp.path(), &content).unwrap();
let chunks = chunked_hash(tmp.path()).expect("chunked_hash should succeed");
assert!(!chunks.is_empty(), "non-empty file should produce chunks");
assert_eq!(chunks[0].0, 0u64, "first chunk must start at offset 0");
for window in chunks.windows(2) {
assert!(
window[1].0 > window[0].0,
"offsets must be strictly increasing, got {} then {}",
window[0].0,
window[1].0
);
}
let last_offset = chunks.last().unwrap().0;
assert!(
(last_offset as usize) < content.len(),
"last chunk offset {last_offset} must be < file size {}",
content.len()
);
for (i, (off, hash)) in chunks.iter().enumerate() {
assert_eq!(
hash.len(),
64,
"chunk {i} (offset {off}) hash must be 64 chars, got {}",
hash.len()
);
assert!(
hash.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"chunk {i} hash must be lowercase hex: {hash}"
);
}
let unique: std::collections::HashSet<&str> =
chunks.iter().map(|(_, h)| h.as_str()).collect();
assert_eq!(
unique.len(),
chunks.len(),
"chunk hashes must be unique for pseudo-random content"
);
}
#[test]
fn test_compute_file_hash_incremental_empty_file() {
let tmp = NamedTempFile::new().unwrap();
fs::write(tmp.path(), b"").unwrap();
let (file_hash, new_chunks) =
compute_file_hash_incremental(tmp.path(), &[]).expect("empty file should not error");
assert!(
new_chunks.is_empty(),
"empty file must produce no chunks, got {new_chunks:?}"
);
assert_eq!(
file_hash,
"af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262", "empty file hash must be BLAKE3 of empty input"
);
assert_eq!(file_hash.len(), 64);
assert!(
file_hash
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"file hash must be lowercase hex: {file_hash}"
);
let stale_old = vec![(0u64, "0".repeat(64))];
let (file_hash_2, new_chunks_2) = compute_file_hash_incremental(tmp.path(), &stale_old)
.expect("empty file with stale old_chunks should not error");
assert_eq!(file_hash, file_hash_2, "empty file hash must be stable");
assert!(
new_chunks_2.is_empty(),
"empty file must produce no chunks even with stale old_chunks"
);
}
#[test]
fn test_compute_file_hash_incremental_small_file() {
let content = vec![0x42u8; 1024];
let tmp = NamedTempFile::new().unwrap();
fs::write(tmp.path(), &content).unwrap();
let (file_hash_1, new_chunks_1) =
compute_file_hash_incremental(tmp.path(), &[]).expect("small file should not error");
assert_eq!(
new_chunks_1.len(),
1,
"small file must produce exactly 1 chunk, got {}",
new_chunks_1.len()
);
assert_eq!(new_chunks_1[0].0, 0u64, "single chunk must start at 0");
assert_eq!(
new_chunks_1[0].1,
compute_content_hash(&content),
"single chunk hash must equal whole-content hash"
);
assert_eq!(file_hash_1.len(), 64, "file hash must be 64-char hex");
let (file_hash_2, new_chunks_2) = compute_file_hash_incremental(tmp.path(), &new_chunks_1)
.expect("second call should not error");
assert_eq!(
file_hash_1, file_hash_2,
"file hash must be deterministic across calls"
);
assert_eq!(
new_chunks_1, new_chunks_2,
"chunk list must be identical when file is unchanged"
);
let mut modified = content.clone();
modified[0] ^= 0xFF;
fs::write(tmp.path(), &modified).unwrap();
let (file_hash_3, new_chunks_3) = compute_file_hash_incremental(tmp.path(), &new_chunks_1)
.expect("post-edit call should not error");
assert_ne!(
file_hash_1, file_hash_3,
"file hash must change after content modification"
);
assert_eq!(new_chunks_3.len(), 1, "small file must still be 1 chunk");
assert_ne!(
new_chunks_3[0].1, new_chunks_1[0].1,
"chunk hash must change after content modification"
);
}
#[test]
fn test_compute_file_hash_incremental_reuses_unchanged_chunks() {
let mut content = vec![0u8; 1024 * 1024];
fill_pseudo_random(&mut content, 0xDEAD_BEEF_CAFE_BABE);
let tmp = NamedTempFile::new().unwrap();
fs::write(tmp.path(), &content).unwrap();
let old_chunks = chunked_hash(tmp.path()).expect("baseline chunked_hash");
assert!(
old_chunks.len() > 100,
"expected > 100 chunks, got {}",
old_chunks.len()
);
let (file_hash, new_chunks) =
compute_file_hash_incremental(tmp.path(), &old_chunks).expect("incremental hash");
assert_eq!(
new_chunks.len(),
old_chunks.len(),
"chunk count must match when file unchanged"
);
for (i, (off, hash)) in new_chunks.iter().enumerate() {
assert_eq!(*off, old_chunks[i].0, "offset mismatch at chunk {i}");
assert_eq!(hash, &old_chunks[i].1, "hash mismatch at chunk {i}");
}
assert_eq!(file_hash.len(), 64, "file hash must be 64-char hex");
assert!(
file_hash
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"file hash must be lowercase hex: {file_hash}"
);
let (file_hash_2, _) =
compute_file_hash_incremental(tmp.path(), &old_chunks).expect("second call");
assert_eq!(file_hash, file_hash_2, "file hash must be deterministic");
}
#[test]
fn test_compute_file_hash_incremental_detects_all_changes() {
let mut content = vec![0u8; 1024 * 1024];
fill_pseudo_random(&mut content, 0xFEED_FACE_1234_5678);
let tmp = NamedTempFile::new().unwrap();
fs::write(tmp.path(), &content).unwrap();
let old_chunks = chunked_hash(tmp.path()).expect("baseline chunked_hash");
let (old_file_hash, _) =
compute_file_hash_incremental(tmp.path(), &old_chunks).expect("baseline incremental");
for &pos in &[100_000usize, 500_000, 900_000] {
for i in 0..200 {
content[pos + i] ^= 0xFF;
}
}
fs::write(tmp.path(), &content).unwrap();
let (new_file_hash, new_chunks) =
compute_file_hash_incremental(tmp.path(), &old_chunks).expect("post-edit incremental");
assert_ne!(
new_file_hash, old_file_hash,
"file hash must change after content modification"
);
let old_map: std::collections::HashMap<u64, &String> =
old_chunks.iter().map(|(o, h)| (*o, h)).collect();
let changed: usize = new_chunks
.iter()
.filter(|(o, h)| old_map.get(o).is_none_or(|oh| *oh != h))
.count();
assert!(
changed > 0,
"expected at least one changed chunk, got 0 (total new={})",
new_chunks.len()
);
}
}
#[cfg(all(test, feature = "cache"))]
mod cached_tests {
use super::*;
use crate::cache::CacheStore;
use std::collections::HashMap;
use std::fs;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tempfile::NamedTempFile;
struct CountingCache {
gets: AtomicUsize,
sets: AtomicUsize,
inner: Mutex<HashMap<String, Vec<u8>>>,
}
impl CountingCache {
fn new() -> Self {
Self {
gets: AtomicUsize::new(0),
sets: AtomicUsize::new(0),
inner: Mutex::new(HashMap::new()),
}
}
fn gets(&self) -> usize {
self.gets.load(Ordering::SeqCst)
}
fn sets(&self) -> usize {
self.sets.load(Ordering::SeqCst)
}
fn snapshot(&self) -> HashMap<String, Vec<u8>> {
self.inner.lock().expect("lock").clone()
}
}
impl CacheStore for CountingCache {
fn get(&self, key: &str) -> Option<Vec<u8>> {
self.gets.fetch_add(1, Ordering::SeqCst);
self.inner.lock().expect("lock").get(key).cloned()
}
fn set(&self, key: &str, val: Vec<u8>) {
self.sets.fetch_add(1, Ordering::SeqCst);
self.inner
.lock()
.expect("lock")
.insert(key.to_string(), val);
}
fn invalidate_all(&self) {
self.inner.lock().expect("lock").clear();
}
}
fn fixed_time() -> SystemTime {
UNIX_EPOCH + Duration::from_secs(1_700_000_000)
}
fn bumped_time() -> SystemTime {
UNIX_EPOCH + Duration::from_secs(1_700_000_100)
}
fn pin_mtime(path: &Path) {
let file = fs::File::open(path).expect("open for set_modified");
file.set_modified(fixed_time()).expect("set_modified");
}
#[test]
fn cached_hash_miss_then_hit_returns_same_value() {
let tmp = NamedTempFile::new().expect("NamedTempFile");
fs::write(tmp.path(), b"hello world").expect("write");
pin_mtime(tmp.path());
let cache = CountingCache::new();
let h1 = compute_file_hash_cached(tmp.path(), Some(&cache)).expect("hash");
assert_eq!(cache.gets(), 1, "first call should query cache (miss)");
assert_eq!(cache.sets(), 1, "first call should store in cache");
let h2 = compute_file_hash_cached(tmp.path(), Some(&cache)).expect("hash");
assert_eq!(h1, h2, "second call should return same hash");
assert_eq!(cache.gets(), 2, "second call should query cache (hit)");
assert_eq!(cache.sets(), 1, "second call should NOT store (hit)");
let uncached = compute_file_hash(tmp.path()).expect("hash");
assert_eq!(h1, uncached);
}
#[test]
fn cached_hash_with_none_cache_works_like_uncached() {
let tmp = NamedTempFile::new().expect("NamedTempFile");
fs::write(tmp.path(), b"hello world").expect("write");
let cached = compute_file_hash_cached(tmp.path(), None).expect("hash");
let uncached = compute_file_hash(tmp.path()).expect("hash");
assert_eq!(cached, uncached);
}
#[test]
fn cached_hash_invalidates_when_mtime_changes() {
let tmp = NamedTempFile::new().expect("NamedTempFile");
fs::write(tmp.path(), b"old content").expect("write");
pin_mtime(tmp.path());
let cache = CountingCache::new();
let h1 = compute_file_hash_cached(tmp.path(), Some(&cache)).expect("hash");
assert_eq!(h1, compute_content_hash(b"old content"));
assert_eq!(cache.gets(), 1);
assert_eq!(cache.sets(), 1);
fs::write(tmp.path(), b"new content").expect("write");
let file = fs::File::open(tmp.path()).expect("open");
file.set_modified(bumped_time()).expect("set_modified");
drop(file);
let h2 = compute_file_hash_cached(tmp.path(), Some(&cache)).expect("hash");
assert_ne!(h1, h2, "mtime change should invalidate cache");
assert_eq!(h2, compute_content_hash(b"new content"));
assert_eq!(cache.gets(), 2, "second call: miss → get");
assert_eq!(cache.sets(), 2, "second call: store → set");
}
#[test]
fn cached_hash_stores_correct_value() {
let tmp = NamedTempFile::new().expect("NamedTempFile");
fs::write(tmp.path(), b"hello world").expect("write");
pin_mtime(tmp.path());
let cache = CountingCache::new();
let h = compute_file_hash_cached(tmp.path(), Some(&cache)).expect("hash");
let snap = cache.snapshot();
assert_eq!(snap.len(), 1, "exactly one entry should be cached");
let (_key, val) = snap.iter().next().expect("one entry");
assert_eq!(
val,
h.as_bytes(),
"cached value should be hash string bytes"
);
assert_eq!(h, compute_content_hash(b"hello world"));
}
}