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}