1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
//! Shared source-file fingerprint inputs for cache invalidation.
use std::fs::Metadata;
use std::time::SystemTime;
use serde::{Deserialize, Serialize};
/// File metadata used to decide whether a source-derived cache entry is fresh.
///
/// This is intentionally metadata-only. Callers that need content validation
/// can combine it with their existing content hash, while cheap caches can use
/// the same freshness shape without inventing their own `(mtime, size)` tuple.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SourceFingerprint {
/// Source file modification time as nanoseconds since the Unix epoch.
///
/// A value of `0` means the timestamp could not be read. Fast metadata-only
/// cache hits should treat that as unknown and miss conservatively.
pub mtime_ns: u64,
/// Source file inode change time (ctime) as nanoseconds since the Unix
/// epoch, or `0` when the platform does not expose it.
///
/// mtime alone is writer-controlled: an editor, a `git checkout`, a
/// codemod, or `touch -r` can restore it byte-for-byte after rewriting a
/// file. When the replacement happens to keep the same length the
/// `(mtime, size)` pair is unchanged and a metadata-only cache hit serves
/// stale analysis for genuinely different content. ctime moves on every
/// inode write and cannot be restored through the normal filesystem API,
/// so pairing it with mtime makes the metadata-only fast path trustworthy.
///
/// Only Unix reports it (`stat.st_ctime`). Windows keeps `0`, which costs
/// the metadata-only fast path (the caller falls through to the read plus
/// content-hash comparison and still hits) until someone wires up
/// `FILE_BASIC_INFO.ChangeTime`.
pub ctime_ns: u64,
/// Source file size in bytes.
pub file_size: u64,
}
impl SourceFingerprint {
/// Build a fingerprint from explicit metadata parts, with no known ctime.
///
/// A fingerprint built this way is never
/// [trustworthy without content](Self::is_trustworthy_without_content).
#[must_use]
pub const fn new(mtime_ns: u64, file_size: u64) -> Self {
Self {
mtime_ns,
ctime_ns: 0,
file_size,
}
}
/// Build a fingerprint from explicit metadata parts, including ctime.
#[must_use]
pub const fn with_ctime(mtime_ns: u64, ctime_ns: u64, file_size: u64) -> Self {
Self {
mtime_ns,
ctime_ns,
file_size,
}
}
/// Build a fingerprint from filesystem metadata.
#[must_use]
pub fn from_metadata(metadata: &Metadata) -> Self {
Self {
mtime_ns: metadata_mtime_ns(metadata),
ctime_ns: metadata_ctime_ns(metadata),
file_size: metadata.len(),
}
}
/// Returns true when the modification time is known.
#[must_use]
pub const fn has_known_mtime(self) -> bool {
self.mtime_ns > 0
}
/// Returns true when this fingerprint may stand in for the file's content.
///
/// Requires both timestamps: mtime detects the ordinary edit, ctime detects
/// the same-size edit whose mtime was restored. A caller that gets `false`
/// must fall through to reading the file and comparing content hashes.
#[must_use]
pub const fn is_trustworthy_without_content(self) -> bool {
self.mtime_ns > 0 && self.ctime_ns > 0
}
}
#[expect(
clippy::cast_possible_truncation,
reason = "filesystem mtimes used for cache invalidation fit in u64 nanoseconds for supported dates"
)]
fn metadata_mtime_ns(metadata: &Metadata) -> u64 {
metadata
.modified()
.ok()
.and_then(|time| time.duration_since(SystemTime::UNIX_EPOCH).ok())
.map_or(0, |duration| duration.as_nanos() as u64)
}
/// Unix inode change time in nanoseconds since the epoch.
///
/// Pre-epoch and unreadable values collapse to `0`, which the fast-path gate
/// reads as "unknown" and therefore untrustworthy.
#[cfg(unix)]
fn metadata_ctime_ns(metadata: &Metadata) -> u64 {
use std::os::unix::fs::MetadataExt;
let seconds = u64::try_from(metadata.ctime()).unwrap_or(0);
let nanos = u64::try_from(metadata.ctime_nsec()).unwrap_or(0);
seconds.saturating_mul(1_000_000_000).saturating_add(nanos)
}
/// Non-Unix platforms do not expose an inode change time.
#[cfg(not(unix))]
fn metadata_ctime_ns(_metadata: &Metadata) -> u64 {
0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn source_fingerprint_preserves_explicit_parts() {
let fingerprint = SourceFingerprint::new(123, 456);
assert_eq!(fingerprint.mtime_ns, 123);
assert_eq!(fingerprint.file_size, 456);
assert!(fingerprint.has_known_mtime());
}
#[test]
fn source_fingerprint_zero_mtime_is_unknown() {
let fingerprint = SourceFingerprint::new(0, 456);
assert!(!fingerprint.has_known_mtime());
}
#[test]
fn source_fingerprint_without_ctime_is_never_content_trustworthy() {
let fingerprint = SourceFingerprint::new(123, 456);
assert!(fingerprint.has_known_mtime());
assert!(!fingerprint.is_trustworthy_without_content());
}
#[test]
fn source_fingerprint_with_both_timestamps_is_content_trustworthy() {
let fingerprint = SourceFingerprint::with_ctime(123, 789, 456);
assert_eq!(fingerprint.ctime_ns, 789);
assert!(fingerprint.is_trustworthy_without_content());
}
#[test]
fn source_fingerprint_differs_when_only_ctime_moved() {
let before = SourceFingerprint::with_ctime(123, 700, 456);
let after = SourceFingerprint::with_ctime(123, 800, 456);
assert_ne!(before, after);
}
#[test]
#[cfg_attr(miri, ignore = "filesystem metadata is blocked by Miri isolation")]
fn source_fingerprint_from_metadata_sets_size() {
let metadata = std::fs::metadata(".").expect("metadata");
let fingerprint = SourceFingerprint::from_metadata(&metadata);
assert_eq!(fingerprint.file_size, metadata.len());
}
#[cfg(unix)]
#[test]
#[cfg_attr(miri, ignore = "filesystem metadata is blocked by Miri isolation")]
fn source_fingerprint_from_metadata_reads_unix_ctime() {
let metadata = std::fs::metadata(".").expect("metadata");
let fingerprint = SourceFingerprint::from_metadata(&metadata);
assert!(fingerprint.ctime_ns > 0);
assert!(fingerprint.is_trustworthy_without_content());
}
}