1use std::fs;
2use std::path::Path;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5pub const CONTENT_HASH_SIZE_CAP: u64 = 4 * 1024 * 1024;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub struct FileFreshness {
9 pub mtime: SystemTime,
10 pub size: u64,
11 pub content_hash: blake3::Hash,
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum FreshnessVerdict {
16 HotFresh,
17 ContentFresh {
18 new_mtime: SystemTime,
19 new_size: u64,
20 },
21 Stale,
22 Deleted,
23}
24
25pub fn hash_bytes(bytes: &[u8]) -> blake3::Hash {
26 blake3::hash(bytes)
27}
28
29pub fn hash_file_if_small(path: &Path, size: u64) -> std::io::Result<Option<blake3::Hash>> {
30 if size > CONTENT_HASH_SIZE_CAP {
31 return Ok(None);
32 }
33 fs::read(path).map(|bytes| Some(hash_bytes(&bytes)))
34}
35
36pub fn zero_hash() -> blake3::Hash {
37 blake3::Hash::from_bytes([0u8; 32])
38}
39
40pub fn collect(path: &Path) -> std::io::Result<FileFreshness> {
41 let metadata = fs::metadata(path)?;
42 let mtime = metadata.modified().unwrap_or(UNIX_EPOCH);
43 let size = metadata.len();
44 let content_hash = hash_file_if_small(path, size)?.unwrap_or_else(zero_hash);
45 Ok(FileFreshness {
46 mtime,
47 size,
48 content_hash,
49 })
50}
51
52pub fn verify_file(path: &Path, cached: &FileFreshness) -> FreshnessVerdict {
53 let Ok(metadata) = fs::metadata(path) else {
54 return FreshnessVerdict::Deleted;
55 };
56 let new_size = metadata.len();
57 let new_mtime = metadata.modified().unwrap_or(UNIX_EPOCH);
58 if new_size == cached.size && new_mtime == cached.mtime {
59 return FreshnessVerdict::HotFresh;
60 }
61 if new_size != cached.size || new_size > CONTENT_HASH_SIZE_CAP {
62 return FreshnessVerdict::Stale;
63 }
64 match hash_file_if_small(path, new_size) {
65 Ok(Some(hash)) if hash == cached.content_hash => FreshnessVerdict::ContentFresh {
66 new_mtime,
67 new_size,
68 },
69 _ => FreshnessVerdict::Stale,
70 }
71}
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76 use std::io::Write;
77
78 fn write(path: &Path, bytes: &[u8]) {
79 fs::write(path, bytes).unwrap();
80 }
81
82 #[test]
83 fn hot_fresh_when_mtime_size_match() {
84 let dir = tempfile::tempdir().unwrap();
85 let path = dir.path().join("a.txt");
86 write(&path, b"same");
87 let fresh = collect(&path).unwrap();
88 assert_eq!(verify_file(&path, &fresh), FreshnessVerdict::HotFresh);
89 }
90
91 #[test]
92 fn content_fresh_when_only_mtime_changes() {
93 let dir = tempfile::tempdir().unwrap();
94 let path = dir.path().join("a.txt");
95 write(&path, b"same");
96 let fresh = collect(&path).unwrap();
97 let mut file = fs::OpenOptions::new().append(true).open(&path).unwrap();
98 file.write_all(b"").unwrap();
99 file.sync_all().unwrap();
100 filetime::set_file_mtime(&path, filetime::FileTime::from_unix_time(1, 0)).unwrap();
101 assert!(matches!(
102 verify_file(&path, &fresh),
103 FreshnessVerdict::ContentFresh { .. }
104 ));
105 }
106
107 #[test]
108 fn stale_when_size_changes() {
109 let dir = tempfile::tempdir().unwrap();
110 let path = dir.path().join("a.txt");
111 write(&path, b"same");
112 let fresh = collect(&path).unwrap();
113 write(&path, b"different");
114 assert_eq!(verify_file(&path, &fresh), FreshnessVerdict::Stale);
115 }
116
117 #[test]
118 fn deleted_when_missing() {
119 let dir = tempfile::tempdir().unwrap();
120 let path = dir.path().join("a.txt");
121 write(&path, b"same");
122 let fresh = collect(&path).unwrap();
123 fs::remove_file(&path).unwrap();
124 assert_eq!(verify_file(&path, &fresh), FreshnessVerdict::Deleted);
125 }
126
127 #[test]
128 fn over_cap_hash_is_not_computed() {
129 let dir = tempfile::tempdir().unwrap();
130 let path = dir.path().join("big.bin");
131 fs::write(&path, vec![0u8; CONTENT_HASH_SIZE_CAP as usize + 1]).unwrap();
132 assert!(hash_file_if_small(&path, CONTENT_HASH_SIZE_CAP + 1)
133 .unwrap()
134 .is_none());
135 }
136}