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")]
88 pub object: Option<FileObjectIdentity>,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
93#[serde(deny_unknown_fields)]
94pub struct FileObjectIdentity {
95 pub device_major: u32,
98 pub device_minor: u32,
101 pub mount_id: u64,
103 pub inode: u64,
105}
106
107impl FileIdentity {
108 pub fn describe(path: &Path, metadata: &std::fs::Metadata) -> Option<Self> {
111 Some(Self {
112 path: path.to_path_buf(),
113 len: metadata.len(),
114 modified: metadata.modified().ok()?,
115 changed: change_token(metadata),
116 object: metadata_object_identity(metadata),
117 })
118 }
119
120 pub fn for_digest_cache(path: &Path, metadata: &std::fs::Metadata) -> io::Result<Option<Self>> {
128 digest_cache_identity(
129 path,
130 metadata,
131 metadata_identity_is_unreliable(path, metadata)?,
132 )
133 }
134
135 pub fn still_describes(&self) -> std::io::Result<bool> {
146 let metadata = std::fs::metadata(&self.path)?;
147 Ok(Self::for_digest_cache(&self.path, &metadata)?.as_ref() == Some(self))
148 }
149
150 pub fn can_skip_content_verification(&self) -> bool {
152 self.changed.is_some() || self.object.is_some()
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct FileSnapshot {
167 identity: FileIdentity,
168 content: Option<CacheDigest>,
169}
170
171impl FileSnapshot {
172 pub fn capture(path: &Path) -> io::Result<Option<Self>> {
174 let metadata = std::fs::metadata(path)?;
175 let unreliable = metadata_identity_is_unreliable(path, &metadata)?;
176 capture_file_snapshot(path, &NoFileDigestCache, unreliable, unreliable, metadata)
177 }
178
179 pub fn capture_with_cache(
182 path: &Path,
183 digests: &dyn FileDigestCache,
184 ) -> io::Result<Option<Self>> {
185 let metadata = std::fs::metadata(path)?;
186 let unreliable = metadata_identity_is_unreliable(path, &metadata)?;
187 capture_file_snapshot(path, digests, unreliable, unreliable, metadata)
188 }
189
190 pub fn capture_content_with_cache(
199 path: &Path,
200 digests: &dyn FileDigestCache,
201 ) -> io::Result<Option<Self>> {
202 let metadata = std::fs::metadata(path)?;
203 let unreliable = metadata_identity_is_unreliable(path, &metadata)?;
207 capture_file_snapshot(path, digests, unreliable, true, metadata)
208 }
209
210 pub fn matches(&self, identity: Option<&FileIdentity>, content: &CacheDigest) -> bool {
218 self.content.as_ref().map_or_else(
219 || identity == Some(&self.identity),
220 |before| {
221 before == content
222 && identity.is_some_and(|after| {
223 self.identity.path == after.path
224 && self.identity.len == after.len
225 && self.identity.object == after.object
226 })
227 },
228 )
229 }
230
231 pub fn proves_content_change(&self) -> bool {
233 self.content.is_some() || self.identity.changed.is_some()
234 }
235}
236
237impl From<FileIdentity> for FileSnapshot {
238 fn from(identity: FileIdentity) -> Self {
239 Self {
240 identity,
241 content: None,
242 }
243 }
244}
245
246#[cfg(target_os = "linux")]
247fn metadata_identity_is_unreliable(path: &Path, metadata: &std::fs::Metadata) -> io::Result<bool> {
248 use std::mem::MaybeUninit;
249 use std::os::unix::ffi::OsStrExt as _;
250 use std::os::unix::fs::MetadataExt as _;
251
252 static FILESYSTEMS: OnceLock<Mutex<std::collections::BTreeMap<u64, bool>>> = OnceLock::new();
253 let filesystems = FILESYSTEMS.get_or_init(|| Mutex::new(std::collections::BTreeMap::new()));
254 if let Some(unreliable) = filesystems.lock().unwrap().get(&metadata.dev()).copied() {
255 return Ok(unreliable);
256 }
257
258 let path = std::ffi::CString::new(path.as_os_str().as_bytes())
259 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains a NUL byte"))?;
260 let mut status = MaybeUninit::<libc::statfs>::zeroed();
261 let result = unsafe { libc::statfs(path.as_ptr(), status.as_mut_ptr()) };
264 if result != 0 {
265 return Err(io::Error::last_os_error());
266 }
267 let status = unsafe { status.assume_init() };
269 let unreliable = status.f_type == 0x6969;
271 filesystems
272 .lock()
273 .unwrap()
274 .insert(metadata.dev(), unreliable);
275 Ok(unreliable)
276}
277
278#[cfg(not(target_os = "linux"))]
279fn metadata_identity_is_unreliable(
280 _path: &Path,
281 _metadata: &std::fs::Metadata,
282) -> io::Result<bool> {
283 Ok(false)
284}
285
286fn capture_file_snapshot(
290 path: &Path,
291 digests: &dyn FileDigestCache,
292 unreliable: bool,
293 content_identity: bool,
294 metadata: std::fs::Metadata,
295) -> io::Result<Option<FileSnapshot>> {
296 let cache_identity = digest_cache_identity(path, &metadata, unreliable)?;
297 let Some(identity) = cache_identity
298 .clone()
299 .or_else(|| FileIdentity::describe(path, &metadata))
300 else {
301 return Ok(None);
302 };
303 let (identity, content) = if content_identity {
304 let resolved = cache_identity
305 .as_ref()
306 .and_then(|identity| {
307 digests
308 .resolve(FileDigestScope::Content, std::slice::from_ref(identity))
309 .pop()
310 })
311 .unwrap_or(FileDigestResolution::Unresolved);
312 let cached = match resolved {
313 FileDigestResolution::Digest(digest)
314 if cache_identity.as_ref().is_some_and(|identity| {
315 identity.len == digest.size && identity.still_describes().unwrap_or(false)
316 }) =>
317 {
318 Some(digest)
319 }
320 FileDigestResolution::Digest(_)
321 | FileDigestResolution::EmbeddedTimestampMacro
322 | FileDigestResolution::Unresolved => None,
323 };
324 if let Some(digest) = cached {
325 (identity, Some(digest))
326 } else {
327 let observed = digest_file_validated(FileDigestScope::Content, path)?
328 .ok_or_else(|| io::Error::other("could not establish a file digest identity"))?;
329 let digest = observed
330 .resolution
331 .into_digest()
332 .ok_or_else(|| io::Error::other("content digest resolution returned no digest"))?;
333 if let Some(file) = observed.cache_identity
334 && file.len == digest.size
335 {
336 digests.record(
337 FileDigestScope::Content,
338 vec![RecordedFileDigest {
339 file,
340 digest: digest.clone(),
341 }],
342 );
343 }
344 (observed.identity, Some(digest))
345 }
346 } else {
347 (identity, None)
348 };
349 Ok(Some(FileSnapshot { identity, content }))
350}
351
352fn digest_cache_identity(
353 path: &Path,
354 metadata: &std::fs::Metadata,
355 unreliable: bool,
356) -> io::Result<Option<FileIdentity>> {
357 if unreliable {
358 nfs_file_identity(path)
359 } else {
360 Ok(FileIdentity::describe(path, metadata))
361 }
362}
363
364#[cfg(target_os = "linux")]
365fn nfs_file_identity(path: &Path) -> io::Result<Option<FileIdentity>> {
366 use std::mem::MaybeUninit;
367 use std::os::unix::ffi::OsStrExt as _;
368
369 let path = std::ffi::CString::new(path.as_os_str().as_bytes())
370 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains a NUL byte"))?;
371 let mut status = MaybeUninit::<LinuxStatx>::zeroed();
372 let result = unsafe {
374 libc::syscall(
375 libc::SYS_statx,
376 libc::AT_FDCWD,
377 path.as_ptr(),
378 0x4000,
382 STATX_IDENTITY_MASK,
383 status.as_mut_ptr(),
384 )
385 };
386 if result != 0 {
387 let error = io::Error::last_os_error();
388 return match error.raw_os_error() {
389 Some(libc::ENOSYS | libc::EINVAL | libc::EOPNOTSUPP) => Ok(None),
390 _ => Err(error),
391 };
392 }
393 let status = unsafe { status.assume_init() };
395 nfs_identity_from_statx(
396 Path::new(std::ffi::OsStr::from_bytes(path.to_bytes())),
397 &status,
398 )
399}
400
401#[cfg(target_os = "linux")]
402fn nfs_identity_from_statx(path: &Path, status: &LinuxStatx) -> io::Result<Option<FileIdentity>> {
403 if status.mask & STATX_IDENTITY_MASK != STATX_IDENTITY_MASK
404 || status.modified.nanos >= 1_000_000_000
405 {
406 return Ok(None);
407 }
408 let modified = system_time(status.modified.seconds, status.modified.nanos)?;
409 Ok(Some(FileIdentity {
410 path: path.to_path_buf(),
411 len: status.size,
412 modified,
413 changed: None,
414 object: Some(FileObjectIdentity {
415 device_major: status.device_major,
416 device_minor: status.device_minor,
417 mount_id: status.mount_id,
418 inode: status.inode,
419 }),
420 }))
421}
422
423#[cfg(not(target_os = "linux"))]
424fn nfs_file_identity(_path: &Path) -> io::Result<Option<FileIdentity>> {
425 Ok(None)
426}
427
428#[cfg(target_os = "linux")]
429fn system_time(seconds: i64, nanos: u32) -> io::Result<SystemTime> {
430 if seconds >= 0 {
431 SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::new(seconds as u64, nanos))
432 } else {
433 SystemTime::UNIX_EPOCH
434 .checked_sub(std::time::Duration::from_secs(seconds.unsigned_abs()))
435 .and_then(|time| time.checked_add(std::time::Duration::from_nanos(nanos.into())))
436 }
437 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "file timestamp is out of range"))
438}
439
440#[cfg(unix)]
442fn change_token(metadata: &std::fs::Metadata) -> Option<(i64, i64)> {
443 use std::os::unix::fs::MetadataExt;
444 Some((metadata.ctime(), metadata.ctime_nsec()))
445}
446
447#[cfg(not(unix))]
450fn change_token(_metadata: &std::fs::Metadata) -> Option<(i64, i64)> {
451 None
452}
453
454#[cfg(unix)]
455fn metadata_object_identity(metadata: &std::fs::Metadata) -> Option<FileObjectIdentity> {
456 use std::os::unix::fs::MetadataExt as _;
457
458 let device = metadata.dev();
459 Some(FileObjectIdentity {
460 device_major: (device >> 32) as u32,
461 device_minor: device as u32,
462 mount_id: 0,
463 inode: metadata.ino(),
464 })
465}
466
467#[cfg(not(unix))]
468fn metadata_object_identity(_metadata: &std::fs::Metadata) -> Option<FileObjectIdentity> {
469 None
470}
471
472#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
474#[serde(deny_unknown_fields)]
475pub struct RecordedFileDigest {
476 pub file: FileIdentity,
478 pub digest: CacheDigest,
480}
481
482#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
490#[serde(rename_all = "snake_case")]
491pub enum FileDigestScope {
492 Content,
494 CcInput,
497}
498
499#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
501#[serde(tag = "kind", rename_all = "snake_case")]
502pub enum FileDigestResolution {
503 Digest(CacheDigest),
505 EmbeddedTimestampMacro,
507 Unresolved,
509}
510
511#[derive(Debug, Clone, PartialEq, Eq)]
513pub struct ValidatedFileDigest {
514 pub resolution: FileDigestResolution,
516 pub identity: FileIdentity,
518 pub cache_identity: Option<FileIdentity>,
520}
521
522impl FileDigestResolution {
523 pub fn into_digest(self) -> Option<CacheDigest> {
525 match self {
526 Self::Digest(digest) => Some(digest),
527 Self::EmbeddedTimestampMacro | Self::Unresolved => None,
528 }
529 }
530}
531
532pub fn digest_file(scope: FileDigestScope, path: &Path) -> io::Result<FileDigestResolution> {
535 let file = std::fs::File::open(path)?;
536 digest_reader(scope, &file).map(|(resolution, _)| resolution)
537}
538
539pub fn digest_file_validated(
542 scope: FileDigestScope,
543 path: &Path,
544) -> io::Result<Option<ValidatedFileDigest>> {
545 let file = std::fs::File::open(path)?;
546 let before = file.metadata()?;
547 if !before.is_file() {
548 return Err(io::Error::new(
549 io::ErrorKind::InvalidInput,
550 format!("digest path is not a regular file: {}", path.display()),
551 ));
552 }
553 let (resolution, size) = digest_reader(scope, &file)?;
554 let after = file.metadata()?;
555 let current_metadata = std::fs::metadata(path)?;
556 let unreliable = metadata_identity_is_unreliable(path, ¤t_metadata)?;
557 let cache_identity = digest_cache_identity(path, ¤t_metadata, unreliable)?;
558 let identity = cache_identity
559 .clone()
560 .or_else(|| FileIdentity::describe(path, ¤t_metadata));
561 let Some(identity) = identity else {
562 return Ok(None);
563 };
564 if !metadata_names_same_file(&after, ¤t_metadata) {
565 return Err(io::Error::new(
566 io::ErrorKind::WouldBlock,
567 "file was replaced while its contents were being read",
568 ));
569 }
570 if !handle_and_path_are_stable(path, &before, &after, &identity, unreliable, size)? {
571 return Err(io::Error::new(
572 io::ErrorKind::WouldBlock,
573 "file changed while its contents were being read",
574 ));
575 }
576 Ok(Some(ValidatedFileDigest {
577 resolution,
578 identity,
579 cache_identity,
580 }))
581}
582
583fn digest_reader(
584 scope: FileDigestScope,
585 file: &std::fs::File,
586) -> io::Result<(FileDigestResolution, u64)> {
587 let mut reader = std::io::BufReader::new(file);
588 let mut hasher = blake3::Hasher::new();
589 let mut size = 0_u64;
590 let longest_macro = TIMESTAMP_MACROS
591 .iter()
592 .map(|macro_name| macro_name.len())
593 .max()
594 .unwrap_or_default();
595 let mut window = Vec::with_capacity(DIGEST_BUFFER_BYTES + longest_macro);
596 let mut chunk = vec![0_u8; DIGEST_BUFFER_BYTES];
597 let mut found_timestamp_macro = false;
598 loop {
599 let read = reader.read(&mut chunk)?;
600 if read == 0 {
601 break;
602 }
603 hasher.update(&chunk[..read]);
604 size = size
605 .checked_add(read as u64)
606 .ok_or_else(|| io::Error::other("file length overflowed u64"))?;
607 if scope == FileDigestScope::CcInput && !found_timestamp_macro {
608 window.extend_from_slice(&chunk[..read]);
609 found_timestamp_macro = TIMESTAMP_MACROS
610 .iter()
611 .any(|macro_name| memchr::memmem::find(&window, macro_name).is_some());
612 let keep = window.len().saturating_sub(longest_macro.saturating_sub(1));
613 window.drain(..keep);
614 }
615 }
616 if found_timestamp_macro {
617 Ok((FileDigestResolution::EmbeddedTimestampMacro, size))
618 } else {
619 Ok((
620 FileDigestResolution::Digest(CacheDigest {
621 algorithm: "blake3".into(),
622 hash: hasher.finalize().to_hex().to_string(),
623 size,
624 }),
625 size,
626 ))
627 }
628}
629
630fn handle_and_path_are_stable(
631 path: &Path,
632 before: &std::fs::Metadata,
633 after: &std::fs::Metadata,
634 current: &FileIdentity,
635 unreliable: bool,
636 digest_size: u64,
637) -> io::Result<bool> {
638 if before.len() != after.len() || after.len() != digest_size || current.len != digest_size {
639 return Ok(false);
640 }
641 if !metadata_names_same_file(before, after) {
642 return Ok(false);
643 }
644 if unreliable {
645 #[cfg(target_os = "linux")]
646 {
647 let before_modified = before.modified()?;
648 let after_modified = after.modified()?;
649 let object_matches = current.object.as_ref().is_none_or(|object| {
650 metadata_matches_object(before, object) && metadata_matches_object(after, object)
651 });
652 return Ok(before_modified == after_modified
653 && after_modified == current.modified
654 && object_matches);
655 }
656 #[cfg(not(target_os = "linux"))]
657 {
658 let _ = (path, before, after, current);
659 return Ok(false);
660 }
661 }
662 let before = FileIdentity::describe(path, before);
663 let after = FileIdentity::describe(path, after);
664 Ok(before.is_some() && before == after && after.as_ref() == Some(current))
665}
666
667#[cfg(unix)]
668fn metadata_names_same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
669 use std::os::unix::fs::MetadataExt as _;
670
671 left.dev() == right.dev() && left.ino() == right.ino()
672}
673
674#[cfg(not(unix))]
675fn metadata_names_same_file(_left: &std::fs::Metadata, _right: &std::fs::Metadata) -> bool {
676 true
677}
678
679#[cfg(target_os = "linux")]
680fn metadata_matches_object(metadata: &std::fs::Metadata, object: &FileObjectIdentity) -> bool {
681 use std::os::unix::fs::MetadataExt as _;
682
683 let device = metadata.dev();
684 let major = ((device >> 8) & 0x0fff) | ((device >> 32) & 0xfffff000);
685 let minor = (device & 0x00ff) | ((device >> 12) & 0xffffff00);
686 u32::try_from(major).ok() == Some(object.device_major)
687 && u32::try_from(minor).ok() == Some(object.device_minor)
688 && metadata.ino() == object.inode
689}
690
691pub trait FileDigestCache: Send + Sync {
697 fn resolve(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<FileDigestResolution> {
699 self.find(scope, files)
700 .into_iter()
701 .map(|digest| {
702 digest.map_or(
703 FileDigestResolution::Unresolved,
704 FileDigestResolution::Digest,
705 )
706 })
707 .collect()
708 }
709 fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>>;
711 fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>);
713}
714
715pub struct NoFileDigestCache;
717
718impl FileDigestCache for NoFileDigestCache {
719 fn resolve(
720 &self,
721 _scope: FileDigestScope,
722 files: &[FileIdentity],
723 ) -> Vec<FileDigestResolution> {
724 vec![FileDigestResolution::Unresolved; files.len()]
725 }
726
727 fn find(&self, _scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
728 vec![None; files.len()]
729 }
730
731 fn record(&self, _scope: FileDigestScope, _entries: Vec<RecordedFileDigest>) {}
732}
733
734#[cfg(test)]
735mod tests {
736 use super::*;
737
738 #[test]
739 fn timestamp_macros_are_detected_at_every_chunk_boundary_split() {
740 let directory = tempfile::tempdir().unwrap();
741 let path = directory.path().join("input.h");
742 for macro_name in TIMESTAMP_MACROS {
743 for split in 0..=macro_name.len() {
745 let mut bytes = vec![b'x'; DIGEST_BUFFER_BYTES - split];
746 bytes.extend_from_slice(macro_name);
747 std::fs::write(&path, &bytes).unwrap();
748 assert_eq!(
749 digest_file(FileDigestScope::CcInput, &path).unwrap(),
750 FileDigestResolution::EmbeddedTimestampMacro,
751 "macro {macro_name:?}, split {split}"
752 );
753 assert_eq!(
755 digest_file(FileDigestScope::Content, &path).unwrap(),
756 FileDigestResolution::Digest(CacheDigest::blake3(&bytes))
757 );
758 }
759 }
760 }
761
762 #[test]
763 fn timestamp_scan_accepts_empty_short_and_near_match_inputs() {
764 let directory = tempfile::tempdir().unwrap();
765 let path = directory.path().join("input.h");
766 for bytes in [
767 b"".as_slice(),
768 b"_",
769 b"__DATE_ __TIME_ __TIMESTAMP_",
770 b"__date__ __time__ __timestamp__",
771 &[0xff, 0, b'_', b'_'],
772 ] {
773 std::fs::write(&path, bytes).unwrap();
774 assert_eq!(
775 digest_file(FileDigestScope::CcInput, &path).unwrap(),
776 FileDigestResolution::Digest(CacheDigest::blake3(bytes))
777 );
778 }
779 }
780
781 #[cfg(unix)]
786 #[test]
787 fn a_content_snapshot_survives_a_hardlink_and_a_metadata_one_does_not() {
788 let directory = tempfile::tempdir().unwrap();
789 let path = directory.path().join("libdep.rmeta");
790 std::fs::write(&path, b"metadata").unwrap();
791 let by_metadata = FileSnapshot::capture(&path).unwrap().unwrap();
792 let by_content = FileSnapshot::capture_content_with_cache(&path, &NoFileDigestCache)
793 .unwrap()
794 .unwrap();
795 let digest = CacheDigest::blake3_file(&path).unwrap();
796 std::thread::sleep(std::time::Duration::from_millis(20));
798 std::fs::hard_link(&path, directory.path().join("metadata.rmeta")).unwrap();
799 let after = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap()).unwrap();
800
801 assert!(by_content.proves_content_change());
802 assert!(by_content.matches(Some(&after), &digest));
803 if by_metadata.proves_content_change() {
804 assert!(!by_metadata.matches(Some(&after), &digest));
805 }
806
807 std::fs::write(&path, b"rewritten").unwrap();
808 let rewritten = CacheDigest::blake3_file(&path).unwrap();
809 let after = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap()).unwrap();
810 assert!(!by_content.matches(Some(&after), &rewritten));
811 }
812
813 #[cfg(unix)]
814 struct RecordingDigestCache {
815 answer: Option<CacheDigest>,
816 recorded: std::sync::Mutex<Vec<RecordedFileDigest>>,
817 }
818
819 #[cfg(unix)]
820 impl FileDigestCache for RecordingDigestCache {
821 fn find(
822 &self,
823 _scope: FileDigestScope,
824 files: &[FileIdentity],
825 ) -> Vec<Option<CacheDigest>> {
826 assert_eq!(files.len(), 1);
827 vec![self.answer.clone()]
828 }
829
830 fn record(&self, _scope: FileDigestScope, entries: Vec<RecordedFileDigest>) {
831 self.recorded.lock().unwrap().extend(entries);
832 }
833 }
834
835 #[cfg(unix)]
836 #[test]
837 fn content_snapshot_records_a_replacement_under_its_own_identity() {
838 let directory = tempfile::tempdir().unwrap();
839 let path = directory.path().join("input.rlib");
840 let replacement = directory.path().join("replacement.rlib");
841 std::fs::write(&path, b"original....").unwrap();
842 std::fs::File::options()
843 .write(true)
844 .open(&path)
845 .unwrap()
846 .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
847 .unwrap();
848 std::fs::write(&replacement, b"replacement!").unwrap();
849 std::fs::File::options()
850 .write(true)
851 .open(&replacement)
852 .unwrap()
853 .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
854 .unwrap();
855 let before = std::fs::metadata(&path).unwrap();
856 let old_identity = FileIdentity::describe(&path, &before).unwrap();
857 std::fs::remove_file(&path).unwrap();
858 std::fs::rename(&replacement, &path).unwrap();
859 let cache = RecordingDigestCache {
860 answer: None,
861 recorded: std::sync::Mutex::new(Vec::new()),
862 };
863
864 let snapshot = capture_file_snapshot(&path, &cache, true, true, before)
865 .unwrap()
866 .unwrap();
867 let current = FileIdentity::for_digest_cache(&path, &std::fs::metadata(&path).unwrap())
868 .unwrap()
869 .unwrap();
870 let digest = CacheDigest::blake3_file(&path).unwrap();
871
872 assert_eq!(current.modified, old_identity.modified);
873 assert_ne!(current.object, old_identity.object);
874 assert_ne!(current, old_identity);
875 assert!(snapshot.matches(Some(¤t), &digest));
876 let recorded = cache.recorded.lock().unwrap();
877 assert_eq!(recorded.len(), 1);
878 assert_eq!(recorded[0].file, current);
879 assert_eq!(recorded[0].digest, digest);
880 }
881
882 #[cfg(unix)]
883 #[test]
884 fn content_snapshot_rejects_a_stale_digest_cache_hit() {
885 let directory = tempfile::tempdir().unwrap();
886 let path = directory.path().join("input.rlib");
887 let replacement = directory.path().join("replacement.rlib");
888 std::fs::write(&path, b"original....").unwrap();
889 std::fs::File::options()
890 .write(true)
891 .open(&path)
892 .unwrap()
893 .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
894 .unwrap();
895 let before = std::fs::metadata(&path).unwrap();
896 std::fs::write(&replacement, b"replacement!").unwrap();
897 std::fs::File::options()
898 .write(true)
899 .open(&replacement)
900 .unwrap()
901 .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
902 .unwrap();
903 std::fs::remove_file(&path).unwrap();
904 std::fs::rename(&replacement, &path).unwrap();
905 let cache = RecordingDigestCache {
906 answer: Some(CacheDigest::blake3(b"original....")),
907 recorded: std::sync::Mutex::new(Vec::new()),
908 };
909
910 let snapshot = capture_file_snapshot(&path, &cache, true, true, before)
911 .unwrap()
912 .unwrap();
913 let current = FileIdentity::for_digest_cache(&path, &std::fs::metadata(&path).unwrap())
914 .unwrap()
915 .unwrap();
916 let digest = CacheDigest::blake3_file(&path).unwrap();
917
918 assert!(snapshot.matches(Some(¤t), &digest));
919 assert_ne!(snapshot.content, Some(CacheDigest::blake3(b"original....")));
920 assert_eq!(cache.recorded.lock().unwrap()[0].digest, digest);
921 }
922
923 #[cfg(unix)]
924 #[test]
925 fn a_replaced_path_does_not_validate_the_open_handle() {
926 let directory = tempfile::tempdir().unwrap();
927 let path = directory.path().join("input.rlib");
928 let replacement = directory.path().join("replacement.rlib");
929 std::fs::write(&path, b"old bytes").unwrap();
930 std::fs::write(&replacement, b"new bytes").unwrap();
931
932 let file = std::fs::File::open(&path).unwrap();
933 let before = file.metadata().unwrap();
934 std::fs::rename(&replacement, &path).unwrap();
935 let (_, size) = digest_reader(FileDigestScope::Content, &file).unwrap();
936 let after = file.metadata().unwrap();
937 let current_metadata = std::fs::metadata(&path).unwrap();
938 let current = FileIdentity::describe(&path, ¤t_metadata).unwrap();
939
940 assert!(!metadata_names_same_file(&after, ¤t_metadata));
941 assert!(
942 !handle_and_path_are_stable(&path, &before, &after, ¤t, false, size).unwrap()
943 );
944 }
945
946 #[test]
947 fn an_identity_describes_the_file_until_it_is_written_or_removed() {
948 let directory = tempfile::tempdir().unwrap();
949 let path = directory.path().join("input.rs");
950 std::fs::write(&path, b"fn main() {}").unwrap();
951 let identity = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap()).unwrap();
952 assert!(identity.still_describes().unwrap());
953
954 std::thread::sleep(std::time::Duration::from_millis(20));
955 std::fs::write(&path, b"fn main() { }").unwrap();
956 assert!(!identity.still_describes().unwrap());
957
958 std::fs::remove_file(&path).unwrap();
959 assert!(identity.still_describes().is_err());
960 }
961
962 #[test]
963 fn a_metadata_snapshot_detects_a_metadata_change() {
964 let directory = tempfile::tempdir().unwrap();
965 let path = directory.path().join("input.rs");
966 std::fs::write(&path, b"fn main() {}").unwrap();
967 let snapshot = capture_file_snapshot(
968 &path,
969 &NoFileDigestCache,
970 false,
971 false,
972 std::fs::metadata(&path).unwrap(),
973 )
974 .unwrap()
975 .unwrap();
976
977 std::fs::File::options()
978 .write(true)
979 .open(&path)
980 .unwrap()
981 .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
982 .unwrap();
983 let identity = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap());
984 let digest = CacheDigest::blake3_file(&path).unwrap();
985
986 assert!(!snapshot.matches(identity.as_ref(), &digest));
987 assert_eq!(snapshot.proves_content_change(), cfg!(unix));
988 }
989
990 #[test]
991 fn a_content_snapshot_ignores_timestamp_churn_but_detects_changed_bytes() {
992 let directory = tempfile::tempdir().unwrap();
993 let path = directory.path().join("input.rs");
994 std::fs::write(&path, b"fn main() {}").unwrap();
995 let snapshot = capture_file_snapshot(
996 &path,
997 &NoFileDigestCache,
998 true,
999 true,
1000 std::fs::metadata(&path).unwrap(),
1001 )
1002 .unwrap()
1003 .unwrap();
1004
1005 let mut identity = snapshot.identity.clone();
1006 identity.changed = identity
1007 .changed
1008 .map(|(seconds, nanos)| (seconds + 1, nanos));
1009 let digest = CacheDigest::blake3_file(&path).unwrap();
1010 assert!(snapshot.matches(Some(&identity), &digest));
1011 assert!(snapshot.proves_content_change());
1012
1013 identity.modified = SystemTime::UNIX_EPOCH;
1014 assert!(snapshot.matches(Some(&identity), &digest));
1015
1016 std::fs::write(&path, b"fn main(){ }").unwrap();
1017 let identity = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap());
1018 let digest = CacheDigest::blake3_file(&path).unwrap();
1019 assert!(!snapshot.matches(identity.as_ref(), &digest));
1020 }
1021
1022 #[test]
1023 fn reliable_metadata_keeps_the_native_identity() {
1024 let directory = tempfile::tempdir().unwrap();
1025 let path = directory.path().join("input.rs");
1026 std::fs::write(&path, b"fn main() {}").unwrap();
1027 let metadata = std::fs::metadata(&path).unwrap();
1028
1029 let identity = digest_cache_identity(&path, &metadata, false)
1030 .unwrap()
1031 .unwrap();
1032
1033 assert_eq!(identity.path, path);
1034 assert_eq!(identity.len, 12);
1035 assert_eq!(identity.object.is_some(), cfg!(unix));
1036 }
1037
1038 #[test]
1039 fn content_snapshot_ignores_nfs_timestamp_churn_but_not_content_or_identity_changes() {
1040 let digest = CacheDigest::blake3(b"nfs bytes");
1041 let identity = FileIdentity {
1042 path: PathBuf::from("/nfs/input.rlib"),
1043 len: digest.size,
1044 modified: SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(10),
1045 changed: Some((10, 1)),
1046 object: Some(FileObjectIdentity {
1047 device_major: 0,
1048 device_minor: 42,
1049 mount_id: 7,
1050 inode: 99,
1051 }),
1052 };
1053 let snapshot = FileSnapshot {
1054 identity: identity.clone(),
1055 content: Some(digest.clone()),
1056 };
1057 for seconds in [9, 11] {
1060 let mut after = identity.clone();
1061 after.changed = Some((seconds, 500));
1062 after.modified = SystemTime::UNIX_EPOCH + std::time::Duration::new(seconds as u64, 500);
1063 assert!(snapshot.matches(Some(&after), &digest));
1064 }
1065
1066 let changed = CacheDigest::blake3(b"NFS bytes");
1069 assert_eq!(changed.size, digest.size);
1070 assert!(!snapshot.matches(Some(&identity), &changed));
1071 assert!(!snapshot.matches(None, &digest));
1072
1073 let mut after = identity.clone();
1074 after.object.as_mut().unwrap().inode += 1;
1075 assert!(!snapshot.matches(Some(&after), &digest));
1076
1077 let mut after = identity.clone();
1078 after.len += 1;
1079 assert!(!snapshot.matches(Some(&after), &digest));
1080
1081 let mut after = identity;
1082 after.path.set_file_name("replacement.rlib");
1083 assert!(!snapshot.matches(Some(&after), &digest));
1084 }
1085
1086 #[cfg(target_os = "linux")]
1087 #[test]
1088 fn incomplete_statx_metadata_falls_back_to_content_hashing() {
1089 let status = unsafe { std::mem::zeroed::<LinuxStatx>() };
1091 assert!(
1092 nfs_identity_from_statx(Path::new("/nfs/input.rlib"), &status)
1093 .unwrap()
1094 .is_none()
1095 );
1096 }
1097}