Skip to main content

deps_core/
mtime_cache.rs

1//! Generic per-path, mtime-gated file cache shared by every ecosystem config-file cache
2//! (`deps-cargo`'s `.cargo/config.toml`/`$CARGO_HOME/config.toml`, `deps-npm`'s `.npmrc`).
3//!
4//! [`MtimeFileCache<T>`] caches **raw, unvalidated** parse results, keyed by file path and
5//! invalidated by mtime — validation, environment-variable expansion, and policy gating are
6//! expected to run per call against the cached raw value, never cached themselves, so a
7//! `didChangeConfiguration` policy change or an environment-variable change takes effect
8//! immediately with no cache invalidation of its own. That split is the caller's
9//! responsibility: this cache only knows how to keep one path's `T` fresh.
10
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13use std::time::SystemTime;
14
15use crate::fs_probe;
16
17/// Upper bound on a [`MtimeFileCache`]'s entry count.
18///
19/// Generous for any realistic project tree, since it is bounded by the number of *distinct*
20/// config files a workspace's ancestor walk can find, not by the number of manifests sharing
21/// them.
22pub const DEFAULT_MAX_CACHED_FILES: usize = 256;
23
24/// Maximum file size [`MtimeFileCache::get_or_parse`] reads before parsing.
25///
26/// Enforced twice: a cheap [`std::fs::Metadata::len`] pre-filter rejects an obviously oversized
27/// file before it is even opened, and [`crate::fs_probe::read_to_string_capped`] then bounds
28/// the actual read itself, so an oversized file (e.g. a crafted `pnpm-workspace.yaml` in a
29/// cloned repository) never reaches `YamlLoader` or any content-based guard like
30/// [`crate::check_yaml_nesting_depth`]/[`crate::check_yaml_expansion`] — those guards run on
31/// content already read into memory, so they cannot bound the read itself. The capped read is
32/// what actually closes the gap: the `stat` pre-filter alone would leave a TOCTOU window where
33/// a symlink swap or concurrent growth between the `stat` and the read could let an oversized
34/// file's content through. 8 MiB matches [`crate::cache`]'s `MAX_CACHEABLE_ENTRY_BYTES` order
35/// of magnitude and is generous for a real config file (`.cargo/config.toml`, `.npmrc`,
36/// `pnpm-workspace.yaml`), which are typically a few KB.
37pub const MAX_CACHED_FILE_BYTES: u64 = 8 * 1024 * 1024;
38
39/// One cached file's mtime plus its parsed value. The mtime lives here, not on `T`, so `T`
40/// stays exactly the caller's existing parsed-value type with no cache-specific field to
41/// strip at every call site.
42struct CacheEntry<T> {
43    mtime: SystemTime,
44    value: Arc<T>,
45}
46
47/// Per-path memoization of a file's parsed contents, invalidated by mtime.
48///
49/// Caches whatever `parse` produces for a file's raw content — validation, expansion, and
50/// policy checks are expected to run per call on the returned value, never cached. Absence
51/// is never cached: only a file that existed, was a regular file, and parsed successfully
52/// gets an entry, so a file created after the cache first found nothing is picked up on the
53/// very next call with no extra bookkeeping.
54pub struct MtimeFileCache<T> {
55    files: dashmap::DashMap<PathBuf, CacheEntry<T>>,
56    /// Last mtime an oversized path was warned about, so [`Self::get_or_parse`] logs at most
57    /// once per distinct file version rather than once per call — an oversized file is never
58    /// cached in `files`, so without this every hover/completion/diagnostic pass touching it
59    /// would otherwise re-emit the same warning (impl-critic S2). Bounded by `capacity`, same
60    /// as `files`: once full, a repeat warning is simply not deduped rather than growing this
61    /// map without limit.
62    oversize_warned: dashmap::DashMap<PathBuf, SystemTime>,
63    capacity: usize,
64    label: &'static str,
65}
66
67impl<T> std::fmt::Debug for MtimeFileCache<T> {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        f.debug_struct("MtimeFileCache")
70            .field("label", &self.label)
71            .field("capacity", &self.capacity)
72            .field("len", &self.files.len())
73            .finish_non_exhaustive()
74    }
75}
76
77impl<T> MtimeFileCache<T> {
78    /// Creates an empty cache holding at most `capacity` entries, labeled `label` for
79    /// diagnostics (e.g. `"cargo config"`, `"npm config"`) when the capacity is reached.
80    #[must_use]
81    pub fn new(capacity: usize, label: &'static str) -> Self {
82        Self {
83            files: dashmap::DashMap::new(),
84            oversize_warned: dashmap::DashMap::new(),
85            capacity,
86            label,
87        }
88    }
89
90    /// Logs the "oversized, not caching" warning at most once per distinct `mtime`, deduping
91    /// via [`Self::oversize_warned`] exactly like the pre-existing stat-based rejection path.
92    /// `len` is the known content length when available (the cheap stat pre-filter has it);
93    /// the capped-read rejection path does not know the exact length without reading the
94    /// whole oversized file, so it passes `None` and the length is simply omitted from the log.
95    fn warn_oversized_once(&self, path: &Path, mtime: Option<SystemTime>, len: Option<u64>) {
96        let already_warned = mtime.is_some_and(|mtime| {
97            self.oversize_warned
98                .get(path)
99                .is_some_and(|warned| *warned == mtime)
100        });
101        if already_warned {
102            return;
103        }
104        tracing::warn!(
105            path = %path.display(),
106            label = self.label,
107            len,
108            cap = MAX_CACHED_FILE_BYTES,
109            "file exceeds mtime file cache size cap; not reading"
110        );
111        if let Some(mtime) = mtime
112            && self.oversize_warned.len() < self.capacity
113        {
114            self.oversize_warned.insert(path.to_path_buf(), mtime);
115        }
116    }
117
118    /// Returns `path`'s parsed contents, from cache if `path`'s mtime is unchanged, else
119    /// re-reading and re-parsing with `parse`. `None` if `path` does not exist, is not a
120    /// regular file, exceeds [`MAX_CACHED_FILE_BYTES`], or cannot be read.
121    ///
122    /// Rejects anything but a regular file (a FIFO, socket, character device, or directory) as
123    /// observed at `stat` time — reading one of those can block the calling thread
124    /// indefinitely, and [`std::fs::metadata`] follows symlinks, so a symlinked regular file
125    /// still resolves. This check is necessarily a point-in-time observation, not a guarantee
126    /// about what [`crate::fs_probe::read_to_string_capped`] will see: a symlink that resolved
127    /// to a regular file at `stat` time can still be swapped to a FIFO before the subsequent
128    /// `open` (the same TOCTOU class as the size check below), which would still block on
129    /// open. Fixing that blocking-open race is out of scope here (it needs `O_NONBLOCK` or
130    /// equivalent); this doc only avoids overclaiming that the gate rules it out.
131    ///
132    /// A file over [`MAX_CACHED_FILE_BYTES`] never reaches `parse` — every content-based
133    /// safety guard (nesting-depth, expansion) only sees content already read into memory, so
134    /// it cannot bound the read itself. This is enforced twice: the `stat` result is checked
135    /// first as a cheap pre-filter (skips opening an obviously huge file), and
136    /// [`crate::fs_probe::read_to_string_capped`] then bounds the read itself regardless of
137    /// what the `stat` reported — so a symlink swap or concurrent growth between the `stat`
138    /// and the read cannot let an oversized file's content slip through (CWE-367).
139    ///
140    /// Always performs one `stat` (the mtime check) — that cost is unavoidable and paid on
141    /// every call, cache hit or not — but reads and parses the file's *content* only on a
142    /// miss. Compares mtime with `!=`, not `>`: a `git checkout` that restores an older file
143    /// moves the mtime backwards, and `>` would then keep serving the stale cached entry.
144    ///
145    /// # Examples
146    ///
147    /// ```
148    /// use deps_core::mtime_cache::{DEFAULT_MAX_CACHED_FILES, MtimeFileCache};
149    /// use std::sync::Arc;
150    ///
151    /// let dir = tempfile::tempdir().unwrap();
152    /// let path = dir.path().join("config.toml");
153    /// std::fs::write(&path, "value = 1").unwrap();
154    ///
155    /// let cache: MtimeFileCache<String> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "example");
156    /// let first = cache.get_or_parse(&path, str::to_owned).unwrap();
157    /// let second = cache.get_or_parse(&path, str::to_owned).unwrap();
158    /// assert!(Arc::ptr_eq(&first, &second), "an unchanged file is served from cache");
159    /// ```
160    pub fn get_or_parse(&self, path: &Path, parse: impl FnOnce(&str) -> T) -> Option<Arc<T>> {
161        let metadata = fs_probe::metadata(path).ok()?;
162        if !metadata.is_file() {
163            return None;
164        }
165        if metadata.len() > MAX_CACHED_FILE_BYTES {
166            self.warn_oversized_once(path, metadata.modified().ok(), Some(metadata.len()));
167            return None;
168        }
169        let mtime = metadata.modified().ok()?;
170
171        if let Some(existing) = self.files.get(path)
172            && existing.mtime == mtime
173        {
174            return Some(Arc::clone(&existing.value));
175        }
176
177        let content = match fs_probe::read_to_string_capped(path, MAX_CACHED_FILE_BYTES).ok()? {
178            Some(content) => content,
179            None => {
180                // The stat-based pre-filter above passed, but the read itself still hit the
181                // cap — a symlink swap or concurrent growth between the two calls (CWE-367).
182                // Same outward behavior as the stat-based rejection: not cached, warned once.
183                self.warn_oversized_once(path, Some(mtime), None);
184                return None;
185            }
186        };
187        let value = Arc::new(parse(&content));
188
189        if !self.files.contains_key(path) && self.files.len() >= self.capacity {
190            tracing::warn!(
191                path = %path.display(),
192                label = self.label,
193                cap = self.capacity,
194                "mtime file cache capacity reached; not caching this file (still used for this parse)"
195            );
196            return Some(value);
197        }
198        self.files.insert(
199            path.to_path_buf(),
200            CacheEntry {
201                mtime,
202                value: Arc::clone(&value),
203            },
204        );
205        Some(value)
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[derive(Debug, PartialEq, Eq)]
214    struct Parsed(String);
215
216    fn parse_upper(content: &str) -> Parsed {
217        Parsed(content.trim().to_uppercase())
218    }
219
220    #[test]
221    fn hit_reuses_same_arc_without_reparsing() {
222        let dir = tempfile::tempdir().unwrap();
223        let path = dir.path().join("file.txt");
224        std::fs::write(&path, "hello").unwrap();
225
226        let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
227        let first = cache.get_or_parse(&path, parse_upper).unwrap();
228        let second = cache.get_or_parse(&path, parse_upper).unwrap();
229
230        assert!(
231            Arc::ptr_eq(&first, &second),
232            "a cache hit must return the same Arc, not re-parse"
233        );
234    }
235
236    #[test]
237    fn hit_does_zero_reads_and_exactly_one_stat() {
238        let dir = tempfile::tempdir().unwrap();
239        let path = dir.path().join("file.txt");
240        std::fs::write(&path, "hello").unwrap();
241
242        let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
243        cache.get_or_parse(&path, parse_upper).unwrap();
244
245        let (stats_before, reads_before) = fs_probe::snapshot();
246        let hit = cache.get_or_parse(&path, parse_upper).unwrap();
247        let (stats_after, reads_after) = fs_probe::snapshot();
248
249        assert_eq!(
250            reads_after - reads_before,
251            0,
252            "a cache hit must perform zero content reads"
253        );
254        assert_eq!(
255            stats_after - stats_before,
256            1,
257            "a cache hit still pays exactly one mtime stat"
258        );
259        assert_eq!(hit.0, "HELLO");
260    }
261
262    #[test]
263    fn forward_mtime_bump_invalidates() {
264        let dir = tempfile::tempdir().unwrap();
265        let path = dir.path().join("file.txt");
266        std::fs::write(&path, "one").unwrap();
267
268        let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
269        let first = cache.get_or_parse(&path, parse_upper).unwrap();
270
271        // Ensure a distinguishable mtime on filesystems with coarse timestamp resolution.
272        let future = SystemTime::now() + std::time::Duration::from_secs(2);
273        std::fs::write(&path, "two").unwrap();
274        // `File::open` is read-only, which lacks `FILE_WRITE_ATTRIBUTES` on Windows and makes
275        // `set_modified` fail with `PermissionDenied`; open for write instead.
276        std::fs::OpenOptions::new()
277            .write(true)
278            .open(&path)
279            .unwrap()
280            .set_modified(future)
281            .unwrap();
282
283        let second = cache.get_or_parse(&path, parse_upper).unwrap();
284        assert!(
285            !Arc::ptr_eq(&first, &second),
286            "an mtime bump must invalidate the cache entry"
287        );
288        assert_eq!(second.0, "TWO");
289    }
290
291    /// A `git checkout` restoring an older file moves the mtime *backwards* — invalidation
292    /// must compare with `!=`, not `>`.
293    #[test]
294    fn backward_mtime_move_invalidates() {
295        let dir = tempfile::tempdir().unwrap();
296        let path = dir.path().join("file.txt");
297        std::fs::write(&path, "one").unwrap();
298        let future = SystemTime::now() + std::time::Duration::from_secs(10);
299        std::fs::OpenOptions::new()
300            .write(true)
301            .open(&path)
302            .unwrap()
303            .set_modified(future)
304            .unwrap();
305
306        let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
307        let first = cache.get_or_parse(&path, parse_upper).unwrap();
308
309        std::fs::write(&path, "two").unwrap();
310        let past = SystemTime::now();
311        std::fs::OpenOptions::new()
312            .write(true)
313            .open(&path)
314            .unwrap()
315            .set_modified(past)
316            .unwrap();
317
318        let second = cache.get_or_parse(&path, parse_upper).unwrap();
319        assert!(
320            !Arc::ptr_eq(&first, &second),
321            "a backwards mtime move must still invalidate the cache entry"
322        );
323    }
324
325    #[test]
326    fn missing_path_returns_none() {
327        let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
328        assert!(
329            cache
330                .get_or_parse(Path::new("/nonexistent/path/file.txt"), parse_upper)
331                .is_none()
332        );
333    }
334
335    /// Decision: a directory at the candidate path must never be treated as cacheable
336    /// content — reading it would fail, but the `is_file` gate rejects it before that read is
337    /// even attempted.
338    #[test]
339    fn directory_path_returns_none() {
340        let dir = tempfile::tempdir().unwrap();
341        let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
342        assert!(cache.get_or_parse(dir.path(), parse_upper).is_none());
343    }
344
345    /// A file over [`MAX_CACHED_FILE_BYTES`] must never reach `parse` at all — rejected here by
346    /// the cheap `stat` pre-filter. `fs_probe::tests::read_to_string_capped_rejects_content_over_cap`
347    /// proves the same bound holds on the read itself, independent of any `stat` result —
348    /// that is what closes the TOCTOU gap (CWE-367) a stat-only check would leave open.
349    #[test]
350    fn oversized_file_returns_none_without_reading_content() {
351        let dir = tempfile::tempdir().unwrap();
352        let path = dir.path().join("huge.txt");
353        let file = std::fs::File::create(&path).unwrap();
354        file.set_len(MAX_CACHED_FILE_BYTES + 1).unwrap();
355        drop(file);
356
357        let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
358        let parse_calls = std::cell::Cell::new(0);
359        let result = cache.get_or_parse(&path, |content| {
360            parse_calls.set(parse_calls.get() + 1);
361            parse_upper(content)
362        });
363
364        assert!(result.is_none());
365        assert_eq!(
366            parse_calls.get(),
367            0,
368            "parse must not run on an oversized file"
369        );
370    }
371
372    /// A file exactly at [`MAX_CACHED_FILE_BYTES`] must still be cached — the capped read
373    /// reads one byte past the cap to detect an overage, and an off-by-one there would
374    /// falsely reject a file that lands exactly on the boundary.
375    #[test]
376    fn file_exactly_at_cap_is_still_cached() {
377        let dir = tempfile::tempdir().unwrap();
378        let path = dir.path().join("exact.txt");
379        let content = "a".repeat(MAX_CACHED_FILE_BYTES as usize);
380        std::fs::write(&path, &content).unwrap();
381
382        let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
383        let result = cache.get_or_parse(&path, parse_upper);
384
385        assert!(result.is_some(), "a file exactly at the cap must be cached");
386    }
387
388    /// Impl-critic S2 regression: an oversized file must warn at most once per distinct
389    /// mtime, not once per call — an oversized file is never memoized in `files`, so without
390    /// this every hover/completion/diagnostic pass touching it would re-emit the warning.
391    #[test]
392    fn oversized_file_warns_once_per_mtime_not_once_per_call() {
393        let dir = tempfile::tempdir().unwrap();
394        let path = dir.path().join("huge.txt");
395        let file = std::fs::File::create(&path).unwrap();
396        file.set_len(MAX_CACHED_FILE_BYTES + 1).unwrap();
397        drop(file);
398
399        let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
400
401        let log = crate::test_util::capture_tracing_output(|| {
402            assert!(cache.get_or_parse(&path, parse_upper).is_none());
403            assert!(cache.get_or_parse(&path, parse_upper).is_none());
404            assert!(cache.get_or_parse(&path, parse_upper).is_none());
405        });
406
407        assert_eq!(
408            log.matches("file exceeds mtime file cache size cap")
409                .count(),
410            1,
411            "expected exactly one warning across three calls with an unchanged mtime: {log}"
412        );
413    }
414
415    /// A file that changes (still oversized) after already being warned about must warn
416    /// again — the dedup is per file *version*, not a one-time-ever suppression.
417    #[test]
418    fn oversized_file_warns_again_after_mtime_changes() {
419        let dir = tempfile::tempdir().unwrap();
420        let path = dir.path().join("huge.txt");
421        let file = std::fs::File::create(&path).unwrap();
422        file.set_len(MAX_CACHED_FILE_BYTES + 1).unwrap();
423        drop(file);
424
425        let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
426
427        let log = crate::test_util::capture_tracing_output(|| {
428            assert!(cache.get_or_parse(&path, parse_upper).is_none());
429
430            // Ensure a distinguishable mtime on filesystems with coarse timestamp resolution.
431            let future = SystemTime::now() + std::time::Duration::from_secs(2);
432            let file = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
433            file.set_len(MAX_CACHED_FILE_BYTES + 2).unwrap();
434            file.set_modified(future).unwrap();
435            drop(file);
436
437            assert!(cache.get_or_parse(&path, parse_upper).is_none());
438        });
439
440        assert_eq!(
441            log.matches("file exceeds mtime file cache size cap")
442                .count(),
443            2,
444            "expected a fresh warning after the file's mtime changed: {log}"
445        );
446    }
447
448    /// At capacity, a freshly parsed value is still returned to the caller but not inserted
449    /// into the map — the parse succeeds, only memoization is declined.
450    #[test]
451    fn capacity_cap_returns_value_without_inserting() {
452        let dir = tempfile::tempdir().unwrap();
453        let path_a = dir.path().join("a.txt");
454        let path_b = dir.path().join("b.txt");
455        std::fs::write(&path_a, "a").unwrap();
456        std::fs::write(&path_b, "b").unwrap();
457
458        let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(1, "test");
459        let first = cache.get_or_parse(&path_a, parse_upper).unwrap();
460        assert_eq!(first.0, "A");
461
462        let second = cache.get_or_parse(&path_b, parse_upper).unwrap();
463        assert_eq!(second.0, "B", "the value is still returned despite the cap");
464
465        // `b` was not cached (capacity already held `a`), so a repeat call re-parses it —
466        // a fresh Arc, not the same one.
467        let second_again = cache.get_or_parse(&path_b, parse_upper).unwrap();
468        assert!(!Arc::ptr_eq(&second, &second_again));
469    }
470}