1use std::collections::BTreeMap;
6use std::fs::Metadata;
7use std::io;
8use std::path::{Path, PathBuf};
9use std::sync::{Arc, OnceLock};
10use std::time::{Duration, SystemTime};
11
12use mos_core::{ContentHash, ContentHasher};
13
14const DOMAIN_TAG: &[u8] = b"mos-eval/external-dependency/v1";
15
16pub const RACY_WINDOW: Duration = Duration::from_secs(2);
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub struct Fingerprint {
27 pub len: u64,
29 pub modified: Option<SystemTime>,
31 pub identity: FileIdentity,
33 pub observed: SystemTime,
35 pub content: ContentHash,
37}
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub struct FileIdentity {
45 pub device: Option<u64>,
47 pub inode: Option<u64>,
49 pub changed: Option<SystemTime>,
51}
52
53impl FileIdentity {
54 pub const NONE: Self = Self {
56 device: None,
57 inode: None,
58 changed: None,
59 };
60
61 #[cfg(unix)]
62 fn of(metadata: &Metadata) -> Self {
63 use std::os::unix::fs::MetadataExt;
64
65 let changed = u64::try_from(metadata.ctime())
66 .ok()
67 .zip(u32::try_from(metadata.ctime_nsec()).ok())
68 .and_then(|(secs, nanos)| {
69 SystemTime::UNIX_EPOCH.checked_add(Duration::new(secs, nanos))
70 });
71 Self {
72 device: Some(metadata.dev()),
73 inode: Some(metadata.ino()),
74 changed,
75 }
76 }
77
78 #[cfg(not(unix))]
79 fn of(_metadata: &Metadata) -> Self {
80 Self::NONE
81 }
82}
83
84impl Fingerprint {
85 fn stat_matches(&self, metadata: &Metadata) -> bool {
86 metadata.len() == self.len
87 && metadata.modified().ok() == self.modified
88 && FileIdentity::of(metadata) == self.identity
89 }
90
91 #[must_use]
95 pub fn is_racy(&self) -> bool {
96 let Some(modified) = self.modified else {
97 return true;
98 };
99 !self
100 .observed
101 .duration_since(modified)
102 .is_ok_and(|age| age >= RACY_WINDOW)
103 }
104}
105
106#[derive(Clone, Copy, Debug, PartialEq, Eq)]
108pub enum ResourceFingerprint {
109 File(Fingerprint),
111 Content(ContentHash),
113}
114
115impl ResourceFingerprint {
116 #[must_use]
118 pub const fn content(self) -> ContentHash {
119 match self {
120 Self::File(file) => file.content,
121 Self::Content(content) => content,
122 }
123 }
124}
125
126#[derive(Clone, Debug)]
128pub struct ResourceData {
129 bytes: Arc<[u8]>,
130 text_index: OnceLock<Option<mos_core::LineIndex>>,
131 fingerprint: ResourceFingerprint,
132}
133
134impl ResourceData {
135 #[must_use]
137 pub fn new(bytes: impl Into<Arc<[u8]>>) -> Self {
138 let bytes = bytes.into();
139 let fingerprint = ResourceFingerprint::Content(fingerprint_bytes(&bytes));
140 Self {
141 bytes,
142 fingerprint,
143 text_index: OnceLock::new(),
144 }
145 }
146}
147
148pub trait ResourceReader {
154 fn read(&self, path: &Path) -> io::Result<ResourceData>;
162}
163
164impl<F> ResourceReader for F
165where
166 F: Fn(&Path) -> io::Result<ResourceData>,
167{
168 fn read(&self, path: &Path) -> io::Result<ResourceData> {
169 self(path)
170 }
171}
172
173#[derive(Clone, Copy, Debug, Default)]
175pub struct FileSystemReader;
176
177impl ResourceReader for FileSystemReader {
178 fn read(&self, path: &Path) -> io::Result<ResourceData> {
179 let (bytes, fingerprint) = read_fingerprinted(path)?;
180 Ok(ResourceData {
181 bytes: bytes.into(),
182 text_index: OnceLock::new(),
183 fingerprint: ResourceFingerprint::File(fingerprint),
184 })
185 }
186}
187
188#[derive(Debug, Default)]
194pub struct ResourceSnapshot {
195 entries: BTreeMap<PathBuf, io::Result<ResourceData>>,
196}
197
198impl ResourceSnapshot {
199 #[must_use]
203 pub fn text_index(&self, path: &Path) -> Option<&mos_core::LineIndex> {
204 let data = self.entries.get(path)?.as_ref().ok()?;
205 data.text_index
206 .get_or_init(|| {
207 std::str::from_utf8(&data.bytes)
208 .ok()
209 .map(mos_core::LineIndex::new)
210 })
211 .as_ref()
212 }
213
214 #[must_use]
216 pub fn get(&self, path: &Path) -> Option<Result<&[u8], &io::Error>> {
217 self.entries
218 .get(path)
219 .map(|result| result.as_ref().map(|data| data.bytes.as_ref()))
220 }
221
222 pub(crate) fn read(
223 &mut self,
224 reader: &dyn ResourceReader,
225 path: &Path,
226 ) -> Result<&[u8], &io::Error> {
227 self.entries
228 .entry(path.to_path_buf())
229 .or_insert_with(|| reader.read(path))
230 .as_ref()
231 .map(|data| data.bytes.as_ref())
232 }
233
234 pub(crate) fn dependencies(&self) -> Vec<ExternalDependency> {
235 self.entries
236 .iter()
237 .map(|(path, result)| ExternalDependency {
238 path: path.clone(),
239 fingerprint: result.as_ref().ok().map(|data| data.fingerprint),
240 })
241 .collect()
242 }
243}
244
245#[derive(Clone, Debug, PartialEq, Eq)]
263pub struct ExternalDependency {
264 pub path: PathBuf,
266 pub fingerprint: Option<ResourceFingerprint>,
268}
269
270impl ExternalDependency {
271 #[must_use]
273 pub fn observe(path: impl Into<PathBuf>) -> Self {
274 let path = path.into();
275 let fingerprint = fingerprint_file(&path).map(ResourceFingerprint::File);
276 Self { path, fingerprint }
277 }
278
279 #[must_use]
291 pub fn is_current(&self) -> bool {
292 let Some(recorded) = &self.fingerprint else {
293 return regular_file_metadata(&self.path).is_none();
294 };
295 let ResourceFingerprint::File(recorded) = recorded else {
296 return false;
297 };
298 let Some(metadata) = regular_file_metadata(&self.path) else {
299 return false;
300 };
301 if recorded.stat_matches(&metadata) && !recorded.is_racy() {
302 return true;
303 }
304 fingerprint_file(&self.path).is_some_and(|now| now.content == recorded.content)
305 }
306}
307
308#[must_use]
319pub fn fingerprint_bytes(bytes: &[u8]) -> ContentHash {
320 let mut hasher = ContentHasher::new();
321 hasher.field(DOMAIN_TAG).field(bytes);
322 hasher.finish()
323}
324
325#[must_use]
329pub fn fingerprint_file(path: &Path) -> Option<Fingerprint> {
330 read_fingerprinted(path)
331 .ok()
332 .map(|(_, fingerprint)| fingerprint)
333}
334
335pub(crate) fn read_fingerprinted(path: &Path) -> io::Result<(Vec<u8>, Fingerprint)> {
340 let metadata = std::fs::metadata(path)?;
341 if !metadata.is_file() {
342 return Err(io::Error::new(
343 io::ErrorKind::InvalidInput,
344 "not a regular file",
345 ));
346 }
347 let observed = SystemTime::now();
348 let bytes = std::fs::read(path)?;
349 let fingerprint = Fingerprint {
350 len: metadata.len(),
351 modified: metadata.modified().ok(),
352 identity: FileIdentity::of(&metadata),
353 observed,
354 content: fingerprint_bytes(&bytes),
355 };
356 Ok((bytes, fingerprint))
357}
358
359fn regular_file_metadata(path: &Path) -> Option<Metadata> {
360 std::fs::metadata(path).ok().filter(Metadata::is_file)
361}
362
363pub(crate) struct ExternalInputs<'a> {
365 pub(crate) source_file: &'a Path,
366 pub(crate) reader: &'a dyn ResourceReader,
367 pub(crate) resources: &'a mut ResourceSnapshot,
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 fn unique_temp_file(name: &str) -> PathBuf {
375 let dir = std::env::temp_dir().join(format!(
376 "mos-eval-dependency-{name}-{}-{}",
377 std::process::id(),
378 SystemTime::now()
379 .duration_since(std::time::UNIX_EPOCH)
380 .map_or(0, |d| d.as_nanos())
381 ));
382 std::fs::create_dir_all(&dir).expect("temp dir");
383 dir.join("file.bin")
384 }
385
386 fn cleanup(path: &Path) {
387 std::fs::remove_dir_all(path.parent().expect("parent")).ok();
388 }
389
390 #[test]
391 fn captured_text_index_is_lazy_reused_and_revision_bound() {
392 let path = Path::new("/virtual/refs.bib");
393 let mut snapshot = ResourceSnapshot::default();
394 let reader = |_: &Path| Ok(ResourceData::new("😀\nold".as_bytes()));
395 assert!(snapshot.read(&reader, path).is_ok());
396 let first = snapshot.text_index(path).expect("UTF-8 resource");
397 let second = snapshot.text_index(path).expect("same captured resource");
398 assert!(std::ptr::eq(first, second));
399 assert_eq!(first.utf16_position(5), (1, 0));
400 let mut next = ResourceSnapshot::default();
401 let changed = |_: &Path| Ok(ResourceData::new(b"new".as_slice()));
402 assert!(next.read(&changed, path).is_ok());
403 assert_eq!(
404 next.text_index(path).map(mos_core::LineIndex::text),
405 Some("new")
406 );
407 assert_eq!(
408 snapshot.text_index(path).map(mos_core::LineIndex::text),
409 Some("😀\nold")
410 );
411 let invalid = Path::new("/virtual/image");
412 assert!(
413 snapshot
414 .read(
415 &|_: &Path| Ok(ResourceData::new([0xff].as_slice())),
416 invalid
417 )
418 .is_ok()
419 );
420 assert!(snapshot.text_index(invalid).is_none());
421 assert!(snapshot.text_index(Path::new("/virtual/absent")).is_none());
422 }
423
424 #[test]
425 fn missing_file_has_no_fingerprint_and_is_current_while_still_missing() {
426 let path = unique_temp_file("missing");
427 assert_eq!(fingerprint_file(&path), None);
428 let dep = ExternalDependency::observe(&path);
429 assert!(dep.is_current());
430 std::fs::write(&path, b"x").expect("write");
431 assert!(!dep.is_current(), "appearing counts as a change");
432 cleanup(&path);
433 }
434
435 #[test]
436 fn directory_is_never_read() {
437 let path = unique_temp_file("dir");
438 let dir = path.parent().expect("parent");
439 assert_eq!(fingerprint_file(dir), None);
440 assert!(ExternalDependency::observe(dir).is_current());
441 cleanup(&path);
442 }
443
444 #[test]
445 fn same_bytes_fingerprint_equal_and_different_bytes_diverge() {
446 let path = unique_temp_file("bytes");
447 std::fs::write(&path, b"one").expect("write");
448 assert_eq!(
449 fingerprint_file(&path).map(|f| f.content),
450 Some(fingerprint_bytes(b"one"))
451 );
452 let dep = ExternalDependency::observe(&path);
453 std::fs::write(&path, b"two").expect("write");
454 assert!(!dep.is_current());
455 std::fs::remove_file(&path).expect("remove");
456 assert!(!dep.is_current(), "disappearing counts as a change");
457 cleanup(&path);
458 }
459
460 #[test]
461 fn unchanged_contents_stay_current_when_only_the_stat_moved() {
462 let path = unique_temp_file("touch");
463 std::fs::write(&path, b"same").expect("write");
464 let recorded = fingerprint_file(&path).expect("fingerprint");
465 let moved = ExternalDependency {
466 path: path.clone(),
467 fingerprint: Some(ResourceFingerprint::File(Fingerprint {
468 modified: Some(SystemTime::UNIX_EPOCH),
469 ..recorded
470 })),
471 };
472 assert!(moved.is_current(), "a stat mismatch falls back to the hash");
473 let stale = ExternalDependency {
474 path: path.clone(),
475 fingerprint: Some(ResourceFingerprint::File(Fingerprint {
476 modified: Some(SystemTime::UNIX_EPOCH),
477 content: fingerprint_bytes(b"other"),
478 ..recorded
479 })),
480 };
481 assert!(!stale.is_current());
482 cleanup(&path);
483 }
484
485 #[test]
486 fn a_fingerprint_taken_right_after_the_write_is_racy_and_always_hashed() {
487 let path = unique_temp_file("racy");
488 std::fs::write(&path, b"same").expect("write");
489 let recorded = fingerprint_file(&path).expect("fingerprint");
490 assert!(recorded.is_racy());
491
492 let lying = ExternalDependency {
493 path: path.clone(),
494 fingerprint: Some(ResourceFingerprint::File(Fingerprint {
495 content: fingerprint_bytes(b"other"),
496 ..recorded
497 })),
498 };
499 assert!(
500 !lying.is_current(),
501 "a matching stat inside the racy window is not trusted"
502 );
503
504 let settled = Fingerprint {
505 observed: recorded.observed + RACY_WINDOW * 2,
506 ..recorded
507 };
508 assert!(!settled.is_racy());
509 let trusted = ExternalDependency {
510 path: path.clone(),
511 fingerprint: Some(ResourceFingerprint::File(Fingerprint {
512 content: fingerprint_bytes(b"other"),
513 ..settled
514 })),
515 };
516 assert!(
517 trusted.is_current(),
518 "outside the racy window a matching stat is proof enough"
519 );
520 cleanup(&path);
521 }
522
523 #[test]
524 fn a_fingerprint_without_mtime_is_racy() {
525 let fingerprint = Fingerprint {
526 len: 0,
527 modified: None,
528 identity: FileIdentity::NONE,
529 observed: SystemTime::UNIX_EPOCH,
530 content: ContentHash(0),
531 };
532 assert!(fingerprint.is_racy());
533 }
534
535 #[cfg(unix)]
536 #[test]
537 fn same_size_replacement_that_restores_the_mtime_moves_the_identity() {
538 let path = unique_temp_file("identity");
539 std::fs::write(&path, b"aaaa").expect("write");
540 let recorded = fingerprint_file(&path).expect("fingerprint");
541 let settled = ExternalDependency {
542 path: path.clone(),
543 fingerprint: Some(ResourceFingerprint::File(Fingerprint {
544 observed: recorded.observed + RACY_WINDOW * 2,
545 ..recorded
546 })),
547 };
548 assert!(settled.is_current());
549
550 let staging = path.with_extension("new");
551 std::fs::write(&staging, b"bbbb").expect("write replacement");
552 std::fs::File::options()
553 .write(true)
554 .open(&staging)
555 .expect("open replacement")
556 .set_modified(recorded.modified.expect("mtime"))
557 .expect("restore mtime");
558 std::fs::rename(&staging, &path).expect("swap in");
559 let metadata = std::fs::metadata(&path).expect("stat");
560 assert_eq!(metadata.len(), recorded.len);
561 assert_eq!(metadata.modified().ok(), recorded.modified);
562 assert_ne!(FileIdentity::of(&metadata), recorded.identity);
563 assert!(
564 !settled.is_current(),
565 "a new inode falls through the stat gate to the hash"
566 );
567 cleanup(&path);
568 }
569}