fallow_types/
source_fingerprint.rs1use std::fs::Metadata;
4use std::time::SystemTime;
5
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub struct SourceFingerprint {
15 pub mtime_ns: u64,
20 pub ctime_ns: u64,
36 pub file_size: u64,
38}
39
40impl SourceFingerprint {
41 #[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 #[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 #[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 #[must_use]
76 pub const fn has_known_mtime(self) -> bool {
77 self.mtime_ns > 0
78 }
79
80 #[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#[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#[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}