Skip to main content

mos_eval/
dependency.rs

1//! External files a lowering read, with the fingerprint each had at the
2//! time, so a cached [`LowerResult`](crate::LowerResult) can be checked
3//! against the filesystem before it is reused (issue #125).
4
5use 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
15/// A modification time this close to the moment the fingerprint was taken is
16/// not trusted by [`ExternalDependency::is_current`]: a second write inside
17/// the same filesystem timestamp tick would leave `stat` unchanged. Two
18/// seconds covers FAT's timestamp resolution.
19pub const RACY_WINDOW: Duration = Duration::from_secs(2);
20
21/// What a regular file looked like when it was read: its `stat` fields, the
22/// moment that `stat` was taken, and a [`fingerprint_bytes`] hash of its
23/// contents.
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub struct Fingerprint {
26    /// File size in bytes.
27    pub len: u64,
28    /// Modification time, when the platform reports one.
29    pub modified: Option<SystemTime>,
30    /// Inode-level identity; all fields are `None` off Unix.
31    pub identity: FileIdentity,
32    /// When the `stat` was taken.
33    pub observed: SystemTime,
34    /// Hash of the bytes that were read.
35    pub content: ContentHash,
36}
37
38/// The device and inode numbers and the status-change time of a file on
39/// Unix. Unlike the modification time, none of these can be set from
40/// userspace, so a rewrite that restores the old mtime still moves one of
41/// them. Every field is `None` on platforms that do not expose them.
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub struct FileIdentity {
44    /// Device number.
45    pub device: Option<u64>,
46    /// Inode number.
47    pub inode: Option<u64>,
48    /// Status-change time (`ctime`), when it is representable.
49    pub changed: Option<SystemTime>,
50}
51
52impl FileIdentity {
53    /// The identity of a file on a platform that exposes none of the fields.
54    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    /// Whether the file was modified within [`RACY_WINDOW`] of the moment the
91    /// fingerprint was taken, or has no modification time at all, so a
92    /// matching `stat` is not proof that the contents are unchanged.
93    #[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/// One external file a lowering depended on: an `#image` / `#figure` raster or
106/// a `#bibliography` source.
107///
108/// `fingerprint` is `None` when the path was not a readable regular file at
109/// lowering time, so a file that later appears is as much a change as one
110/// that is edited.
111///
112/// # Examples
113///
114/// ```
115/// use std::path::Path;
116///
117/// use mos_eval::ExternalDependency;
118///
119/// let dep = ExternalDependency::observe(Path::new("/nonexistent/x.png"));
120/// assert_eq!(dep.fingerprint, None);
121/// assert!(dep.is_current());
122/// ```
123#[derive(Clone, Debug, PartialEq, Eq)]
124pub struct ExternalDependency {
125    /// The resolved filesystem path that was read.
126    pub path: PathBuf,
127    /// The file's [`Fingerprint`], or `None` when it could not be read.
128    pub fingerprint: Option<Fingerprint>,
129}
130
131impl ExternalDependency {
132    /// Record `path` with the fingerprint it has right now.
133    #[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    /// Whether the file on disk still matches the recorded fingerprint.
141    ///
142    /// A `stat` settles the common cases: a file whose size, modification
143    /// time, and [`FileIdentity`] are unchanged is current, and a file that
144    /// appeared or vanished is not. The bytes are re-read and hashed only when
145    /// the `stat` differs or the fingerprint [is racy](Fingerprint::is_racy),
146    /// so a `touch` that leaves the contents alone still counts as current,
147    /// while a same-size rewrite that restores the old mtime is caught by the
148    /// inode identity on Unix and by the racy window everywhere.
149    #[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/// Fingerprint raw file bytes for dependency comparison.
165///
166/// # Examples
167///
168/// ```
169/// use mos_eval::fingerprint_bytes;
170///
171/// assert_eq!(fingerprint_bytes(b"a"), fingerprint_bytes(b"a"));
172/// assert_ne!(fingerprint_bytes(b"a"), fingerprint_bytes(b"b"));
173/// ```
174#[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/// The [`Fingerprint`] of the regular file at `path`, or `None` when there is
182/// no readable regular file there. Directories, devices, and pipes are never
183/// opened.
184#[must_use]
185pub fn fingerprint_file(path: &Path) -> Option<Fingerprint> {
186    read_fingerprinted(path)
187        .ok()
188        .map(|(_, fingerprint)| fingerprint)
189}
190
191/// Read the regular file at `path` and fingerprint it in one pass. The
192/// `stat` is taken before the read, so a write that lands between the two
193/// shows up as a changed `stat` on the next [`ExternalDependency::is_current`]
194/// and forces a hash comparison.
195pub(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/// The dependencies observed so far while lowering one document, keyed by
220/// path so a file read twice is recorded once.
221#[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
250/// What a directive lowerer needs to touch the filesystem: the `.mos` file
251/// paths resolve against, and the set that records every file it reads.
252pub(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}