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    #[tracing::instrument(skip_all, fields(path = %path.display(), label = self.label), level = "debug")]
161    pub fn get_or_parse(&self, path: &Path, parse: impl FnOnce(&str) -> T) -> Option<Arc<T>> {
162        let metadata = fs_probe::metadata(path).ok()?;
163        if !metadata.is_file() {
164            return None;
165        }
166        if metadata.len() > MAX_CACHED_FILE_BYTES {
167            self.warn_oversized_once(path, metadata.modified().ok(), Some(metadata.len()));
168            return None;
169        }
170        let mtime = metadata.modified().ok()?;
171
172        if let Some(existing) = self.files.get(path)
173            && existing.mtime == mtime
174        {
175            return Some(Arc::clone(&existing.value));
176        }
177
178        let content = match fs_probe::read_to_string_capped(path, MAX_CACHED_FILE_BYTES).ok()? {
179            Some(content) => content,
180            None => {
181                // The stat-based pre-filter passed, but the read still hit the cap — a symlink
182                // swap or concurrent growth between the two calls (CWE-367).
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)]
210#[expect(
211    clippy::cast_possible_truncation,
212    reason = "#673: fixed test-fixture constants cast to usize never approach truncation range"
213)]
214mod tests {
215    use super::*;
216
217    #[derive(Debug, PartialEq, Eq)]
218    struct Parsed(String);
219
220    fn parse_upper(content: &str) -> Parsed {
221        Parsed(content.trim().to_uppercase())
222    }
223
224    #[test]
225    fn hit_reuses_same_arc_without_reparsing() {
226        let dir = tempfile::tempdir().unwrap();
227        let path = dir.path().join("file.txt");
228        std::fs::write(&path, "hello").unwrap();
229
230        // Held even without diffing here: `get_or_parse` bumps the same process-global
231        // fs_probe counters `hit_does_zero_reads_and_exactly_one_stat` diffs elsewhere, which
232        // a concurrent test thread could otherwise corrupt mid-diff.
233        let _guard = fs_probe::snapshot_guard();
234        let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
235        let first = cache.get_or_parse(&path, parse_upper).unwrap();
236        let second = cache.get_or_parse(&path, parse_upper).unwrap();
237
238        assert!(
239            Arc::ptr_eq(&first, &second),
240            "a cache hit must return the same Arc, not re-parse"
241        );
242    }
243
244    #[test]
245    fn hit_does_zero_reads_and_exactly_one_stat() {
246        let dir = tempfile::tempdir().unwrap();
247        let path = dir.path().join("file.txt");
248        std::fs::write(&path, "hello").unwrap();
249
250        // Acquired before the priming call too: that warm-up still does one real stat/read
251        // through fs_probe, which must not race a sibling test's own measured window either.
252        let _guard = fs_probe::snapshot_guard();
253        let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
254        cache.get_or_parse(&path, parse_upper).unwrap();
255
256        let (stats_before, reads_before) = fs_probe::snapshot();
257        let hit = cache.get_or_parse(&path, parse_upper).unwrap();
258        let (stats_after, reads_after) = fs_probe::snapshot();
259
260        assert_eq!(
261            reads_after - reads_before,
262            0,
263            "a cache hit must perform zero content reads"
264        );
265        assert_eq!(
266            stats_after - stats_before,
267            1,
268            "a cache hit still pays exactly one mtime stat"
269        );
270        assert_eq!(hit.0, "HELLO");
271    }
272
273    #[test]
274    fn forward_mtime_bump_invalidates() {
275        let dir = tempfile::tempdir().unwrap();
276        let path = dir.path().join("file.txt");
277        std::fs::write(&path, "one").unwrap();
278
279        // See the comment in `hit_reuses_same_arc_without_reparsing` on why a non-diffing
280        // test still needs this guard.
281        let _guard = fs_probe::snapshot_guard();
282        let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
283        let first = cache.get_or_parse(&path, parse_upper).unwrap();
284
285        // Ensure a distinguishable mtime on filesystems with coarse timestamp resolution.
286        let future = SystemTime::now() + std::time::Duration::from_secs(2);
287        std::fs::write(&path, "two").unwrap();
288        // `File::open` is read-only, which lacks `FILE_WRITE_ATTRIBUTES` on Windows and makes
289        // `set_modified` fail with `PermissionDenied`; open for write instead.
290        std::fs::OpenOptions::new()
291            .write(true)
292            .open(&path)
293            .unwrap()
294            .set_modified(future)
295            .unwrap();
296
297        let second = cache.get_or_parse(&path, parse_upper).unwrap();
298        assert!(
299            !Arc::ptr_eq(&first, &second),
300            "an mtime bump must invalidate the cache entry"
301        );
302        assert_eq!(second.0, "TWO");
303    }
304
305    /// A `git checkout` restoring an older file moves the mtime *backwards* — invalidation
306    /// must compare with `!=`, not `>`.
307    #[test]
308    fn backward_mtime_move_invalidates() {
309        let dir = tempfile::tempdir().unwrap();
310        let path = dir.path().join("file.txt");
311        std::fs::write(&path, "one").unwrap();
312        let future = SystemTime::now() + std::time::Duration::from_secs(10);
313        std::fs::OpenOptions::new()
314            .write(true)
315            .open(&path)
316            .unwrap()
317            .set_modified(future)
318            .unwrap();
319
320        // See the comment in `hit_reuses_same_arc_without_reparsing` on why a non-diffing
321        // test still needs this guard.
322        let _guard = fs_probe::snapshot_guard();
323        let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
324        let first = cache.get_or_parse(&path, parse_upper).unwrap();
325
326        std::fs::write(&path, "two").unwrap();
327        let past = SystemTime::now();
328        std::fs::OpenOptions::new()
329            .write(true)
330            .open(&path)
331            .unwrap()
332            .set_modified(past)
333            .unwrap();
334
335        let second = cache.get_or_parse(&path, parse_upper).unwrap();
336        assert!(
337            !Arc::ptr_eq(&first, &second),
338            "a backwards mtime move must still invalidate the cache entry"
339        );
340    }
341
342    #[test]
343    fn missing_path_returns_none() {
344        // See the comment in `hit_reuses_same_arc_without_reparsing` on why a non-diffing
345        // test still needs this guard.
346        let _guard = fs_probe::snapshot_guard();
347        let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
348        assert!(
349            cache
350                .get_or_parse(Path::new("/nonexistent/path/file.txt"), parse_upper)
351                .is_none()
352        );
353    }
354
355    /// Decision: a directory at the candidate path must never be treated as cacheable
356    /// content — reading it would fail, but the `is_file` gate rejects it before that read is
357    /// even attempted.
358    #[test]
359    fn directory_path_returns_none() {
360        let dir = tempfile::tempdir().unwrap();
361        // See the comment in `hit_reuses_same_arc_without_reparsing` on why a non-diffing
362        // test still needs this guard.
363        let _guard = fs_probe::snapshot_guard();
364        let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
365        assert!(cache.get_or_parse(dir.path(), parse_upper).is_none());
366    }
367
368    /// A file over [`MAX_CACHED_FILE_BYTES`] must never reach `parse` at all — rejected here by
369    /// the cheap `stat` pre-filter. `fs_probe::tests::read_to_string_capped_rejects_content_over_cap`
370    /// proves the same bound holds on the read itself, independent of any `stat` result —
371    /// that is what closes the TOCTOU gap (CWE-367) a stat-only check would leave open.
372    #[test]
373    fn oversized_file_returns_none_without_reading_content() {
374        let dir = tempfile::tempdir().unwrap();
375        let path = dir.path().join("huge.txt");
376        let file = std::fs::File::create(&path).unwrap();
377        file.set_len(MAX_CACHED_FILE_BYTES + 1).unwrap();
378        drop(file);
379
380        // See the comment in `hit_reuses_same_arc_without_reparsing` on why a non-diffing
381        // test still needs this guard.
382        let _guard = fs_probe::snapshot_guard();
383        let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
384        let parse_calls = std::cell::Cell::new(0);
385        let result = cache.get_or_parse(&path, |content| {
386            parse_calls.set(parse_calls.get() + 1);
387            parse_upper(content)
388        });
389
390        assert!(result.is_none());
391        assert_eq!(
392            parse_calls.get(),
393            0,
394            "parse must not run on an oversized file"
395        );
396    }
397
398    /// A file exactly at [`MAX_CACHED_FILE_BYTES`] must still be cached — the capped read
399    /// reads one byte past the cap to detect an overage, and an off-by-one there would
400    /// falsely reject a file that lands exactly on the boundary.
401    #[test]
402    fn file_exactly_at_cap_is_still_cached() {
403        let dir = tempfile::tempdir().unwrap();
404        let path = dir.path().join("exact.txt");
405        let content = "a".repeat(MAX_CACHED_FILE_BYTES as usize);
406        std::fs::write(&path, &content).unwrap();
407
408        // See the comment in `hit_reuses_same_arc_without_reparsing` on why a non-diffing
409        // test still needs this guard.
410        let _guard = fs_probe::snapshot_guard();
411        let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
412        let result = cache.get_or_parse(&path, parse_upper);
413
414        assert!(result.is_some(), "a file exactly at the cap must be cached");
415    }
416
417    /// Impl-critic S2 regression: an oversized file must warn at most once per distinct
418    /// mtime, not once per call — an oversized file is never memoized in `files`, so without
419    /// this every hover/completion/diagnostic pass touching it would re-emit the warning.
420    #[cfg(feature = "test-util")]
421    #[test]
422    fn oversized_file_warns_once_per_mtime_not_once_per_call() {
423        let dir = tempfile::tempdir().unwrap();
424        let path = dir.path().join("huge.txt");
425        let file = std::fs::File::create(&path).unwrap();
426        file.set_len(MAX_CACHED_FILE_BYTES + 1).unwrap();
427        drop(file);
428
429        // See the comment in `hit_reuses_same_arc_without_reparsing` on why a non-diffing
430        // test still needs this guard.
431        let _guard = fs_probe::snapshot_guard();
432        let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
433
434        let log = crate::test_util::capture_tracing_output(|| {
435            assert!(cache.get_or_parse(&path, parse_upper).is_none());
436            assert!(cache.get_or_parse(&path, parse_upper).is_none());
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            1,
444            "expected exactly one warning across three calls with an unchanged mtime: {log}"
445        );
446    }
447
448    /// A file that changes (still oversized) after already being warned about must warn
449    /// again — the dedup is per file *version*, not a one-time-ever suppression.
450    #[cfg(feature = "test-util")]
451    #[test]
452    fn oversized_file_warns_again_after_mtime_changes() {
453        let dir = tempfile::tempdir().unwrap();
454        let path = dir.path().join("huge.txt");
455        let file = std::fs::File::create(&path).unwrap();
456        file.set_len(MAX_CACHED_FILE_BYTES + 1).unwrap();
457        drop(file);
458
459        // See the comment in `hit_reuses_same_arc_without_reparsing` on why a non-diffing
460        // test still needs this guard.
461        let _guard = fs_probe::snapshot_guard();
462        let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(DEFAULT_MAX_CACHED_FILES, "test");
463
464        let log = crate::test_util::capture_tracing_output(|| {
465            assert!(cache.get_or_parse(&path, parse_upper).is_none());
466
467            // Ensure a distinguishable mtime on filesystems with coarse timestamp resolution.
468            let future = SystemTime::now() + std::time::Duration::from_secs(2);
469            let file = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
470            file.set_len(MAX_CACHED_FILE_BYTES + 2).unwrap();
471            file.set_modified(future).unwrap();
472            drop(file);
473
474            assert!(cache.get_or_parse(&path, parse_upper).is_none());
475        });
476
477        assert_eq!(
478            log.matches("file exceeds mtime file cache size cap")
479                .count(),
480            2,
481            "expected a fresh warning after the file's mtime changed: {log}"
482        );
483    }
484
485    /// At capacity, a freshly parsed value is still returned to the caller but not inserted
486    /// into the map — the parse succeeds, only memoization is declined.
487    #[test]
488    fn capacity_cap_returns_value_without_inserting() {
489        let dir = tempfile::tempdir().unwrap();
490        let path_a = dir.path().join("a.txt");
491        let path_b = dir.path().join("b.txt");
492        std::fs::write(&path_a, "a").unwrap();
493        std::fs::write(&path_b, "b").unwrap();
494
495        // See the comment in `hit_reuses_same_arc_without_reparsing` on why a non-diffing
496        // test still needs this guard.
497        let _guard = fs_probe::snapshot_guard();
498        let cache: MtimeFileCache<Parsed> = MtimeFileCache::new(1, "test");
499        let first = cache.get_or_parse(&path_a, parse_upper).unwrap();
500        assert_eq!(first.0, "A");
501
502        let second = cache.get_or_parse(&path_b, parse_upper).unwrap();
503        assert_eq!(second.0, "B", "the value is still returned despite the cap");
504
505        // `b` was not cached (capacity already held `a`), so a repeat call re-parses it —
506        // a fresh Arc, not the same one.
507        let second_again = cache.get_or_parse(&path_b, parse_upper).unwrap();
508        assert!(!Arc::ptr_eq(&second, &second_again));
509    }
510}