1use crate::CacheDigest;
2use serde::{Deserialize, Serialize};
3use std::io;
4use std::path::{Path, PathBuf};
5use std::time::SystemTime;
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct FileIdentity {
20 pub path: PathBuf,
22 pub len: u64,
24 pub modified: SystemTime,
26 pub changed: Option<(i64, i64)>,
28}
29
30impl FileIdentity {
31 pub fn describe(path: &Path, metadata: &std::fs::Metadata) -> Option<Self> {
34 Some(Self {
35 path: path.to_path_buf(),
36 len: metadata.len(),
37 modified: metadata.modified().ok()?,
38 changed: change_token(metadata),
39 })
40 }
41
42 pub fn for_digest_cache(path: &Path, metadata: &std::fs::Metadata) -> io::Result<Option<Self>> {
48 Ok(digest_cache_identity(
49 path,
50 metadata,
51 metadata_identity_is_unreliable(path)?,
52 ))
53 }
54
55 pub fn still_describes(&self) -> std::io::Result<bool> {
66 let metadata = std::fs::metadata(&self.path)?;
67 Ok(Self::describe(&self.path, &metadata).as_ref() == Some(self))
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct FileSnapshot {
83 identity: FileIdentity,
84 content: Option<CacheDigest>,
85}
86
87impl FileSnapshot {
88 pub fn capture(path: &Path) -> io::Result<Option<Self>> {
90 capture_file_snapshot(path, metadata_identity_is_unreliable(path)?)
91 }
92
93 pub fn matches(&self, identity: Option<&FileIdentity>, content: &CacheDigest) -> bool {
100 self.content.as_ref().map_or_else(
101 || identity == Some(&self.identity),
102 |before| {
103 before == content
104 && identity.is_some_and(|after| {
105 self.identity.path == after.path
106 && self.identity.len == after.len
107 && self.identity.modified == after.modified
108 })
109 },
110 )
111 }
112
113 pub fn proves_content_change(&self) -> bool {
115 self.content.is_some() || self.identity.changed.is_some()
116 }
117}
118
119impl From<FileIdentity> for FileSnapshot {
120 fn from(identity: FileIdentity) -> Self {
121 Self {
122 identity,
123 content: None,
124 }
125 }
126}
127
128#[cfg(target_os = "linux")]
129fn metadata_identity_is_unreliable(path: &Path) -> io::Result<bool> {
130 use std::mem::MaybeUninit;
131 use std::os::unix::ffi::OsStrExt as _;
132
133 let path = std::ffi::CString::new(path.as_os_str().as_bytes())
134 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains a NUL byte"))?;
135 let mut status = MaybeUninit::<libc::statfs>::zeroed();
136 let result = unsafe { libc::statfs(path.as_ptr(), status.as_mut_ptr()) };
139 if result != 0 {
140 return Err(io::Error::last_os_error());
141 }
142 let status = unsafe { status.assume_init() };
144 Ok(status.f_type == 0x6969)
146}
147
148#[cfg(not(target_os = "linux"))]
149fn metadata_identity_is_unreliable(_path: &Path) -> io::Result<bool> {
150 Ok(false)
151}
152
153fn capture_file_snapshot(path: &Path, content_identity: bool) -> io::Result<Option<FileSnapshot>> {
154 let metadata = std::fs::metadata(path)?;
155 let Some(identity) = FileIdentity::describe(path, &metadata) else {
156 return Ok(None);
157 };
158 let content = content_identity
159 .then(|| {
160 CacheDigest::blake3_file(path).map_err(|error| io::Error::other(error.to_string()))
161 })
162 .transpose()?;
163 Ok(Some(FileSnapshot { identity, content }))
164}
165
166fn digest_cache_identity(
167 path: &Path,
168 metadata: &std::fs::Metadata,
169 unreliable: bool,
170) -> Option<FileIdentity> {
171 if unreliable {
172 None
173 } else {
174 FileIdentity::describe(path, metadata)
175 }
176}
177
178#[cfg(unix)]
180fn change_token(metadata: &std::fs::Metadata) -> Option<(i64, i64)> {
181 use std::os::unix::fs::MetadataExt;
182 Some((metadata.ctime(), metadata.ctime_nsec()))
183}
184
185#[cfg(not(unix))]
188fn change_token(_metadata: &std::fs::Metadata) -> Option<(i64, i64)> {
189 None
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(deny_unknown_fields)]
195pub struct RecordedFileDigest {
196 pub file: FileIdentity,
198 pub digest: CacheDigest,
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
210#[serde(rename_all = "snake_case")]
211pub enum FileDigestScope {
212 Content,
214 CcInput,
217}
218
219pub trait FileDigestCache: Send + Sync {
225 fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>>;
227 fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>);
229}
230
231pub struct NoFileDigestCache;
233
234impl FileDigestCache for NoFileDigestCache {
235 fn find(&self, _scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
236 vec![None; files.len()]
237 }
238
239 fn record(&self, _scope: FileDigestScope, _entries: Vec<RecordedFileDigest>) {}
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn an_identity_describes_the_file_until_it_is_written_or_removed() {
248 let directory = tempfile::tempdir().unwrap();
249 let path = directory.path().join("input.rs");
250 std::fs::write(&path, b"fn main() {}").unwrap();
251 let identity = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap()).unwrap();
252 assert!(identity.still_describes().unwrap());
253
254 std::thread::sleep(std::time::Duration::from_millis(20));
255 std::fs::write(&path, b"fn main() { }").unwrap();
256 assert!(!identity.still_describes().unwrap());
257
258 std::fs::remove_file(&path).unwrap();
259 assert!(identity.still_describes().is_err());
260 }
261
262 #[test]
263 fn a_metadata_snapshot_detects_a_metadata_change() {
264 let directory = tempfile::tempdir().unwrap();
265 let path = directory.path().join("input.rs");
266 std::fs::write(&path, b"fn main() {}").unwrap();
267 let snapshot = capture_file_snapshot(&path, false).unwrap().unwrap();
268
269 std::fs::File::options()
270 .write(true)
271 .open(&path)
272 .unwrap()
273 .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
274 .unwrap();
275 let identity = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap());
276 let digest = CacheDigest::blake3_file(&path).unwrap();
277
278 assert!(!snapshot.matches(identity.as_ref(), &digest));
279 assert_eq!(snapshot.proves_content_change(), cfg!(unix));
280 }
281
282 #[test]
283 fn a_content_snapshot_ignores_change_token_churn_but_detects_other_changes() {
284 let directory = tempfile::tempdir().unwrap();
285 let path = directory.path().join("input.rs");
286 std::fs::write(&path, b"fn main() {}").unwrap();
287 let snapshot = capture_file_snapshot(&path, true).unwrap().unwrap();
288
289 let mut identity =
290 FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap()).unwrap();
291 identity.changed = identity
292 .changed
293 .map(|(seconds, nanos)| (seconds + 1, nanos));
294 let digest = CacheDigest::blake3_file(&path).unwrap();
295 assert!(snapshot.matches(Some(&identity), &digest));
296 assert!(snapshot.proves_content_change());
297
298 identity.modified = SystemTime::UNIX_EPOCH;
299 assert!(!snapshot.matches(Some(&identity), &digest));
300
301 std::fs::write(&path, b"fn main(){ }").unwrap();
302 let identity = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap());
303 let digest = CacheDigest::blake3_file(&path).unwrap();
304 assert!(!snapshot.matches(identity.as_ref(), &digest));
305 }
306
307 #[test]
308 fn unreliable_metadata_is_not_used_as_a_digest_cache_identity() {
309 let directory = tempfile::tempdir().unwrap();
310 let path = directory.path().join("input.rs");
311 std::fs::write(&path, b"fn main() {}").unwrap();
312 let metadata = std::fs::metadata(&path).unwrap();
313
314 let identity = digest_cache_identity(&path, &metadata, false).unwrap();
315
316 assert_eq!(identity.path, path);
317 assert_eq!(identity.len, 12);
318 assert!(digest_cache_identity(&identity.path, &metadata, true).is_none());
319 }
320}