1use crate::CacheDigest;
2use serde::{Deserialize, Serialize};
3use std::io::{self, Read as _};
4use std::path::{Path, PathBuf};
5#[cfg(target_os = "linux")]
6use std::sync::{Mutex, OnceLock};
7use std::time::SystemTime;
8
9const DIGEST_BUFFER_BYTES: usize = 64 * 1024;
10const TIMESTAMP_MACROS: &[&[u8]] = &[b"__DATE__", b"__TIME__", b"__TIMESTAMP__"];
11
12#[cfg(target_os = "linux")]
13const STATX_IDENTITY_MASK: u32 = 0x100 | 0x200 | 0x40 | 0x1000;
14
15#[cfg(target_os = "linux")]
18#[repr(C)]
19struct LinuxStatxTimestamp {
20 seconds: i64,
21 nanos: u32,
22 reserved: i32,
23}
24
25#[cfg(target_os = "linux")]
26#[repr(C)]
27struct LinuxStatx {
28 mask: u32,
29 block_size: u32,
30 attributes: u64,
31 links: u32,
32 uid: u32,
33 gid: u32,
34 mode: u16,
35 reserved0: u16,
36 inode: u64,
37 size: u64,
38 blocks: u64,
39 attributes_mask: u64,
40 accessed: LinuxStatxTimestamp,
41 created: LinuxStatxTimestamp,
42 changed: LinuxStatxTimestamp,
43 modified: LinuxStatxTimestamp,
44 rdev_major: u32,
45 rdev_minor: u32,
46 device_major: u32,
47 device_minor: u32,
48 mount_id: u64,
49 direct_io_memory_alignment: u32,
50 direct_io_offset_alignment: u32,
51 subvolume: u64,
52 atomic_write_unit_min: u32,
53 atomic_write_unit_max: u32,
54 atomic_write_segments_max: u32,
55 direct_io_read_offset_alignment: u32,
56 atomic_write_unit_max_opt: u32,
57 reserved1: u32,
58 reserved2: [u64; 8],
59}
60
61#[cfg(target_os = "linux")]
62const _: () = assert!(std::mem::size_of::<LinuxStatx>() == 256);
63
64#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
75#[serde(deny_unknown_fields)]
76pub struct FileIdentity {
77 pub path: PathBuf,
79 pub len: u64,
81 pub modified: SystemTime,
83 pub changed: Option<(i64, i64)>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub object: Option<FileObjectIdentity>,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
92#[serde(deny_unknown_fields)]
93pub struct FileObjectIdentity {
94 pub device_major: u32,
96 pub device_minor: u32,
98 pub mount_id: u64,
100 pub inode: u64,
102}
103
104impl FileIdentity {
105 pub fn describe(path: &Path, metadata: &std::fs::Metadata) -> Option<Self> {
108 Some(Self {
109 path: path.to_path_buf(),
110 len: metadata.len(),
111 modified: metadata.modified().ok()?,
112 changed: change_token(metadata),
113 object: None,
114 })
115 }
116
117 pub fn for_digest_cache(path: &Path, metadata: &std::fs::Metadata) -> io::Result<Option<Self>> {
125 digest_cache_identity(
126 path,
127 metadata,
128 metadata_identity_is_unreliable(path, metadata)?,
129 )
130 }
131
132 pub fn still_describes(&self) -> std::io::Result<bool> {
143 let metadata = std::fs::metadata(&self.path)?;
144 Ok(Self::for_digest_cache(&self.path, &metadata)?.as_ref() == Some(self))
145 }
146
147 pub fn can_skip_content_verification(&self) -> bool {
149 self.changed.is_some() || self.object.is_some()
150 }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct FileSnapshot {
165 identity: FileIdentity,
166 content: Option<CacheDigest>,
167}
168
169impl FileSnapshot {
170 pub fn capture(path: &Path) -> io::Result<Option<Self>> {
172 let metadata = std::fs::metadata(path)?;
173 capture_file_snapshot(
174 path,
175 &NoFileDigestCache,
176 metadata_identity_is_unreliable(path, &metadata)?,
177 metadata,
178 )
179 }
180
181 pub fn capture_with_cache(
184 path: &Path,
185 digests: &dyn FileDigestCache,
186 ) -> io::Result<Option<Self>> {
187 let metadata = std::fs::metadata(path)?;
188 capture_file_snapshot(
189 path,
190 digests,
191 metadata_identity_is_unreliable(path, &metadata)?,
192 metadata,
193 )
194 }
195
196 pub fn matches(&self, identity: Option<&FileIdentity>, content: &CacheDigest) -> bool {
203 self.content.as_ref().map_or_else(
204 || identity == Some(&self.identity),
205 |before| {
206 before == content
207 && identity.is_some_and(|after| {
208 self.identity.path == after.path
209 && self.identity.len == after.len
210 && self.identity.modified == after.modified
211 && self.identity.object == after.object
212 })
213 },
214 )
215 }
216
217 pub fn proves_content_change(&self) -> bool {
219 self.content.is_some() || self.identity.changed.is_some()
220 }
221}
222
223impl From<FileIdentity> for FileSnapshot {
224 fn from(identity: FileIdentity) -> Self {
225 Self {
226 identity,
227 content: None,
228 }
229 }
230}
231
232#[cfg(target_os = "linux")]
233fn metadata_identity_is_unreliable(path: &Path, metadata: &std::fs::Metadata) -> io::Result<bool> {
234 use std::mem::MaybeUninit;
235 use std::os::unix::ffi::OsStrExt as _;
236 use std::os::unix::fs::MetadataExt as _;
237
238 static FILESYSTEMS: OnceLock<Mutex<std::collections::BTreeMap<u64, bool>>> = OnceLock::new();
239 let filesystems = FILESYSTEMS.get_or_init(|| Mutex::new(std::collections::BTreeMap::new()));
240 if let Some(unreliable) = filesystems.lock().unwrap().get(&metadata.dev()).copied() {
241 return Ok(unreliable);
242 }
243
244 let path = std::ffi::CString::new(path.as_os_str().as_bytes())
245 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains a NUL byte"))?;
246 let mut status = MaybeUninit::<libc::statfs>::zeroed();
247 let result = unsafe { libc::statfs(path.as_ptr(), status.as_mut_ptr()) };
250 if result != 0 {
251 return Err(io::Error::last_os_error());
252 }
253 let status = unsafe { status.assume_init() };
255 let unreliable = status.f_type == 0x6969;
257 filesystems
258 .lock()
259 .unwrap()
260 .insert(metadata.dev(), unreliable);
261 Ok(unreliable)
262}
263
264#[cfg(not(target_os = "linux"))]
265fn metadata_identity_is_unreliable(
266 _path: &Path,
267 _metadata: &std::fs::Metadata,
268) -> io::Result<bool> {
269 Ok(false)
270}
271
272fn capture_file_snapshot(
273 path: &Path,
274 digests: &dyn FileDigestCache,
275 content_identity: bool,
276 metadata: std::fs::Metadata,
277) -> io::Result<Option<FileSnapshot>> {
278 let cache_identity = digest_cache_identity(path, &metadata, content_identity)?;
279 let Some(identity) = cache_identity
280 .clone()
281 .or_else(|| FileIdentity::describe(path, &metadata))
282 else {
283 return Ok(None);
284 };
285 let content = if content_identity {
286 let resolved = cache_identity
287 .as_ref()
288 .and_then(|identity| {
289 digests
290 .resolve(FileDigestScope::Content, std::slice::from_ref(identity))
291 .pop()
292 })
293 .unwrap_or(FileDigestResolution::Unresolved);
294 let (digest, fresh) = match resolved {
295 FileDigestResolution::Digest(digest) => (digest, false),
296 FileDigestResolution::EmbeddedTimestampMacro | FileDigestResolution::Unresolved => {
297 let digest = digest_file(FileDigestScope::Content, path)?
298 .into_digest()
299 .ok_or_else(|| {
300 io::Error::other("content digest resolution returned no digest")
301 })?;
302 (digest, true)
303 }
304 };
305 if fresh
306 && let Some(file) = cache_identity
307 && file.len == digest.size
308 {
309 digests.record(
310 FileDigestScope::Content,
311 vec![RecordedFileDigest {
312 file,
313 digest: digest.clone(),
314 }],
315 );
316 }
317 Some(digest)
318 } else {
319 None
320 };
321 Ok(Some(FileSnapshot { identity, content }))
322}
323
324fn digest_cache_identity(
325 path: &Path,
326 metadata: &std::fs::Metadata,
327 unreliable: bool,
328) -> io::Result<Option<FileIdentity>> {
329 if unreliable {
330 nfs_file_identity(path)
331 } else {
332 Ok(FileIdentity::describe(path, metadata))
333 }
334}
335
336#[cfg(target_os = "linux")]
337fn nfs_file_identity(path: &Path) -> io::Result<Option<FileIdentity>> {
338 use std::mem::MaybeUninit;
339 use std::os::unix::ffi::OsStrExt as _;
340
341 let path = std::ffi::CString::new(path.as_os_str().as_bytes())
342 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains a NUL byte"))?;
343 let mut status = MaybeUninit::<LinuxStatx>::zeroed();
344 let result = unsafe {
346 libc::syscall(
347 libc::SYS_statx,
348 libc::AT_FDCWD,
349 path.as_ptr(),
350 0x4000,
354 STATX_IDENTITY_MASK,
355 status.as_mut_ptr(),
356 )
357 };
358 if result != 0 {
359 let error = io::Error::last_os_error();
360 return match error.raw_os_error() {
361 Some(libc::ENOSYS | libc::EINVAL | libc::EOPNOTSUPP) => Ok(None),
362 _ => Err(error),
363 };
364 }
365 let status = unsafe { status.assume_init() };
367 nfs_identity_from_statx(
368 Path::new(std::ffi::OsStr::from_bytes(path.to_bytes())),
369 &status,
370 )
371}
372
373#[cfg(target_os = "linux")]
374fn nfs_identity_from_statx(path: &Path, status: &LinuxStatx) -> io::Result<Option<FileIdentity>> {
375 if status.mask & STATX_IDENTITY_MASK != STATX_IDENTITY_MASK
376 || status.modified.nanos >= 1_000_000_000
377 {
378 return Ok(None);
379 }
380 let modified = system_time(status.modified.seconds, status.modified.nanos)?;
381 Ok(Some(FileIdentity {
382 path: path.to_path_buf(),
383 len: status.size,
384 modified,
385 changed: None,
386 object: Some(FileObjectIdentity {
387 device_major: status.device_major,
388 device_minor: status.device_minor,
389 mount_id: status.mount_id,
390 inode: status.inode,
391 }),
392 }))
393}
394
395#[cfg(not(target_os = "linux"))]
396fn nfs_file_identity(_path: &Path) -> io::Result<Option<FileIdentity>> {
397 Ok(None)
398}
399
400#[cfg(target_os = "linux")]
401fn system_time(seconds: i64, nanos: u32) -> io::Result<SystemTime> {
402 if seconds >= 0 {
403 SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::new(seconds as u64, nanos))
404 } else {
405 SystemTime::UNIX_EPOCH
406 .checked_sub(std::time::Duration::from_secs(seconds.unsigned_abs()))
407 .and_then(|time| time.checked_add(std::time::Duration::from_nanos(nanos.into())))
408 }
409 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "file timestamp is out of range"))
410}
411
412#[cfg(unix)]
414fn change_token(metadata: &std::fs::Metadata) -> Option<(i64, i64)> {
415 use std::os::unix::fs::MetadataExt;
416 Some((metadata.ctime(), metadata.ctime_nsec()))
417}
418
419#[cfg(not(unix))]
422fn change_token(_metadata: &std::fs::Metadata) -> Option<(i64, i64)> {
423 None
424}
425
426#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
428#[serde(deny_unknown_fields)]
429pub struct RecordedFileDigest {
430 pub file: FileIdentity,
432 pub digest: CacheDigest,
434}
435
436#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
444#[serde(rename_all = "snake_case")]
445pub enum FileDigestScope {
446 Content,
448 CcInput,
451}
452
453#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
455#[serde(tag = "kind", rename_all = "snake_case")]
456pub enum FileDigestResolution {
457 Digest(CacheDigest),
459 EmbeddedTimestampMacro,
461 Unresolved,
463}
464
465impl FileDigestResolution {
466 pub fn into_digest(self) -> Option<CacheDigest> {
468 match self {
469 Self::Digest(digest) => Some(digest),
470 Self::EmbeddedTimestampMacro | Self::Unresolved => None,
471 }
472 }
473}
474
475pub fn digest_file(scope: FileDigestScope, path: &Path) -> io::Result<FileDigestResolution> {
478 let file = std::fs::File::open(path)?;
479 let mut reader = std::io::BufReader::new(file);
480 let mut hasher = blake3::Hasher::new();
481 let mut size = 0_u64;
482 let longest_macro = TIMESTAMP_MACROS
483 .iter()
484 .map(|macro_name| macro_name.len())
485 .max()
486 .unwrap_or_default();
487 let mut window = Vec::with_capacity(DIGEST_BUFFER_BYTES + longest_macro);
488 let mut chunk = vec![0_u8; DIGEST_BUFFER_BYTES];
489 let mut found_timestamp_macro = false;
490 loop {
491 let read = reader.read(&mut chunk)?;
492 if read == 0 {
493 break;
494 }
495 hasher.update(&chunk[..read]);
496 size = size
497 .checked_add(read as u64)
498 .ok_or_else(|| io::Error::other("file length overflowed u64"))?;
499 if scope == FileDigestScope::CcInput && !found_timestamp_macro {
500 window.extend_from_slice(&chunk[..read]);
501 found_timestamp_macro = TIMESTAMP_MACROS
502 .iter()
503 .any(|macro_name| contains_subslice(&window, macro_name));
504 let keep = window.len().saturating_sub(longest_macro.saturating_sub(1));
505 window.drain(..keep);
506 }
507 }
508 if found_timestamp_macro {
509 Ok(FileDigestResolution::EmbeddedTimestampMacro)
510 } else {
511 Ok(FileDigestResolution::Digest(CacheDigest {
512 algorithm: "blake3".into(),
513 hash: hasher.finalize().to_hex().to_string(),
514 size,
515 }))
516 }
517}
518
519fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
520 !needle.is_empty()
521 && haystack.len() >= needle.len()
522 && haystack
523 .windows(needle.len())
524 .any(|window| window == needle)
525}
526
527pub trait FileDigestCache: Send + Sync {
533 fn resolve(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<FileDigestResolution> {
535 self.find(scope, files)
536 .into_iter()
537 .map(|digest| {
538 digest.map_or(
539 FileDigestResolution::Unresolved,
540 FileDigestResolution::Digest,
541 )
542 })
543 .collect()
544 }
545 fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>>;
547 fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>);
549}
550
551pub struct NoFileDigestCache;
553
554impl FileDigestCache for NoFileDigestCache {
555 fn resolve(
556 &self,
557 _scope: FileDigestScope,
558 files: &[FileIdentity],
559 ) -> Vec<FileDigestResolution> {
560 vec![FileDigestResolution::Unresolved; files.len()]
561 }
562
563 fn find(&self, _scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
564 vec![None; files.len()]
565 }
566
567 fn record(&self, _scope: FileDigestScope, _entries: Vec<RecordedFileDigest>) {}
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573
574 #[test]
575 fn an_identity_describes_the_file_until_it_is_written_or_removed() {
576 let directory = tempfile::tempdir().unwrap();
577 let path = directory.path().join("input.rs");
578 std::fs::write(&path, b"fn main() {}").unwrap();
579 let identity = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap()).unwrap();
580 assert!(identity.still_describes().unwrap());
581
582 std::thread::sleep(std::time::Duration::from_millis(20));
583 std::fs::write(&path, b"fn main() { }").unwrap();
584 assert!(!identity.still_describes().unwrap());
585
586 std::fs::remove_file(&path).unwrap();
587 assert!(identity.still_describes().is_err());
588 }
589
590 #[test]
591 fn a_metadata_snapshot_detects_a_metadata_change() {
592 let directory = tempfile::tempdir().unwrap();
593 let path = directory.path().join("input.rs");
594 std::fs::write(&path, b"fn main() {}").unwrap();
595 let snapshot = capture_file_snapshot(
596 &path,
597 &NoFileDigestCache,
598 false,
599 std::fs::metadata(&path).unwrap(),
600 )
601 .unwrap()
602 .unwrap();
603
604 std::fs::File::options()
605 .write(true)
606 .open(&path)
607 .unwrap()
608 .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
609 .unwrap();
610 let identity = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap());
611 let digest = CacheDigest::blake3_file(&path).unwrap();
612
613 assert!(!snapshot.matches(identity.as_ref(), &digest));
614 assert_eq!(snapshot.proves_content_change(), cfg!(unix));
615 }
616
617 #[test]
618 fn a_content_snapshot_ignores_change_token_churn_but_detects_other_changes() {
619 let directory = tempfile::tempdir().unwrap();
620 let path = directory.path().join("input.rs");
621 std::fs::write(&path, b"fn main() {}").unwrap();
622 let snapshot = capture_file_snapshot(
623 &path,
624 &NoFileDigestCache,
625 true,
626 std::fs::metadata(&path).unwrap(),
627 )
628 .unwrap()
629 .unwrap();
630
631 let mut identity = snapshot.identity.clone();
632 identity.changed = identity
633 .changed
634 .map(|(seconds, nanos)| (seconds + 1, nanos));
635 let digest = CacheDigest::blake3_file(&path).unwrap();
636 assert!(snapshot.matches(Some(&identity), &digest));
637 assert!(snapshot.proves_content_change());
638
639 identity.modified = SystemTime::UNIX_EPOCH;
640 assert!(!snapshot.matches(Some(&identity), &digest));
641
642 std::fs::write(&path, b"fn main(){ }").unwrap();
643 let identity = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap());
644 let digest = CacheDigest::blake3_file(&path).unwrap();
645 assert!(!snapshot.matches(identity.as_ref(), &digest));
646 }
647
648 #[test]
649 fn reliable_metadata_keeps_the_native_identity() {
650 let directory = tempfile::tempdir().unwrap();
651 let path = directory.path().join("input.rs");
652 std::fs::write(&path, b"fn main() {}").unwrap();
653 let metadata = std::fs::metadata(&path).unwrap();
654
655 let identity = digest_cache_identity(&path, &metadata, false)
656 .unwrap()
657 .unwrap();
658
659 assert_eq!(identity.path, path);
660 assert_eq!(identity.len, 12);
661 assert!(identity.object.is_none());
662 }
663
664 #[test]
665 fn content_snapshot_ignores_nfs_ctime_churn_but_not_object_replacement() {
666 let digest = CacheDigest::blake3(b"nfs bytes");
667 let identity = FileIdentity {
668 path: PathBuf::from("/nfs/input.rlib"),
669 len: digest.size,
670 modified: SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(10),
671 changed: Some((10, 1)),
672 object: Some(FileObjectIdentity {
673 device_major: 0,
674 device_minor: 42,
675 mount_id: 7,
676 inode: 99,
677 }),
678 };
679 let snapshot = FileSnapshot {
680 identity: identity.clone(),
681 content: Some(digest.clone()),
682 };
683 let mut after = identity;
684 after.changed = Some((9, 500));
685 assert!(snapshot.matches(Some(&after), &digest));
686
687 after.object.as_mut().unwrap().inode += 1;
688 assert!(!snapshot.matches(Some(&after), &digest));
689 after.object.as_mut().unwrap().inode -= 1;
690 after.modified += std::time::Duration::from_secs(1);
691 assert!(!snapshot.matches(Some(&after), &digest));
692 }
693
694 #[cfg(target_os = "linux")]
695 #[test]
696 fn incomplete_statx_metadata_falls_back_to_content_hashing() {
697 let status = unsafe { std::mem::zeroed::<LinuxStatx>() };
699 assert!(
700 nfs_identity_from_statx(Path::new("/nfs/input.rlib"), &status)
701 .unwrap()
702 .is_none()
703 );
704 }
705}