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
91#[expect(
92    clippy::cast_possible_truncation,
93    reason = "filesystem mtimes used for cache invalidation fit in u64 nanoseconds for supported dates"
94)]
95fn metadata_mtime_ns(metadata: &Metadata) -> u64 {
96    metadata
97        .modified()
98        .ok()
99        .and_then(|time| time.duration_since(SystemTime::UNIX_EPOCH).ok())
100        .map_or(0, |duration| duration.as_nanos() as u64)
101}
102
103/// Unix inode change time in nanoseconds since the epoch.
104///
105/// Pre-epoch and unreadable values collapse to `0`, which the fast-path gate
106/// reads as "unknown" and therefore untrustworthy.
107#[cfg(unix)]
108fn metadata_ctime_ns(metadata: &Metadata) -> u64 {
109    use std::os::unix::fs::MetadataExt;
110
111    let seconds = u64::try_from(metadata.ctime()).unwrap_or(0);
112    let nanos = u64::try_from(metadata.ctime_nsec()).unwrap_or(0);
113    seconds.saturating_mul(1_000_000_000).saturating_add(nanos)
114}
115
116/// Non-Unix platforms do not expose an inode change time.
117#[cfg(not(unix))]
118fn metadata_ctime_ns(_metadata: &Metadata) -> u64 {
119    0
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn source_fingerprint_preserves_explicit_parts() {
128        let fingerprint = SourceFingerprint::new(123, 456);
129        assert_eq!(fingerprint.mtime_ns, 123);
130        assert_eq!(fingerprint.file_size, 456);
131        assert!(fingerprint.has_known_mtime());
132    }
133
134    #[test]
135    fn source_fingerprint_zero_mtime_is_unknown() {
136        let fingerprint = SourceFingerprint::new(0, 456);
137        assert!(!fingerprint.has_known_mtime());
138    }
139
140    #[test]
141    fn source_fingerprint_without_ctime_is_never_content_trustworthy() {
142        let fingerprint = SourceFingerprint::new(123, 456);
143        assert!(fingerprint.has_known_mtime());
144        assert!(!fingerprint.is_trustworthy_without_content());
145    }
146
147    #[test]
148    fn source_fingerprint_with_both_timestamps_is_content_trustworthy() {
149        let fingerprint = SourceFingerprint::with_ctime(123, 789, 456);
150        assert_eq!(fingerprint.ctime_ns, 789);
151        assert!(fingerprint.is_trustworthy_without_content());
152    }
153
154    #[test]
155    fn source_fingerprint_differs_when_only_ctime_moved() {
156        let before = SourceFingerprint::with_ctime(123, 700, 456);
157        let after = SourceFingerprint::with_ctime(123, 800, 456);
158        assert_ne!(before, after);
159    }
160
161    #[test]
162    #[cfg_attr(miri, ignore = "filesystem metadata is blocked by Miri isolation")]
163    fn source_fingerprint_from_metadata_sets_size() {
164        let metadata = std::fs::metadata(".").expect("metadata");
165
166        let fingerprint = SourceFingerprint::from_metadata(&metadata);
167
168        assert_eq!(fingerprint.file_size, metadata.len());
169    }
170
171    #[cfg(unix)]
172    #[test]
173    #[cfg_attr(miri, ignore = "filesystem metadata is blocked by Miri isolation")]
174    fn source_fingerprint_from_metadata_reads_unix_ctime() {
175        let metadata = std::fs::metadata(".").expect("metadata");
176
177        let fingerprint = SourceFingerprint::from_metadata(&metadata);
178
179        assert!(fingerprint.ctime_ns > 0);
180        assert!(fingerprint.is_trustworthy_without_content());
181    }
182}