Skip to main content

fallow_types/
source_fingerprint.rs

1//! Shared source-file fingerprint inputs for cache invalidation.
2
3use std::fs::Metadata;
4use std::time::SystemTime;
5
6use serde::{Deserialize, Serialize};
7
8/// File metadata used to decide whether a source-derived cache entry is fresh.
9///
10/// This is intentionally metadata-only. Callers that need content validation
11/// can combine it with their existing content hash, while cheap caches can use
12/// the same freshness shape without inventing their own `(mtime, size)` tuple.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub struct SourceFingerprint {
15    /// Source file modification time as nanoseconds since the Unix epoch.
16    ///
17    /// A value of `0` means the timestamp could not be read. Fast metadata-only
18    /// cache hits should treat that as unknown and miss conservatively.
19    pub mtime_ns: u64,
20    /// Source file inode change time (ctime) as nanoseconds since the Unix
21    /// epoch, or `0` when the platform does not expose it.
22    ///
23    /// mtime alone is writer-controlled: an editor, a `git checkout`, a
24    /// codemod, or `touch -r` can restore it byte-for-byte after rewriting a
25    /// file. When the replacement happens to keep the same length the
26    /// `(mtime, size)` pair is unchanged and a metadata-only cache hit serves
27    /// stale analysis for genuinely different content. ctime moves on every
28    /// inode write and cannot be restored through the normal filesystem API,
29    /// so pairing it with mtime makes the metadata-only fast path trustworthy.
30    ///
31    /// Only Unix reports it (`stat.st_ctime`). Windows keeps `0`, which costs
32    /// the metadata-only fast path (the caller falls through to the read plus
33    /// content-hash comparison and still hits) until someone wires up
34    /// `FILE_BASIC_INFO.ChangeTime`.
35    pub ctime_ns: u64,
36    /// Source file size in bytes.
37    pub file_size: u64,
38}
39
40impl SourceFingerprint {
41    /// Build a fingerprint from explicit metadata parts, with no known ctime.
42    ///
43    /// A fingerprint built this way is never
44    /// [trustworthy without content](Self::is_trustworthy_without_content).
45    #[must_use]
46    pub const fn new(mtime_ns: u64, file_size: u64) -> Self {
47        Self {
48            mtime_ns,
49            ctime_ns: 0,
50            file_size,
51        }
52    }
53
54    /// Build a fingerprint from explicit metadata parts, including ctime.
55    #[must_use]
56    pub const fn with_ctime(mtime_ns: u64, ctime_ns: u64, file_size: u64) -> Self {
57        Self {
58            mtime_ns,
59            ctime_ns,
60            file_size,
61        }
62    }
63
64    /// Build a fingerprint from filesystem metadata.
65    #[must_use]
66    pub fn from_metadata(metadata: &Metadata) -> Self {
67        Self {
68            mtime_ns: metadata_mtime_ns(metadata),
69            ctime_ns: metadata_ctime_ns(metadata),
70            file_size: metadata.len(),
71        }
72    }
73
74    /// Returns true when the modification time is known.
75    #[must_use]
76    pub const fn has_known_mtime(self) -> bool {
77        self.mtime_ns > 0
78    }
79
80    /// Returns true when this fingerprint may stand in for the file's content.
81    ///
82    /// Requires both timestamps: mtime detects the ordinary edit, ctime detects
83    /// the same-size edit whose mtime was restored. A caller that gets `false`
84    /// must fall through to reading the file and comparing content hashes.
85    #[must_use]
86    pub const fn is_trustworthy_without_content(self) -> bool {
87        self.mtime_ns > 0 && self.ctime_ns > 0
88    }
89
90    /// Returns true when both timestamps are older than `read_started_ns` by
91    /// at least [`TIMESTAMP_SETTLE_WINDOW_NS`].
92    ///
93    /// `read_started_ns` is a wall-clock time that the caller took before it
94    /// read the file content that it stores beside this fingerprint. A write
95    /// after that read gets a ctime of `read_started_ns` or later, so it
96    /// cannot keep a ctime that is a full window older. A write in the same
97    /// filesystem timestamp tick as the previous write can keep both
98    /// timestamps, so a fingerprint inside the window does not prove the
99    /// content.
100    ///
101    /// An mtime in the future never settles. That is safe: the file only
102    /// costs a content read on each run until the clock passes it.
103    #[must_use]
104    pub const fn is_settled_before(self, read_started_ns: u64) -> bool {
105        let newest = if self.mtime_ns > self.ctime_ns {
106            self.mtime_ns
107        } else {
108            self.ctime_ns
109        };
110        self.is_trustworthy_without_content()
111            && newest.saturating_add(TIMESTAMP_SETTLE_WINDOW_NS) <= read_started_ns
112    }
113
114    /// The fingerprint that a cache may store beside content read at or after
115    /// `read_started_ns`.
116    ///
117    /// A fingerprint that is not [settled](Self::is_settled_before) loses its
118    /// ctime, so it is never trustworthy without content. A later run then
119    /// compares the content hash, and stores the full fingerprint once the
120    /// timestamps are old enough.
121    #[must_use]
122    pub const fn for_content_read_at(self, read_started_ns: u64) -> Self {
123        if self.is_settled_before(read_started_ns) {
124            self
125        } else {
126            Self {
127                ctime_ns: 0,
128                ..self
129            }
130        }
131    }
132}
133
134/// The age that a file timestamp must have before a cache trusts it without
135/// a content check.
136///
137/// The window is larger than the coarsest timestamp resolution in use (two
138/// seconds on FAT, one second on HFS+ and ext3) and the lag of a coarse kernel
139/// clock behind the wall clock. It assumes that the clock that stamps the
140/// files is within the window of the local clock. A network filesystem whose
141/// server clock is further behind can still hide a same-tick write.
142pub const TIMESTAMP_SETTLE_WINDOW_NS: u64 = 3_000_000_000;
143
144/// The current wall-clock time in nanoseconds since the Unix epoch, or `0`
145/// when the clock is before the epoch.
146///
147/// Take this time before the content read whose fingerprint a cache stores.
148/// A `0` makes each fingerprint unsettled, which is the safe direction.
149#[must_use]
150pub fn now_ns() -> u64 {
151    SystemTime::now()
152        .duration_since(SystemTime::UNIX_EPOCH)
153        .ok()
154        .and_then(|duration| u64::try_from(duration.as_nanos()).ok())
155        .unwrap_or(0)
156}
157
158#[expect(
159    clippy::cast_possible_truncation,
160    reason = "filesystem mtimes used for cache invalidation fit in u64 nanoseconds for supported dates"
161)]
162fn metadata_mtime_ns(metadata: &Metadata) -> u64 {
163    metadata
164        .modified()
165        .ok()
166        .and_then(|time| time.duration_since(SystemTime::UNIX_EPOCH).ok())
167        .map_or(0, |duration| duration.as_nanos() as u64)
168}
169
170/// Unix inode change time in nanoseconds since the epoch.
171///
172/// Pre-epoch and unreadable values collapse to `0`, which the fast-path gate
173/// reads as "unknown" and therefore untrustworthy.
174#[cfg(unix)]
175fn metadata_ctime_ns(metadata: &Metadata) -> u64 {
176    use std::os::unix::fs::MetadataExt;
177
178    let seconds = u64::try_from(metadata.ctime()).unwrap_or(0);
179    let nanos = u64::try_from(metadata.ctime_nsec()).unwrap_or(0);
180    seconds.saturating_mul(1_000_000_000).saturating_add(nanos)
181}
182
183/// Non-Unix platforms do not expose an inode change time.
184#[cfg(not(unix))]
185fn metadata_ctime_ns(_metadata: &Metadata) -> u64 {
186    0
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn source_fingerprint_preserves_explicit_parts() {
195        let fingerprint = SourceFingerprint::new(123, 456);
196        assert_eq!(fingerprint.mtime_ns, 123);
197        assert_eq!(fingerprint.file_size, 456);
198        assert!(fingerprint.has_known_mtime());
199    }
200
201    #[test]
202    fn source_fingerprint_zero_mtime_is_unknown() {
203        let fingerprint = SourceFingerprint::new(0, 456);
204        assert!(!fingerprint.has_known_mtime());
205    }
206
207    #[test]
208    fn source_fingerprint_without_ctime_is_never_content_trustworthy() {
209        let fingerprint = SourceFingerprint::new(123, 456);
210        assert!(fingerprint.has_known_mtime());
211        assert!(!fingerprint.is_trustworthy_without_content());
212    }
213
214    #[test]
215    fn source_fingerprint_with_both_timestamps_is_content_trustworthy() {
216        let fingerprint = SourceFingerprint::with_ctime(123, 789, 456);
217        assert_eq!(fingerprint.ctime_ns, 789);
218        assert!(fingerprint.is_trustworthy_without_content());
219    }
220
221    #[test]
222    fn a_fingerprint_inside_the_settle_window_is_not_settled() {
223        let fingerprint = SourceFingerprint::with_ctime(1_000, 2_000, 456);
224        let read_started = 2_000 + TIMESTAMP_SETTLE_WINDOW_NS - 1;
225
226        assert!(!fingerprint.is_settled_before(read_started));
227        let stored = fingerprint.for_content_read_at(read_started);
228        assert_eq!(stored.ctime_ns, 0);
229        assert_eq!(stored.mtime_ns, 1_000);
230        assert!(!stored.is_trustworthy_without_content());
231    }
232
233    #[test]
234    fn a_fingerprint_older_than_the_settle_window_is_kept() {
235        let fingerprint = SourceFingerprint::with_ctime(1_000, 2_000, 456);
236        let read_started = 2_000 + TIMESTAMP_SETTLE_WINDOW_NS;
237
238        assert!(fingerprint.is_settled_before(read_started));
239        assert_eq!(fingerprint.for_content_read_at(read_started), fingerprint);
240    }
241
242    #[test]
243    fn a_future_mtime_is_not_settled() {
244        let fingerprint = SourceFingerprint::with_ctime(u64::MAX - 1, 2_000, 456);
245
246        assert!(!fingerprint.is_settled_before(2_000 + TIMESTAMP_SETTLE_WINDOW_NS));
247    }
248
249    #[test]
250    fn a_fingerprint_without_ctime_is_never_settled() {
251        let fingerprint = SourceFingerprint::new(1_000, 456);
252
253        assert!(!fingerprint.is_settled_before(u64::MAX));
254    }
255
256    #[test]
257    fn source_fingerprint_differs_when_only_ctime_moved() {
258        let before = SourceFingerprint::with_ctime(123, 700, 456);
259        let after = SourceFingerprint::with_ctime(123, 800, 456);
260        assert_ne!(before, after);
261    }
262
263    #[test]
264    #[cfg_attr(miri, ignore = "filesystem metadata is blocked by Miri isolation")]
265    fn source_fingerprint_from_metadata_sets_size() {
266        let metadata = std::fs::metadata(".").expect("metadata");
267
268        let fingerprint = SourceFingerprint::from_metadata(&metadata);
269
270        assert_eq!(fingerprint.file_size, metadata.len());
271    }
272
273    #[cfg(unix)]
274    #[test]
275    #[cfg_attr(miri, ignore = "filesystem metadata is blocked by Miri isolation")]
276    fn source_fingerprint_from_metadata_reads_unix_ctime() {
277        let metadata = std::fs::metadata(".").expect("metadata");
278
279        let fingerprint = SourceFingerprint::from_metadata(&metadata);
280
281        assert!(fingerprint.ctime_ns > 0);
282        assert!(fingerprint.is_trustworthy_without_content());
283    }
284}