Skip to main content

aft/
cache_freshness.rs

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    verify_file_inner(path, cached, false)
54}
55
56pub fn verify_file_strict(path: &Path, cached: &FileFreshness) -> FreshnessVerdict {
57    verify_file_inner(path, cached, true)
58}
59
60fn verify_file_inner(
61    path: &Path,
62    cached: &FileFreshness,
63    hash_matching_metadata: bool,
64) -> FreshnessVerdict {
65    let Ok(metadata) = fs::metadata(path) else {
66        return FreshnessVerdict::Deleted;
67    };
68    let new_size = metadata.len();
69    let new_mtime = metadata.modified().unwrap_or(UNIX_EPOCH);
70    if new_size == cached.size && new_mtime == cached.mtime {
71        if hash_matching_metadata
72            && new_size <= CONTENT_HASH_SIZE_CAP
73            && cached.content_hash != zero_hash()
74        {
75            return match hash_file_if_small(path, new_size) {
76                Ok(Some(hash)) if hash == cached.content_hash => FreshnessVerdict::HotFresh,
77                _ => FreshnessVerdict::Stale,
78            };
79        }
80        return FreshnessVerdict::HotFresh;
81    }
82    if new_size != cached.size || new_size > CONTENT_HASH_SIZE_CAP {
83        return FreshnessVerdict::Stale;
84    }
85    match hash_file_if_small(path, new_size) {
86        Ok(Some(hash)) if hash == cached.content_hash => FreshnessVerdict::ContentFresh {
87            new_mtime,
88            new_size,
89        },
90        _ => FreshnessVerdict::Stale,
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use std::io::Write;
98
99    fn write(path: &Path, bytes: &[u8]) {
100        fs::write(path, bytes).unwrap();
101    }
102
103    #[test]
104    fn hot_fresh_when_mtime_size_match() {
105        let dir = tempfile::tempdir().unwrap();
106        let path = dir.path().join("a.txt");
107        write(&path, b"same");
108        let fresh = collect(&path).unwrap();
109        assert_eq!(verify_file(&path, &fresh), FreshnessVerdict::HotFresh);
110    }
111
112    #[test]
113    fn strict_detects_same_mtime_same_size_content_change() {
114        let dir = tempfile::tempdir().unwrap();
115        let path = dir.path().join("a.txt");
116        let original_mtime = filetime::FileTime::from_unix_time(1_700_000_000, 0);
117        write(&path, b"alpha");
118        filetime::set_file_mtime(&path, original_mtime).unwrap();
119        let fresh = collect(&path).unwrap();
120
121        write(&path, b"bravo");
122        filetime::set_file_mtime(&path, original_mtime).unwrap();
123
124        assert_eq!(verify_file(&path, &fresh), FreshnessVerdict::HotFresh);
125        assert_eq!(verify_file_strict(&path, &fresh), FreshnessVerdict::Stale);
126    }
127
128    #[test]
129    fn content_fresh_when_only_mtime_changes() {
130        let dir = tempfile::tempdir().unwrap();
131        let path = dir.path().join("a.txt");
132        write(&path, b"same");
133        let fresh = collect(&path).unwrap();
134        let mut file = fs::OpenOptions::new().append(true).open(&path).unwrap();
135        file.write_all(b"").unwrap();
136        file.sync_all().unwrap();
137        filetime::set_file_mtime(&path, filetime::FileTime::from_unix_time(1, 0)).unwrap();
138        assert!(matches!(
139            verify_file(&path, &fresh),
140            FreshnessVerdict::ContentFresh { .. }
141        ));
142    }
143
144    #[test]
145    fn stale_when_size_changes() {
146        let dir = tempfile::tempdir().unwrap();
147        let path = dir.path().join("a.txt");
148        write(&path, b"same");
149        let fresh = collect(&path).unwrap();
150        write(&path, b"different");
151        assert_eq!(verify_file(&path, &fresh), FreshnessVerdict::Stale);
152    }
153
154    #[test]
155    fn deleted_when_missing() {
156        let dir = tempfile::tempdir().unwrap();
157        let path = dir.path().join("a.txt");
158        write(&path, b"same");
159        let fresh = collect(&path).unwrap();
160        fs::remove_file(&path).unwrap();
161        assert_eq!(verify_file(&path, &fresh), FreshnessVerdict::Deleted);
162    }
163
164    #[test]
165    fn over_cap_hash_is_not_computed() {
166        let dir = tempfile::tempdir().unwrap();
167        let path = dir.path().join("big.bin");
168        fs::write(&path, vec![0u8; CONTENT_HASH_SIZE_CAP as usize + 1]).unwrap();
169        assert!(hash_file_if_small(&path, CONTENT_HASH_SIZE_CAP + 1)
170            .unwrap()
171            .is_none());
172    }
173}