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