Skip to main content

mbx_cache_core/agent/
file_digest.rs

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/// Stable Linux UAPI layout. `libc` omits statx on older musl headers even
16/// though the kernel syscall and wire structure are available there.
17#[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/// The on-disk identity a recorded file digest describes.
65///
66/// The same trade [`VerifiedBlob`] makes for CAS reads, offered to shims for
67/// the files they hash: an overwrite moves the modification time and a
68/// truncation changes the length, so a digest recorded against both stands
69/// until either does. Where the platform reports a metadata-change time the
70/// identity carries that too, and it is the part a writer cannot restore: a
71/// rewrite that puts the modification time back still moves the change time,
72/// so only filesystems without one fall back to the freshness model the
73/// surrounding build tool already lives on.
74#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
75#[serde(deny_unknown_fields)]
76pub struct FileIdentity {
77    /// Absolute path of the file.
78    pub path: PathBuf,
79    /// Length of the file in bytes.
80    pub len: u64,
81    /// Modification time of the file.
82    pub modified: SystemTime,
83    /// Platform metadata-change token, where one exists.
84    pub changed: Option<(i64, i64)>,
85    /// Stable object identity used when an NFS client's change time is not.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub object: Option<FileObjectIdentity>,
88}
89
90/// The kernel identity of one file object on one mounted filesystem.
91#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
92#[serde(deny_unknown_fields)]
93pub struct FileObjectIdentity {
94    /// Device major number reported by `statx`.
95    pub device_major: u32,
96    /// Device minor number reported by `statx`.
97    pub device_minor: u32,
98    /// Mount identifier in this mount namespace.
99    pub mount_id: u64,
100    /// Inode number within the mounted filesystem.
101    pub inode: u64,
102}
103
104impl FileIdentity {
105    /// Describe a file from metadata already in hand, or nothing when the
106    /// filesystem reports no modification time to compare against later.
107    pub fn describe(path: &Path, metadata: &std::fs::Metadata) -> Option<Self> {
108        Some(Self {
109            path: path.to_path_buf(),
110            len: metadata.len(),
111            modified: metadata.modified().ok()?,
112            changed: change_token(metadata),
113            object: None,
114        })
115    }
116
117    /// Describe a file only when metadata can safely stand in for its digest.
118    ///
119    /// Linux NFS may revise cached change times without a write, so it uses a
120    /// cached object/length/mtime identity that deliberately omits ctime. This
121    /// is the same timestamp-freshness contract as Cargo and [`VerifiedBlob`];
122    /// forcing a server round trip per input would serialize large NFS builds.
123    /// If the kernel cannot supply every required field, callers hash instead.
124    pub fn for_digest_cache(path: &Path, metadata: &std::fs::Metadata) -> io::Result<Option<Self>> {
125        digest_cache_identity(
126            path,
127            metadata,
128            metadata_identity_is_unreliable(path, metadata)?,
129        )
130    }
131
132    /// Whether the file at this identity's path still has exactly this
133    /// identity, so the digest recorded against it still describes the bytes on
134    /// disk without reading them again.
135    ///
136    /// Length alone would miss an overwrite that keeps the size, and the
137    /// modification time can be put back by whoever rewrote the file. The
138    /// change time cannot be set from user space, so where the platform reports
139    /// one a rewrite that restores the old modification time still shows. A
140    /// file that has vanished is an error rather than a change, so the caller
141    /// can tell the two apart.
142    pub fn still_describes(&self) -> std::io::Result<bool> {
143        let metadata = std::fs::metadata(&self.path)?;
144        Ok(Self::for_digest_cache(&self.path, &metadata)?.as_ref() == Some(self))
145    }
146
147    /// Whether this identity is strong enough to stand in for a second read.
148    pub fn can_skip_content_verification(&self) -> bool {
149        self.changed.is_some() || self.object.is_some()
150    }
151}
152
153/// A pre-operation snapshot that can prove whether a file's contents changed.
154///
155/// Most filesystems provide a stable metadata-change token, so the inexpensive
156/// identity is sufficient. Linux NFS can reconcile client and server timestamps
157/// after a writer has closed the file, making two metadata reads disagree
158/// without any intervening write. Those files carry a content digest instead,
159/// while retaining length and file identity to detect replacement. Callers use
160/// the same comparison either way and do not need to know which filesystem
161/// supplied the file.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct FileSnapshot {
164    identity: FileIdentity,
165    content: Option<CacheDigest>,
166}
167
168impl FileSnapshot {
169    /// Capture the strongest comparison the file's filesystem can support.
170    pub fn capture(path: &Path) -> io::Result<Option<Self>> {
171        let metadata = std::fs::metadata(path)?;
172        capture_file_snapshot(
173            path,
174            &NoFileDigestCache,
175            metadata_identity_is_unreliable(path, &metadata)?,
176            metadata,
177        )
178    }
179
180    /// Capture a snapshot while reusing or publishing a content digest through
181    /// the session ledger when the filesystem requires one.
182    pub fn capture_with_cache(
183        path: &Path,
184        digests: &dyn FileDigestCache,
185    ) -> io::Result<Option<Self>> {
186        let metadata = std::fs::metadata(path)?;
187        capture_file_snapshot(
188            path,
189            digests,
190            metadata_identity_is_unreliable(path, &metadata)?,
191            metadata,
192        )
193    }
194
195    /// Whether `identity` and `content` still describe this snapshot.
196    ///
197    /// A content-backed snapshot ignores both timestamps: NFS can reconcile
198    /// mtime as well as ctime without a content change. Matching endpoint
199    /// digests cannot detect an intervening edit that restores the original
200    /// bytes. Digest-cache lookup still uses timestamps to decide when a
201    /// digest needs refreshing; this comparison performs no additional reads.
202    pub fn matches(&self, identity: Option<&FileIdentity>, content: &CacheDigest) -> bool {
203        self.content.as_ref().map_or_else(
204            || identity == Some(&self.identity),
205            |before| {
206                before == content
207                    && identity.is_some_and(|after| {
208                        self.identity.path == after.path
209                            && self.identity.len == after.len
210                            && self.identity.object == after.object
211                    })
212            },
213        )
214    }
215
216    /// Whether a mismatch proves the file's contents changed.
217    pub fn proves_content_change(&self) -> bool {
218        self.content.is_some() || self.identity.changed.is_some()
219    }
220}
221
222impl From<FileIdentity> for FileSnapshot {
223    fn from(identity: FileIdentity) -> Self {
224        Self {
225            identity,
226            content: None,
227        }
228    }
229}
230
231#[cfg(target_os = "linux")]
232fn metadata_identity_is_unreliable(path: &Path, metadata: &std::fs::Metadata) -> io::Result<bool> {
233    use std::mem::MaybeUninit;
234    use std::os::unix::ffi::OsStrExt as _;
235    use std::os::unix::fs::MetadataExt as _;
236
237    static FILESYSTEMS: OnceLock<Mutex<std::collections::BTreeMap<u64, bool>>> = OnceLock::new();
238    let filesystems = FILESYSTEMS.get_or_init(|| Mutex::new(std::collections::BTreeMap::new()));
239    if let Some(unreliable) = filesystems.lock().unwrap().get(&metadata.dev()).copied() {
240        return Ok(unreliable);
241    }
242
243    let path = std::ffi::CString::new(path.as_os_str().as_bytes())
244        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains a NUL byte"))?;
245    let mut status = MaybeUninit::<libc::statfs>::zeroed();
246    // SAFETY: `path` is NUL-terminated and `status` points to writable,
247    // correctly sized storage. statfs initializes it before returning success.
248    let result = unsafe { libc::statfs(path.as_ptr(), status.as_mut_ptr()) };
249    if result != 0 {
250        return Err(io::Error::last_os_error());
251    }
252    // SAFETY: statfs returned success and initialized `status`.
253    let status = unsafe { status.assume_init() };
254    // libc gives NFS_SUPER_MAGIC a different signedness from statfs::f_type on musl.
255    let unreliable = status.f_type == 0x6969;
256    filesystems
257        .lock()
258        .unwrap()
259        .insert(metadata.dev(), unreliable);
260    Ok(unreliable)
261}
262
263#[cfg(not(target_os = "linux"))]
264fn metadata_identity_is_unreliable(
265    _path: &Path,
266    _metadata: &std::fs::Metadata,
267) -> io::Result<bool> {
268    Ok(false)
269}
270
271fn capture_file_snapshot(
272    path: &Path,
273    digests: &dyn FileDigestCache,
274    content_identity: bool,
275    metadata: std::fs::Metadata,
276) -> io::Result<Option<FileSnapshot>> {
277    let cache_identity = digest_cache_identity(path, &metadata, content_identity)?;
278    let Some(identity) = cache_identity
279        .clone()
280        .or_else(|| FileIdentity::describe(path, &metadata))
281    else {
282        return Ok(None);
283    };
284    let content = if content_identity {
285        let resolved = cache_identity
286            .as_ref()
287            .and_then(|identity| {
288                digests
289                    .resolve(FileDigestScope::Content, std::slice::from_ref(identity))
290                    .pop()
291            })
292            .unwrap_or(FileDigestResolution::Unresolved);
293        let (digest, fresh) = match resolved {
294            FileDigestResolution::Digest(digest) => (digest, false),
295            FileDigestResolution::EmbeddedTimestampMacro | FileDigestResolution::Unresolved => {
296                let digest = digest_file(FileDigestScope::Content, path)?
297                    .into_digest()
298                    .ok_or_else(|| {
299                        io::Error::other("content digest resolution returned no digest")
300                    })?;
301                (digest, true)
302            }
303        };
304        if fresh
305            && let Some(file) = cache_identity
306            && file.len == digest.size
307        {
308            digests.record(
309                FileDigestScope::Content,
310                vec![RecordedFileDigest {
311                    file,
312                    digest: digest.clone(),
313                }],
314            );
315        }
316        Some(digest)
317    } else {
318        None
319    };
320    Ok(Some(FileSnapshot { identity, content }))
321}
322
323fn digest_cache_identity(
324    path: &Path,
325    metadata: &std::fs::Metadata,
326    unreliable: bool,
327) -> io::Result<Option<FileIdentity>> {
328    if unreliable {
329        nfs_file_identity(path)
330    } else {
331        Ok(FileIdentity::describe(path, metadata))
332    }
333}
334
335#[cfg(target_os = "linux")]
336fn nfs_file_identity(path: &Path) -> io::Result<Option<FileIdentity>> {
337    use std::mem::MaybeUninit;
338    use std::os::unix::ffi::OsStrExt as _;
339
340    let path = std::ffi::CString::new(path.as_os_str().as_bytes())
341        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains a NUL byte"))?;
342    let mut status = MaybeUninit::<LinuxStatx>::zeroed();
343    // SAFETY: `path` is NUL-terminated and `status` names writable storage.
344    let result = unsafe {
345        libc::syscall(
346            libc::SYS_statx,
347            libc::AT_FDCWD,
348            path.as_ptr(),
349            // AT_STATX_DONT_SYNC: the surrounding build already trusts cached
350            // mtimes, and FORCE_SYNC costs one NFS RPC for every dependency
351            // edge rather than every distinct file.
352            0x4000,
353            STATX_IDENTITY_MASK,
354            status.as_mut_ptr(),
355        )
356    };
357    if result != 0 {
358        let error = io::Error::last_os_error();
359        return match error.raw_os_error() {
360            Some(libc::ENOSYS | libc::EINVAL | libc::EOPNOTSUPP) => Ok(None),
361            _ => Err(error),
362        };
363    }
364    // SAFETY: statx returned success and initialized `status`.
365    let status = unsafe { status.assume_init() };
366    nfs_identity_from_statx(
367        Path::new(std::ffi::OsStr::from_bytes(path.to_bytes())),
368        &status,
369    )
370}
371
372#[cfg(target_os = "linux")]
373fn nfs_identity_from_statx(path: &Path, status: &LinuxStatx) -> io::Result<Option<FileIdentity>> {
374    if status.mask & STATX_IDENTITY_MASK != STATX_IDENTITY_MASK
375        || status.modified.nanos >= 1_000_000_000
376    {
377        return Ok(None);
378    }
379    let modified = system_time(status.modified.seconds, status.modified.nanos)?;
380    Ok(Some(FileIdentity {
381        path: path.to_path_buf(),
382        len: status.size,
383        modified,
384        changed: None,
385        object: Some(FileObjectIdentity {
386            device_major: status.device_major,
387            device_minor: status.device_minor,
388            mount_id: status.mount_id,
389            inode: status.inode,
390        }),
391    }))
392}
393
394#[cfg(not(target_os = "linux"))]
395fn nfs_file_identity(_path: &Path) -> io::Result<Option<FileIdentity>> {
396    Ok(None)
397}
398
399#[cfg(target_os = "linux")]
400fn system_time(seconds: i64, nanos: u32) -> io::Result<SystemTime> {
401    if seconds >= 0 {
402        SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::new(seconds as u64, nanos))
403    } else {
404        SystemTime::UNIX_EPOCH
405            .checked_sub(std::time::Duration::from_secs(seconds.unsigned_abs()))
406            .and_then(|time| time.checked_add(std::time::Duration::from_nanos(nanos.into())))
407    }
408    .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "file timestamp is out of range"))
409}
410
411/// The metadata-change time as an opaque token, where the platform has one.
412#[cfg(unix)]
413fn change_token(metadata: &std::fs::Metadata) -> Option<(i64, i64)> {
414    use std::os::unix::fs::MetadataExt;
415    Some((metadata.ctime(), metadata.ctime_nsec()))
416}
417
418/// Windows reports creation rather than metadata-change time, which a rewrite
419/// does not move, so no token is better than a misleading one.
420#[cfg(not(unix))]
421fn change_token(_metadata: &std::fs::Metadata) -> Option<(i64, i64)> {
422    None
423}
424
425/// A file digest recorded against the identity it was read under.
426#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
427#[serde(deny_unknown_fields)]
428pub struct RecordedFileDigest {
429    /// Identity of the file when its contents were hashed.
430    pub file: FileIdentity,
431    /// Digest of those contents.
432    pub digest: CacheDigest,
433}
434
435/// What a recorded file digest may stand in for.
436///
437/// Adapters prove different things when they read a file: the cc adapter's
438/// input scan also establishes that a source embeds no timestamp macro, which
439/// a digest recorded by the rustc adapter never checked. Scoping the ledger
440/// keeps one adapter's shortcut from resting on a property another adapter
441/// never established.
442#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
443#[serde(rename_all = "snake_case")]
444pub enum FileDigestScope {
445    /// The digest describes the file's contents and nothing more.
446    Content,
447    /// The digest describes a cc compiler input that also passed the
448    /// timestamp-macro scan.
449    CcInput,
450}
451
452/// The outcome of resolving one file under a digest scope.
453#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
454#[serde(tag = "kind", rename_all = "snake_case")]
455pub enum FileDigestResolution {
456    /// The file's content digest, with any scope-specific checks complete.
457    Digest(CacheDigest),
458    /// A C/C++ input contains a time-dependent preprocessor macro.
459    EmbeddedTimestampMacro,
460    /// No shared resolver was available; the caller must read the file.
461    Unresolved,
462}
463
464impl FileDigestResolution {
465    /// Extract the digest when resolution succeeded.
466    pub fn into_digest(self) -> Option<CacheDigest> {
467        match self {
468            Self::Digest(digest) => Some(digest),
469            Self::EmbeddedTimestampMacro | Self::Unresolved => None,
470        }
471    }
472}
473
474/// Read and hash a file once, applying the checks required by `scope` in that
475/// same pass.
476pub fn digest_file(scope: FileDigestScope, path: &Path) -> io::Result<FileDigestResolution> {
477    let file = std::fs::File::open(path)?;
478    let mut reader = std::io::BufReader::new(file);
479    let mut hasher = blake3::Hasher::new();
480    let mut size = 0_u64;
481    let longest_macro = TIMESTAMP_MACROS
482        .iter()
483        .map(|macro_name| macro_name.len())
484        .max()
485        .unwrap_or_default();
486    let mut window = Vec::with_capacity(DIGEST_BUFFER_BYTES + longest_macro);
487    let mut chunk = vec![0_u8; DIGEST_BUFFER_BYTES];
488    let mut found_timestamp_macro = false;
489    loop {
490        let read = reader.read(&mut chunk)?;
491        if read == 0 {
492            break;
493        }
494        hasher.update(&chunk[..read]);
495        size = size
496            .checked_add(read as u64)
497            .ok_or_else(|| io::Error::other("file length overflowed u64"))?;
498        if scope == FileDigestScope::CcInput && !found_timestamp_macro {
499            window.extend_from_slice(&chunk[..read]);
500            found_timestamp_macro = TIMESTAMP_MACROS
501                .iter()
502                .any(|macro_name| contains_subslice(&window, macro_name));
503            let keep = window.len().saturating_sub(longest_macro.saturating_sub(1));
504            window.drain(..keep);
505        }
506    }
507    if found_timestamp_macro {
508        Ok(FileDigestResolution::EmbeddedTimestampMacro)
509    } else {
510        Ok(FileDigestResolution::Digest(CacheDigest {
511            algorithm: "blake3".into(),
512            hash: hasher.finalize().to_hex().to_string(),
513            size,
514        }))
515    }
516}
517
518fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
519    !needle.is_empty()
520        && haystack.len() >= needle.len()
521        && haystack
522            .windows(needle.len())
523            .any(|window| window == needle)
524}
525
526/// Recorded digests a session may consult instead of rehashing a file.
527///
528/// The agent's file-digest ledger answers through this everywhere a caller
529/// holds one; the no-op implementation stands in where reuse must not happen,
530/// such as under verification.
531pub trait FileDigestCache: Send + Sync {
532    /// Resolve these identities, coalescing concurrent misses where supported.
533    fn resolve(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<FileDigestResolution> {
534        self.find(scope, files)
535            .into_iter()
536            .map(|digest| {
537                digest.map_or(
538                    FileDigestResolution::Unresolved,
539                    FileDigestResolution::Digest,
540                )
541            })
542            .collect()
543    }
544    /// Recorded digests for these identities, in request order.
545    fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>>;
546    /// Record digests for files read under these identities.
547    fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>);
548}
549
550/// A [`FileDigestCache`] that remembers nothing and finds nothing.
551pub struct NoFileDigestCache;
552
553impl FileDigestCache for NoFileDigestCache {
554    fn resolve(
555        &self,
556        _scope: FileDigestScope,
557        files: &[FileIdentity],
558    ) -> Vec<FileDigestResolution> {
559        vec![FileDigestResolution::Unresolved; files.len()]
560    }
561
562    fn find(&self, _scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
563        vec![None; files.len()]
564    }
565
566    fn record(&self, _scope: FileDigestScope, _entries: Vec<RecordedFileDigest>) {}
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572
573    #[test]
574    fn an_identity_describes_the_file_until_it_is_written_or_removed() {
575        let directory = tempfile::tempdir().unwrap();
576        let path = directory.path().join("input.rs");
577        std::fs::write(&path, b"fn main() {}").unwrap();
578        let identity = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap()).unwrap();
579        assert!(identity.still_describes().unwrap());
580
581        std::thread::sleep(std::time::Duration::from_millis(20));
582        std::fs::write(&path, b"fn main() { }").unwrap();
583        assert!(!identity.still_describes().unwrap());
584
585        std::fs::remove_file(&path).unwrap();
586        assert!(identity.still_describes().is_err());
587    }
588
589    #[test]
590    fn a_metadata_snapshot_detects_a_metadata_change() {
591        let directory = tempfile::tempdir().unwrap();
592        let path = directory.path().join("input.rs");
593        std::fs::write(&path, b"fn main() {}").unwrap();
594        let snapshot = capture_file_snapshot(
595            &path,
596            &NoFileDigestCache,
597            false,
598            std::fs::metadata(&path).unwrap(),
599        )
600        .unwrap()
601        .unwrap();
602
603        std::fs::File::options()
604            .write(true)
605            .open(&path)
606            .unwrap()
607            .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
608            .unwrap();
609        let identity = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap());
610        let digest = CacheDigest::blake3_file(&path).unwrap();
611
612        assert!(!snapshot.matches(identity.as_ref(), &digest));
613        assert_eq!(snapshot.proves_content_change(), cfg!(unix));
614    }
615
616    #[test]
617    fn a_content_snapshot_ignores_timestamp_churn_but_detects_changed_bytes() {
618        let directory = tempfile::tempdir().unwrap();
619        let path = directory.path().join("input.rs");
620        std::fs::write(&path, b"fn main() {}").unwrap();
621        let snapshot = capture_file_snapshot(
622            &path,
623            &NoFileDigestCache,
624            true,
625            std::fs::metadata(&path).unwrap(),
626        )
627        .unwrap()
628        .unwrap();
629
630        let mut identity = snapshot.identity.clone();
631        identity.changed = identity
632            .changed
633            .map(|(seconds, nanos)| (seconds + 1, nanos));
634        let digest = CacheDigest::blake3_file(&path).unwrap();
635        assert!(snapshot.matches(Some(&identity), &digest));
636        assert!(snapshot.proves_content_change());
637
638        identity.modified = SystemTime::UNIX_EPOCH;
639        assert!(snapshot.matches(Some(&identity), &digest));
640
641        std::fs::write(&path, b"fn main(){ }").unwrap();
642        let identity = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap());
643        let digest = CacheDigest::blake3_file(&path).unwrap();
644        assert!(!snapshot.matches(identity.as_ref(), &digest));
645    }
646
647    #[test]
648    fn reliable_metadata_keeps_the_native_identity() {
649        let directory = tempfile::tempdir().unwrap();
650        let path = directory.path().join("input.rs");
651        std::fs::write(&path, b"fn main() {}").unwrap();
652        let metadata = std::fs::metadata(&path).unwrap();
653
654        let identity = digest_cache_identity(&path, &metadata, false)
655            .unwrap()
656            .unwrap();
657
658        assert_eq!(identity.path, path);
659        assert_eq!(identity.len, 12);
660        assert!(identity.object.is_none());
661    }
662
663    #[test]
664    fn content_snapshot_ignores_nfs_timestamp_churn_but_not_content_or_identity_changes() {
665        let digest = CacheDigest::blake3(b"nfs bytes");
666        let identity = FileIdentity {
667            path: PathBuf::from("/nfs/input.rlib"),
668            len: digest.size,
669            modified: SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(10),
670            changed: Some((10, 1)),
671            object: Some(FileObjectIdentity {
672                device_major: 0,
673                device_minor: 42,
674                mount_id: 7,
675                inode: 99,
676            }),
677        };
678        let snapshot = FileSnapshot {
679            identity: identity.clone(),
680            content: Some(digest.clone()),
681        };
682        // Client/server reconciliation can move either timestamp in either
683        // direction. None of these observations proves another write.
684        for seconds in [9, 11] {
685            let mut after = identity.clone();
686            after.changed = Some((seconds, 500));
687            after.modified = SystemTime::UNIX_EPOCH + std::time::Duration::new(seconds as u64, 500);
688            assert!(snapshot.matches(Some(&after), &digest));
689        }
690
691        // Timestamp tolerance does not admit changed bytes, even when their
692        // length and all metadata remain the same.
693        let changed = CacheDigest::blake3(b"NFS bytes");
694        assert_eq!(changed.size, digest.size);
695        assert!(!snapshot.matches(Some(&identity), &changed));
696        assert!(!snapshot.matches(None, &digest));
697
698        let mut after = identity.clone();
699        after.object.as_mut().unwrap().inode += 1;
700        assert!(!snapshot.matches(Some(&after), &digest));
701
702        let mut after = identity.clone();
703        after.len += 1;
704        assert!(!snapshot.matches(Some(&after), &digest));
705
706        let mut after = identity;
707        after.path.set_file_name("replacement.rlib");
708        assert!(!snapshot.matches(Some(&after), &digest));
709    }
710
711    #[cfg(target_os = "linux")]
712    #[test]
713    fn incomplete_statx_metadata_falls_back_to_content_hashing() {
714        // SAFETY: all-zero is a valid unpopulated statx result for this parser.
715        let status = unsafe { std::mem::zeroed::<LinuxStatx>() };
716        assert!(
717            nfs_identity_from_statx(Path::new("/nfs/input.rlib"), &status)
718                .unwrap()
719                .is_none()
720        );
721    }
722}