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 capture_file_snapshot(
176 path,
177 &NoFileDigestCache,
178 metadata_identity_is_unreliable(path, &metadata)?,
179 metadata,
180 )
181 }
182
183 pub fn capture_with_cache(
186 path: &Path,
187 digests: &dyn FileDigestCache,
188 ) -> io::Result<Option<Self>> {
189 let metadata = std::fs::metadata(path)?;
190 capture_file_snapshot(
191 path,
192 digests,
193 metadata_identity_is_unreliable(path, &metadata)?,
194 metadata,
195 )
196 }
197
198 pub fn matches(&self, identity: Option<&FileIdentity>, content: &CacheDigest) -> bool {
206 self.content.as_ref().map_or_else(
207 || identity == Some(&self.identity),
208 |before| {
209 before == content
210 && identity.is_some_and(|after| {
211 self.identity.path == after.path
212 && self.identity.len == after.len
213 && self.identity.object == after.object
214 })
215 },
216 )
217 }
218
219 pub fn proves_content_change(&self) -> bool {
221 self.content.is_some() || self.identity.changed.is_some()
222 }
223}
224
225impl From<FileIdentity> for FileSnapshot {
226 fn from(identity: FileIdentity) -> Self {
227 Self {
228 identity,
229 content: None,
230 }
231 }
232}
233
234#[cfg(target_os = "linux")]
235fn metadata_identity_is_unreliable(path: &Path, metadata: &std::fs::Metadata) -> io::Result<bool> {
236 use std::mem::MaybeUninit;
237 use std::os::unix::ffi::OsStrExt as _;
238 use std::os::unix::fs::MetadataExt as _;
239
240 static FILESYSTEMS: OnceLock<Mutex<std::collections::BTreeMap<u64, bool>>> = OnceLock::new();
241 let filesystems = FILESYSTEMS.get_or_init(|| Mutex::new(std::collections::BTreeMap::new()));
242 if let Some(unreliable) = filesystems.lock().unwrap().get(&metadata.dev()).copied() {
243 return Ok(unreliable);
244 }
245
246 let path = std::ffi::CString::new(path.as_os_str().as_bytes())
247 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains a NUL byte"))?;
248 let mut status = MaybeUninit::<libc::statfs>::zeroed();
249 let result = unsafe { libc::statfs(path.as_ptr(), status.as_mut_ptr()) };
252 if result != 0 {
253 return Err(io::Error::last_os_error());
254 }
255 let status = unsafe { status.assume_init() };
257 let unreliable = status.f_type == 0x6969;
259 filesystems
260 .lock()
261 .unwrap()
262 .insert(metadata.dev(), unreliable);
263 Ok(unreliable)
264}
265
266#[cfg(not(target_os = "linux"))]
267fn metadata_identity_is_unreliable(
268 _path: &Path,
269 _metadata: &std::fs::Metadata,
270) -> io::Result<bool> {
271 Ok(false)
272}
273
274fn capture_file_snapshot(
275 path: &Path,
276 digests: &dyn FileDigestCache,
277 content_identity: bool,
278 metadata: std::fs::Metadata,
279) -> io::Result<Option<FileSnapshot>> {
280 let cache_identity = digest_cache_identity(path, &metadata, content_identity)?;
281 let Some(identity) = cache_identity
282 .clone()
283 .or_else(|| FileIdentity::describe(path, &metadata))
284 else {
285 return Ok(None);
286 };
287 let (identity, content) = if content_identity {
288 let resolved = cache_identity
289 .as_ref()
290 .and_then(|identity| {
291 digests
292 .resolve(FileDigestScope::Content, std::slice::from_ref(identity))
293 .pop()
294 })
295 .unwrap_or(FileDigestResolution::Unresolved);
296 let cached = match resolved {
297 FileDigestResolution::Digest(digest)
298 if cache_identity.as_ref().is_some_and(|identity| {
299 identity.len == digest.size && identity.still_describes().unwrap_or(false)
300 }) =>
301 {
302 Some(digest)
303 }
304 FileDigestResolution::Digest(_)
305 | FileDigestResolution::EmbeddedTimestampMacro
306 | FileDigestResolution::Unresolved => None,
307 };
308 if let Some(digest) = cached {
309 (identity, Some(digest))
310 } else {
311 let observed = digest_file_validated(FileDigestScope::Content, path)?
312 .ok_or_else(|| io::Error::other("could not establish a file digest identity"))?;
313 let digest = observed
314 .resolution
315 .into_digest()
316 .ok_or_else(|| io::Error::other("content digest resolution returned no digest"))?;
317 if let Some(file) = observed.cache_identity
318 && file.len == digest.size
319 {
320 digests.record(
321 FileDigestScope::Content,
322 vec![RecordedFileDigest {
323 file,
324 digest: digest.clone(),
325 }],
326 );
327 }
328 (observed.identity, Some(digest))
329 }
330 } else {
331 (identity, None)
332 };
333 Ok(Some(FileSnapshot { identity, content }))
334}
335
336fn digest_cache_identity(
337 path: &Path,
338 metadata: &std::fs::Metadata,
339 unreliable: bool,
340) -> io::Result<Option<FileIdentity>> {
341 if unreliable {
342 nfs_file_identity(path)
343 } else {
344 Ok(FileIdentity::describe(path, metadata))
345 }
346}
347
348#[cfg(target_os = "linux")]
349fn nfs_file_identity(path: &Path) -> io::Result<Option<FileIdentity>> {
350 use std::mem::MaybeUninit;
351 use std::os::unix::ffi::OsStrExt as _;
352
353 let path = std::ffi::CString::new(path.as_os_str().as_bytes())
354 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains a NUL byte"))?;
355 let mut status = MaybeUninit::<LinuxStatx>::zeroed();
356 let result = unsafe {
358 libc::syscall(
359 libc::SYS_statx,
360 libc::AT_FDCWD,
361 path.as_ptr(),
362 0x4000,
366 STATX_IDENTITY_MASK,
367 status.as_mut_ptr(),
368 )
369 };
370 if result != 0 {
371 let error = io::Error::last_os_error();
372 return match error.raw_os_error() {
373 Some(libc::ENOSYS | libc::EINVAL | libc::EOPNOTSUPP) => Ok(None),
374 _ => Err(error),
375 };
376 }
377 let status = unsafe { status.assume_init() };
379 nfs_identity_from_statx(
380 Path::new(std::ffi::OsStr::from_bytes(path.to_bytes())),
381 &status,
382 )
383}
384
385#[cfg(target_os = "linux")]
386fn nfs_identity_from_statx(path: &Path, status: &LinuxStatx) -> io::Result<Option<FileIdentity>> {
387 if status.mask & STATX_IDENTITY_MASK != STATX_IDENTITY_MASK
388 || status.modified.nanos >= 1_000_000_000
389 {
390 return Ok(None);
391 }
392 let modified = system_time(status.modified.seconds, status.modified.nanos)?;
393 Ok(Some(FileIdentity {
394 path: path.to_path_buf(),
395 len: status.size,
396 modified,
397 changed: None,
398 object: Some(FileObjectIdentity {
399 device_major: status.device_major,
400 device_minor: status.device_minor,
401 mount_id: status.mount_id,
402 inode: status.inode,
403 }),
404 }))
405}
406
407#[cfg(not(target_os = "linux"))]
408fn nfs_file_identity(_path: &Path) -> io::Result<Option<FileIdentity>> {
409 Ok(None)
410}
411
412#[cfg(target_os = "linux")]
413fn system_time(seconds: i64, nanos: u32) -> io::Result<SystemTime> {
414 if seconds >= 0 {
415 SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::new(seconds as u64, nanos))
416 } else {
417 SystemTime::UNIX_EPOCH
418 .checked_sub(std::time::Duration::from_secs(seconds.unsigned_abs()))
419 .and_then(|time| time.checked_add(std::time::Duration::from_nanos(nanos.into())))
420 }
421 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "file timestamp is out of range"))
422}
423
424#[cfg(unix)]
426fn change_token(metadata: &std::fs::Metadata) -> Option<(i64, i64)> {
427 use std::os::unix::fs::MetadataExt;
428 Some((metadata.ctime(), metadata.ctime_nsec()))
429}
430
431#[cfg(not(unix))]
434fn change_token(_metadata: &std::fs::Metadata) -> Option<(i64, i64)> {
435 None
436}
437
438#[cfg(unix)]
439fn metadata_object_identity(metadata: &std::fs::Metadata) -> Option<FileObjectIdentity> {
440 use std::os::unix::fs::MetadataExt as _;
441
442 let device = metadata.dev();
443 Some(FileObjectIdentity {
444 device_major: (device >> 32) as u32,
445 device_minor: device as u32,
446 mount_id: 0,
447 inode: metadata.ino(),
448 })
449}
450
451#[cfg(not(unix))]
452fn metadata_object_identity(_metadata: &std::fs::Metadata) -> Option<FileObjectIdentity> {
453 None
454}
455
456#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
458#[serde(deny_unknown_fields)]
459pub struct RecordedFileDigest {
460 pub file: FileIdentity,
462 pub digest: CacheDigest,
464}
465
466#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
474#[serde(rename_all = "snake_case")]
475pub enum FileDigestScope {
476 Content,
478 CcInput,
481}
482
483#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
485#[serde(tag = "kind", rename_all = "snake_case")]
486pub enum FileDigestResolution {
487 Digest(CacheDigest),
489 EmbeddedTimestampMacro,
491 Unresolved,
493}
494
495#[derive(Debug, Clone, PartialEq, Eq)]
497pub struct ValidatedFileDigest {
498 pub resolution: FileDigestResolution,
500 pub identity: FileIdentity,
502 pub cache_identity: Option<FileIdentity>,
504}
505
506impl FileDigestResolution {
507 pub fn into_digest(self) -> Option<CacheDigest> {
509 match self {
510 Self::Digest(digest) => Some(digest),
511 Self::EmbeddedTimestampMacro | Self::Unresolved => None,
512 }
513 }
514}
515
516pub fn digest_file(scope: FileDigestScope, path: &Path) -> io::Result<FileDigestResolution> {
519 let file = std::fs::File::open(path)?;
520 digest_reader(scope, &file).map(|(resolution, _)| resolution)
521}
522
523pub fn digest_file_validated(
526 scope: FileDigestScope,
527 path: &Path,
528) -> io::Result<Option<ValidatedFileDigest>> {
529 let file = std::fs::File::open(path)?;
530 let before = file.metadata()?;
531 if !before.is_file() {
532 return Err(io::Error::new(
533 io::ErrorKind::InvalidInput,
534 format!("digest path is not a regular file: {}", path.display()),
535 ));
536 }
537 let (resolution, size) = digest_reader(scope, &file)?;
538 let after = file.metadata()?;
539 let current_metadata = std::fs::metadata(path)?;
540 let unreliable = metadata_identity_is_unreliable(path, ¤t_metadata)?;
541 let cache_identity = digest_cache_identity(path, ¤t_metadata, unreliable)?;
542 let identity = cache_identity
543 .clone()
544 .or_else(|| FileIdentity::describe(path, ¤t_metadata));
545 let Some(identity) = identity else {
546 return Ok(None);
547 };
548 if !metadata_names_same_file(&after, ¤t_metadata) {
549 return Err(io::Error::new(
550 io::ErrorKind::WouldBlock,
551 "file was replaced while its contents were being read",
552 ));
553 }
554 if !handle_and_path_are_stable(path, &before, &after, &identity, unreliable, size)? {
555 return Err(io::Error::new(
556 io::ErrorKind::WouldBlock,
557 "file changed while its contents were being read",
558 ));
559 }
560 Ok(Some(ValidatedFileDigest {
561 resolution,
562 identity,
563 cache_identity,
564 }))
565}
566
567fn digest_reader(
568 scope: FileDigestScope,
569 file: &std::fs::File,
570) -> io::Result<(FileDigestResolution, u64)> {
571 let mut reader = std::io::BufReader::new(file);
572 let mut hasher = blake3::Hasher::new();
573 let mut size = 0_u64;
574 let longest_macro = TIMESTAMP_MACROS
575 .iter()
576 .map(|macro_name| macro_name.len())
577 .max()
578 .unwrap_or_default();
579 let mut window = Vec::with_capacity(DIGEST_BUFFER_BYTES + longest_macro);
580 let mut chunk = vec![0_u8; DIGEST_BUFFER_BYTES];
581 let mut found_timestamp_macro = false;
582 loop {
583 let read = reader.read(&mut chunk)?;
584 if read == 0 {
585 break;
586 }
587 hasher.update(&chunk[..read]);
588 size = size
589 .checked_add(read as u64)
590 .ok_or_else(|| io::Error::other("file length overflowed u64"))?;
591 if scope == FileDigestScope::CcInput && !found_timestamp_macro {
592 window.extend_from_slice(&chunk[..read]);
593 found_timestamp_macro = TIMESTAMP_MACROS
594 .iter()
595 .any(|macro_name| contains_subslice(&window, macro_name));
596 let keep = window.len().saturating_sub(longest_macro.saturating_sub(1));
597 window.drain(..keep);
598 }
599 }
600 if found_timestamp_macro {
601 Ok((FileDigestResolution::EmbeddedTimestampMacro, size))
602 } else {
603 Ok((
604 FileDigestResolution::Digest(CacheDigest {
605 algorithm: "blake3".into(),
606 hash: hasher.finalize().to_hex().to_string(),
607 size,
608 }),
609 size,
610 ))
611 }
612}
613
614fn handle_and_path_are_stable(
615 path: &Path,
616 before: &std::fs::Metadata,
617 after: &std::fs::Metadata,
618 current: &FileIdentity,
619 unreliable: bool,
620 digest_size: u64,
621) -> io::Result<bool> {
622 if before.len() != after.len() || after.len() != digest_size || current.len != digest_size {
623 return Ok(false);
624 }
625 if !metadata_names_same_file(before, after) {
626 return Ok(false);
627 }
628 if unreliable {
629 #[cfg(target_os = "linux")]
630 {
631 let before_modified = before.modified()?;
632 let after_modified = after.modified()?;
633 let object_matches = current.object.as_ref().is_none_or(|object| {
634 metadata_matches_object(before, object) && metadata_matches_object(after, object)
635 });
636 return Ok(before_modified == after_modified
637 && after_modified == current.modified
638 && object_matches);
639 }
640 #[cfg(not(target_os = "linux"))]
641 {
642 let _ = (path, before, after, current);
643 return Ok(false);
644 }
645 }
646 let before = FileIdentity::describe(path, before);
647 let after = FileIdentity::describe(path, after);
648 Ok(before.is_some() && before == after && after.as_ref() == Some(current))
649}
650
651#[cfg(unix)]
652fn metadata_names_same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
653 use std::os::unix::fs::MetadataExt as _;
654
655 left.dev() == right.dev() && left.ino() == right.ino()
656}
657
658#[cfg(not(unix))]
659fn metadata_names_same_file(_left: &std::fs::Metadata, _right: &std::fs::Metadata) -> bool {
660 true
661}
662
663#[cfg(target_os = "linux")]
664fn metadata_matches_object(metadata: &std::fs::Metadata, object: &FileObjectIdentity) -> bool {
665 use std::os::unix::fs::MetadataExt as _;
666
667 let device = metadata.dev();
668 let major = ((device >> 8) & 0x0fff) | ((device >> 32) & 0xfffff000);
669 let minor = (device & 0x00ff) | ((device >> 12) & 0xffffff00);
670 u32::try_from(major).ok() == Some(object.device_major)
671 && u32::try_from(minor).ok() == Some(object.device_minor)
672 && metadata.ino() == object.inode
673}
674
675fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
676 !needle.is_empty()
677 && haystack.len() >= needle.len()
678 && haystack
679 .windows(needle.len())
680 .any(|window| window == needle)
681}
682
683pub trait FileDigestCache: Send + Sync {
689 fn resolve(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<FileDigestResolution> {
691 self.find(scope, files)
692 .into_iter()
693 .map(|digest| {
694 digest.map_or(
695 FileDigestResolution::Unresolved,
696 FileDigestResolution::Digest,
697 )
698 })
699 .collect()
700 }
701 fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>>;
703 fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>);
705}
706
707pub struct NoFileDigestCache;
709
710impl FileDigestCache for NoFileDigestCache {
711 fn resolve(
712 &self,
713 _scope: FileDigestScope,
714 files: &[FileIdentity],
715 ) -> Vec<FileDigestResolution> {
716 vec![FileDigestResolution::Unresolved; files.len()]
717 }
718
719 fn find(&self, _scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
720 vec![None; files.len()]
721 }
722
723 fn record(&self, _scope: FileDigestScope, _entries: Vec<RecordedFileDigest>) {}
724}
725
726#[cfg(test)]
727mod tests {
728 use super::*;
729
730 #[cfg(unix)]
731 struct RecordingDigestCache {
732 answer: Option<CacheDigest>,
733 recorded: std::sync::Mutex<Vec<RecordedFileDigest>>,
734 }
735
736 #[cfg(unix)]
737 impl FileDigestCache for RecordingDigestCache {
738 fn find(
739 &self,
740 _scope: FileDigestScope,
741 files: &[FileIdentity],
742 ) -> Vec<Option<CacheDigest>> {
743 assert_eq!(files.len(), 1);
744 vec![self.answer.clone()]
745 }
746
747 fn record(&self, _scope: FileDigestScope, entries: Vec<RecordedFileDigest>) {
748 self.recorded.lock().unwrap().extend(entries);
749 }
750 }
751
752 #[cfg(unix)]
753 #[test]
754 fn content_snapshot_records_a_replacement_under_its_own_identity() {
755 let directory = tempfile::tempdir().unwrap();
756 let path = directory.path().join("input.rlib");
757 let replacement = directory.path().join("replacement.rlib");
758 std::fs::write(&path, b"original....").unwrap();
759 std::fs::File::options()
760 .write(true)
761 .open(&path)
762 .unwrap()
763 .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
764 .unwrap();
765 std::fs::write(&replacement, b"replacement!").unwrap();
766 std::fs::File::options()
767 .write(true)
768 .open(&replacement)
769 .unwrap()
770 .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
771 .unwrap();
772 let before = std::fs::metadata(&path).unwrap();
773 let old_identity = FileIdentity::describe(&path, &before).unwrap();
774 std::fs::remove_file(&path).unwrap();
775 std::fs::rename(&replacement, &path).unwrap();
776 let cache = RecordingDigestCache {
777 answer: None,
778 recorded: std::sync::Mutex::new(Vec::new()),
779 };
780
781 let snapshot = capture_file_snapshot(&path, &cache, true, before)
782 .unwrap()
783 .unwrap();
784 let current = FileIdentity::for_digest_cache(&path, &std::fs::metadata(&path).unwrap())
785 .unwrap()
786 .unwrap();
787 let digest = CacheDigest::blake3_file(&path).unwrap();
788
789 assert_eq!(current.modified, old_identity.modified);
790 assert_ne!(current.object, old_identity.object);
791 assert_ne!(current, old_identity);
792 assert!(snapshot.matches(Some(¤t), &digest));
793 let recorded = cache.recorded.lock().unwrap();
794 assert_eq!(recorded.len(), 1);
795 assert_eq!(recorded[0].file, current);
796 assert_eq!(recorded[0].digest, digest);
797 }
798
799 #[cfg(unix)]
800 #[test]
801 fn content_snapshot_rejects_a_stale_digest_cache_hit() {
802 let directory = tempfile::tempdir().unwrap();
803 let path = directory.path().join("input.rlib");
804 let replacement = directory.path().join("replacement.rlib");
805 std::fs::write(&path, b"original....").unwrap();
806 std::fs::File::options()
807 .write(true)
808 .open(&path)
809 .unwrap()
810 .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
811 .unwrap();
812 let before = std::fs::metadata(&path).unwrap();
813 std::fs::write(&replacement, b"replacement!").unwrap();
814 std::fs::File::options()
815 .write(true)
816 .open(&replacement)
817 .unwrap()
818 .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
819 .unwrap();
820 std::fs::remove_file(&path).unwrap();
821 std::fs::rename(&replacement, &path).unwrap();
822 let cache = RecordingDigestCache {
823 answer: Some(CacheDigest::blake3(b"original....")),
824 recorded: std::sync::Mutex::new(Vec::new()),
825 };
826
827 let snapshot = capture_file_snapshot(&path, &cache, true, before)
828 .unwrap()
829 .unwrap();
830 let current = FileIdentity::for_digest_cache(&path, &std::fs::metadata(&path).unwrap())
831 .unwrap()
832 .unwrap();
833 let digest = CacheDigest::blake3_file(&path).unwrap();
834
835 assert!(snapshot.matches(Some(¤t), &digest));
836 assert_ne!(snapshot.content, Some(CacheDigest::blake3(b"original....")));
837 assert_eq!(cache.recorded.lock().unwrap()[0].digest, digest);
838 }
839
840 #[cfg(unix)]
841 #[test]
842 fn a_replaced_path_does_not_validate_the_open_handle() {
843 let directory = tempfile::tempdir().unwrap();
844 let path = directory.path().join("input.rlib");
845 let replacement = directory.path().join("replacement.rlib");
846 std::fs::write(&path, b"old bytes").unwrap();
847 std::fs::write(&replacement, b"new bytes").unwrap();
848
849 let file = std::fs::File::open(&path).unwrap();
850 let before = file.metadata().unwrap();
851 std::fs::rename(&replacement, &path).unwrap();
852 let (_, size) = digest_reader(FileDigestScope::Content, &file).unwrap();
853 let after = file.metadata().unwrap();
854 let current_metadata = std::fs::metadata(&path).unwrap();
855 let current = FileIdentity::describe(&path, ¤t_metadata).unwrap();
856
857 assert!(!metadata_names_same_file(&after, ¤t_metadata));
858 assert!(
859 !handle_and_path_are_stable(&path, &before, &after, ¤t, false, size).unwrap()
860 );
861 }
862
863 #[test]
864 fn an_identity_describes_the_file_until_it_is_written_or_removed() {
865 let directory = tempfile::tempdir().unwrap();
866 let path = directory.path().join("input.rs");
867 std::fs::write(&path, b"fn main() {}").unwrap();
868 let identity = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap()).unwrap();
869 assert!(identity.still_describes().unwrap());
870
871 std::thread::sleep(std::time::Duration::from_millis(20));
872 std::fs::write(&path, b"fn main() { }").unwrap();
873 assert!(!identity.still_describes().unwrap());
874
875 std::fs::remove_file(&path).unwrap();
876 assert!(identity.still_describes().is_err());
877 }
878
879 #[test]
880 fn a_metadata_snapshot_detects_a_metadata_change() {
881 let directory = tempfile::tempdir().unwrap();
882 let path = directory.path().join("input.rs");
883 std::fs::write(&path, b"fn main() {}").unwrap();
884 let snapshot = capture_file_snapshot(
885 &path,
886 &NoFileDigestCache,
887 false,
888 std::fs::metadata(&path).unwrap(),
889 )
890 .unwrap()
891 .unwrap();
892
893 std::fs::File::options()
894 .write(true)
895 .open(&path)
896 .unwrap()
897 .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
898 .unwrap();
899 let identity = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap());
900 let digest = CacheDigest::blake3_file(&path).unwrap();
901
902 assert!(!snapshot.matches(identity.as_ref(), &digest));
903 assert_eq!(snapshot.proves_content_change(), cfg!(unix));
904 }
905
906 #[test]
907 fn a_content_snapshot_ignores_timestamp_churn_but_detects_changed_bytes() {
908 let directory = tempfile::tempdir().unwrap();
909 let path = directory.path().join("input.rs");
910 std::fs::write(&path, b"fn main() {}").unwrap();
911 let snapshot = capture_file_snapshot(
912 &path,
913 &NoFileDigestCache,
914 true,
915 std::fs::metadata(&path).unwrap(),
916 )
917 .unwrap()
918 .unwrap();
919
920 let mut identity = snapshot.identity.clone();
921 identity.changed = identity
922 .changed
923 .map(|(seconds, nanos)| (seconds + 1, nanos));
924 let digest = CacheDigest::blake3_file(&path).unwrap();
925 assert!(snapshot.matches(Some(&identity), &digest));
926 assert!(snapshot.proves_content_change());
927
928 identity.modified = SystemTime::UNIX_EPOCH;
929 assert!(snapshot.matches(Some(&identity), &digest));
930
931 std::fs::write(&path, b"fn main(){ }").unwrap();
932 let identity = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap());
933 let digest = CacheDigest::blake3_file(&path).unwrap();
934 assert!(!snapshot.matches(identity.as_ref(), &digest));
935 }
936
937 #[test]
938 fn reliable_metadata_keeps_the_native_identity() {
939 let directory = tempfile::tempdir().unwrap();
940 let path = directory.path().join("input.rs");
941 std::fs::write(&path, b"fn main() {}").unwrap();
942 let metadata = std::fs::metadata(&path).unwrap();
943
944 let identity = digest_cache_identity(&path, &metadata, false)
945 .unwrap()
946 .unwrap();
947
948 assert_eq!(identity.path, path);
949 assert_eq!(identity.len, 12);
950 assert_eq!(identity.object.is_some(), cfg!(unix));
951 }
952
953 #[test]
954 fn content_snapshot_ignores_nfs_timestamp_churn_but_not_content_or_identity_changes() {
955 let digest = CacheDigest::blake3(b"nfs bytes");
956 let identity = FileIdentity {
957 path: PathBuf::from("/nfs/input.rlib"),
958 len: digest.size,
959 modified: SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(10),
960 changed: Some((10, 1)),
961 object: Some(FileObjectIdentity {
962 device_major: 0,
963 device_minor: 42,
964 mount_id: 7,
965 inode: 99,
966 }),
967 };
968 let snapshot = FileSnapshot {
969 identity: identity.clone(),
970 content: Some(digest.clone()),
971 };
972 for seconds in [9, 11] {
975 let mut after = identity.clone();
976 after.changed = Some((seconds, 500));
977 after.modified = SystemTime::UNIX_EPOCH + std::time::Duration::new(seconds as u64, 500);
978 assert!(snapshot.matches(Some(&after), &digest));
979 }
980
981 let changed = CacheDigest::blake3(b"NFS bytes");
984 assert_eq!(changed.size, digest.size);
985 assert!(!snapshot.matches(Some(&identity), &changed));
986 assert!(!snapshot.matches(None, &digest));
987
988 let mut after = identity.clone();
989 after.object.as_mut().unwrap().inode += 1;
990 assert!(!snapshot.matches(Some(&after), &digest));
991
992 let mut after = identity.clone();
993 after.len += 1;
994 assert!(!snapshot.matches(Some(&after), &digest));
995
996 let mut after = identity;
997 after.path.set_file_name("replacement.rlib");
998 assert!(!snapshot.matches(Some(&after), &digest));
999 }
1000
1001 #[cfg(target_os = "linux")]
1002 #[test]
1003 fn incomplete_statx_metadata_falls_back_to_content_hashing() {
1004 let status = unsafe { std::mem::zeroed::<LinuxStatx>() };
1006 assert!(
1007 nfs_identity_from_statx(Path::new("/nfs/input.rlib"), &status)
1008 .unwrap()
1009 .is_none()
1010 );
1011 }
1012}