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}