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 pub racy: bool,
523}
524
525impl FileDigestResolution {
526 pub fn into_digest(self) -> Option<CacheDigest> {
528 match self {
529 Self::Digest(digest) => Some(digest),
530 Self::EmbeddedTimestampMacro | Self::Unresolved => None,
531 }
532 }
533}
534
535pub fn digest_file(scope: FileDigestScope, path: &Path) -> io::Result<FileDigestResolution> {
538 let file = std::fs::File::open(path)?;
539 digest_reader(scope, &file).map(|(resolution, _)| resolution)
540}
541
542pub fn digest_file_validated(
545 scope: FileDigestScope,
546 path: &Path,
547) -> io::Result<Option<ValidatedFileDigest>> {
548 let started = SystemTime::now();
551 let file = std::fs::File::open(path)?;
552 let before = file.metadata()?;
553 if !before.is_file() {
554 return Err(io::Error::new(
555 io::ErrorKind::InvalidInput,
556 format!("digest path is not a regular file: {}", path.display()),
557 ));
558 }
559 let (resolution, size) = digest_reader(scope, &file)?;
560 let after = file.metadata()?;
561 let current_metadata = std::fs::metadata(path)?;
562 let unreliable = metadata_identity_is_unreliable(path, ¤t_metadata)?;
563 let cache_identity = digest_cache_identity(path, ¤t_metadata, unreliable)?;
564 let identity = cache_identity
565 .clone()
566 .or_else(|| FileIdentity::describe(path, ¤t_metadata));
567 let Some(identity) = identity else {
568 return Ok(None);
569 };
570 if !metadata_names_same_file(&after, ¤t_metadata) {
571 return Err(io::Error::new(
572 io::ErrorKind::WouldBlock,
573 "file was replaced while its contents were being read",
574 ));
575 }
576 if !handle_and_path_are_stable(path, &before, &after, &identity, unreliable, size)? {
577 return Err(io::Error::new(
578 io::ErrorKind::WouldBlock,
579 "file changed while its contents were being read",
580 ));
581 }
582 let racy = cache_identity
587 .as_ref()
588 .is_some_and(|identity| identity_is_racy(identity, started));
589 let cache_identity = cache_identity.filter(|_| !racy);
590 Ok(Some(ValidatedFileDigest {
591 resolution,
592 identity,
593 cache_identity,
594 racy,
595 }))
596}
597
598const FINE_TIMESTAMP_RACE_WINDOW: std::time::Duration = std::time::Duration::from_millis(20);
602
603const COARSE_TIMESTAMP_RACE_WINDOW: std::time::Duration = std::time::Duration::from_secs(2);
606
607fn identity_is_racy(identity: &FileIdentity, observed_at: SystemTime) -> bool {
616 let nanos = |time: SystemTime| {
617 time.duration_since(SystemTime::UNIX_EPOCH)
618 .map_or(0, |since| since.subsec_nanos())
619 };
620 let coarse = nanos(identity.modified) == 0
621 && identity
622 .changed
623 .is_none_or(|(_, changed_nanos)| changed_nanos == 0);
624 let window = if coarse {
625 COARSE_TIMESTAMP_RACE_WINDOW
626 } else {
627 FINE_TIMESTAMP_RACE_WINDOW
628 };
629 let recent = |time: SystemTime| {
630 observed_at
631 .duration_since(time)
632 .map_or(true, |age| age < window)
633 };
634 recent(identity.modified)
635 || identity.changed.is_some_and(|(seconds, changed_nanos)| {
636 u64::try_from(seconds).map_or(true, |seconds| {
637 recent(
638 SystemTime::UNIX_EPOCH
639 + std::time::Duration::new(seconds, changed_nanos.try_into().unwrap_or(0)),
640 )
641 })
642 })
643}
644
645fn digest_reader(
646 scope: FileDigestScope,
647 file: &std::fs::File,
648) -> io::Result<(FileDigestResolution, u64)> {
649 let mut reader = std::io::BufReader::new(file);
650 let mut hasher = blake3::Hasher::new();
651 let mut size = 0_u64;
652 let longest_macro = TIMESTAMP_MACROS
653 .iter()
654 .map(|macro_name| macro_name.len())
655 .max()
656 .unwrap_or_default();
657 let mut window = Vec::with_capacity(DIGEST_BUFFER_BYTES + longest_macro);
658 let mut chunk = vec![0_u8; DIGEST_BUFFER_BYTES];
659 let mut found_timestamp_macro = false;
660 loop {
661 let read = reader.read(&mut chunk)?;
662 if read == 0 {
663 break;
664 }
665 hasher.update(&chunk[..read]);
666 size = size
667 .checked_add(read as u64)
668 .ok_or_else(|| io::Error::other("file length overflowed u64"))?;
669 if scope == FileDigestScope::CcInput && !found_timestamp_macro {
670 window.extend_from_slice(&chunk[..read]);
671 found_timestamp_macro = TIMESTAMP_MACROS
672 .iter()
673 .any(|macro_name| memchr::memmem::find(&window, macro_name).is_some());
674 let keep = window.len().saturating_sub(longest_macro.saturating_sub(1));
675 window.drain(..keep);
676 }
677 }
678 if found_timestamp_macro {
679 Ok((FileDigestResolution::EmbeddedTimestampMacro, size))
680 } else {
681 Ok((
682 FileDigestResolution::Digest(CacheDigest {
683 algorithm: "blake3".into(),
684 hash: hasher.finalize().to_hex().to_string(),
685 size,
686 }),
687 size,
688 ))
689 }
690}
691
692fn handle_and_path_are_stable(
693 path: &Path,
694 before: &std::fs::Metadata,
695 after: &std::fs::Metadata,
696 current: &FileIdentity,
697 unreliable: bool,
698 digest_size: u64,
699) -> io::Result<bool> {
700 if before.len() != after.len() || after.len() != digest_size || current.len != digest_size {
701 return Ok(false);
702 }
703 if !metadata_names_same_file(before, after) {
704 return Ok(false);
705 }
706 if unreliable {
707 #[cfg(target_os = "linux")]
708 {
709 let before_modified = before.modified()?;
710 let after_modified = after.modified()?;
711 let object_matches = current.object.as_ref().is_none_or(|object| {
712 metadata_matches_object(before, object) && metadata_matches_object(after, object)
713 });
714 return Ok(before_modified == after_modified
715 && after_modified == current.modified
716 && object_matches);
717 }
718 #[cfg(not(target_os = "linux"))]
719 {
720 let _ = (path, before, after, current);
721 return Ok(false);
722 }
723 }
724 let before = FileIdentity::describe(path, before);
725 let after = FileIdentity::describe(path, after);
726 Ok(before.is_some() && before == after && after.as_ref() == Some(current))
727}
728
729#[cfg(unix)]
730fn metadata_names_same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
731 use std::os::unix::fs::MetadataExt as _;
732
733 left.dev() == right.dev() && left.ino() == right.ino()
734}
735
736#[cfg(not(unix))]
737fn metadata_names_same_file(_left: &std::fs::Metadata, _right: &std::fs::Metadata) -> bool {
738 true
739}
740
741#[cfg(target_os = "linux")]
742fn metadata_matches_object(metadata: &std::fs::Metadata, object: &FileObjectIdentity) -> bool {
743 use std::os::unix::fs::MetadataExt as _;
744
745 let device = metadata.dev();
746 let major = ((device >> 8) & 0x0fff) | ((device >> 32) & 0xfffff000);
747 let minor = (device & 0x00ff) | ((device >> 12) & 0xffffff00);
748 u32::try_from(major).ok() == Some(object.device_major)
749 && u32::try_from(minor).ok() == Some(object.device_minor)
750 && metadata.ino() == object.inode
751}
752
753pub trait FileDigestCache: Send + Sync {
759 fn resolve(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<FileDigestResolution> {
761 self.find(scope, files)
762 .into_iter()
763 .map(|digest| {
764 digest.map_or(
765 FileDigestResolution::Unresolved,
766 FileDigestResolution::Digest,
767 )
768 })
769 .collect()
770 }
771 fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>>;
773 fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>);
775}
776
777pub struct NoFileDigestCache;
779
780impl FileDigestCache for NoFileDigestCache {
781 fn resolve(
782 &self,
783 _scope: FileDigestScope,
784 files: &[FileIdentity],
785 ) -> Vec<FileDigestResolution> {
786 vec![FileDigestResolution::Unresolved; files.len()]
787 }
788
789 fn find(&self, _scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
790 vec![None; files.len()]
791 }
792
793 fn record(&self, _scope: FileDigestScope, _entries: Vec<RecordedFileDigest>) {}
794}
795
796#[cfg(test)]
797mod tests {
798 use super::*;
799
800 #[test]
801 fn timestamp_macros_are_detected_at_every_chunk_boundary_split() {
802 let directory = tempfile::tempdir().unwrap();
803 let path = directory.path().join("input.h");
804 for macro_name in TIMESTAMP_MACROS {
805 for split in 0..=macro_name.len() {
807 let mut bytes = vec![b'x'; DIGEST_BUFFER_BYTES - split];
808 bytes.extend_from_slice(macro_name);
809 std::fs::write(&path, &bytes).unwrap();
810 assert_eq!(
811 digest_file(FileDigestScope::CcInput, &path).unwrap(),
812 FileDigestResolution::EmbeddedTimestampMacro,
813 "macro {macro_name:?}, split {split}"
814 );
815 assert_eq!(
817 digest_file(FileDigestScope::Content, &path).unwrap(),
818 FileDigestResolution::Digest(CacheDigest::blake3(&bytes))
819 );
820 }
821 }
822 }
823
824 #[test]
825 fn timestamp_scan_accepts_empty_short_and_near_match_inputs() {
826 let directory = tempfile::tempdir().unwrap();
827 let path = directory.path().join("input.h");
828 for bytes in [
829 b"".as_slice(),
830 b"_",
831 b"__DATE_ __TIME_ __TIMESTAMP_",
832 b"__date__ __time__ __timestamp__",
833 &[0xff, 0, b'_', b'_'],
834 ] {
835 std::fs::write(&path, bytes).unwrap();
836 assert_eq!(
837 digest_file(FileDigestScope::CcInput, &path).unwrap(),
838 FileDigestResolution::Digest(CacheDigest::blake3(bytes))
839 );
840 }
841 }
842
843 #[cfg(unix)]
848 #[test]
849 fn a_content_snapshot_survives_a_hardlink_and_a_metadata_one_does_not() {
850 let directory = tempfile::tempdir().unwrap();
851 let path = directory.path().join("libdep.rmeta");
852 std::fs::write(&path, b"metadata").unwrap();
853 let by_metadata = FileSnapshot::capture(&path).unwrap().unwrap();
854 let by_content = FileSnapshot::capture_content_with_cache(&path, &NoFileDigestCache)
855 .unwrap()
856 .unwrap();
857 let digest = CacheDigest::blake3_file(&path).unwrap();
858 std::thread::sleep(std::time::Duration::from_millis(20));
860 std::fs::hard_link(&path, directory.path().join("metadata.rmeta")).unwrap();
861 let after = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap()).unwrap();
862
863 assert!(by_content.proves_content_change());
864 assert!(by_content.matches(Some(&after), &digest));
865 if by_metadata.proves_content_change() {
866 assert!(!by_metadata.matches(Some(&after), &digest));
867 }
868
869 std::fs::write(&path, b"rewritten").unwrap();
870 let rewritten = CacheDigest::blake3_file(&path).unwrap();
871 let after = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap()).unwrap();
872 assert!(!by_content.matches(Some(&after), &rewritten));
873 }
874
875 #[cfg(unix)]
876 struct RecordingDigestCache {
877 answer: Option<CacheDigest>,
878 recorded: std::sync::Mutex<Vec<RecordedFileDigest>>,
879 }
880
881 #[cfg(unix)]
882 impl FileDigestCache for RecordingDigestCache {
883 fn find(
884 &self,
885 _scope: FileDigestScope,
886 files: &[FileIdentity],
887 ) -> Vec<Option<CacheDigest>> {
888 assert_eq!(files.len(), 1);
889 vec![self.answer.clone()]
890 }
891
892 fn record(&self, _scope: FileDigestScope, entries: Vec<RecordedFileDigest>) {
893 self.recorded.lock().unwrap().extend(entries);
894 }
895 }
896
897 #[cfg(unix)]
898 #[test]
899 fn content_snapshot_records_a_replacement_under_its_own_identity() {
900 let directory = tempfile::tempdir().unwrap();
901 let path = directory.path().join("input.rlib");
902 let replacement = directory.path().join("replacement.rlib");
903 std::fs::write(&path, b"original....").unwrap();
904 std::fs::File::options()
905 .write(true)
906 .open(&path)
907 .unwrap()
908 .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
909 .unwrap();
910 std::fs::write(&replacement, b"replacement!").unwrap();
911 std::fs::File::options()
912 .write(true)
913 .open(&replacement)
914 .unwrap()
915 .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
916 .unwrap();
917 let before = std::fs::metadata(&path).unwrap();
918 let old_identity = FileIdentity::describe(&path, &before).unwrap();
919 std::fs::remove_file(&path).unwrap();
920 std::fs::rename(&replacement, &path).unwrap();
921 let cache = RecordingDigestCache {
922 answer: None,
923 recorded: std::sync::Mutex::new(Vec::new()),
924 };
925
926 settle_past_timestamp_race();
927 let snapshot = capture_file_snapshot(&path, &cache, true, true, before)
928 .unwrap()
929 .unwrap();
930 let current = FileIdentity::for_digest_cache(&path, &std::fs::metadata(&path).unwrap())
931 .unwrap()
932 .unwrap();
933 let digest = CacheDigest::blake3_file(&path).unwrap();
934
935 assert_eq!(current.modified, old_identity.modified);
936 assert_ne!(current.object, old_identity.object);
937 assert_ne!(current, old_identity);
938 assert!(snapshot.matches(Some(¤t), &digest));
939 let recorded = cache.recorded.lock().unwrap();
940 assert_eq!(recorded.len(), 1);
941 assert_eq!(recorded[0].file, current);
942 assert_eq!(recorded[0].digest, digest);
943 }
944
945 #[cfg(unix)]
946 #[test]
947 fn content_snapshot_rejects_a_stale_digest_cache_hit() {
948 let directory = tempfile::tempdir().unwrap();
949 let path = directory.path().join("input.rlib");
950 let replacement = directory.path().join("replacement.rlib");
951 std::fs::write(&path, b"original....").unwrap();
952 std::fs::File::options()
953 .write(true)
954 .open(&path)
955 .unwrap()
956 .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
957 .unwrap();
958 let before = std::fs::metadata(&path).unwrap();
959 std::fs::write(&replacement, b"replacement!").unwrap();
960 std::fs::File::options()
961 .write(true)
962 .open(&replacement)
963 .unwrap()
964 .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
965 .unwrap();
966 std::fs::remove_file(&path).unwrap();
967 std::fs::rename(&replacement, &path).unwrap();
968 let cache = RecordingDigestCache {
969 answer: Some(CacheDigest::blake3(b"original....")),
970 recorded: std::sync::Mutex::new(Vec::new()),
971 };
972
973 settle_past_timestamp_race();
974 let snapshot = capture_file_snapshot(&path, &cache, true, true, before)
975 .unwrap()
976 .unwrap();
977 let current = FileIdentity::for_digest_cache(&path, &std::fs::metadata(&path).unwrap())
978 .unwrap()
979 .unwrap();
980 let digest = CacheDigest::blake3_file(&path).unwrap();
981
982 assert!(snapshot.matches(Some(¤t), &digest));
983 assert_ne!(snapshot.content, Some(CacheDigest::blake3(b"original....")));
984 assert_eq!(cache.recorded.lock().unwrap()[0].digest, digest);
985 }
986
987 #[cfg(unix)]
988 #[test]
989 fn a_replaced_path_does_not_validate_the_open_handle() {
990 let directory = tempfile::tempdir().unwrap();
991 let path = directory.path().join("input.rlib");
992 let replacement = directory.path().join("replacement.rlib");
993 std::fs::write(&path, b"old bytes").unwrap();
994 std::fs::write(&replacement, b"new bytes").unwrap();
995
996 let file = std::fs::File::open(&path).unwrap();
997 let before = file.metadata().unwrap();
998 std::fs::rename(&replacement, &path).unwrap();
999 let (_, size) = digest_reader(FileDigestScope::Content, &file).unwrap();
1000 let after = file.metadata().unwrap();
1001 let current_metadata = std::fs::metadata(&path).unwrap();
1002 let current = FileIdentity::describe(&path, ¤t_metadata).unwrap();
1003
1004 assert!(!metadata_names_same_file(&after, ¤t_metadata));
1005 assert!(
1006 !handle_and_path_are_stable(&path, &before, &after, ¤t, false, size).unwrap()
1007 );
1008 }
1009
1010 #[test]
1011 fn an_identity_describes_the_file_until_it_is_written_or_removed() {
1012 let directory = tempfile::tempdir().unwrap();
1013 let path = directory.path().join("input.rs");
1014 std::fs::write(&path, b"fn main() {}").unwrap();
1015 let identity = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap()).unwrap();
1016 assert!(identity.still_describes().unwrap());
1017
1018 std::thread::sleep(std::time::Duration::from_millis(20));
1019 std::fs::write(&path, b"fn main() { }").unwrap();
1020 assert!(!identity.still_describes().unwrap());
1021
1022 std::fs::remove_file(&path).unwrap();
1023 assert!(identity.still_describes().is_err());
1024 }
1025
1026 #[test]
1027 fn a_metadata_snapshot_detects_a_metadata_change() {
1028 let directory = tempfile::tempdir().unwrap();
1029 let path = directory.path().join("input.rs");
1030 std::fs::write(&path, b"fn main() {}").unwrap();
1031 let snapshot = capture_file_snapshot(
1032 &path,
1033 &NoFileDigestCache,
1034 false,
1035 false,
1036 std::fs::metadata(&path).unwrap(),
1037 )
1038 .unwrap()
1039 .unwrap();
1040
1041 std::fs::File::options()
1042 .write(true)
1043 .open(&path)
1044 .unwrap()
1045 .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
1046 .unwrap();
1047 let identity = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap());
1048 let digest = CacheDigest::blake3_file(&path).unwrap();
1049
1050 assert!(!snapshot.matches(identity.as_ref(), &digest));
1051 assert_eq!(snapshot.proves_content_change(), cfg!(unix));
1052 }
1053
1054 #[test]
1055 fn a_content_snapshot_ignores_timestamp_churn_but_detects_changed_bytes() {
1056 let directory = tempfile::tempdir().unwrap();
1057 let path = directory.path().join("input.rs");
1058 std::fs::write(&path, b"fn main() {}").unwrap();
1059 let snapshot = capture_file_snapshot(
1060 &path,
1061 &NoFileDigestCache,
1062 true,
1063 true,
1064 std::fs::metadata(&path).unwrap(),
1065 )
1066 .unwrap()
1067 .unwrap();
1068
1069 let mut identity = snapshot.identity.clone();
1070 identity.changed = identity
1071 .changed
1072 .map(|(seconds, nanos)| (seconds + 1, nanos));
1073 let digest = CacheDigest::blake3_file(&path).unwrap();
1074 assert!(snapshot.matches(Some(&identity), &digest));
1075 assert!(snapshot.proves_content_change());
1076
1077 identity.modified = SystemTime::UNIX_EPOCH;
1078 assert!(snapshot.matches(Some(&identity), &digest));
1079
1080 std::fs::write(&path, b"fn main(){ }").unwrap();
1081 let identity = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap());
1082 let digest = CacheDigest::blake3_file(&path).unwrap();
1083 assert!(!snapshot.matches(identity.as_ref(), &digest));
1084 }
1085
1086 #[test]
1087 fn reliable_metadata_keeps_the_native_identity() {
1088 let directory = tempfile::tempdir().unwrap();
1089 let path = directory.path().join("input.rs");
1090 std::fs::write(&path, b"fn main() {}").unwrap();
1091 let metadata = std::fs::metadata(&path).unwrap();
1092
1093 let identity = digest_cache_identity(&path, &metadata, false)
1094 .unwrap()
1095 .unwrap();
1096
1097 assert_eq!(identity.path, path);
1098 assert_eq!(identity.len, 12);
1099 assert_eq!(identity.object.is_some(), cfg!(unix));
1100 }
1101
1102 #[test]
1103 fn content_snapshot_ignores_nfs_timestamp_churn_but_not_content_or_identity_changes() {
1104 let digest = CacheDigest::blake3(b"nfs bytes");
1105 let identity = FileIdentity {
1106 path: PathBuf::from("/nfs/input.rlib"),
1107 len: digest.size,
1108 modified: SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(10),
1109 changed: Some((10, 1)),
1110 object: Some(FileObjectIdentity {
1111 device_major: 0,
1112 device_minor: 42,
1113 mount_id: 7,
1114 inode: 99,
1115 }),
1116 };
1117 let snapshot = FileSnapshot {
1118 identity: identity.clone(),
1119 content: Some(digest.clone()),
1120 };
1121 for seconds in [9, 11] {
1124 let mut after = identity.clone();
1125 after.changed = Some((seconds, 500));
1126 after.modified = SystemTime::UNIX_EPOCH + std::time::Duration::new(seconds as u64, 500);
1127 assert!(snapshot.matches(Some(&after), &digest));
1128 }
1129
1130 let changed = CacheDigest::blake3(b"NFS bytes");
1133 assert_eq!(changed.size, digest.size);
1134 assert!(!snapshot.matches(Some(&identity), &changed));
1135 assert!(!snapshot.matches(None, &digest));
1136
1137 let mut after = identity.clone();
1138 after.object.as_mut().unwrap().inode += 1;
1139 assert!(!snapshot.matches(Some(&after), &digest));
1140
1141 let mut after = identity.clone();
1142 after.len += 1;
1143 assert!(!snapshot.matches(Some(&after), &digest));
1144
1145 let mut after = identity;
1146 after.path.set_file_name("replacement.rlib");
1147 assert!(!snapshot.matches(Some(&after), &digest));
1148 }
1149
1150 #[cfg(target_os = "linux")]
1151 #[test]
1152 fn incomplete_statx_metadata_falls_back_to_content_hashing() {
1153 let status = unsafe { std::mem::zeroed::<LinuxStatx>() };
1155 assert!(
1156 nfs_identity_from_statx(Path::new("/nfs/input.rlib"), &status)
1157 .unwrap()
1158 .is_none()
1159 );
1160 }
1161
1162 fn settle_past_timestamp_race() {
1165 std::thread::sleep(FINE_TIMESTAMP_RACE_WINDOW * 2);
1166 }
1167
1168 #[test]
1171 fn a_digest_of_a_recently_changed_file_is_not_offered_for_reuse() {
1172 let directory = tempfile::tempdir().unwrap();
1173 let path = directory.path().join("val.rs");
1174 std::fs::write(&path, b"pub const VAL: &str = \"v1\";").unwrap();
1175 std::fs::File::options()
1176 .write(true)
1177 .open(&path)
1178 .unwrap()
1179 .set_times(
1180 std::fs::FileTimes::new()
1181 .set_modified(SystemTime::now() + std::time::Duration::from_secs(3600)),
1182 )
1183 .unwrap();
1184
1185 let fresh = digest_file_validated(FileDigestScope::Content, &path)
1186 .unwrap()
1187 .unwrap();
1188 assert!(fresh.resolution.into_digest().is_some());
1189 assert!(fresh.racy);
1190 assert_eq!(fresh.cache_identity, None);
1191 }
1192
1193 #[test]
1194 fn a_digest_of_a_settled_file_is_offered_for_reuse() {
1195 let directory = tempfile::tempdir().unwrap();
1196 let path = directory.path().join("val.rs");
1197 std::fs::write(&path, b"pub const VAL: &str = \"v1\";").unwrap();
1198
1199 settle_past_timestamp_race();
1200 let settled = digest_file_validated(FileDigestScope::Content, &path)
1201 .unwrap()
1202 .unwrap();
1203 assert!(!settled.racy);
1204 assert!(settled.cache_identity.is_some());
1205 }
1206
1207 #[test]
1208 fn racy_window_follows_the_timestamp_resolution() {
1209 let at = |seconds: u64, nanos: u32| {
1210 SystemTime::UNIX_EPOCH + std::time::Duration::new(seconds, nanos)
1211 };
1212 let identity = |modified: SystemTime, changed: Option<(i64, i64)>| FileIdentity {
1213 path: PathBuf::from("/x"),
1214 len: 1,
1215 modified,
1216 changed,
1217 object: None,
1218 };
1219 let now = at(1_000, 500_000_000);
1220 let fine = |age_ms: u64| {
1221 let when = now - std::time::Duration::from_millis(age_ms);
1222 let since = when.duration_since(SystemTime::UNIX_EPOCH).unwrap();
1223 identity(
1224 when,
1225 Some((since.as_secs() as i64, i64::from(since.subsec_nanos()))),
1226 )
1227 };
1228 assert!(identity_is_racy(&fine(5), now));
1229 assert!(!identity_is_racy(&fine(25), now));
1230 let touched = identity(at(10, 7), Some((1_000, 499_000_000)));
1232 assert!(identity_is_racy(&touched, now));
1233 let coarse = |age: u64| identity(at(1_000 - age, 0), Some((1_000 - age as i64, 0)));
1235 assert!(identity_is_racy(&coarse(1), at(1_000, 0)));
1236 assert!(!identity_is_racy(&coarse(3), at(1_000, 0)));
1237 assert!(identity_is_racy(&identity(at(2_000, 1), None), now));
1239 }
1240}