1use std::collections::BTreeMap;
6use std::fs::Metadata;
7use std::io;
8use std::path::{Path, PathBuf};
9use std::time::{Duration, SystemTime};
10
11use mos_core::{ContentHash, ContentHasher};
12
13const DOMAIN_TAG: &[u8] = b"mos-eval/external-dependency/v1";
14
15pub const RACY_WINDOW: Duration = Duration::from_secs(2);
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub struct Fingerprint {
26 pub len: u64,
28 pub modified: Option<SystemTime>,
30 pub identity: FileIdentity,
32 pub observed: SystemTime,
34 pub content: ContentHash,
36}
37
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub struct FileIdentity {
44 pub device: Option<u64>,
46 pub inode: Option<u64>,
48 pub changed: Option<SystemTime>,
50}
51
52impl FileIdentity {
53 pub const NONE: Self = Self {
55 device: None,
56 inode: None,
57 changed: None,
58 };
59
60 #[cfg(unix)]
61 fn of(metadata: &Metadata) -> Self {
62 use std::os::unix::fs::MetadataExt;
63
64 let changed = u64::try_from(metadata.ctime())
65 .ok()
66 .zip(u32::try_from(metadata.ctime_nsec()).ok())
67 .and_then(|(secs, nanos)| {
68 SystemTime::UNIX_EPOCH.checked_add(Duration::new(secs, nanos))
69 });
70 Self {
71 device: Some(metadata.dev()),
72 inode: Some(metadata.ino()),
73 changed,
74 }
75 }
76
77 #[cfg(not(unix))]
78 fn of(_metadata: &Metadata) -> Self {
79 Self::NONE
80 }
81}
82
83impl Fingerprint {
84 fn stat_matches(&self, metadata: &Metadata) -> bool {
85 metadata.len() == self.len
86 && metadata.modified().ok() == self.modified
87 && FileIdentity::of(metadata) == self.identity
88 }
89
90 #[must_use]
94 pub fn is_racy(&self) -> bool {
95 let Some(modified) = self.modified else {
96 return true;
97 };
98 !self
99 .observed
100 .duration_since(modified)
101 .is_ok_and(|age| age >= RACY_WINDOW)
102 }
103}
104
105#[derive(Clone, Debug, PartialEq, Eq)]
124pub struct ExternalDependency {
125 pub path: PathBuf,
127 pub fingerprint: Option<Fingerprint>,
129}
130
131impl ExternalDependency {
132 #[must_use]
134 pub fn observe(path: impl Into<PathBuf>) -> Self {
135 let path = path.into();
136 let fingerprint = fingerprint_file(&path);
137 Self { path, fingerprint }
138 }
139
140 #[must_use]
150 pub fn is_current(&self) -> bool {
151 let Some(recorded) = &self.fingerprint else {
152 return regular_file_metadata(&self.path).is_none();
153 };
154 let Some(metadata) = regular_file_metadata(&self.path) else {
155 return false;
156 };
157 if recorded.stat_matches(&metadata) && !recorded.is_racy() {
158 return true;
159 }
160 fingerprint_file(&self.path).is_some_and(|now| now.content == recorded.content)
161 }
162}
163
164#[must_use]
175pub fn fingerprint_bytes(bytes: &[u8]) -> ContentHash {
176 let mut hasher = ContentHasher::new();
177 hasher.field(DOMAIN_TAG).field(bytes);
178 hasher.finish()
179}
180
181#[must_use]
185pub fn fingerprint_file(path: &Path) -> Option<Fingerprint> {
186 read_fingerprinted(path)
187 .ok()
188 .map(|(_, fingerprint)| fingerprint)
189}
190
191pub(crate) fn read_fingerprinted(path: &Path) -> io::Result<(Vec<u8>, Fingerprint)> {
196 let metadata = std::fs::metadata(path)?;
197 if !metadata.is_file() {
198 return Err(io::Error::new(
199 io::ErrorKind::InvalidInput,
200 "not a regular file",
201 ));
202 }
203 let observed = SystemTime::now();
204 let bytes = std::fs::read(path)?;
205 let fingerprint = Fingerprint {
206 len: metadata.len(),
207 modified: metadata.modified().ok(),
208 identity: FileIdentity::of(&metadata),
209 observed,
210 content: fingerprint_bytes(&bytes),
211 };
212 Ok((bytes, fingerprint))
213}
214
215fn regular_file_metadata(path: &Path) -> Option<Metadata> {
216 std::fs::metadata(path).ok().filter(Metadata::is_file)
217}
218
219#[derive(Debug, Default)]
222pub(crate) struct DependencySet {
223 entries: BTreeMap<PathBuf, Option<Fingerprint>>,
224}
225
226impl DependencySet {
227 pub(crate) fn record(&mut self, path: PathBuf, fingerprint: Option<Fingerprint>) {
228 self.entries.insert(path, fingerprint);
229 }
230
231 pub(crate) fn into_vec(self) -> Vec<ExternalDependency> {
232 self.entries
233 .into_iter()
234 .map(|(path, fingerprint)| ExternalDependency { path, fingerprint })
235 .collect()
236 }
237}
238
239impl From<Vec<ExternalDependency>> for DependencySet {
240 fn from(dependencies: Vec<ExternalDependency>) -> Self {
241 Self {
242 entries: dependencies
243 .into_iter()
244 .map(|dep| (dep.path, dep.fingerprint))
245 .collect(),
246 }
247 }
248}
249
250pub(crate) struct ExternalInputs<'a> {
253 pub(crate) source_file: &'a Path,
254 pub(crate) dependencies: &'a mut DependencySet,
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 fn unique_temp_file(name: &str) -> PathBuf {
262 let dir = std::env::temp_dir().join(format!(
263 "mos-eval-dependency-{name}-{}-{}",
264 std::process::id(),
265 SystemTime::now()
266 .duration_since(std::time::UNIX_EPOCH)
267 .map_or(0, |d| d.as_nanos())
268 ));
269 std::fs::create_dir_all(&dir).expect("temp dir");
270 dir.join("file.bin")
271 }
272
273 fn cleanup(path: &Path) {
274 std::fs::remove_dir_all(path.parent().expect("parent")).ok();
275 }
276
277 #[test]
278 fn missing_file_has_no_fingerprint_and_is_current_while_still_missing() {
279 let path = unique_temp_file("missing");
280 assert_eq!(fingerprint_file(&path), None);
281 let dep = ExternalDependency::observe(&path);
282 assert!(dep.is_current());
283 std::fs::write(&path, b"x").expect("write");
284 assert!(!dep.is_current(), "appearing counts as a change");
285 cleanup(&path);
286 }
287
288 #[test]
289 fn directory_is_never_read() {
290 let path = unique_temp_file("dir");
291 let dir = path.parent().expect("parent");
292 assert_eq!(fingerprint_file(dir), None);
293 assert!(ExternalDependency::observe(dir).is_current());
294 cleanup(&path);
295 }
296
297 #[test]
298 fn same_bytes_fingerprint_equal_and_different_bytes_diverge() {
299 let path = unique_temp_file("bytes");
300 std::fs::write(&path, b"one").expect("write");
301 assert_eq!(
302 fingerprint_file(&path).map(|f| f.content),
303 Some(fingerprint_bytes(b"one"))
304 );
305 let dep = ExternalDependency::observe(&path);
306 std::fs::write(&path, b"two").expect("write");
307 assert!(!dep.is_current());
308 std::fs::remove_file(&path).expect("remove");
309 assert!(!dep.is_current(), "disappearing counts as a change");
310 cleanup(&path);
311 }
312
313 #[test]
314 fn unchanged_contents_stay_current_when_only_the_stat_moved() {
315 let path = unique_temp_file("touch");
316 std::fs::write(&path, b"same").expect("write");
317 let dep = ExternalDependency::observe(&path);
318 let recorded = dep.fingerprint.expect("fingerprint");
319 let moved = ExternalDependency {
320 path: path.clone(),
321 fingerprint: Some(Fingerprint {
322 modified: Some(SystemTime::UNIX_EPOCH),
323 ..recorded
324 }),
325 };
326 assert!(moved.is_current(), "a stat mismatch falls back to the hash");
327 let stale = ExternalDependency {
328 path: path.clone(),
329 fingerprint: Some(Fingerprint {
330 modified: Some(SystemTime::UNIX_EPOCH),
331 content: fingerprint_bytes(b"other"),
332 ..recorded
333 }),
334 };
335 assert!(!stale.is_current());
336 cleanup(&path);
337 }
338
339 #[test]
340 fn a_fingerprint_taken_right_after_the_write_is_racy_and_always_hashed() {
341 let path = unique_temp_file("racy");
342 std::fs::write(&path, b"same").expect("write");
343 let recorded = fingerprint_file(&path).expect("fingerprint");
344 assert!(recorded.is_racy());
345
346 let lying = ExternalDependency {
347 path: path.clone(),
348 fingerprint: Some(Fingerprint {
349 content: fingerprint_bytes(b"other"),
350 ..recorded
351 }),
352 };
353 assert!(
354 !lying.is_current(),
355 "a matching stat inside the racy window is not trusted"
356 );
357
358 let settled = Fingerprint {
359 observed: recorded.observed + RACY_WINDOW * 2,
360 ..recorded
361 };
362 assert!(!settled.is_racy());
363 let trusted = ExternalDependency {
364 path: path.clone(),
365 fingerprint: Some(Fingerprint {
366 content: fingerprint_bytes(b"other"),
367 ..settled
368 }),
369 };
370 assert!(
371 trusted.is_current(),
372 "outside the racy window a matching stat is proof enough"
373 );
374 cleanup(&path);
375 }
376
377 #[test]
378 fn a_fingerprint_without_mtime_is_racy() {
379 let fingerprint = Fingerprint {
380 len: 0,
381 modified: None,
382 identity: FileIdentity::NONE,
383 observed: SystemTime::UNIX_EPOCH,
384 content: ContentHash(0),
385 };
386 assert!(fingerprint.is_racy());
387 }
388
389 #[cfg(unix)]
390 #[test]
391 fn same_size_replacement_that_restores_the_mtime_moves_the_identity() {
392 let path = unique_temp_file("identity");
393 std::fs::write(&path, b"aaaa").expect("write");
394 let recorded = fingerprint_file(&path).expect("fingerprint");
395 let settled = ExternalDependency {
396 path: path.clone(),
397 fingerprint: Some(Fingerprint {
398 observed: recorded.observed + RACY_WINDOW * 2,
399 ..recorded
400 }),
401 };
402 assert!(settled.is_current());
403
404 let staging = path.with_extension("new");
405 std::fs::write(&staging, b"bbbb").expect("write replacement");
406 std::fs::File::options()
407 .write(true)
408 .open(&staging)
409 .expect("open replacement")
410 .set_modified(recorded.modified.expect("mtime"))
411 .expect("restore mtime");
412 std::fs::rename(&staging, &path).expect("swap in");
413 let metadata = std::fs::metadata(&path).expect("stat");
414 assert_eq!(metadata.len(), recorded.len);
415 assert_eq!(metadata.modified().ok(), recorded.modified);
416 assert_ne!(FileIdentity::of(&metadata), recorded.identity);
417 assert!(
418 !settled.is_current(),
419 "a new inode falls through the stat gate to the hash"
420 );
421 cleanup(&path);
422 }
423
424 #[test]
425 fn dependency_set_dedupes_by_path_and_orders_deterministically() {
426 let fp = |n: u128| {
427 Some(Fingerprint {
428 len: 1,
429 modified: None,
430 identity: FileIdentity::NONE,
431 observed: SystemTime::UNIX_EPOCH,
432 content: ContentHash(n),
433 })
434 };
435 let mut set = DependencySet::default();
436 set.record(PathBuf::from("b"), None);
437 set.record(PathBuf::from("a"), fp(1));
438 set.record(PathBuf::from("b"), fp(2));
439 let deps = set.into_vec();
440 assert_eq!(
441 deps,
442 vec![
443 ExternalDependency {
444 path: PathBuf::from("a"),
445 fingerprint: fp(1),
446 },
447 ExternalDependency {
448 path: PathBuf::from("b"),
449 fingerprint: fp(2),
450 },
451 ]
452 );
453 assert_eq!(DependencySet::from(deps.clone()).into_vec(), deps);
454 }
455}