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/// Unix object identity also distinguishes an atomic replacement when both
73/// files happen to share one filesystem timestamp tick.
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 to detect replacement independently of
86    /// timestamp resolution.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub object: Option<FileObjectIdentity>,
89}
90
91/// The kernel identity of one file object on one mounted filesystem.
92#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
93#[serde(deny_unknown_fields)]
94pub struct FileObjectIdentity {
95    /// Device identity component. Linux NFS uses the major number reported by
96    /// `statx`; other Unix filesystems store the high half of `st_dev`.
97    pub device_major: u32,
98    /// Device identity component. Linux NFS uses the minor number reported by
99    /// `statx`; other Unix filesystems store the low half of `st_dev`.
100    pub device_minor: u32,
101    /// Mount identifier in this mount namespace, or zero when unavailable.
102    pub mount_id: u64,
103    /// Inode number within the mounted filesystem.
104    pub inode: u64,
105}
106
107impl FileIdentity {
108    /// Describe a file from metadata already in hand, or nothing when the
109    /// filesystem reports no modification time to compare against later.
110    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    /// Describe a file only when metadata can safely stand in for its digest.
121    ///
122    /// Linux NFS may revise cached change times without a write, so it uses a
123    /// cached object/length/mtime identity that deliberately omits ctime. This
124    /// is the same timestamp-freshness contract as Cargo and [`VerifiedBlob`];
125    /// forcing a server round trip per input would serialize large NFS builds.
126    /// If the kernel cannot supply every required field, callers hash instead.
127    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    /// Whether the file at this identity's path still has exactly this
136    /// identity, so the digest recorded against it still describes the bytes on
137    /// disk without reading them again.
138    ///
139    /// Length alone would miss an overwrite that keeps the size, and the
140    /// modification time can be put back by whoever rewrote the file. The
141    /// change time cannot be set from user space, so where the platform reports
142    /// one a rewrite that restores the old modification time still shows. A
143    /// file that has vanished is an error rather than a change, so the caller
144    /// can tell the two apart.
145    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    /// Whether this identity is strong enough to stand in for a second read.
151    pub fn can_skip_content_verification(&self) -> bool {
152        self.changed.is_some() || self.object.is_some()
153    }
154}
155
156/// A pre-operation snapshot that can prove whether a file's contents changed.
157///
158/// Most filesystems provide a stable metadata-change token, so the inexpensive
159/// identity is sufficient once its timestamp tick has settled. Recently written
160/// files carry a content digest alongside their metadata to detect another
161/// write within that tick.
162/// Linux NFS can reconcile client and server timestamps after a writer has
163/// closed the file, making two metadata reads disagree without any intervening
164/// write. Those files carry a content digest instead,
165/// while retaining length and file identity to detect replacement. Callers use
166/// the same comparison either way and do not need to know which filesystem
167/// supplied the file.
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct FileSnapshot {
170    identity: FileIdentity,
171    content: Option<CacheDigest>,
172    require_full_identity: bool,
173}
174
175impl FileSnapshot {
176    /// Capture the strongest comparison the file's filesystem can support.
177    pub fn capture(path: &Path) -> io::Result<Option<Self>> {
178        Self::capture_with_cache(path, &NoFileDigestCache)
179    }
180
181    /// Capture a snapshot while reusing or publishing a content digest through
182    /// the session ledger when the filesystem or a recent write requires one.
183    pub fn capture_with_cache(
184        path: &Path,
185        digests: &dyn FileDigestCache,
186    ) -> io::Result<Option<Self>> {
187        let observed_at = SystemTime::now();
188        let metadata = std::fs::metadata(path)?;
189        let unreliable = metadata_identity_is_unreliable(path, &metadata)?;
190        capture_file_snapshot(path, digests, unreliable, unreliable, metadata, observed_at)
191    }
192
193    /// Capture a content-backed snapshot whatever the filesystem, for a file
194    /// whose metadata is expected to change without its bytes.
195    ///
196    /// A compiler artifact is one: rustc hardlinks the `.rmeta` it just
197    /// emitted into its incremental session directory when it finishes, which
198    /// bumps the change token of a file a pipelined dependent is already
199    /// reading. Comparing the bytes, length, and file object instead accepts
200    /// that and still rejects a replacement or a rewrite.
201    pub fn capture_content_with_cache(
202        path: &Path,
203        digests: &dyn FileDigestCache,
204    ) -> io::Result<Option<Self>> {
205        let observed_at = SystemTime::now();
206        let metadata = std::fs::metadata(path)?;
207        // The ledger identity still follows the filesystem: recording under
208        // the ordinary identity and looking up under the NFS one would miss
209        // every time, and every dependent would re-hash the artifact.
210        let unreliable = metadata_identity_is_unreliable(path, &metadata)?;
211        capture_file_snapshot(path, digests, unreliable, true, metadata, observed_at)
212    }
213
214    /// Whether `identity` and `content` still describe this snapshot.
215    ///
216    /// Recently written local sources require both their full metadata
217    /// identity and content digest to match. NFS and explicitly requested
218    /// content snapshots ignore both timestamps: they can change without a
219    /// content change. For those timestamp-tolerant snapshots, matching digests
220    /// cannot detect an intervening edit that restores the original bytes.
221    /// Digest-cache lookup still uses timestamps to decide when a digest needs
222    /// refreshing; this comparison performs no additional reads.
223    pub fn matches(&self, identity: Option<&FileIdentity>, content: &CacheDigest) -> bool {
224        self.content.as_ref().map_or_else(
225            || identity == Some(&self.identity),
226            |before| {
227                before == content
228                    && identity.is_some_and(|after| {
229                        if self.require_full_identity {
230                            &self.identity == after
231                        } else {
232                            self.identity.path == after.path
233                                && self.identity.len == after.len
234                                && self.identity.object == after.object
235                        }
236                    })
237            },
238        )
239    }
240
241    /// Whether `identity` differs from this metadata snapshot in its change
242    /// token alone: same length, modification time and file object.
243    ///
244    /// The token also moves for writes that leave the bytes alone. macOS
245    /// records extended attributes (`com.apple.provenance`,
246    /// `com.apple.macl`) on a file a second or two after an app writes it,
247    /// long after the timestamp race window, so a source saved or checked
248    /// out just before a build changes its token mid-compile. Such a change
249    /// cannot prove new contents.
250    pub fn differs_only_in_change_token(&self, identity: Option<&FileIdentity>) -> bool {
251        self.content.is_none()
252            && identity.is_some_and(|after| {
253                after.changed != self.identity.changed
254                    && after.path == self.identity.path
255                    && after.len == self.identity.len
256                    && after.modified == self.identity.modified
257                    && after.object == self.identity.object
258            })
259    }
260
261    /// Whether a mismatch proves the file's contents changed.
262    pub fn proves_content_change(&self) -> bool {
263        self.content.is_some() || self.identity.changed.is_some()
264    }
265}
266
267impl From<FileIdentity> for FileSnapshot {
268    fn from(identity: FileIdentity) -> Self {
269        Self {
270            identity,
271            content: None,
272            require_full_identity: true,
273        }
274    }
275}
276
277#[cfg(target_os = "linux")]
278fn metadata_identity_is_unreliable(path: &Path, metadata: &std::fs::Metadata) -> io::Result<bool> {
279    use std::mem::MaybeUninit;
280    use std::os::unix::ffi::OsStrExt as _;
281    use std::os::unix::fs::MetadataExt as _;
282
283    static FILESYSTEMS: OnceLock<Mutex<std::collections::BTreeMap<u64, bool>>> = OnceLock::new();
284    let filesystems = FILESYSTEMS.get_or_init(|| Mutex::new(std::collections::BTreeMap::new()));
285    if let Some(unreliable) = filesystems.lock().unwrap().get(&metadata.dev()).copied() {
286        return Ok(unreliable);
287    }
288
289    let path = std::ffi::CString::new(path.as_os_str().as_bytes())
290        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains a NUL byte"))?;
291    let mut status = MaybeUninit::<libc::statfs>::zeroed();
292    // SAFETY: `path` is NUL-terminated and `status` points to writable,
293    // correctly sized storage. statfs initializes it before returning success.
294    let result = unsafe { libc::statfs(path.as_ptr(), status.as_mut_ptr()) };
295    if result != 0 {
296        return Err(io::Error::last_os_error());
297    }
298    // SAFETY: statfs returned success and initialized `status`.
299    let status = unsafe { status.assume_init() };
300    // libc gives NFS_SUPER_MAGIC a different signedness from statfs::f_type on musl.
301    let unreliable = status.f_type == 0x6969;
302    filesystems
303        .lock()
304        .unwrap()
305        .insert(metadata.dev(), unreliable);
306    Ok(unreliable)
307}
308
309#[cfg(not(target_os = "linux"))]
310fn metadata_identity_is_unreliable(
311    _path: &Path,
312    _metadata: &std::fs::Metadata,
313) -> io::Result<bool> {
314    Ok(false)
315}
316
317/// `unreliable` selects the ledger identity the filesystem calls for, as
318/// [`digest_file_validated`] does when it records one; `content_identity`
319/// asks for bytes to be compared while tolerating timestamp changes.
320fn capture_file_snapshot(
321    path: &Path,
322    digests: &dyn FileDigestCache,
323    unreliable: bool,
324    content_identity: bool,
325    metadata: std::fs::Metadata,
326    observed_at: SystemTime,
327) -> io::Result<Option<FileSnapshot>> {
328    let cache_identity = digest_cache_identity(path, &metadata, unreliable)?;
329    let Some(identity) = cache_identity
330        .clone()
331        .or_else(|| FileIdentity::describe(path, &metadata))
332    else {
333        return Ok(None);
334    };
335    // Equal metadata cannot vouch for bytes written within the same timestamp
336    // tick. Such snapshots need content, and a ledger hit under that identity
337    // could describe bytes from an earlier write in the tick.
338    let racy = identity_is_racy(&identity, observed_at);
339    let cache_identity = cache_identity.filter(|_| !racy);
340    let (identity, content) = if content_identity || racy {
341        let resolved = cache_identity
342            .as_ref()
343            .and_then(|identity| {
344                digests
345                    .resolve(FileDigestScope::Content, std::slice::from_ref(identity))
346                    .pop()
347            })
348            .unwrap_or(FileDigestResolution::Unresolved);
349        let cached = match resolved {
350            FileDigestResolution::Digest(digest)
351                if cache_identity.as_ref().is_some_and(|identity| {
352                    identity.len == digest.size && identity.still_describes().unwrap_or(false)
353                }) =>
354            {
355                Some(digest)
356            }
357            FileDigestResolution::Digest(_)
358            | FileDigestResolution::EmbeddedTimestampMacro
359            | FileDigestResolution::Unresolved => None,
360        };
361        if let Some(digest) = cached {
362            (identity, Some(digest))
363        } else {
364            let observed = digest_file_validated(FileDigestScope::Content, path)?
365                .ok_or_else(|| io::Error::other("could not establish a file digest identity"))?;
366            let digest = observed
367                .resolution
368                .into_digest()
369                .ok_or_else(|| io::Error::other("content digest resolution returned no digest"))?;
370            if let Some(file) = observed.cache_identity
371                && file.len == digest.size
372            {
373                digests.record(
374                    FileDigestScope::Content,
375                    vec![RecordedFileDigest {
376                        file,
377                        digest: digest.clone(),
378                    }],
379                );
380            }
381            (observed.identity, Some(digest))
382        }
383    } else {
384        (identity, None)
385    };
386    Ok(Some(FileSnapshot {
387        identity,
388        content,
389        require_full_identity: !unreliable && !content_identity,
390    }))
391}
392
393fn digest_cache_identity(
394    path: &Path,
395    metadata: &std::fs::Metadata,
396    unreliable: bool,
397) -> io::Result<Option<FileIdentity>> {
398    if unreliable {
399        nfs_file_identity(path)
400    } else {
401        Ok(FileIdentity::describe(path, metadata))
402    }
403}
404
405#[cfg(target_os = "linux")]
406fn nfs_file_identity(path: &Path) -> io::Result<Option<FileIdentity>> {
407    use std::mem::MaybeUninit;
408    use std::os::unix::ffi::OsStrExt as _;
409
410    let path = std::ffi::CString::new(path.as_os_str().as_bytes())
411        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains a NUL byte"))?;
412    let mut status = MaybeUninit::<LinuxStatx>::zeroed();
413    // SAFETY: `path` is NUL-terminated and `status` names writable storage.
414    let result = unsafe {
415        libc::syscall(
416            libc::SYS_statx,
417            libc::AT_FDCWD,
418            path.as_ptr(),
419            // AT_STATX_DONT_SYNC: the surrounding build already trusts cached
420            // mtimes, and FORCE_SYNC costs one NFS RPC for every dependency
421            // edge rather than every distinct file.
422            0x4000,
423            STATX_IDENTITY_MASK,
424            status.as_mut_ptr(),
425        )
426    };
427    if result != 0 {
428        let error = io::Error::last_os_error();
429        return match error.raw_os_error() {
430            Some(libc::ENOSYS | libc::EINVAL | libc::EOPNOTSUPP) => Ok(None),
431            _ => Err(error),
432        };
433    }
434    // SAFETY: statx returned success and initialized `status`.
435    let status = unsafe { status.assume_init() };
436    nfs_identity_from_statx(
437        Path::new(std::ffi::OsStr::from_bytes(path.to_bytes())),
438        &status,
439    )
440}
441
442#[cfg(target_os = "linux")]
443fn nfs_identity_from_statx(path: &Path, status: &LinuxStatx) -> io::Result<Option<FileIdentity>> {
444    if status.mask & STATX_IDENTITY_MASK != STATX_IDENTITY_MASK
445        || status.modified.nanos >= 1_000_000_000
446    {
447        return Ok(None);
448    }
449    let modified = system_time(status.modified.seconds, status.modified.nanos)?;
450    Ok(Some(FileIdentity {
451        path: path.to_path_buf(),
452        len: status.size,
453        modified,
454        changed: None,
455        object: Some(FileObjectIdentity {
456            device_major: status.device_major,
457            device_minor: status.device_minor,
458            mount_id: status.mount_id,
459            inode: status.inode,
460        }),
461    }))
462}
463
464#[cfg(not(target_os = "linux"))]
465fn nfs_file_identity(_path: &Path) -> io::Result<Option<FileIdentity>> {
466    Ok(None)
467}
468
469#[cfg(target_os = "linux")]
470fn system_time(seconds: i64, nanos: u32) -> io::Result<SystemTime> {
471    if seconds >= 0 {
472        SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::new(seconds as u64, nanos))
473    } else {
474        SystemTime::UNIX_EPOCH
475            .checked_sub(std::time::Duration::from_secs(seconds.unsigned_abs()))
476            .and_then(|time| time.checked_add(std::time::Duration::from_nanos(nanos.into())))
477    }
478    .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "file timestamp is out of range"))
479}
480
481/// The metadata-change time as an opaque token, where the platform has one.
482#[cfg(unix)]
483fn change_token(metadata: &std::fs::Metadata) -> Option<(i64, i64)> {
484    use std::os::unix::fs::MetadataExt;
485    Some((metadata.ctime(), metadata.ctime_nsec()))
486}
487
488/// Windows reports creation rather than metadata-change time, which a rewrite
489/// does not move, so no token is better than a misleading one.
490#[cfg(not(unix))]
491fn change_token(_metadata: &std::fs::Metadata) -> Option<(i64, i64)> {
492    None
493}
494
495#[cfg(unix)]
496fn metadata_object_identity(metadata: &std::fs::Metadata) -> Option<FileObjectIdentity> {
497    use std::os::unix::fs::MetadataExt as _;
498
499    let device = metadata.dev();
500    Some(FileObjectIdentity {
501        device_major: (device >> 32) as u32,
502        device_minor: device as u32,
503        mount_id: 0,
504        inode: metadata.ino(),
505    })
506}
507
508#[cfg(not(unix))]
509fn metadata_object_identity(_metadata: &std::fs::Metadata) -> Option<FileObjectIdentity> {
510    None
511}
512
513/// A file digest recorded against the identity it was read under.
514#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
515#[serde(deny_unknown_fields)]
516pub struct RecordedFileDigest {
517    /// Identity of the file when its contents were hashed.
518    pub file: FileIdentity,
519    /// Digest of those contents.
520    pub digest: CacheDigest,
521}
522
523/// What a recorded file digest may stand in for.
524///
525/// Adapters prove different things when they read a file: the cc adapter's
526/// input scan also establishes that a source embeds no timestamp macro, which
527/// a digest recorded by the rustc adapter never checked. Scoping the ledger
528/// keeps one adapter's shortcut from resting on a property another adapter
529/// never established.
530#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
531#[serde(rename_all = "snake_case")]
532pub enum FileDigestScope {
533    /// The digest describes the file's contents and nothing more.
534    Content,
535    /// The digest describes a cc compiler input that also passed the
536    /// timestamp-macro scan.
537    CcInput,
538}
539
540/// The outcome of resolving one file under a digest scope.
541#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
542#[serde(tag = "kind", rename_all = "snake_case")]
543pub enum FileDigestResolution {
544    /// The file's content digest, with any scope-specific checks complete.
545    Digest(CacheDigest),
546    /// A C/C++ input contains a time-dependent preprocessor macro.
547    EmbeddedTimestampMacro,
548    /// No shared resolver was available; the caller must read the file.
549    Unresolved,
550}
551
552/// A file digest tied to the file object and pathname observed while reading.
553#[derive(Debug, Clone, PartialEq, Eq)]
554pub struct ValidatedFileDigest {
555    /// Scope-specific outcome of reading the file.
556    pub resolution: FileDigestResolution,
557    /// Identity of the pathname after the validated read.
558    pub identity: FileIdentity,
559    /// Identity suitable for reuse in the digest ledger, when available.
560    pub cache_identity: Option<FileIdentity>,
561    /// The file was written too recently for its digest to be reused, so
562    /// `cache_identity` was withheld. The digest is right for this read only.
563    pub racy: bool,
564}
565
566impl FileDigestResolution {
567    /// Extract the digest when resolution succeeded.
568    pub fn into_digest(self) -> Option<CacheDigest> {
569        match self {
570            Self::Digest(digest) => Some(digest),
571            Self::EmbeddedTimestampMacro | Self::Unresolved => None,
572        }
573    }
574}
575
576/// Read and hash a file once, applying the checks required by `scope` in that
577/// same pass.
578pub fn digest_file(scope: FileDigestScope, path: &Path) -> io::Result<FileDigestResolution> {
579    let file = std::fs::File::open(path)?;
580    digest_reader(scope, &file).map(|(resolution, _)| resolution)
581}
582
583/// Read and hash a regular file only when the open handle remains stable and
584/// the pathname still names that same file after the read.
585pub fn digest_file_validated(
586    scope: FileDigestScope,
587    path: &Path,
588) -> io::Result<Option<ValidatedFileDigest>> {
589    // Taken before the read: a slow read must not make a file look settled
590    // that was still inside its timestamp tick when the first byte was read.
591    let started = SystemTime::now();
592    let file = std::fs::File::open(path)?;
593    let before = file.metadata()?;
594    if !before.is_file() {
595        return Err(io::Error::new(
596            io::ErrorKind::InvalidInput,
597            format!("digest path is not a regular file: {}", path.display()),
598        ));
599    }
600    let (resolution, size) = digest_reader(scope, &file)?;
601    let after = file.metadata()?;
602    let current_metadata = std::fs::metadata(path)?;
603    let unreliable = metadata_identity_is_unreliable(path, &current_metadata)?;
604    let cache_identity = digest_cache_identity(path, &current_metadata, unreliable)?;
605    let identity = cache_identity
606        .clone()
607        .or_else(|| FileIdentity::describe(path, &current_metadata));
608    let Some(identity) = identity else {
609        return Ok(None);
610    };
611    if !metadata_names_same_file(&after, &current_metadata) {
612        return Err(io::Error::new(
613            io::ErrorKind::WouldBlock,
614            "file was replaced while its contents were being read",
615        ));
616    }
617    if !handle_and_path_are_stable(path, &before, &after, &identity, unreliable, size)? {
618        return Err(io::Error::new(
619            io::ErrorKind::WouldBlock,
620            "file changed while its contents were being read",
621        ));
622    }
623    // A digest recorded this close to the file's last write cannot be trusted
624    // later: a second write in the same timestamp tick leaves the identity
625    // unchanged, so the record would keep answering for bytes it never read.
626    // The caller still gets the digest; it just is not offered for reuse.
627    let racy = cache_identity
628        .as_ref()
629        .is_some_and(|identity| identity_is_racy(identity, started));
630    let cache_identity = cache_identity.filter(|_| !racy);
631    Ok(Some(ValidatedFileDigest {
632        resolution,
633        identity,
634        cache_identity,
635        racy,
636    }))
637}
638
639/// Timestamp tick of a filesystem that keeps sub-second times. The kernel
640/// stamps files from a coarse clock that advances once per scheduler tick,
641/// at most 10 ms, so twice that leaves a margin.
642const FINE_TIMESTAMP_RACE_WINDOW: std::time::Duration = std::time::Duration::from_millis(20);
643
644/// Timestamp tick of a filesystem that keeps whole seconds (ext3, HFS+) or two
645/// (FAT).
646const COARSE_TIMESTAMP_RACE_WINDOW: std::time::Duration = std::time::Duration::from_secs(2);
647
648/// Whether the file was last written so recently that another write could
649/// still land in the timestamp tick the identity records.
650///
651/// The same "racily clean" hazard git guards its index against: metadata that
652/// is equal before and after cannot show a write that left it unchanged, so a
653/// digest is only reusable once the file's last change is older than the
654/// filesystem's timestamp resolution when the read began (`observed_at`). A timestamp in
655/// the future is treated as racy too.
656fn identity_is_racy(identity: &FileIdentity, observed_at: SystemTime) -> bool {
657    let nanos = |time: SystemTime| {
658        time.duration_since(SystemTime::UNIX_EPOCH)
659            .map_or(0, |since| since.subsec_nanos())
660    };
661    let coarse = nanos(identity.modified) == 0
662        && identity
663            .changed
664            .is_none_or(|(_, changed_nanos)| changed_nanos == 0);
665    let window = if coarse {
666        COARSE_TIMESTAMP_RACE_WINDOW
667    } else {
668        FINE_TIMESTAMP_RACE_WINDOW
669    };
670    let recent = |time: SystemTime| {
671        observed_at
672            .duration_since(time)
673            .map_or(true, |age| age < window)
674    };
675    recent(identity.modified)
676        || identity.changed.is_some_and(|(seconds, changed_nanos)| {
677            u64::try_from(seconds).map_or(true, |seconds| {
678                recent(
679                    SystemTime::UNIX_EPOCH
680                        + std::time::Duration::new(seconds, changed_nanos.try_into().unwrap_or(0)),
681                )
682            })
683        })
684}
685
686fn digest_reader(
687    scope: FileDigestScope,
688    file: &std::fs::File,
689) -> io::Result<(FileDigestResolution, u64)> {
690    let mut reader = std::io::BufReader::new(file);
691    let mut hasher = blake3::Hasher::new();
692    let mut size = 0_u64;
693    let longest_macro = TIMESTAMP_MACROS
694        .iter()
695        .map(|macro_name| macro_name.len())
696        .max()
697        .unwrap_or_default();
698    let mut window = Vec::with_capacity(DIGEST_BUFFER_BYTES + longest_macro);
699    let mut chunk = vec![0_u8; DIGEST_BUFFER_BYTES];
700    let mut found_timestamp_macro = false;
701    loop {
702        let read = reader.read(&mut chunk)?;
703        if read == 0 {
704            break;
705        }
706        hasher.update(&chunk[..read]);
707        size = size
708            .checked_add(read as u64)
709            .ok_or_else(|| io::Error::other("file length overflowed u64"))?;
710        if scope == FileDigestScope::CcInput && !found_timestamp_macro {
711            window.extend_from_slice(&chunk[..read]);
712            found_timestamp_macro = TIMESTAMP_MACROS
713                .iter()
714                .any(|macro_name| memchr::memmem::find(&window, macro_name).is_some());
715            let keep = window.len().saturating_sub(longest_macro.saturating_sub(1));
716            window.drain(..keep);
717        }
718    }
719    if found_timestamp_macro {
720        Ok((FileDigestResolution::EmbeddedTimestampMacro, size))
721    } else {
722        Ok((
723            FileDigestResolution::Digest(CacheDigest {
724                algorithm: "blake3".into(),
725                hash: hasher.finalize().to_hex().to_string(),
726                size,
727            }),
728            size,
729        ))
730    }
731}
732
733fn handle_and_path_are_stable(
734    path: &Path,
735    before: &std::fs::Metadata,
736    after: &std::fs::Metadata,
737    current: &FileIdentity,
738    unreliable: bool,
739    digest_size: u64,
740) -> io::Result<bool> {
741    if before.len() != after.len() || after.len() != digest_size || current.len != digest_size {
742        return Ok(false);
743    }
744    if !metadata_names_same_file(before, after) {
745        return Ok(false);
746    }
747    if unreliable {
748        #[cfg(target_os = "linux")]
749        {
750            let before_modified = before.modified()?;
751            let after_modified = after.modified()?;
752            let object_matches = current.object.as_ref().is_none_or(|object| {
753                metadata_matches_object(before, object) && metadata_matches_object(after, object)
754            });
755            return Ok(before_modified == after_modified
756                && after_modified == current.modified
757                && object_matches);
758        }
759        #[cfg(not(target_os = "linux"))]
760        {
761            let _ = (path, before, after, current);
762            return Ok(false);
763        }
764    }
765    let before = FileIdentity::describe(path, before);
766    let after = FileIdentity::describe(path, after);
767    Ok(before.is_some() && before == after && after.as_ref() == Some(current))
768}
769
770#[cfg(unix)]
771fn metadata_names_same_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
772    use std::os::unix::fs::MetadataExt as _;
773
774    left.dev() == right.dev() && left.ino() == right.ino()
775}
776
777#[cfg(not(unix))]
778fn metadata_names_same_file(_left: &std::fs::Metadata, _right: &std::fs::Metadata) -> bool {
779    true
780}
781
782#[cfg(target_os = "linux")]
783fn metadata_matches_object(metadata: &std::fs::Metadata, object: &FileObjectIdentity) -> bool {
784    use std::os::unix::fs::MetadataExt as _;
785
786    let device = metadata.dev();
787    let major = ((device >> 8) & 0x0fff) | ((device >> 32) & 0xfffff000);
788    let minor = (device & 0x00ff) | ((device >> 12) & 0xffffff00);
789    u32::try_from(major).ok() == Some(object.device_major)
790        && u32::try_from(minor).ok() == Some(object.device_minor)
791        && metadata.ino() == object.inode
792}
793
794/// Recorded digests a session may consult instead of rehashing a file.
795///
796/// The agent's file-digest ledger answers through this everywhere a caller
797/// holds one; the no-op implementation stands in where reuse must not happen,
798/// such as under verification.
799pub trait FileDigestCache: Send + Sync {
800    /// Resolve these identities, coalescing concurrent misses where supported.
801    fn resolve(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<FileDigestResolution> {
802        self.find(scope, files)
803            .into_iter()
804            .map(|digest| {
805                digest.map_or(
806                    FileDigestResolution::Unresolved,
807                    FileDigestResolution::Digest,
808                )
809            })
810            .collect()
811    }
812    /// Recorded digests for these identities, in request order.
813    fn find(&self, scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>>;
814    /// Record digests for files read under these identities.
815    fn record(&self, scope: FileDigestScope, entries: Vec<RecordedFileDigest>);
816}
817
818/// A [`FileDigestCache`] that remembers nothing and finds nothing.
819pub struct NoFileDigestCache;
820
821impl FileDigestCache for NoFileDigestCache {
822    fn resolve(
823        &self,
824        _scope: FileDigestScope,
825        files: &[FileIdentity],
826    ) -> Vec<FileDigestResolution> {
827        vec![FileDigestResolution::Unresolved; files.len()]
828    }
829
830    fn find(&self, _scope: FileDigestScope, files: &[FileIdentity]) -> Vec<Option<CacheDigest>> {
831        vec![None; files.len()]
832    }
833
834    fn record(&self, _scope: FileDigestScope, _entries: Vec<RecordedFileDigest>) {}
835}
836
837#[cfg(test)]
838mod tests {
839    use super::*;
840
841    #[cfg(target_os = "linux")]
842    #[test]
843    fn an_nfs_snapshot_consults_the_session_digest_ledger() {
844        struct CountingCache(std::sync::atomic::AtomicUsize);
845        impl FileDigestCache for CountingCache {
846            fn find(
847                &self,
848                scope: FileDigestScope,
849                files: &[FileIdentity],
850            ) -> Vec<Option<CacheDigest>> {
851                assert_eq!(scope, FileDigestScope::Content);
852                assert_eq!(files.len(), 1);
853                assert_eq!(files[0].changed, None);
854                assert!(files[0].object.is_some());
855                self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
856                vec![None]
857            }
858            fn record(&self, _scope: FileDigestScope, _entries: Vec<RecordedFileDigest>) {}
859        }
860        let directory = tempfile::tempdir().unwrap();
861        let path = directory.path().join("input.rlib");
862        std::fs::write(&path, b"nfs bytes").unwrap();
863        if nfs_file_identity(&path).unwrap().is_none() {
864            eprintln!("skipping: statx cannot supply the NFS ledger identity");
865            return;
866        }
867        let cache = CountingCache(std::sync::atomic::AtomicUsize::new(0));
868        let metadata = std::fs::metadata(&path).unwrap();
869        // Settled: a racy identity skips the ledger by design.
870        let observed_at = metadata.modified().unwrap() + std::time::Duration::from_secs(3);
871        let snapshot = capture_file_snapshot(&path, &cache, true, true, metadata, observed_at)
872            .unwrap()
873            .unwrap();
874        assert_eq!(snapshot.content, Some(CacheDigest::blake3(b"nfs bytes")));
875        assert_eq!(cache.0.load(std::sync::atomic::Ordering::Relaxed), 1);
876    }
877
878    #[test]
879    fn timestamp_macros_are_detected_at_every_chunk_boundary_split() {
880        let directory = tempfile::tempdir().unwrap();
881        let path = directory.path().join("input.h");
882        for macro_name in TIMESTAMP_MACROS {
883            // Include a macro starting at the boundary and one ending there.
884            for split in 0..=macro_name.len() {
885                let mut bytes = vec![b'x'; DIGEST_BUFFER_BYTES - split];
886                bytes.extend_from_slice(macro_name);
887                std::fs::write(&path, &bytes).unwrap();
888                assert_eq!(
889                    digest_file(FileDigestScope::CcInput, &path).unwrap(),
890                    FileDigestResolution::EmbeddedTimestampMacro,
891                    "macro {macro_name:?}, split {split}"
892                );
893                // The same bytes remain hashable outside the C/C++ scope.
894                assert_eq!(
895                    digest_file(FileDigestScope::Content, &path).unwrap(),
896                    FileDigestResolution::Digest(CacheDigest::blake3(&bytes))
897                );
898            }
899        }
900    }
901
902    #[test]
903    fn timestamp_scan_accepts_empty_short_and_near_match_inputs() {
904        let directory = tempfile::tempdir().unwrap();
905        let path = directory.path().join("input.h");
906        for bytes in [
907            b"".as_slice(),
908            b"_",
909            b"__DATE_ __TIME_ __TIMESTAMP_",
910            b"__date__ __time__ __timestamp__",
911            &[0xff, 0, b'_', b'_'],
912        ] {
913            std::fs::write(&path, bytes).unwrap();
914            assert_eq!(
915                digest_file(FileDigestScope::CcInput, &path).unwrap(),
916                FileDigestResolution::Digest(CacheDigest::blake3(bytes))
917            );
918        }
919    }
920
921    /// rustc links a finished `.rmeta` into its incremental directory, which
922    /// changes the file's metadata but not its bytes. A metadata snapshot
923    /// cannot tell that from a rewrite; a content snapshot can, and still
924    /// notices when the bytes do change.
925    #[cfg(unix)]
926    #[test]
927    fn a_content_snapshot_survives_a_hardlink_and_a_metadata_one_does_not() {
928        let directory = tempfile::tempdir().unwrap();
929        let path = directory.path().join("libdep.rmeta");
930        std::fs::write(&path, b"metadata").unwrap();
931        let metadata = std::fs::metadata(&path).unwrap();
932        let observed_at = metadata.modified().unwrap() + std::time::Duration::from_secs(3);
933        let by_metadata = capture_file_snapshot(
934            &path,
935            &NoFileDigestCache,
936            false,
937            false,
938            metadata,
939            observed_at,
940        )
941        .unwrap()
942        .unwrap();
943        let by_content = FileSnapshot::capture_content_with_cache(&path, &NoFileDigestCache)
944            .unwrap()
945            .unwrap();
946        let digest = CacheDigest::blake3_file(&path).unwrap();
947        // A new ctime, so the change token cannot be mistaken for the old one.
948        std::thread::sleep(std::time::Duration::from_millis(20));
949        std::fs::hard_link(&path, directory.path().join("metadata.rmeta")).unwrap();
950        let after = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap()).unwrap();
951
952        assert!(by_content.proves_content_change());
953        assert!(by_content.matches(Some(&after), &digest));
954        if by_metadata.proves_content_change() {
955            assert!(!by_metadata.matches(Some(&after), &digest));
956        }
957
958        std::fs::write(&path, b"rewritten").unwrap();
959        let rewritten = CacheDigest::blake3_file(&path).unwrap();
960        let after = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap()).unwrap();
961        assert!(!by_content.matches(Some(&after), &rewritten));
962    }
963
964    #[cfg(unix)]
965    struct RecordingDigestCache {
966        answer: Option<CacheDigest>,
967        recorded: std::sync::Mutex<Vec<RecordedFileDigest>>,
968    }
969
970    #[cfg(unix)]
971    impl FileDigestCache for RecordingDigestCache {
972        fn find(
973            &self,
974            _scope: FileDigestScope,
975            files: &[FileIdentity],
976        ) -> Vec<Option<CacheDigest>> {
977            assert_eq!(files.len(), 1);
978            vec![self.answer.clone()]
979        }
980
981        fn record(&self, _scope: FileDigestScope, entries: Vec<RecordedFileDigest>) {
982            self.recorded.lock().unwrap().extend(entries);
983        }
984    }
985
986    #[cfg(unix)]
987    #[test]
988    fn content_snapshot_records_a_replacement_under_its_own_identity() {
989        let directory = tempfile::tempdir().unwrap();
990        let path = directory.path().join("input.rlib");
991        let replacement = directory.path().join("replacement.rlib");
992        std::fs::write(&path, b"original....").unwrap();
993        std::fs::File::options()
994            .write(true)
995            .open(&path)
996            .unwrap()
997            .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
998            .unwrap();
999        std::fs::write(&replacement, b"replacement!").unwrap();
1000        std::fs::File::options()
1001            .write(true)
1002            .open(&replacement)
1003            .unwrap()
1004            .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
1005            .unwrap();
1006        let before = std::fs::metadata(&path).unwrap();
1007        let old_identity = FileIdentity::describe(&path, &before).unwrap();
1008        std::fs::remove_file(&path).unwrap();
1009        std::fs::rename(&replacement, &path).unwrap();
1010        let cache = RecordingDigestCache {
1011            answer: None,
1012            recorded: std::sync::Mutex::new(Vec::new()),
1013        };
1014
1015        settle_past_timestamp_race();
1016        let snapshot = capture_file_snapshot(&path, &cache, true, true, before, SystemTime::now())
1017            .unwrap()
1018            .unwrap();
1019        let current = FileIdentity::for_digest_cache(&path, &std::fs::metadata(&path).unwrap())
1020            .unwrap()
1021            .unwrap();
1022        let digest = CacheDigest::blake3_file(&path).unwrap();
1023
1024        assert_eq!(current.modified, old_identity.modified);
1025        assert_ne!(current.object, old_identity.object);
1026        assert_ne!(current, old_identity);
1027        assert!(snapshot.matches(Some(&current), &digest));
1028        let recorded = cache.recorded.lock().unwrap();
1029        assert_eq!(recorded.len(), 1);
1030        assert_eq!(recorded[0].file, current);
1031        assert_eq!(recorded[0].digest, digest);
1032    }
1033
1034    #[cfg(unix)]
1035    #[test]
1036    fn content_snapshot_rejects_a_stale_digest_cache_hit() {
1037        let directory = tempfile::tempdir().unwrap();
1038        let path = directory.path().join("input.rlib");
1039        let replacement = directory.path().join("replacement.rlib");
1040        std::fs::write(&path, b"original....").unwrap();
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 before = std::fs::metadata(&path).unwrap();
1048        std::fs::write(&replacement, b"replacement!").unwrap();
1049        std::fs::File::options()
1050            .write(true)
1051            .open(&replacement)
1052            .unwrap()
1053            .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
1054            .unwrap();
1055        std::fs::remove_file(&path).unwrap();
1056        std::fs::rename(&replacement, &path).unwrap();
1057        let cache = RecordingDigestCache {
1058            answer: Some(CacheDigest::blake3(b"original....")),
1059            recorded: std::sync::Mutex::new(Vec::new()),
1060        };
1061
1062        settle_past_timestamp_race();
1063        let snapshot = capture_file_snapshot(&path, &cache, true, true, before, SystemTime::now())
1064            .unwrap()
1065            .unwrap();
1066        let current = FileIdentity::for_digest_cache(&path, &std::fs::metadata(&path).unwrap())
1067            .unwrap()
1068            .unwrap();
1069        let digest = CacheDigest::blake3_file(&path).unwrap();
1070
1071        assert!(snapshot.matches(Some(&current), &digest));
1072        assert_ne!(snapshot.content, Some(CacheDigest::blake3(b"original....")));
1073        assert_eq!(cache.recorded.lock().unwrap()[0].digest, digest);
1074    }
1075
1076    #[cfg(unix)]
1077    #[test]
1078    fn a_replaced_path_does_not_validate_the_open_handle() {
1079        let directory = tempfile::tempdir().unwrap();
1080        let path = directory.path().join("input.rlib");
1081        let replacement = directory.path().join("replacement.rlib");
1082        std::fs::write(&path, b"old bytes").unwrap();
1083        std::fs::write(&replacement, b"new bytes").unwrap();
1084
1085        let file = std::fs::File::open(&path).unwrap();
1086        let before = file.metadata().unwrap();
1087        std::fs::rename(&replacement, &path).unwrap();
1088        let (_, size) = digest_reader(FileDigestScope::Content, &file).unwrap();
1089        let after = file.metadata().unwrap();
1090        let current_metadata = std::fs::metadata(&path).unwrap();
1091        let current = FileIdentity::describe(&path, &current_metadata).unwrap();
1092
1093        assert!(!metadata_names_same_file(&after, &current_metadata));
1094        assert!(
1095            !handle_and_path_are_stable(&path, &before, &after, &current, false, size).unwrap()
1096        );
1097    }
1098
1099    #[test]
1100    fn an_identity_describes_the_file_until_it_is_written_or_removed() {
1101        let directory = tempfile::tempdir().unwrap();
1102        let path = directory.path().join("input.rs");
1103        std::fs::write(&path, b"fn main() {}").unwrap();
1104        let identity = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap()).unwrap();
1105        assert!(identity.still_describes().unwrap());
1106
1107        std::thread::sleep(std::time::Duration::from_millis(20));
1108        std::fs::write(&path, b"fn main() { }").unwrap();
1109        assert!(!identity.still_describes().unwrap());
1110
1111        std::fs::remove_file(&path).unwrap();
1112        assert!(identity.still_describes().is_err());
1113    }
1114
1115    #[test]
1116    fn a_racy_snapshot_rejects_a_same_length_rewrite_with_identical_metadata() {
1117        let directory = tempfile::tempdir().unwrap();
1118        let path = directory.path().join("input.rs");
1119        std::fs::write(&path, b"fn main() {}").unwrap();
1120        let metadata = std::fs::metadata(&path).unwrap();
1121        let observed_at = metadata.modified().unwrap();
1122        let snapshot = capture_file_snapshot(
1123            &path,
1124            &NoFileDigestCache,
1125            false,
1126            false,
1127            metadata,
1128            observed_at,
1129        )
1130        .unwrap()
1131        .unwrap();
1132        let identity = snapshot.identity.clone();
1133        let original = CacheDigest::blake3_file(&path).unwrap();
1134        assert!(snapshot.matches(Some(&identity), &original));
1135
1136        std::fs::write(&path, b"fn main(){ }").unwrap();
1137        let rewritten = CacheDigest::blake3_file(&path).unwrap();
1138        assert_eq!(original.size, rewritten.size);
1139        assert_ne!(original, rewritten);
1140        // Model a filesystem tick collision independently of the host's
1141        // actual timestamp resolution: every metadata field is unchanged.
1142        assert!(!snapshot.matches(Some(&identity), &rewritten));
1143        assert!(snapshot.proves_content_change());
1144    }
1145
1146    #[cfg(unix)]
1147    #[test]
1148    fn a_racy_local_snapshot_rejects_an_edit_that_restores_the_original_bytes() {
1149        let directory = tempfile::tempdir().unwrap();
1150        let path = directory.path().join("input.rs");
1151        std::fs::write(&path, b"fn main() {}").unwrap();
1152        let metadata = std::fs::metadata(&path).unwrap();
1153        let observed_at = metadata.modified().unwrap();
1154        let snapshot = capture_file_snapshot(
1155            &path,
1156            &NoFileDigestCache,
1157            false,
1158            false,
1159            metadata,
1160            observed_at,
1161        )
1162        .unwrap()
1163        .unwrap();
1164        let original = CacheDigest::blake3_file(&path).unwrap();
1165        assert!(snapshot.matches(Some(&snapshot.identity), &original));
1166
1167        std::fs::write(&path, b"fn main(){ }").unwrap();
1168        std::fs::write(&path, b"fn main() {}").unwrap();
1169        let restored = CacheDigest::blake3_file(&path).unwrap();
1170        assert_eq!(original, restored);
1171        // Model a changed ctime independently of the host's timestamp tick.
1172        // The compiler may have consumed the intervening bytes even though
1173        // discovery sees the original content and mtime again.
1174        let mut after = snapshot.identity.clone();
1175        let (seconds, nanos) = after.changed.unwrap();
1176        after.changed = Some((seconds + 1, nanos));
1177        assert!(!snapshot.matches(Some(&after), &restored));
1178    }
1179
1180    #[test]
1181    fn a_settled_snapshot_keeps_the_metadata_only_path() {
1182        let directory = tempfile::tempdir().unwrap();
1183        let path = directory.path().join("input.rs");
1184        std::fs::write(&path, b"fn main() {}").unwrap();
1185        let metadata = std::fs::metadata(&path).unwrap();
1186        let observed_at = metadata.modified().unwrap() + std::time::Duration::from_secs(3);
1187        // Once metadata has been read, this path must not open the file or
1188        // consult the content ledger just to capture a settled snapshot.
1189        std::fs::remove_file(&path).unwrap();
1190        let snapshot = capture_file_snapshot(
1191            &path,
1192            &NoFileDigestCache,
1193            false,
1194            false,
1195            metadata,
1196            observed_at,
1197        )
1198        .unwrap()
1199        .unwrap();
1200        assert!(snapshot.content.is_none());
1201    }
1202
1203    #[cfg(unix)]
1204    #[test]
1205    fn a_racy_snapshot_does_not_reuse_a_digest_with_identical_metadata() {
1206        let directory = tempfile::tempdir().unwrap();
1207        let path = directory.path().join("input.rs");
1208        std::fs::write(&path, b"fn main() {}").unwrap();
1209        let metadata = std::fs::metadata(&path).unwrap();
1210        let observed_at = metadata.modified().unwrap();
1211        let cache = RecordingDigestCache {
1212            answer: Some(CacheDigest::blake3(b"fn main(){ }")),
1213            recorded: std::sync::Mutex::new(Vec::new()),
1214        };
1215        for content_identity in [false, true] {
1216            let snapshot = capture_file_snapshot(
1217                &path,
1218                &cache,
1219                false,
1220                content_identity,
1221                metadata.clone(),
1222                observed_at,
1223            )
1224            .unwrap()
1225            .unwrap();
1226            assert_eq!(snapshot.content, Some(CacheDigest::blake3(b"fn main() {}")));
1227        }
1228    }
1229
1230    #[test]
1231    fn a_metadata_snapshot_detects_a_metadata_change() {
1232        let directory = tempfile::tempdir().unwrap();
1233        let path = directory.path().join("input.rs");
1234        std::fs::write(&path, b"fn main() {}").unwrap();
1235        let metadata = std::fs::metadata(&path).unwrap();
1236        let observed_at = metadata.modified().unwrap() + std::time::Duration::from_secs(3);
1237        let snapshot = capture_file_snapshot(
1238            &path,
1239            &NoFileDigestCache,
1240            false,
1241            false,
1242            metadata,
1243            observed_at,
1244        )
1245        .unwrap()
1246        .unwrap();
1247
1248        std::fs::File::options()
1249            .write(true)
1250            .open(&path)
1251            .unwrap()
1252            .set_times(std::fs::FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
1253            .unwrap();
1254        let identity = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap());
1255        let digest = CacheDigest::blake3_file(&path).unwrap();
1256
1257        assert!(!snapshot.matches(identity.as_ref(), &digest));
1258        assert_eq!(snapshot.proves_content_change(), cfg!(unix));
1259    }
1260
1261    #[test]
1262    fn a_content_snapshot_ignores_timestamp_churn_but_detects_changed_bytes() {
1263        for (unreliable, content_identity) in [(true, false), (true, true), (false, true)] {
1264            let directory = tempfile::tempdir().unwrap();
1265            let path = directory.path().join("input.rs");
1266            std::fs::write(&path, b"fn main() {}").unwrap();
1267            let snapshot = capture_file_snapshot(
1268                &path,
1269                &NoFileDigestCache,
1270                unreliable,
1271                content_identity,
1272                std::fs::metadata(&path).unwrap(),
1273                SystemTime::now(),
1274            )
1275            .unwrap()
1276            .unwrap();
1277
1278            let mut identity = snapshot.identity.clone();
1279            identity.changed = identity
1280                .changed
1281                .map(|(seconds, nanos)| (seconds + 1, nanos));
1282            let digest = CacheDigest::blake3_file(&path).unwrap();
1283            assert!(snapshot.matches(Some(&identity), &digest));
1284            assert!(snapshot.proves_content_change());
1285
1286            identity.modified = SystemTime::UNIX_EPOCH;
1287            assert!(snapshot.matches(Some(&identity), &digest));
1288
1289            std::fs::write(&path, b"fn main(){ }").unwrap();
1290            let identity = FileIdentity::describe(&path, &std::fs::metadata(&path).unwrap());
1291            let digest = CacheDigest::blake3_file(&path).unwrap();
1292            assert!(!snapshot.matches(identity.as_ref(), &digest));
1293        }
1294    }
1295
1296    #[test]
1297    fn an_nfs_object_identity_can_skip_a_second_content_read() {
1298        let identity = FileIdentity {
1299            path: PathBuf::from("/nfs/input.rlib"),
1300            len: 9,
1301            modified: SystemTime::UNIX_EPOCH,
1302            changed: None,
1303            object: Some(FileObjectIdentity {
1304                device_major: 0,
1305                device_minor: 42,
1306                mount_id: 7,
1307                inode: 99,
1308            }),
1309        };
1310        assert!(identity.can_skip_content_verification());
1311        let without_object = FileIdentity {
1312            object: None,
1313            ..identity
1314        };
1315        assert!(!without_object.can_skip_content_verification());
1316    }
1317
1318    #[test]
1319    fn reliable_metadata_keeps_the_native_identity() {
1320        let directory = tempfile::tempdir().unwrap();
1321        let path = directory.path().join("input.rs");
1322        std::fs::write(&path, b"fn main() {}").unwrap();
1323        let metadata = std::fs::metadata(&path).unwrap();
1324
1325        let identity = digest_cache_identity(&path, &metadata, false)
1326            .unwrap()
1327            .unwrap();
1328
1329        assert_eq!(identity.path, path);
1330        assert_eq!(identity.len, 12);
1331        assert_eq!(identity.object.is_some(), cfg!(unix));
1332    }
1333
1334    #[test]
1335    fn content_snapshot_ignores_nfs_timestamp_churn_but_not_content_or_identity_changes() {
1336        let digest = CacheDigest::blake3(b"nfs bytes");
1337        let identity = FileIdentity {
1338            path: PathBuf::from("/nfs/input.rlib"),
1339            len: digest.size,
1340            modified: SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(10),
1341            changed: Some((10, 1)),
1342            object: Some(FileObjectIdentity {
1343                device_major: 0,
1344                device_minor: 42,
1345                mount_id: 7,
1346                inode: 99,
1347            }),
1348        };
1349        let snapshot = FileSnapshot {
1350            identity: identity.clone(),
1351            content: Some(digest.clone()),
1352            require_full_identity: false,
1353        };
1354        // Client/server reconciliation can move either timestamp in either
1355        // direction. None of these observations proves another write.
1356        for seconds in [9, 11] {
1357            let mut after = identity.clone();
1358            after.changed = Some((seconds, 500));
1359            after.modified = SystemTime::UNIX_EPOCH + std::time::Duration::new(seconds as u64, 500);
1360            assert!(snapshot.matches(Some(&after), &digest));
1361        }
1362
1363        // Timestamp tolerance does not admit changed bytes, even when their
1364        // length and all metadata remain the same.
1365        let changed = CacheDigest::blake3(b"NFS bytes");
1366        assert_eq!(changed.size, digest.size);
1367        assert!(!snapshot.matches(Some(&identity), &changed));
1368        assert!(!snapshot.matches(None, &digest));
1369
1370        let mut after = identity.clone();
1371        after.object.as_mut().unwrap().inode += 1;
1372        assert!(!snapshot.matches(Some(&after), &digest));
1373
1374        let mut after = identity.clone();
1375        after.len += 1;
1376        assert!(!snapshot.matches(Some(&after), &digest));
1377
1378        let mut after = identity;
1379        after.path.set_file_name("replacement.rlib");
1380        assert!(!snapshot.matches(Some(&after), &digest));
1381    }
1382
1383    #[cfg(target_os = "linux")]
1384    #[test]
1385    fn incomplete_statx_metadata_falls_back_to_content_hashing() {
1386        // SAFETY: all-zero is a valid unpopulated statx result for this parser.
1387        let status = unsafe { std::mem::zeroed::<LinuxStatx>() };
1388        assert!(
1389            nfs_identity_from_statx(Path::new("/nfs/input.rlib"), &status)
1390                .unwrap()
1391                .is_none()
1392        );
1393    }
1394
1395    /// Let a just-written file age past the window in which its digest is
1396    /// withheld from reuse.
1397    fn settle_past_timestamp_race() {
1398        std::thread::sleep(FINE_TIMESTAMP_RACE_WINDOW * 2);
1399    }
1400
1401    /// A modification time ahead of the clock is racy however slowly the test
1402    /// runs, so the withheld case does not depend on how long a stall lasts.
1403    #[test]
1404    fn a_digest_of_a_recently_changed_file_is_not_offered_for_reuse() {
1405        let directory = tempfile::tempdir().unwrap();
1406        let path = directory.path().join("val.rs");
1407        std::fs::write(&path, b"pub const VAL: &str = \"v1\";").unwrap();
1408        std::fs::File::options()
1409            .write(true)
1410            .open(&path)
1411            .unwrap()
1412            .set_times(
1413                std::fs::FileTimes::new()
1414                    .set_modified(SystemTime::now() + std::time::Duration::from_secs(3600)),
1415            )
1416            .unwrap();
1417
1418        let fresh = digest_file_validated(FileDigestScope::Content, &path)
1419            .unwrap()
1420            .unwrap();
1421        assert!(fresh.resolution.into_digest().is_some());
1422        assert!(fresh.racy);
1423        assert_eq!(fresh.cache_identity, None);
1424    }
1425
1426    #[test]
1427    fn a_digest_of_a_settled_file_is_offered_for_reuse() {
1428        let directory = tempfile::tempdir().unwrap();
1429        let path = directory.path().join("val.rs");
1430        std::fs::write(&path, b"pub const VAL: &str = \"v1\";").unwrap();
1431
1432        settle_past_timestamp_race();
1433        let settled = digest_file_validated(FileDigestScope::Content, &path)
1434            .unwrap()
1435            .unwrap();
1436        assert!(!settled.racy);
1437        assert!(settled.cache_identity.is_some());
1438    }
1439
1440    #[test]
1441    fn racy_window_follows_the_timestamp_resolution() {
1442        let at = |seconds: u64, nanos: u32| {
1443            SystemTime::UNIX_EPOCH + std::time::Duration::new(seconds, nanos)
1444        };
1445        let identity = |modified: SystemTime, changed: Option<(i64, i64)>| FileIdentity {
1446            path: PathBuf::from("/x"),
1447            len: 1,
1448            modified,
1449            changed,
1450            object: None,
1451        };
1452        let now = at(1_000, 500_000_000);
1453        let fine = |age_ms: u64| {
1454            let when = now - std::time::Duration::from_millis(age_ms);
1455            let since = when.duration_since(SystemTime::UNIX_EPOCH).unwrap();
1456            identity(
1457                when,
1458                Some((since.as_secs() as i64, i64::from(since.subsec_nanos()))),
1459            )
1460        };
1461        assert!(identity_is_racy(&fine(5), now));
1462        assert!(!identity_is_racy(&fine(25), now));
1463        // A ctime newer than the mtime counts even when the mtime is old.
1464        let touched = identity(at(10, 7), Some((1_000, 499_000_000)));
1465        assert!(identity_is_racy(&touched, now));
1466        // Whole-second stamps are withheld for two seconds, even without ctime.
1467        for with_ctime in [false, true] {
1468            let coarse = |age: u64| {
1469                identity(
1470                    at(1_000 - age, 0),
1471                    with_ctime.then_some((1_000 - age as i64, 0)),
1472                )
1473            };
1474            assert!(identity_is_racy(&coarse(1), at(1_000, 0)));
1475            assert!(!identity_is_racy(&coarse(3), at(1_000, 0)));
1476        }
1477        // A timestamp in the future is racy.
1478        assert!(identity_is_racy(&identity(at(2_000, 1), None), now));
1479    }
1480}