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)]
163pub struct FileSnapshot {
164 identity: FileIdentity,
165 content: Option<CacheDigest>,
166}
167
168impl FileSnapshot {
169 pub fn capture(path: &Path) -> io::Result<Option<Self>> {
171 let metadata = std::fs::metadata(path)?;
172 capture_file_snapshot(
173 path,
174 &NoFileDigestCache,
175 metadata_identity_is_unreliable(path, &metadata)?,
176 metadata,
177 )
178 }
179
180 pub fn capture_with_cache(
183 path: &Path,
184 digests: &dyn FileDigestCache,
185 ) -> io::Result<Option<Self>> {
186 let metadata = std::fs::metadata(path)?;
187 capture_file_snapshot(
188 path,
189 digests,
190 metadata_identity_is_unreliable(path, &metadata)?,
191 metadata,
192 )
193 }
194
195 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.object == after.object
211 })
212 },
213 )
214 }
215
216 pub fn proves_content_change(&self) -> bool {
218 self.content.is_some() || self.identity.changed.is_some()
219 }
220}
221
222impl From<FileIdentity> for FileSnapshot {
223 fn from(identity: FileIdentity) -> Self {
224 Self {
225 identity,
226 content: None,
227 }
228 }
229}
230
231#[cfg(target_os = "linux")]
232fn metadata_identity_is_unreliable(path: &Path, metadata: &std::fs::Metadata) -> io::Result<bool> {
233 use std::mem::MaybeUninit;
234 use std::os::unix::ffi::OsStrExt as _;
235 use std::os::unix::fs::MetadataExt as _;
236
237 static FILESYSTEMS: OnceLock<Mutex<std::collections::BTreeMap<u64, bool>>> = OnceLock::new();
238 let filesystems = FILESYSTEMS.get_or_init(|| Mutex::new(std::collections::BTreeMap::new()));
239 if let Some(unreliable) = filesystems.lock().unwrap().get(&metadata.dev()).copied() {
240 return Ok(unreliable);
241 }
242
243 let path = std::ffi::CString::new(path.as_os_str().as_bytes())
244 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains a NUL byte"))?;
245 let mut status = MaybeUninit::<libc::statfs>::zeroed();
246 let result = unsafe { libc::statfs(path.as_ptr(), status.as_mut_ptr()) };
249 if result != 0 {
250 return Err(io::Error::last_os_error());
251 }
252 let status = unsafe { status.assume_init() };
254 let unreliable = status.f_type == 0x6969;
256 filesystems
257 .lock()
258 .unwrap()
259 .insert(metadata.dev(), unreliable);
260 Ok(unreliable)
261}
262
263#[cfg(not(target_os = "linux"))]
264fn metadata_identity_is_unreliable(
265 _path: &Path,
266 _metadata: &std::fs::Metadata,
267) -> io::Result<bool> {
268 Ok(false)
269}
270
271fn capture_file_snapshot(
272 path: &Path,
273 digests: &dyn FileDigestCache,
274 content_identity: bool,
275 metadata: std::fs::Metadata,
276) -> io::Result<Option<FileSnapshot>> {
277 let cache_identity = digest_cache_identity(path, &metadata, content_identity)?;
278 let Some(identity) = cache_identity
279 .clone()
280 .or_else(|| FileIdentity::describe(path, &metadata))
281 else {
282 return Ok(None);
283 };
284 let content = if content_identity {
285 let resolved = cache_identity
286 .as_ref()
287 .and_then(|identity| {
288 digests
289 .resolve(FileDigestScope::Content, std::slice::from_ref(identity))
290 .pop()
291 })
292 .unwrap_or(FileDigestResolution::Unresolved);
293 let (digest, fresh) = match resolved {
294 FileDigestResolution::Digest(digest) => (digest, false),
295 FileDigestResolution::EmbeddedTimestampMacro | FileDigestResolution::Unresolved => {
296 let digest = digest_file(FileDigestScope::Content, path)?
297 .into_digest()
298 .ok_or_else(|| {
299 io::Error::other("content digest resolution returned no digest")
300 })?;
301 (digest, true)
302 }
303 };
304 if fresh
305 && let Some(file) = cache_identity
306 && file.len == digest.size
307 {
308 digests.record(
309 FileDigestScope::Content,
310 vec![RecordedFileDigest {
311 file,
312 digest: digest.clone(),
313 }],
314 );
315 }
316 Some(digest)
317 } else {
318 None
319 };
320 Ok(Some(FileSnapshot { identity, content }))
321}
322
323fn digest_cache_identity(
324 path: &Path,
325 metadata: &std::fs::Metadata,
326 unreliable: bool,
327) -> io::Result<Option<FileIdentity>> {
328 if unreliable {
329 nfs_file_identity(path)
330 } else {
331 Ok(FileIdentity::describe(path, metadata))
332 }
333}
334
335#[cfg(target_os = "linux")]
336fn nfs_file_identity(path: &Path) -> io::Result<Option<FileIdentity>> {
337 use std::mem::MaybeUninit;
338 use std::os::unix::ffi::OsStrExt as _;
339
340 let path = std::ffi::CString::new(path.as_os_str().as_bytes())
341 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains a NUL byte"))?;
342 let mut status = MaybeUninit::<LinuxStatx>::zeroed();
343 let result = unsafe {
345 libc::syscall(
346 libc::SYS_statx,
347 libc::AT_FDCWD,
348 path.as_ptr(),
349 0x4000,
353 STATX_IDENTITY_MASK,
354 status.as_mut_ptr(),
355 )
356 };
357 if result != 0 {
358 let error = io::Error::last_os_error();
359 return match error.raw_os_error() {
360 Some(libc::ENOSYS | libc::EINVAL | libc::EOPNOTSUPP) => Ok(None),
361 _ => Err(error),
362 };
363 }
364 let status = unsafe { status.assume_init() };
366 nfs_identity_from_statx(
367 Path::new(std::ffi::OsStr::from_bytes(path.to_bytes())),
368 &status,
369 )
370}
371
372#[cfg(target_os = "linux")]
373fn nfs_identity_from_statx(path: &Path, status: &LinuxStatx) -> io::Result<Option<FileIdentity>> {
374 if status.mask & STATX_IDENTITY_MASK != STATX_IDENTITY_MASK
375 || status.modified.nanos >= 1_000_000_000
376 {
377 return Ok(None);
378 }
379 let modified = system_time(status.modified.seconds, status.modified.nanos)?;
380 Ok(Some(FileIdentity {
381 path: path.to_path_buf(),
382 len: status.size,
383 modified,
384 changed: None,
385 object: Some(FileObjectIdentity {
386 device_major: status.device_major,
387 device_minor: status.device_minor,
388 mount_id: status.mount_id,
389 inode: status.inode,
390 }),
391 }))
392}
393
394#[cfg(not(target_os = "linux"))]
395fn nfs_file_identity(_path: &Path) -> io::Result<Option<FileIdentity>> {
396 Ok(None)
397}
398
399#[cfg(target_os = "linux")]
400fn system_time(seconds: i64, nanos: u32) -> io::Result<SystemTime> {
401 if seconds >= 0 {
402 SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::new(seconds as u64, nanos))
403 } else {
404 SystemTime::UNIX_EPOCH
405 .checked_sub(std::time::Duration::from_secs(seconds.unsigned_abs()))
406 .and_then(|time| time.checked_add(std::time::Duration::from_nanos(nanos.into())))
407 }
408 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "file timestamp is out of range"))
409}
410
411#[cfg(unix)]
413fn change_token(metadata: &std::fs::Metadata) -> Option<(i64, i64)> {
414 use std::os::unix::fs::MetadataExt;
415 Some((metadata.ctime(), metadata.ctime_nsec()))
416}
417
418#[cfg(not(unix))]
421fn change_token(_metadata: &std::fs::Metadata) -> Option<(i64, i64)> {
422 None
423}
424
425#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
427#[serde(deny_unknown_fields)]
428pub struct RecordedFileDigest {
429 pub file: FileIdentity,
431 pub digest: CacheDigest,
433}
434
435#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
443#[serde(rename_all = "snake_case")]
444pub enum FileDigestScope {
445 Content,
447 CcInput,
450}
451
452#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
454#[serde(tag = "kind", rename_all = "snake_case")]
455pub enum FileDigestResolution {
456 Digest(CacheDigest),
458 EmbeddedTimestampMacro,
460 Unresolved,
462}
463
464impl FileDigestResolution {
465 pub fn into_digest(self) -> Option<CacheDigest> {
467 match self {
468 Self::Digest(digest) => Some(digest),
469 Self::EmbeddedTimestampMacro | Self::Unresolved => None,
470 }
471 }
472}
473
474pub fn digest_file(scope: FileDigestScope, path: &Path) -> io::Result<FileDigestResolution> {
477 let file = std::fs::File::open(path)?;
478 let mut reader = std::io::BufReader::new(file);
479 let mut hasher = blake3::Hasher::new();
480 let mut size = 0_u64;
481 let longest_macro = TIMESTAMP_MACROS
482 .iter()
483 .map(|macro_name| macro_name.len())
484 .max()
485 .unwrap_or_default();
486 let mut window = Vec::with_capacity(DIGEST_BUFFER_BYTES + longest_macro);
487 let mut chunk = vec![0_u8; DIGEST_BUFFER_BYTES];
488 let mut found_timestamp_macro = false;
489 loop {
490 let read = reader.read(&mut chunk)?;
491 if read == 0 {
492 break;
493 }
494 hasher.update(&chunk[..read]);
495 size = size
496 .checked_add(read as u64)
497 .ok_or_else(|| io::Error::other("file length overflowed u64"))?;
498 if scope == FileDigestScope::CcInput && !found_timestamp_macro {
499 window.extend_from_slice(&chunk[..read]);
500 found_timestamp_macro = TIMESTAMP_MACROS
501 .iter()
502 .any(|macro_name| contains_subslice(&window, macro_name));
503 let keep = window.len().saturating_sub(longest_macro.saturating_sub(1));
504 window.drain(..keep);
505 }
506 }
507 if found_timestamp_macro {
508 Ok(FileDigestResolution::EmbeddedTimestampMacro)
509 } else {
510 Ok(FileDigestResolution::Digest(CacheDigest {
511 algorithm: "blake3".into(),
512 hash: hasher.finalize().to_hex().to_string(),
513 size,
514 }))
515 }
516}
517
518fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
519 !needle.is_empty()
520 && haystack.len() >= needle.len()
521 && haystack
522 .windows(needle.len())
523 .any(|window| window == needle)
524}
525
526pub trait FileDigestCache: Send + Sync {
532 fn resolve(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<FileDigestResolution> {
534 self.find(scope, files)
535 .into_iter()
536 .map(|digest| {
537 digest.map_or(
538 FileDigestResolution::Unresolved,
539 FileDigestResolution::Digest,
540 )
541 })
542 .collect()
543 }
544 fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>>;
546 fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>);
548}
549
550pub struct NoFileDigestCache;
552
553impl FileDigestCache for NoFileDigestCache {
554 fn resolve(
555 &self,
556 _scope: FileDigestScope,
557 files: &[FileIdentity],
558 ) -> Vec<FileDigestResolution> {
559 vec![FileDigestResolution::Unresolved; files.len()]
560 }
561
562 fn find(&self, _scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
563 vec![None; files.len()]
564 }
565
566 fn record(&self, _scope: FileDigestScope, _entries: Vec<RecordedFileDigest>) {}
567}
568
569#[cfg(test)]
570mod tests {
571 use super::*;
572
573 #[test]
574 fn an_identity_describes_the_file_until_it_is_written_or_removed() {
575 let directory = tempfile::tempdir().unwrap();
576 let path = directory.path().join("input.rs");
577 std::fs::write(&path, b"fn main() {}").unwrap();
578 let identity = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap()).unwrap();
579 assert!(identity.still_describes().unwrap());
580
581 std::thread::sleep(std::time::Duration::from_millis(20));
582 std::fs::write(&path, b"fn main() { }").unwrap();
583 assert!(!identity.still_describes().unwrap());
584
585 std::fs::remove_file(&path).unwrap();
586 assert!(identity.still_describes().is_err());
587 }
588
589 #[test]
590 fn a_metadata_snapshot_detects_a_metadata_change() {
591 let directory = tempfile::tempdir().unwrap();
592 let path = directory.path().join("input.rs");
593 std::fs::write(&path, b"fn main() {}").unwrap();
594 let snapshot = capture_file_snapshot(
595 &path,
596 &NoFileDigestCache,
597 false,
598 std::fs::metadata(&path).unwrap(),
599 )
600 .unwrap()
601 .unwrap();
602
603 std::fs::File::options()
604 .write(true)
605 .open(&path)
606 .unwrap()
607 .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
608 .unwrap();
609 let identity = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap());
610 let digest = CacheDigest::blake3_file(&path).unwrap();
611
612 assert!(!snapshot.matches(identity.as_ref(), &digest));
613 assert_eq!(snapshot.proves_content_change(), cfg!(unix));
614 }
615
616 #[test]
617 fn a_content_snapshot_ignores_timestamp_churn_but_detects_changed_bytes() {
618 let directory = tempfile::tempdir().unwrap();
619 let path = directory.path().join("input.rs");
620 std::fs::write(&path, b"fn main() {}").unwrap();
621 let snapshot = capture_file_snapshot(
622 &path,
623 &NoFileDigestCache,
624 true,
625 std::fs::metadata(&path).unwrap(),
626 )
627 .unwrap()
628 .unwrap();
629
630 let mut identity = snapshot.identity.clone();
631 identity.changed = identity
632 .changed
633 .map(|(seconds, nanos)| (seconds + 1, nanos));
634 let digest = CacheDigest::blake3_file(&path).unwrap();
635 assert!(snapshot.matches(Some(&identity), &digest));
636 assert!(snapshot.proves_content_change());
637
638 identity.modified = SystemTime::UNIX_EPOCH;
639 assert!(snapshot.matches(Some(&identity), &digest));
640
641 std::fs::write(&path, b"fn main(){ }").unwrap();
642 let identity = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap());
643 let digest = CacheDigest::blake3_file(&path).unwrap();
644 assert!(!snapshot.matches(identity.as_ref(), &digest));
645 }
646
647 #[test]
648 fn reliable_metadata_keeps_the_native_identity() {
649 let directory = tempfile::tempdir().unwrap();
650 let path = directory.path().join("input.rs");
651 std::fs::write(&path, b"fn main() {}").unwrap();
652 let metadata = std::fs::metadata(&path).unwrap();
653
654 let identity = digest_cache_identity(&path, &metadata, false)
655 .unwrap()
656 .unwrap();
657
658 assert_eq!(identity.path, path);
659 assert_eq!(identity.len, 12);
660 assert!(identity.object.is_none());
661 }
662
663 #[test]
664 fn content_snapshot_ignores_nfs_timestamp_churn_but_not_content_or_identity_changes() {
665 let digest = CacheDigest::blake3(b"nfs bytes");
666 let identity = FileIdentity {
667 path: PathBuf::from("/nfs/input.rlib"),
668 len: digest.size,
669 modified: SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(10),
670 changed: Some((10, 1)),
671 object: Some(FileObjectIdentity {
672 device_major: 0,
673 device_minor: 42,
674 mount_id: 7,
675 inode: 99,
676 }),
677 };
678 let snapshot = FileSnapshot {
679 identity: identity.clone(),
680 content: Some(digest.clone()),
681 };
682 for seconds in [9, 11] {
685 let mut after = identity.clone();
686 after.changed = Some((seconds, 500));
687 after.modified = SystemTime::UNIX_EPOCH + std::time::Duration::new(seconds as u64, 500);
688 assert!(snapshot.matches(Some(&after), &digest));
689 }
690
691 let changed = CacheDigest::blake3(b"NFS bytes");
694 assert_eq!(changed.size, digest.size);
695 assert!(!snapshot.matches(Some(&identity), &changed));
696 assert!(!snapshot.matches(None, &digest));
697
698 let mut after = identity.clone();
699 after.object.as_mut().unwrap().inode += 1;
700 assert!(!snapshot.matches(Some(&after), &digest));
701
702 let mut after = identity.clone();
703 after.len += 1;
704 assert!(!snapshot.matches(Some(&after), &digest));
705
706 let mut after = identity;
707 after.path.set_file_name("replacement.rlib");
708 assert!(!snapshot.matches(Some(&after), &digest));
709 }
710
711 #[cfg(target_os = "linux")]
712 #[test]
713 fn incomplete_statx_metadata_falls_back_to_content_hashing() {
714 let status = unsafe { std::mem::zeroed::<LinuxStatx>() };
716 assert!(
717 nfs_identity_from_statx(Path::new("/nfs/input.rlib"), &status)
718 .unwrap()
719 .is_none()
720 );
721 }
722}