deps_core/lockfile.rs
1//! Lock file parsing abstractions.
2//!
3//! Provides generic types and traits for parsing lock files across different
4//! package ecosystems (Cargo.lock, package-lock.json, poetry.lock, etc.).
5//!
6//! Lock files contain resolved dependency versions, allowing instant display
7//! without network requests to registries.
8
9use crate::error::{DepsError, Result};
10use crate::fs_probe;
11use dashmap::DashMap;
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14use std::time::{Instant, SystemTime};
15use url::Url;
16
17/// Maximum depth to search for workspace root lock file.
18const MAX_WORKSPACE_DEPTH: usize = 5;
19
20/// Maximum lock file size [`read_lockfile_content`] reads before parsing.
21///
22/// Deliberately a separate, larger constant than [`crate::mtime_cache::MAX_CACHED_FILE_BYTES`]
23/// — that cap is scoped to small config files (`.cargo/config.toml`, `.npmrc`, typically a
24/// few KB), while a lock file records every transitively resolved package across an entire
25/// dependency graph, and a large npm monorepo `package-lock.json` can legitimately run well
26/// past 8 MiB. 32 MiB matches [`crate::cache`]'s `MAX_RESPONSE_BYTES` order of magnitude —
27/// generous for any realistic lock file while still bounding the read against a
28/// maliciously large one discovered by an unauthenticated ancestor walk over a cloned
29/// repository (CWE-400).
30pub const MAX_LOCKFILE_BYTES: u64 = 32 * 1024 * 1024;
31
32/// Reads a lock file's contents, wrapping any I/O failure into a [`DepsError::ParseError`]
33/// tagged with the ecosystem's `file_type` label and the file's path.
34///
35/// Every `LockFileProvider::parse_lockfile` implementation reads its lock file the same
36/// way; this shares that boilerplate and keeps the error message format consistent
37/// across ecosystems.
38///
39/// Bounded by [`MAX_LOCKFILE_BYTES`] via [`fs_probe::read_to_string_capped`] — a lock file
40/// is discovered by an unauthenticated ancestor walk ([`locate_lockfile_for_manifest`]) over
41/// a possibly hostile cloned repository, so nothing here may assume it is reasonably sized
42/// or a regular file before reading it in full (CWE-400). The whole stat-then-read sequence
43/// runs as one unit on the blocking-thread pool via [`tokio::task::spawn_blocking`], not on
44/// the calling tokio worker thread: both steps are synchronous I/O with no `.await` of their
45/// own, and every `LockFileProvider::parse_lockfile` call site sits on the LSP request path,
46/// where a worker thread blocked on a `stat` or an 8+ MiB read — or indefinitely, on a FIFO —
47/// would violate the project's non-blocking-handler rule (#963).
48///
49/// # Errors
50///
51/// Returns [`DepsError::ParseError`] if the file cannot be read (e.g. missing, not a
52/// regular file, unreadable, invalid UTF-8), exceeds [`MAX_LOCKFILE_BYTES`], or the
53/// blocking read task panicked.
54///
55/// # Examples
56///
57/// ```no_run
58/// use deps_core::lockfile::read_lockfile_content;
59/// use std::path::Path;
60///
61/// # async fn example() -> deps_core::error::Result<()> {
62/// let content = read_lockfile_content(Path::new("Cargo.lock"), "Cargo.lock").await?;
63/// println!("{} bytes read", content.len());
64/// # Ok(())
65/// # }
66/// ```
67#[tracing::instrument(skip_all, fields(path = %path.display(), file_type = %file_type), level = "debug")]
68pub async fn read_lockfile_content(path: &Path, file_type: &str) -> Result<String> {
69 let to_parse_error = |e: std::io::Error| DepsError::ParseError {
70 file_type: format!("{file_type} at {}", path.display()),
71 source: Box::new(e),
72 };
73 let oversized_error = || {
74 to_parse_error(std::io::Error::new(
75 std::io::ErrorKind::InvalidData,
76 format!("exceeds {MAX_LOCKFILE_BYTES} byte size cap"),
77 ))
78 };
79
80 // Entered inside the closure below so the `path`/`file_type` fields this function's
81 // `#[tracing::instrument]` attaches to its span still show up on events logged from the
82 // blocking pool — a span is not implicitly active on whatever thread `spawn_blocking`
83 // happens to run its closure on.
84 let span = tracing::Span::current();
85 let path_buf = path.to_path_buf();
86 let read_result = tokio::task::spawn_blocking(move || {
87 let _entered = span.enter();
88 // Cheap `stat` pre-filter (mirrors `MtimeFileCache::get_or_parse`): rejects a
89 // non-regular file (can block indefinitely) and an oversized file before opening. The
90 // capped read below still enforces the size bound regardless, closing the TOCTOU gap
91 // (CWE-367) a stat-only check would leave open.
92 if let Ok(metadata) = fs_probe::metadata(&path_buf) {
93 if !metadata.is_file() {
94 return Err(std::io::Error::new(
95 std::io::ErrorKind::InvalidInput,
96 "not a regular file",
97 ));
98 }
99 if metadata.len() > MAX_LOCKFILE_BYTES {
100 tracing::warn!(
101 path = %path_buf.display(),
102 len = metadata.len(),
103 cap = MAX_LOCKFILE_BYTES,
104 "lock file exceeds size cap; not reading"
105 );
106 return Err(std::io::Error::new(
107 std::io::ErrorKind::InvalidData,
108 format!("exceeds {MAX_LOCKFILE_BYTES} byte size cap"),
109 ));
110 }
111 }
112
113 fs_probe::read_to_string_capped(&path_buf, MAX_LOCKFILE_BYTES)
114 })
115 .await
116 .map_err(|e| to_parse_error(std::io::Error::other(e)))?;
117
118 match read_result.map_err(to_parse_error)? {
119 Some(content) => Ok(content),
120 None => {
121 tracing::warn!(
122 path = %path.display(),
123 cap = MAX_LOCKFILE_BYTES,
124 "lock file exceeds size cap during read; not reading"
125 );
126 Err(oversized_error())
127 }
128 }
129}
130
131/// Reads a lock file via [`read_lockfile_content`] and runs `parse` on the blocking-thread
132/// pool, returning the parsed result.
133///
134/// Every `LockFileProvider::parse_lockfile` implementation reads its lock file's content and
135/// then parses it; this shares that second half of the boilerplate too. The parse step is
136/// CPU-bound work over content bounded by the same [`MAX_LOCKFILE_BYTES`] cap as the read
137/// (a `Cargo.lock`/`package-lock.json`/... TOML, JSON, or YAML document up to 32 MiB), so —
138/// like the read itself — it must never run on the calling tokio worker thread: every
139/// `LockFileProvider::parse_lockfile` call site sits on the LSP request path, where a worker
140/// thread blocked on parsing a multi-megabyte document would violate the project's
141/// non-blocking-handler rule.
142///
143/// # Errors
144///
145/// Returns whatever [`read_lockfile_content`] returns for a read failure, whatever `parse`
146/// itself returns for a malformed document, or a [`DepsError::ParseError`] if the blocking
147/// parse task panics or is cancelled.
148///
149/// # Examples
150///
151/// ```no_run
152/// use deps_core::lockfile::{read_and_parse_lockfile, ResolvedPackages};
153/// use std::path::Path;
154///
155/// # async fn example() -> deps_core::error::Result<()> {
156/// let packages: ResolvedPackages = read_and_parse_lockfile(
157/// Path::new("Cargo.lock"),
158/// "Cargo.lock",
159/// |content| Ok(ResolvedPackages::new()), // real parsers inspect `content`
160/// )
161/// .await?;
162/// # Ok(())
163/// # }
164/// ```
165#[tracing::instrument(skip_all, fields(path = %path.display(), file_type = %file_type), level = "debug")]
166pub async fn read_and_parse_lockfile<T, F>(path: &Path, file_type: &str, parse: F) -> Result<T>
167where
168 F: FnOnce(String) -> Result<T> + Send + 'static,
169 T: Send + 'static,
170{
171 let content = read_lockfile_content(path, file_type).await?;
172
173 tokio::task::spawn_blocking(move || parse(content))
174 .await
175 .map_err(|e| DepsError::ParseError {
176 file_type: format!("{file_type} at {}", path.display()),
177 source: Box::new(std::io::Error::other(e)),
178 })?
179}
180
181/// Resolves a manifest URI to an absolute local filesystem path, rejecting a non-`file:`
182/// scheme, a relative path, or a non-local host.
183///
184/// `url::Url::to_file_path` on its own already refuses a non-empty, non-`localhost` host (it
185/// checks cannot-be-a-base and host) — but it does **not** check the URI's scheme at all, so a
186/// non-`file:` URI shaped like a real path (e.g. `untitled:/etc/passwd`, VS Code's
187/// untitled-buffer scheme) would otherwise silently resolve to a real local path (#1084,
188/// confirmed empirically: `untitled:/etc/x`, `git:/etc/x`, `vscode-remote:/etc/x` all resolve
189/// via `to_file_path`). The explicit host check here is kept as defense-in-depth alongside the
190/// scheme check, rather than relying solely on `to_file_path`'s own internal host validation,
191/// so both guards are visible at this one call site. The general-purpose guard for resolving
192/// any client-supplied manifest/document URI to a real filesystem path before touching disk —
193/// not limited to lock file discovery despite living in this module. Shared by
194/// [`locate_lockfile_for_manifest`] and every ecosystem-local lock file locator that needs the
195/// same manifest-path resolution (e.g. `deps-nuget`'s multi-project fallback), as well as
196/// non-lock-file call sites across `deps-cargo`, `deps-nuget`, `deps-pypi`, `deps-npm`,
197/// `deps-gradle`, and `deps-lsp` (workspace-root and config discovery, document links,
198/// cold-start document loading, and watched-file-change handling) that resolve the same kind
199/// of URI for the same reason (#1090) — so the guard is defined once, not re-derived per call
200/// site.
201///
202/// # Examples
203///
204/// ```no_run
205/// use deps_core::lockfile::resolve_manifest_file_path;
206/// use url::Url;
207///
208/// // `no_run`: `/path/to/Cargo.toml` is not absolute on Windows (no drive prefix), so
209/// // `Url::from_file_path` would return `Err` there — same reason the example right
210/// // below (`locate_lockfile_for_manifest`'s) is also `no_run`.
211/// let file_uri = Url::from_file_path("/path/to/Cargo.toml").unwrap();
212/// assert!(resolve_manifest_file_path(&file_uri).is_some());
213///
214/// let non_file_uri: Url = "untitled:/path/to/Cargo.toml".parse().unwrap();
215/// assert!(resolve_manifest_file_path(&non_file_uri).is_none());
216/// ```
217#[must_use]
218pub fn resolve_manifest_file_path(manifest_uri: &Url) -> Option<PathBuf> {
219 if !manifest_uri.scheme().eq_ignore_ascii_case("file") {
220 return None;
221 }
222 if let Some(host) = manifest_uri.host_str()
223 && !host.is_empty()
224 && !host.eq_ignore_ascii_case("localhost")
225 {
226 return None;
227 }
228 let manifest_path = manifest_uri.to_file_path().ok()?;
229 manifest_path.is_absolute().then_some(manifest_path)
230}
231
232/// Generic lock file locator.
233///
234/// Searches for lock files in the following order:
235/// 1. Same directory as the manifest
236/// 2. Parent directories (up to MAX_WORKSPACE_DEPTH levels) for workspace root
237///
238/// Each candidate is checked with [`fs_probe::is_file`], not a plain existence check — a
239/// FIFO or socket happening to sit at a lock file's conventional name must never be
240/// returned as "found", since [`read_lockfile_content`] opening one would block
241/// indefinitely.
242///
243/// This function is ecosystem-agnostic and works with any lock file name.
244///
245/// # Arguments
246///
247/// * `manifest_uri` - URI of the manifest file
248/// * `lockfile_names` - List of possible lock file names to search for
249///
250/// # Returns
251///
252/// Path to the first found lock file, or None if not found.
253///
254/// # Examples
255///
256/// ```no_run
257/// use deps_core::lockfile::locate_lockfile_for_manifest;
258/// use url::Url;
259///
260/// let manifest_uri = Url::from_file_path("/path/to/Cargo.toml").unwrap();
261/// let lockfile_names = &["Cargo.lock"];
262///
263/// if let Some(path) = locate_lockfile_for_manifest(&manifest_uri, lockfile_names) {
264/// println!("Found lock file at: {}", path.display());
265/// }
266/// ```
267pub fn locate_lockfile_for_manifest(
268 manifest_uri: &Url,
269 lockfile_names: &[&str],
270) -> Option<PathBuf> {
271 let manifest_path = resolve_manifest_file_path(manifest_uri)?;
272 let manifest_dir = manifest_path.parent()?;
273
274 let mut lock_path = manifest_dir.to_path_buf();
275
276 for &name in lockfile_names {
277 lock_path.push(name);
278 if fs_probe::is_file(&lock_path) {
279 tracing::debug!("Found {} at: {}", name, lock_path.display());
280 return Some(lock_path);
281 }
282 lock_path.pop();
283 }
284
285 // Search up the directory tree for workspace root
286 let Some(mut current_dir) = manifest_dir.parent() else {
287 tracing::debug!("No lock file found for: {:?}", manifest_uri);
288 return None;
289 };
290
291 for depth in 0..MAX_WORKSPACE_DEPTH {
292 lock_path.clear();
293 lock_path.push(current_dir);
294
295 for &name in lockfile_names {
296 lock_path.push(name);
297 if fs_probe::is_file(&lock_path) {
298 tracing::debug!(
299 "Found workspace {} at depth {}: {}",
300 name,
301 depth + 1,
302 lock_path.display()
303 );
304 return Some(lock_path);
305 }
306 lock_path.pop();
307 }
308
309 match current_dir.parent() {
310 Some(parent) => current_dir = parent,
311 None => break,
312 }
313 }
314
315 tracing::debug!("No lock file found for: {:?}", manifest_uri);
316 None
317}
318
319/// Resolved package information from a lock file.
320///
321/// Contains the exact version and source information for a dependency
322/// as resolved by the package manager.
323#[non_exhaustive]
324#[derive(Debug, Clone, PartialEq, Eq)]
325pub struct ResolvedPackage {
326 /// Package name
327 pub name: String,
328 /// Resolved version (exact version from lock file)
329 pub version: String,
330 /// Source information (registry URL, git commit, path)
331 pub source: ResolvedSource,
332 /// Dependencies of this package (for dependency tree analysis)
333 pub dependencies: Vec<String>,
334}
335
336impl ResolvedPackage {
337 /// Constructs a `ResolvedPackage` from its three required fields, with
338 /// [`Self::dependencies`] left empty — chain [`Self::with_dependencies`] to attach them.
339 ///
340 /// Needed because [`Self`] is `#[non_exhaustive]`: a struct literal only works inside
341 /// this crate, so every other crate must go through this constructor instead.
342 ///
343 /// # Examples
344 ///
345 /// ```
346 /// use deps_core::lockfile::{ResolvedPackage, ResolvedSource};
347 ///
348 /// let package = ResolvedPackage::new(
349 /// "serde".to_string(),
350 /// "1.0.195".to_string(),
351 /// ResolvedSource::Registry {
352 /// url: "https://github.com/rust-lang/crates.io-index".into(),
353 /// checksum: "abc123".into(),
354 /// },
355 /// );
356 /// assert!(package.dependencies.is_empty());
357 /// ```
358 #[must_use]
359 pub fn new(name: String, version: String, source: ResolvedSource) -> Self {
360 Self {
361 name,
362 version,
363 source,
364 dependencies: Vec::new(),
365 }
366 }
367
368 /// Attaches this package's own dependencies. See [`Self::dependencies`].
369 #[must_use]
370 pub fn with_dependencies(mut self, dependencies: Vec<String>) -> Self {
371 self.dependencies = dependencies;
372 self
373 }
374}
375
376/// Source of a resolved dependency.
377///
378/// Indicates where the package was downloaded from or how it was resolved.
379#[non_exhaustive]
380#[derive(Debug, Clone, PartialEq, Eq)]
381pub enum ResolvedSource {
382 /// From a registry with optional checksum
383 Registry {
384 /// Registry URL
385 url: String,
386 /// Checksum/integrity hash
387 checksum: String,
388 },
389 /// From git with commit hash
390 Git {
391 /// Git repository URL
392 url: String,
393 /// Commit SHA or tag
394 rev: String,
395 },
396 /// From local file system
397 Path {
398 /// Relative or absolute path
399 path: String,
400 },
401}
402
403/// Collection of resolved packages from a lock file.
404///
405/// Supports multiple versions per package name, returning the highest
406/// semver version through public API methods.
407///
408/// # Examples
409///
410/// ```
411/// use deps_core::lockfile::{ResolvedPackages, ResolvedPackage, ResolvedSource};
412///
413/// let mut packages = ResolvedPackages::new();
414/// packages.insert(
415/// ResolvedPackage::new(
416/// "serde".into(),
417/// "1.0.195".into(),
418/// ResolvedSource::Registry {
419/// url: "https://github.com/rust-lang/crates.io-index".into(),
420/// checksum: "abc123".into(),
421/// },
422/// )
423/// .with_dependencies(vec!["serde_derive".into()]),
424/// );
425///
426/// assert_eq!(packages.version("serde"), Some("1.0.195"));
427/// assert_eq!(packages.len(), 1);
428/// ```
429#[derive(Debug, Default, Clone)]
430pub struct ResolvedPackages {
431 packages: HashMap<String, Vec<ResolvedPackage>>,
432}
433
434/// Orders two version strings, preferring a valid semver parse over a non-parseable one and
435/// falling back to a lexicographic compare when both (or neither) parse.
436///
437/// Shared by [`best_package`] and the per-occurrence candidate selection in
438/// [`crate::lsp_helpers::resolve_in_use_version`] (issue #649) so a lock file entry with a
439/// non-semver version string is never ordered by two diverging policies depending on which
440/// caller is asking.
441pub(crate) fn compare_lockfile_versions(a: &str, b: &str) -> std::cmp::Ordering {
442 match (semver::Version::parse(a), semver::Version::parse(b)) {
443 (Ok(va), Ok(vb)) => va.cmp(&vb),
444 (Ok(_), Err(_)) => std::cmp::Ordering::Greater,
445 (Err(_), Ok(_)) => std::cmp::Ordering::Less,
446 (Err(_), Err(_)) => a.cmp(b),
447 }
448}
449
450/// Returns the package with the highest semver version from a slice.
451fn best_package(packages: &[ResolvedPackage]) -> Option<&ResolvedPackage> {
452 packages
453 .iter()
454 .max_by(|a, b| compare_lockfile_versions(&a.version, &b.version))
455}
456
457impl ResolvedPackages {
458 /// Creates a new empty collection.
459 pub fn new() -> Self {
460 Self {
461 packages: HashMap::new(),
462 }
463 }
464
465 /// Inserts a resolved package, storing all versions per name.
466 pub fn insert(&mut self, package: ResolvedPackage) {
467 self.packages
468 .entry(package.name.clone())
469 .or_default()
470 .push(package);
471 }
472
473 /// Gets the resolved package with the highest semver version.
474 pub fn get(&self, name: &str) -> Option<&ResolvedPackage> {
475 self.packages.get(name).and_then(|v| best_package(v))
476 }
477
478 /// Gets the highest resolved version string for a package.
479 pub fn version(&self, name: &str) -> Option<&str> {
480 self.get(name).map(|p| p.version.as_str())
481 }
482
483 /// Returns all stored versions for a package.
484 pub fn all(&self, name: &str) -> Option<&[ResolvedPackage]> {
485 self.packages.get(name).map(|v| v.as_slice())
486 }
487
488 /// Returns an iterator yielding every stored version for each unique package name,
489 /// unlike [`Self::iter`] which collapses each name down to `best_package`'s single
490 /// pick.
491 ///
492 /// The building block for per-occurrence lock-file resolution (issue #649): a caller
493 /// disambiguating two manifest occurrences of the same resolved package name (e.g. a
494 /// Cargo `package = "..."` rename pinning an older major alongside a plain dependency
495 /// on the current major) needs every retained version for that name, not just the one
496 /// [`Self::iter`]/[`Self::into_map`] would collapse it to.
497 pub fn iter_all(&self) -> impl Iterator<Item = (&String, &[ResolvedPackage])> {
498 self.packages
499 .iter()
500 .map(|(name, versions)| (name, versions.as_slice()))
501 }
502
503 /// Returns the number of unique package names.
504 pub fn len(&self) -> usize {
505 self.packages.len()
506 }
507
508 /// Returns true if there are no resolved packages.
509 pub fn is_empty(&self) -> bool {
510 self.packages.is_empty()
511 }
512
513 /// Returns an iterator yielding the best version per unique package name.
514 pub fn iter(&self) -> impl Iterator<Item = (&String, &ResolvedPackage)> {
515 self.packages.keys().filter_map(|name| {
516 self.packages
517 .get(name)
518 .and_then(|v| best_package(v).map(|p| (name, p)))
519 })
520 }
521
522 /// Converts into a HashMap with the best version per package name.
523 pub fn into_map(self) -> HashMap<String, ResolvedPackage> {
524 self.packages
525 .into_iter()
526 .filter_map(|(name, versions)| best_package(&versions).cloned().map(|p| (name, p)))
527 .collect()
528 }
529}
530
531/// Lock file provider trait for ecosystem-specific implementations.
532///
533/// Implementations parse lock files for a specific package ecosystem
534/// (Cargo.lock, package-lock.json, etc.) and extract resolved versions.
535///
536/// # Examples
537///
538/// ```no_run
539/// use deps_core::lockfile::{LockFileProvider, ResolvedPackages};
540/// use std::path::{Path, PathBuf};
541/// use url::Url;
542///
543/// struct MyLockParser;
544///
545/// impl LockFileProvider for MyLockParser {
546/// fn locate_lockfile(&self, manifest_uri: &Url) -> Option<PathBuf> {
547/// let manifest_path = manifest_uri.to_file_path().ok()?;
548/// let lock_path = manifest_path.with_file_name("my.lock");
549/// lock_path.exists().then_some(lock_path)
550/// }
551///
552/// fn parse_lockfile<'a>(&'a self, lockfile_path: &'a Path) -> std::pin::Pin<Box<dyn std::future::Future<Output = deps_core::error::Result<ResolvedPackages>> + Send + 'a>> {
553/// Box::pin(async move {
554/// // Parse lock file format and extract packages
555/// Ok(ResolvedPackages::new())
556/// })
557/// }
558/// }
559/// ```
560pub trait LockFileProvider: Send + Sync {
561 /// Locates the lock file for a given manifest URI.
562 ///
563 /// Returns `None` if:
564 /// - Lock file doesn't exist
565 /// - Manifest path cannot be determined from URI
566 /// - Workspace root search fails
567 ///
568 /// # Arguments
569 ///
570 /// * `manifest_uri` - URI of the manifest file (Cargo.toml, package.json, etc.)
571 ///
572 /// # Returns
573 ///
574 /// Path to lock file if found
575 fn locate_lockfile(&self, manifest_uri: &Url) -> Option<PathBuf>;
576
577 /// Parses a lock file and extracts resolved packages.
578 ///
579 /// # Arguments
580 ///
581 /// * `lockfile_path` - Path to the lock file
582 ///
583 /// # Returns
584 ///
585 /// ResolvedPackages on success, error if parse fails
586 ///
587 /// # Errors
588 ///
589 /// Returns an error if:
590 /// - File cannot be read
591 /// - File format is invalid
592 /// - Required fields are missing
593 fn parse_lockfile<'a>(
594 &'a self,
595 lockfile_path: &'a Path,
596 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ResolvedPackages>> + Send + 'a>>;
597}
598
599/// Upper bound on a [`LockFileCache`]'s entry count.
600///
601/// Matches [`crate::mtime_cache::DEFAULT_MAX_CACHED_FILES`] — the same generous bound for
602/// any realistic workspace's set of *distinct* lock files. Without a cap, a long-running
603/// server session that opens many workspaces over time (or one pointed at a monorepo with
604/// many independently-locked sub-projects) would grow this cache unboundedly, since entries
605/// previously survived even a `textDocument/didClose` (#962).
606///
607/// Bounds this cache's worst-case retained content at `DEFAULT_MAX_CACHED_LOCKFILES *
608/// MAX_LOCKFILE_BYTES` (256 * 32 MiB = 8 GiB of raw lock file content, before accounting for
609/// parsed [`ResolvedPackages`] overhead) — generous headroom rather than a tight budget,
610/// since a byte-accounted eviction policy (like [`crate::cache::HttpCache`]'s) is more
611/// precision than this entry-count-keyed cache needs.
612pub const DEFAULT_MAX_CACHED_LOCKFILES: usize = 256;
613
614/// Cached lock file entry with staleness detection.
615struct CachedLockFile {
616 packages: ResolvedPackages,
617 modified_at: SystemTime,
618 /// When this entry was last parsed *or* served from a cache hit — the recency key
619 /// [`LockFileCache::evict_oldest`] evicts by once the cache is at capacity. Refreshed on
620 /// every access (not only on (re)parse), so eviction is genuinely LRU rather than
621 /// FIFO-by-parse-time — otherwise a stable, frequently-read lock file would look oldest
622 /// and get evicted first, while one that keeps changing (and reparsing) would never age
623 /// out.
624 parsed_at: Instant,
625}
626
627/// Cache for parsed lock files with automatic staleness detection.
628///
629/// Caches parsed lock file contents and checks file modification time
630/// to avoid re-parsing unchanged files. Thread-safe for concurrent access.
631///
632/// Bounded by a capacity ([`DEFAULT_MAX_CACHED_LOCKFILES`] by default, via [`Self::new`]; a
633/// custom bound via [`Self::with_capacity`]): once full, inserting a new key evicts the
634/// least-recently-used entry first, keyed by `CachedLockFile::parsed_at`, which is
635/// refreshed on every cache hit as well as every (re)parse (#962).
636///
637/// # Examples
638///
639/// ```no_run
640/// use deps_core::lockfile::LockFileCache;
641/// use std::path::Path;
642///
643/// # async fn example() -> deps_core::error::Result<()> {
644/// let cache = LockFileCache::new();
645/// // First call parses the file
646/// // Second call returns cached result if file hasn't changed
647/// # Ok(())
648/// # }
649/// ```
650pub struct LockFileCache {
651 entries: DashMap<PathBuf, CachedLockFile>,
652 capacity: usize,
653}
654
655impl LockFileCache {
656 /// Creates a new empty lock file cache, capped at [`DEFAULT_MAX_CACHED_LOCKFILES`]
657 /// entries.
658 #[must_use]
659 pub fn new() -> Self {
660 Self::with_capacity(DEFAULT_MAX_CACHED_LOCKFILES)
661 }
662
663 /// Creates a new empty lock file cache, capped at `capacity` entries.
664 ///
665 /// # Examples
666 ///
667 /// ```
668 /// use deps_core::lockfile::LockFileCache;
669 ///
670 /// let cache = LockFileCache::with_capacity(64);
671 /// assert!(cache.is_empty());
672 /// ```
673 #[must_use]
674 pub fn with_capacity(capacity: usize) -> Self {
675 Self {
676 entries: DashMap::new(),
677 capacity,
678 }
679 }
680
681 /// Evicts the least-recently-used entry, making room for one new insert.
682 ///
683 /// A full scan of `entries` by [`CachedLockFile::parsed_at`] — acceptable at this
684 /// cache's scale (bounded by `capacity`, [`DEFAULT_MAX_CACHED_LOCKFILES`] by default),
685 /// unlike [`crate::osv`]'s cache, which is sized an order of magnitude larger and batches
686 /// its eviction accordingly. Logs at `warn` when eviction actually runs, mirroring
687 /// [`crate::mtime_cache::MtimeFileCache`]'s own at-capacity warning.
688 fn evict_oldest(&self) {
689 let oldest = self
690 .entries
691 .iter()
692 .min_by_key(|entry| entry.parsed_at)
693 .map(|entry| entry.key().clone());
694 if let Some(key) = oldest {
695 tracing::warn!(
696 path = %key.display(),
697 capacity = self.capacity,
698 "lock file cache capacity reached; evicting least-recently-used entry"
699 );
700 self.entries.remove(&key);
701 }
702 }
703
704 /// Gets parsed packages from cache or parses the lock file.
705 ///
706 /// Checks file modification time to detect changes. If the file
707 /// has been modified since last parse, re-parses it. Otherwise,
708 /// returns the cached result.
709 ///
710 /// # Arguments
711 ///
712 /// * `provider` - Lock file provider implementation
713 /// * `lockfile_path` - Path to the lock file
714 ///
715 /// # Returns
716 ///
717 /// Resolved packages on success
718 ///
719 /// # Errors
720 ///
721 /// Returns error if file cannot be read or parsed
722 pub async fn get_or_parse(
723 &self,
724 provider: &dyn LockFileProvider,
725 lockfile_path: &Path,
726 ) -> Result<ResolvedPackages> {
727 // Extract owned data from the cache entry before awaiting: the DashMap shard
728 // `Ref` returned by `entries.get` must not be held across `.await`, or a
729 // concurrent access to the same key blocks for the duration (#350, same hazard
730 // class as #333).
731 let cached = self
732 .entries
733 .get(lockfile_path)
734 .map(|entry| (entry.modified_at, entry.packages.clone()));
735
736 if let Some((cached_modified_at, cached_packages)) = cached
737 && let Ok(metadata) = tokio::fs::metadata(lockfile_path).await
738 && let Ok(mtime) = metadata.modified()
739 && mtime <= cached_modified_at
740 {
741 tracing::debug!("Lock file cache hit: {}", lockfile_path.display());
742 // Refresh the recency marker on a hit too, not only on (re)parse: otherwise
743 // eviction is FIFO-by-parse-time rather than LRU, evicting a stable,
744 // frequently-read lock file before one that keeps changing and reparsing.
745 if let Some(mut entry) = self.entries.get_mut(lockfile_path) {
746 entry.parsed_at = Instant::now();
747 }
748 return Ok(cached_packages);
749 }
750
751 // Stat *before* parsing and use that pre-parse mtime as the cache key's freshness
752 // marker: stat'ing after would let a concurrent rewrite mid-parse store content from
753 // the old version under the new version's mtime, looking fresh when stale (#359).
754 tracing::debug!("Lock file cache miss: {}", lockfile_path.display());
755 let metadata = tokio::fs::metadata(lockfile_path).await?;
756 let modified_at = metadata.modified()?;
757
758 let packages = provider.parse_lockfile(lockfile_path).await?;
759
760 if !self.entries.contains_key(lockfile_path) && self.entries.len() >= self.capacity {
761 self.evict_oldest();
762 }
763 self.entries.insert(
764 lockfile_path.to_path_buf(),
765 CachedLockFile {
766 packages: packages.clone(),
767 modified_at,
768 parsed_at: Instant::now(),
769 },
770 );
771
772 Ok(packages)
773 }
774
775 /// Invalidates cached entry for a lock file.
776 ///
777 /// Forces next access to re-parse the file. Use when you know
778 /// the file has changed but modification time might not reflect it.
779 pub fn invalidate(&self, lockfile_path: &Path) {
780 self.entries.remove(lockfile_path);
781 }
782
783 /// Returns the number of cached lock files.
784 pub fn len(&self) -> usize {
785 self.entries.len()
786 }
787
788 /// Returns true if the cache is empty.
789 pub fn is_empty(&self) -> bool {
790 self.entries.is_empty()
791 }
792}
793
794impl Default for LockFileCache {
795 fn default() -> Self {
796 Self::new()
797 }
798}
799
800#[cfg(test)]
801mod tests {
802 use super::*;
803
804 /// Builds a URI whose path component is `manifest_path`'s real, absolute path, with the
805 /// `file://` prefix `Url::from_file_path` would produce replaced by `prefix` (e.g.
806 /// `"untitled:"` or `"file://attacker.example"`).
807 ///
808 /// Building this via `format!("{prefix}{}", manifest_path.display())` would panic on
809 /// Windows: `Path::display()` there uses `\` separators and an unescaped drive letter,
810 /// neither of which is a legal URI path character, so `.parse::<Url>()` fails. Routing
811 /// through `Url::from_file_path` first reuses its own drive-letter/separator/percent-
812 /// encoding handling, so the resulting string parses on every platform.
813 fn non_file_uri(prefix: &str, manifest_path: &std::path::Path) -> Url {
814 let file_uri = Url::from_file_path(manifest_path).unwrap();
815 let path_part = file_uri.as_str().strip_prefix("file://").unwrap();
816 format!("{prefix}{path_part}").parse().unwrap()
817 }
818
819 #[tokio::test]
820 async fn test_read_lockfile_content_success() {
821 // Held per `fs_probe::snapshot_guard`'s doc: `read_lockfile_content` transitively
822 // touches fs_probe, and this test runs in the same binary as other modules' (e.g.
823 // `mtime_cache`'s) diffing tests.
824 let _guard = fs_probe::snapshot_guard_async().await;
825 let temp_dir = tempfile::tempdir().unwrap();
826 let lock_path = temp_dir.path().join("Cargo.lock");
827 std::fs::write(&lock_path, "version = 4").unwrap();
828
829 let content = read_lockfile_content(&lock_path, "Cargo.lock")
830 .await
831 .unwrap();
832
833 assert_eq!(content, "version = 4");
834 }
835
836 /// An oversized lock file must be rejected by the capped read rather than read into
837 /// memory in full (CWE-400) — `read_lockfile_content` previously had no size gate at
838 /// all (#607). Uses a sparse file (`set_len`, all-zero bytes, valid UTF-8) so the test
839 /// does not need to write `MAX_LOCKFILE_BYTES` of real content to disk — `unwrap_err()`
840 /// panics outright if the cap is not actually applied, since a sparse file's all-NUL
841 /// content is otherwise perfectly valid `String` content for the success path to return.
842 #[tokio::test]
843 async fn test_read_lockfile_content_rejects_oversized_file() {
844 // See the comment in `test_read_lockfile_content_success` on why this guard is
845 // needed here.
846 let _guard = fs_probe::snapshot_guard_async().await;
847 let temp_dir = tempfile::tempdir().unwrap();
848 let lock_path = temp_dir.path().join("Cargo.lock");
849 let file = std::fs::File::create(&lock_path).unwrap();
850 file.set_len(MAX_LOCKFILE_BYTES + 1).unwrap();
851
852 let err = read_lockfile_content(&lock_path, "Cargo.lock")
853 .await
854 .unwrap_err();
855
856 match err {
857 DepsError::ParseError { file_type, .. } => {
858 assert_eq!(file_type, format!("Cargo.lock at {}", lock_path.display()));
859 }
860 other => panic!("Expected ParseError, got: {other:?}"),
861 }
862 }
863
864 /// A non-regular file (a directory here, portable across platforms unlike a FIFO) at
865 /// the lock file path must be rejected by the `is_file` stat pre-filter, not handed to
866 /// `File::open`/`read_to_string_capped` — the same class of hazard `fs_probe::is_file`'s
867 /// own doc warns about (a FIFO would block the read indefinitely).
868 #[tokio::test]
869 async fn test_read_lockfile_content_rejects_non_regular_file() {
870 // See the comment in `test_read_lockfile_content_success` on why this guard is
871 // needed here.
872 let _guard = fs_probe::snapshot_guard_async().await;
873 let temp_dir = tempfile::tempdir().unwrap();
874 let lock_path = temp_dir.path().join("Cargo.lock");
875 std::fs::create_dir(&lock_path).unwrap();
876
877 let err = read_lockfile_content(&lock_path, "Cargo.lock")
878 .await
879 .unwrap_err();
880
881 match err {
882 DepsError::ParseError { file_type, .. } => {
883 assert_eq!(file_type, format!("Cargo.lock at {}", lock_path.display()));
884 }
885 other => panic!("Expected ParseError, got: {other:?}"),
886 }
887 }
888
889 #[tokio::test]
890 async fn test_read_lockfile_content_missing_file_wraps_error() {
891 // See the comment in `test_read_lockfile_content_success` on why this guard is
892 // needed here.
893 let _guard = fs_probe::snapshot_guard_async().await;
894 let temp_dir = tempfile::tempdir().unwrap();
895 let lock_path = temp_dir.path().join("Cargo.lock");
896
897 let err = read_lockfile_content(&lock_path, "Cargo.lock")
898 .await
899 .unwrap_err();
900
901 match err {
902 DepsError::ParseError { file_type, .. } => {
903 assert_eq!(file_type, format!("Cargo.lock at {}", lock_path.display()));
904 }
905 other => panic!("Expected ParseError, got: {other:?}"),
906 }
907 }
908
909 /// The `spawn_blocking` `JoinError`→`DepsError::ParseError` mapping is issue #723's core
910 /// concern (a panicking/blocking parse must not silently misbehave or unwind the caller),
911 /// but had zero executable coverage — only the `no_run` doctest, which never runs. A
912 /// panicking `parse` closure must surface as `Err(DepsError::ParseError)`, labeled the
913 /// same way as every other `read_and_parse_lockfile` failure (`"{file_type} at {path}"`),
914 /// with the panic message preserved in `source` (tokio's `JoinError: Display` carries it).
915 #[tokio::test]
916 async fn test_read_and_parse_lockfile_panic_in_parse_becomes_parse_error() {
917 // See the comment in `test_read_lockfile_content_success` on why this guard is
918 // needed here.
919 let _guard = fs_probe::snapshot_guard_async().await;
920 let temp_dir = tempfile::tempdir().unwrap();
921 let lock_path = temp_dir.path().join("Cargo.lock");
922 std::fs::write(&lock_path, "version = 4").unwrap();
923
924 let err = read_and_parse_lockfile(&lock_path, "Cargo.lock", |_content| -> Result<()> {
925 panic!("boom")
926 })
927 .await
928 .unwrap_err();
929
930 match err {
931 DepsError::ParseError { file_type, source } => {
932 assert_eq!(file_type, format!("Cargo.lock at {}", lock_path.display()));
933 assert!(
934 source.to_string().contains("boom"),
935 "panic message should be preserved in the error source, got: {source}"
936 );
937 }
938 other => panic!("Expected ParseError, got: {other:?}"),
939 }
940 }
941
942 /// Proves `parse` actually runs off the calling (async) thread via `spawn_blocking`, not
943 /// merely that the return type happens to line up — the whole point of the fix is that
944 /// CPU-bound parsing no longer executes on the tokio worker driving the LSP request.
945 #[tokio::test]
946 async fn test_read_and_parse_lockfile_runs_parse_off_calling_thread() {
947 // See the comment in `test_read_lockfile_content_success` on why this guard is
948 // needed here.
949 let _guard = fs_probe::snapshot_guard_async().await;
950 let temp_dir = tempfile::tempdir().unwrap();
951 let lock_path = temp_dir.path().join("Cargo.lock");
952 std::fs::write(&lock_path, "version = 4").unwrap();
953
954 let calling_thread = std::thread::current().id();
955
956 let content = read_and_parse_lockfile(&lock_path, "Cargo.lock", move |content| {
957 assert_ne!(
958 std::thread::current().id(),
959 calling_thread,
960 "parse must run on the blocking pool, not the calling thread"
961 );
962 Ok(content)
963 })
964 .await
965 .unwrap();
966
967 assert_eq!(content, "version = 4");
968 }
969
970 #[test]
971 fn test_resolved_packages_new() {
972 let packages = ResolvedPackages::new();
973 assert!(packages.is_empty());
974 assert_eq!(packages.len(), 0);
975 }
976
977 #[test]
978 fn test_resolved_packages_insert_and_get() {
979 let mut packages = ResolvedPackages::new();
980
981 let pkg = ResolvedPackage {
982 name: "serde".into(),
983 version: "1.0.195".into(),
984 source: ResolvedSource::Registry {
985 url: "https://github.com/rust-lang/crates.io-index".into(),
986 checksum: "abc123".into(),
987 },
988 dependencies: vec!["serde_derive".into()],
989 };
990
991 packages.insert(pkg);
992
993 assert_eq!(packages.len(), 1);
994 assert!(!packages.is_empty());
995 assert_eq!(packages.version("serde"), Some("1.0.195"));
996
997 let retrieved = packages.get("serde");
998 assert!(retrieved.is_some());
999 assert_eq!(retrieved.unwrap().name, "serde");
1000 assert_eq!(retrieved.unwrap().dependencies.len(), 1);
1001 }
1002
1003 #[test]
1004 fn test_resolved_packages_get_nonexistent() {
1005 let packages = ResolvedPackages::new();
1006 assert_eq!(packages.get("nonexistent"), None);
1007 assert_eq!(packages.version("nonexistent"), None);
1008 }
1009
1010 #[test]
1011 fn test_resolved_packages_replace() {
1012 let mut packages = ResolvedPackages::new();
1013
1014 packages.insert(ResolvedPackage {
1015 name: "serde".into(),
1016 version: "1.0.0".into(),
1017 source: ResolvedSource::Registry {
1018 url: "test".into(),
1019 checksum: "old".into(),
1020 },
1021 dependencies: vec![],
1022 });
1023
1024 packages.insert(ResolvedPackage {
1025 name: "serde".into(),
1026 version: "1.0.195".into(),
1027 source: ResolvedSource::Registry {
1028 url: "test".into(),
1029 checksum: "new".into(),
1030 },
1031 dependencies: vec![],
1032 });
1033
1034 // Both versions stored, but len counts unique names
1035 assert_eq!(packages.len(), 1);
1036 assert_eq!(packages.version("serde"), Some("1.0.195"));
1037 // Both versions accessible via get_all
1038 assert_eq!(packages.all("serde").unwrap().len(), 2);
1039 }
1040
1041 #[test]
1042 fn test_resolved_packages_multiple_versions() {
1043 let mut packages = ResolvedPackages::new();
1044
1045 packages.insert(ResolvedPackage {
1046 name: "serde".into(),
1047 version: "1.0.195".into(),
1048 source: ResolvedSource::Registry {
1049 url: "test".into(),
1050 checksum: "a".into(),
1051 },
1052 dependencies: vec![],
1053 });
1054
1055 packages.insert(ResolvedPackage {
1056 name: "serde".into(),
1057 version: "0.9.0".into(),
1058 source: ResolvedSource::Registry {
1059 url: "test".into(),
1060 checksum: "b".into(),
1061 },
1062 dependencies: vec![],
1063 });
1064
1065 packages.insert(ResolvedPackage {
1066 name: "serde".into(),
1067 version: "2.0.0-beta.1".into(),
1068 source: ResolvedSource::Registry {
1069 url: "test".into(),
1070 checksum: "c".into(),
1071 },
1072 dependencies: vec![],
1073 });
1074
1075 assert_eq!(packages.len(), 1);
1076 assert_eq!(packages.version("serde"), Some("2.0.0-beta.1"));
1077 assert_eq!(packages.all("serde").unwrap().len(), 3);
1078 }
1079
1080 #[test]
1081 fn test_resolved_packages_non_semver_fallback() {
1082 let mut packages = ResolvedPackages::new();
1083
1084 packages.insert(ResolvedPackage {
1085 name: "weird".into(),
1086 version: "abc".into(),
1087 source: ResolvedSource::Path { path: ".".into() },
1088 dependencies: vec![],
1089 });
1090
1091 packages.insert(ResolvedPackage {
1092 name: "weird".into(),
1093 version: "xyz".into(),
1094 source: ResolvedSource::Path { path: ".".into() },
1095 dependencies: vec![],
1096 });
1097
1098 // Falls back to string comparison: "xyz" > "abc"
1099 assert_eq!(packages.version("weird"), Some("xyz"));
1100 }
1101
1102 #[test]
1103 fn test_resolved_packages_semver_preferred_over_non_semver() {
1104 let mut packages = ResolvedPackages::new();
1105
1106 packages.insert(ResolvedPackage {
1107 name: "mixed".into(),
1108 version: "not-a-version".into(),
1109 source: ResolvedSource::Path { path: ".".into() },
1110 dependencies: vec![],
1111 });
1112
1113 packages.insert(ResolvedPackage {
1114 name: "mixed".into(),
1115 version: "1.0.0".into(),
1116 source: ResolvedSource::Path { path: ".".into() },
1117 dependencies: vec![],
1118 });
1119
1120 // Parseable semver is preferred over non-parseable
1121 assert_eq!(packages.version("mixed"), Some("1.0.0"));
1122 }
1123
1124 #[test]
1125 fn test_resolved_source_equality() {
1126 let source1 = ResolvedSource::Registry {
1127 url: "https://test.com".into(),
1128 checksum: "abc".into(),
1129 };
1130 let source2 = ResolvedSource::Registry {
1131 url: "https://test.com".into(),
1132 checksum: "abc".into(),
1133 };
1134 let source3 = ResolvedSource::Git {
1135 url: "https://github.com/test".into(),
1136 rev: "abc123".into(),
1137 };
1138
1139 assert_eq!(source1, source2);
1140 assert_ne!(source1, source3);
1141 }
1142
1143 #[test]
1144 fn test_resolved_packages_iter() {
1145 let mut packages = ResolvedPackages::new();
1146
1147 packages.insert(ResolvedPackage {
1148 name: "serde".into(),
1149 version: "1.0.0".into(),
1150 source: ResolvedSource::Registry {
1151 url: "test".into(),
1152 checksum: "a".into(),
1153 },
1154 dependencies: vec![],
1155 });
1156
1157 packages.insert(ResolvedPackage {
1158 name: "tokio".into(),
1159 version: "1.0.0".into(),
1160 source: ResolvedSource::Registry {
1161 url: "test".into(),
1162 checksum: "b".into(),
1163 },
1164 dependencies: vec![],
1165 });
1166
1167 let count = packages.iter().count();
1168 assert_eq!(count, 2);
1169
1170 let names: Vec<_> = packages.iter().map(|(name, _)| name.as_str()).collect();
1171 assert!(names.contains(&"serde"));
1172 assert!(names.contains(&"tokio"));
1173 }
1174
1175 #[test]
1176 fn test_resolved_packages_into_map() {
1177 let mut packages = ResolvedPackages::new();
1178
1179 packages.insert(ResolvedPackage {
1180 name: "serde".into(),
1181 version: "1.0.0".into(),
1182 source: ResolvedSource::Registry {
1183 url: "test".into(),
1184 checksum: "a".into(),
1185 },
1186 dependencies: vec![],
1187 });
1188
1189 let map = packages.into_map();
1190 assert_eq!(map.len(), 1);
1191 assert!(map.contains_key("serde"));
1192 }
1193
1194 #[test]
1195 fn test_lockfile_cache_new() {
1196 let cache = LockFileCache::new();
1197 assert!(cache.is_empty());
1198 assert_eq!(cache.len(), 0);
1199 assert_eq!(
1200 cache.capacity, DEFAULT_MAX_CACHED_LOCKFILES,
1201 "new() must use the documented default capacity"
1202 );
1203 }
1204
1205 #[test]
1206 fn test_lockfile_cache_invalidate() {
1207 let cache = LockFileCache::new();
1208 let test_path = PathBuf::from("/test/Cargo.lock");
1209
1210 cache.entries.insert(
1211 test_path.clone(),
1212 CachedLockFile {
1213 packages: ResolvedPackages::new(),
1214 modified_at: SystemTime::now(),
1215 parsed_at: Instant::now(),
1216 },
1217 );
1218
1219 assert_eq!(cache.len(), 1);
1220
1221 cache.invalidate(&test_path);
1222 assert_eq!(cache.len(), 0);
1223 assert!(cache.is_empty());
1224 }
1225
1226 #[test]
1227 fn test_lockfile_cache_with_capacity() {
1228 let cache = LockFileCache::with_capacity(64);
1229 assert_eq!(cache.capacity, 64);
1230 assert!(cache.is_empty());
1231 }
1232
1233 /// #962 regression: a [`LockFileCache`] at capacity must evict the least-recently-parsed
1234 /// entry before inserting a new key, never grow past its configured bound.
1235 #[tokio::test]
1236 async fn test_get_or_parse_evicts_oldest_entry_at_capacity() {
1237 // See the comment in `test_get_or_parse_cache_hit_does_not_reparse` on why this
1238 // guard is needed here.
1239 let _guard = fs_probe::snapshot_guard_async().await;
1240 let temp_dir = tempfile::tempdir().unwrap();
1241 let lock_a = temp_dir.path().join("a.lock");
1242 let lock_b = temp_dir.path().join("b.lock");
1243 let lock_c = temp_dir.path().join("c.lock");
1244 std::fs::write(&lock_a, "a").unwrap();
1245 std::fs::write(&lock_b, "b").unwrap();
1246 std::fs::write(&lock_c, "c").unwrap();
1247
1248 let provider = CountingLockFileProvider::new();
1249 let cache = LockFileCache::with_capacity(2);
1250
1251 cache.get_or_parse(&provider, &lock_a).await.unwrap();
1252 cache.get_or_parse(&provider, &lock_b).await.unwrap();
1253 assert_eq!(
1254 cache.len(),
1255 2,
1256 "cache should hold exactly `capacity` entries"
1257 );
1258
1259 // `a` is now the oldest by `parsed_at`; inserting a third distinct key must evict
1260 // it rather than growing the cache past its capacity.
1261 cache.get_or_parse(&provider, &lock_c).await.unwrap();
1262 assert_eq!(
1263 cache.len(),
1264 2,
1265 "cache must not grow past its configured capacity"
1266 );
1267 assert!(
1268 cache.entries.contains_key(&lock_b),
1269 "the more recently parsed entry must survive eviction"
1270 );
1271 assert!(
1272 cache.entries.contains_key(&lock_c),
1273 "the newly inserted entry must be present"
1274 );
1275 assert!(
1276 !cache.entries.contains_key(&lock_a),
1277 "the least-recently-parsed entry must have been evicted"
1278 );
1279
1280 // Re-fetching the evicted `a` must trigger a fresh parse, not a cache hit.
1281 let parses_before = provider.parse_count();
1282 cache.get_or_parse(&provider, &lock_a).await.unwrap();
1283 assert_eq!(
1284 provider.parse_count(),
1285 parses_before + 1,
1286 "an evicted entry must be re-parsed on next access"
1287 );
1288 }
1289
1290 /// A repeat `get_or_parse` for an already-cached key at capacity must not trigger
1291 /// eviction — the cache-hit path returns before `entries.insert` is ever reached.
1292 #[tokio::test]
1293 async fn test_get_or_parse_cache_hit_at_capacity_does_not_evict() {
1294 // See the comment in `test_get_or_parse_cache_hit_does_not_reparse` on why this
1295 // guard is needed here.
1296 let _guard = fs_probe::snapshot_guard_async().await;
1297 let temp_dir = tempfile::tempdir().unwrap();
1298 let lock_a = temp_dir.path().join("a.lock");
1299 let lock_b = temp_dir.path().join("b.lock");
1300 std::fs::write(&lock_a, "a").unwrap();
1301 std::fs::write(&lock_b, "b").unwrap();
1302
1303 let provider = CountingLockFileProvider::new();
1304 let cache = LockFileCache::with_capacity(2);
1305
1306 cache.get_or_parse(&provider, &lock_a).await.unwrap();
1307 cache.get_or_parse(&provider, &lock_b).await.unwrap();
1308
1309 // Re-fetch `a` (unchanged mtime): a cache hit, must not evict `b`.
1310 cache.get_or_parse(&provider, &lock_a).await.unwrap();
1311 assert_eq!(cache.len(), 2);
1312 assert!(cache.entries.contains_key(&lock_a));
1313 assert!(cache.entries.contains_key(&lock_b));
1314 }
1315
1316 #[test]
1317 fn test_locate_lockfile_for_manifest_same_directory() {
1318 // Held per `fs_probe::snapshot_guard`'s doc: `locate_lockfile_for_manifest`
1319 // transitively touches fs_probe (via `fs_probe::is_file`), and this test runs in the
1320 // same binary as other modules' (e.g. `mtime_cache`'s) diffing tests.
1321 let _guard = fs_probe::snapshot_guard();
1322 let temp_dir = tempfile::tempdir().unwrap();
1323 let manifest_path = temp_dir.path().join("Cargo.toml");
1324 let lock_path = temp_dir.path().join("Cargo.lock");
1325
1326 std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
1327 std::fs::write(&lock_path, "version = 4").unwrap();
1328
1329 let manifest_uri = Url::from_file_path(&manifest_path).unwrap();
1330 let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
1331
1332 assert!(located.is_some());
1333 assert_eq!(located.unwrap(), lock_path);
1334 }
1335
1336 #[test]
1337 fn test_locate_lockfile_for_manifest_workspace_root() {
1338 // See the comment in `test_locate_lockfile_for_manifest_same_directory` on why this
1339 // guard is needed here.
1340 let _guard = fs_probe::snapshot_guard();
1341 let temp_dir = tempfile::tempdir().unwrap();
1342 let workspace_lock = temp_dir.path().join("Cargo.lock");
1343 let member_dir = temp_dir.path().join("crates").join("member");
1344 std::fs::create_dir_all(&member_dir).unwrap();
1345 let member_manifest = member_dir.join("Cargo.toml");
1346
1347 std::fs::write(&workspace_lock, "version = 4").unwrap();
1348 std::fs::write(&member_manifest, "[package]\nname = \"member\"").unwrap();
1349
1350 let manifest_uri = Url::from_file_path(&member_manifest).unwrap();
1351 let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
1352
1353 assert!(located.is_some());
1354 assert_eq!(located.unwrap(), workspace_lock);
1355 }
1356
1357 /// A directory named `Cargo.lock` (portable stand-in for a FIFO, which
1358 /// `std::fs::exists`-style checks would also wrongly treat as "found") must not be
1359 /// returned as a located lock file — `fs_probe::is_file` rejects anything but a
1360 /// regular file, unlike the plain `Path::exists()` this locator used before the fix.
1361 #[test]
1362 fn test_locate_lockfile_for_manifest_skips_non_regular_file() {
1363 // See the comment in `test_locate_lockfile_for_manifest_same_directory` on why this
1364 // guard is needed here.
1365 let _guard = fs_probe::snapshot_guard();
1366 let temp_dir = tempfile::tempdir().unwrap();
1367 let manifest_path = temp_dir.path().join("Cargo.toml");
1368 let lock_path = temp_dir.path().join("Cargo.lock");
1369
1370 std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
1371 std::fs::create_dir(&lock_path).unwrap();
1372
1373 let manifest_uri = Url::from_file_path(&manifest_path).unwrap();
1374 let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
1375
1376 assert!(
1377 located.is_none(),
1378 "a directory at the lock file path must not be treated as a found lock file"
1379 );
1380 }
1381
1382 /// #1084 regression: `url::Url::to_file_path` does not check the URI's scheme, so a
1383 /// hierarchical non-`file:` URI shaped like a real path (e.g. VS Code's `untitled:`
1384 /// scheme) must be rejected before any filesystem resolution is attempted, never
1385 /// resolved against the real filesystem.
1386 #[test]
1387 fn test_locate_lockfile_for_manifest_rejects_non_file_uri() {
1388 // See the comment in `test_locate_lockfile_for_manifest_same_directory` on why this
1389 // guard is needed here.
1390 let _guard = fs_probe::snapshot_guard();
1391 // A real manifest + lockfile pair on disk: without the scheme guard, the unguarded
1392 // `to_file_path` would still find this lockfile since the path component is real —
1393 // unlike a nonexistent-directory URI, which would return `None` for an unrelated
1394 // reason regardless.
1395 let temp_dir = tempfile::tempdir().unwrap();
1396 let manifest_path = temp_dir.path().join("Cargo.toml");
1397 let lock_path = temp_dir.path().join("Cargo.lock");
1398 std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
1399 std::fs::write(&lock_path, "version = 4").unwrap();
1400
1401 let manifest_uri = non_file_uri("untitled:", &manifest_path);
1402
1403 let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
1404
1405 assert!(
1406 located.is_none(),
1407 "a non-file-scheme URI must never resolve to a filesystem path, even when that \
1408 path names a real, existing lock file"
1409 );
1410 }
1411
1412 /// #1084 S3: a `file://` URI carrying a non-`localhost` host must be rejected by
1413 /// [`resolve_manifest_file_path`], not resolved against the local filesystem as if the
1414 /// host were not there. `url::Url::to_file_path` already refuses this internally
1415 /// (verified empirically: it checks cannot-be-a-base and host), but the explicit host
1416 /// check in `resolve_manifest_file_path` pins the behavior at this call site regardless
1417 /// of that internal detail, so this test protects against either implementation
1418 /// drifting silently.
1419 ///
1420 /// `#[cfg(unix)]`: the WHATWG URL Standard's file-host parsing unconditionally drops a
1421 /// `file://` URL's host whenever the path immediately following it looks like a Windows
1422 /// drive letter (`url` crate's `parser.rs`: "For file URLs that have a host and whose
1423 /// path starts with the windows drive letter we just remove the host" — this is
1424 /// spec-mandated parsing behavior, not an OS-gated code path, but it only *triggers* on
1425 /// a real Windows machine, where `tempfile::tempdir()`'s absolute path is drive-lettered;
1426 /// on Unix the path never looks like a drive letter, so the host survives parsing).
1427 /// Concretely: `file://attacker.example/C:/x` parses with `host: None`, not
1428 /// `Some("attacker.example")`, on Windows — the malicious host is discarded before
1429 /// `resolve_manifest_file_path` ever sees it, and the URI resolves to the local
1430 /// drive-letter path directly, same as if no host had been specified at all. This is
1431 /// safe (the "remote host" never actually gets treated as a target), just not what this
1432 /// test's `assert!(located.is_none())` expects, so it is Unix-only rather than adjusted
1433 /// to a different, weaker assertion guessed at without a Windows environment to verify
1434 /// the exact resulting behavior against.
1435 #[cfg(unix)]
1436 #[test]
1437 fn test_locate_lockfile_for_manifest_rejects_remote_host_file_uri() {
1438 // See the comment in `test_locate_lockfile_for_manifest_same_directory` on why this
1439 // guard is needed here.
1440 let _guard = fs_probe::snapshot_guard();
1441 let temp_dir = tempfile::tempdir().unwrap();
1442 let manifest_path = temp_dir.path().join("Cargo.toml");
1443 let lock_path = temp_dir.path().join("Cargo.lock");
1444 std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
1445 std::fs::write(&lock_path, "version = 4").unwrap();
1446
1447 let manifest_uri = non_file_uri("file://attacker.example", &manifest_path);
1448
1449 let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
1450
1451 assert!(
1452 located.is_none(),
1453 "a file: URI carrying a non-localhost host must never resolve to a filesystem path"
1454 );
1455 }
1456
1457 /// A `file:` URI with an empty or `localhost` host must still resolve normally — the
1458 /// host guard in [`resolve_manifest_file_path`] must not reject the common, safe case.
1459 ///
1460 /// `#[cfg(unix)]`: this crate's other Windows-path tests (see `deps_core::test_util::
1461 /// test_uri` and the `#1071` migration handoffs) establish that `url::Url::to_file_path`
1462 /// needs a drive-letter-shaped first path segment to resolve on Windows at all; a bare
1463 /// `file://localhost/tmp/...`-style fixture with no drive letter is not a portable test
1464 /// input there, so this is kept Unix-only rather than guessed at without a Windows
1465 /// environment to verify against.
1466 #[cfg(unix)]
1467 #[test]
1468 fn test_locate_lockfile_for_manifest_accepts_localhost_file_uri() {
1469 // See the comment in `test_locate_lockfile_for_manifest_same_directory` on why this
1470 // guard is needed here.
1471 let _guard = fs_probe::snapshot_guard();
1472 let temp_dir = tempfile::tempdir().unwrap();
1473 let manifest_path = temp_dir.path().join("Cargo.toml");
1474 let lock_path = temp_dir.path().join("Cargo.lock");
1475 std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
1476 std::fs::write(&lock_path, "version = 4").unwrap();
1477
1478 let manifest_uri = non_file_uri("file://localhost", &manifest_path);
1479
1480 let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
1481
1482 assert_eq!(located, Some(lock_path));
1483 }
1484
1485 #[test]
1486 fn test_locate_lockfile_for_manifest_not_found() {
1487 // See the comment in `test_locate_lockfile_for_manifest_same_directory` on why this
1488 // guard is needed here.
1489 let _guard = fs_probe::snapshot_guard();
1490 let temp_dir = tempfile::tempdir().unwrap();
1491 let manifest_path = temp_dir.path().join("Cargo.toml");
1492 std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
1493
1494 let manifest_uri = Url::from_file_path(&manifest_path).unwrap();
1495 let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
1496
1497 assert!(located.is_none());
1498 }
1499
1500 #[test]
1501 fn test_locate_lockfile_for_manifest_multiple_names() {
1502 // See the comment in `test_locate_lockfile_for_manifest_same_directory` on why this
1503 // guard is needed here.
1504 let _guard = fs_probe::snapshot_guard();
1505 let temp_dir = tempfile::tempdir().unwrap();
1506 let manifest_path = temp_dir.path().join("pyproject.toml");
1507 let uv_lock = temp_dir.path().join("uv.lock");
1508
1509 std::fs::write(&manifest_path, "[project]\nname = \"test\"").unwrap();
1510 std::fs::write(&uv_lock, "version = 1").unwrap();
1511
1512 let manifest_uri = Url::from_file_path(&manifest_path).unwrap();
1513 // poetry.lock doesn't exist, but uv.lock does - should find uv.lock
1514 let located = locate_lockfile_for_manifest(&manifest_uri, &["poetry.lock", "uv.lock"]);
1515
1516 assert!(located.is_some());
1517 assert_eq!(located.unwrap(), uv_lock);
1518 }
1519
1520 /// Stub [`LockFileProvider`] that counts `parse_lockfile` invocations and returns
1521 /// a package whose version is the lock file's trimmed content, so tests can
1522 /// observe both call count and which content was actually parsed.
1523 struct CountingLockFileProvider {
1524 parse_count: std::sync::atomic::AtomicUsize,
1525 }
1526
1527 impl CountingLockFileProvider {
1528 fn new() -> Self {
1529 Self {
1530 parse_count: std::sync::atomic::AtomicUsize::new(0),
1531 }
1532 }
1533
1534 fn parse_count(&self) -> usize {
1535 self.parse_count.load(std::sync::atomic::Ordering::SeqCst)
1536 }
1537 }
1538
1539 impl LockFileProvider for CountingLockFileProvider {
1540 fn locate_lockfile(&self, _manifest_uri: &Url) -> Option<PathBuf> {
1541 None
1542 }
1543
1544 fn parse_lockfile<'a>(
1545 &'a self,
1546 lockfile_path: &'a Path,
1547 ) -> std::pin::Pin<
1548 Box<dyn std::future::Future<Output = Result<ResolvedPackages>> + Send + 'a>,
1549 > {
1550 Box::pin(async move {
1551 self.parse_count
1552 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1553 let content = read_lockfile_content(lockfile_path, "test.lock").await?;
1554
1555 let mut packages = ResolvedPackages::new();
1556 packages.insert(ResolvedPackage {
1557 name: "test-package".into(),
1558 version: content.trim().to_string(),
1559 source: ResolvedSource::Path { path: ".".into() },
1560 dependencies: vec![],
1561 });
1562 Ok(packages)
1563 })
1564 }
1565 }
1566
1567 #[tokio::test]
1568 async fn test_get_or_parse_cache_hit_does_not_reparse() {
1569 // Held per `fs_probe::snapshot_guard`'s doc: the stub provider's `parse_lockfile`
1570 // calls `read_lockfile_content`, which transitively touches fs_probe.
1571 let _guard = fs_probe::snapshot_guard_async().await;
1572 let temp_dir = tempfile::tempdir().unwrap();
1573 let lock_path = temp_dir.path().join("test.lock");
1574 std::fs::write(&lock_path, "1.0.0").unwrap();
1575
1576 let provider = CountingLockFileProvider::new();
1577 let cache = LockFileCache::new();
1578
1579 let first = cache.get_or_parse(&provider, &lock_path).await.unwrap();
1580 let second = cache.get_or_parse(&provider, &lock_path).await.unwrap();
1581
1582 assert_eq!(provider.parse_count(), 1, "second call should hit cache");
1583 assert_eq!(first.version("test-package"), Some("1.0.0"));
1584 assert_eq!(second.version("test-package"), Some("1.0.0"));
1585 }
1586
1587 /// A lock file that does not exist (never cached, or deleted since) must surface as an
1588 /// `Err`, not panic or silently return empty/stale data — `get_or_parse` has no dedicated
1589 /// missing-file branch of its own; its cache-miss path's `tokio::fs::metadata` stat simply
1590 /// propagates the failure via `?`.
1591 #[tokio::test]
1592 async fn test_get_or_parse_missing_file_returns_error() {
1593 // See the comment in `test_get_or_parse_cache_hit_does_not_reparse` on why this
1594 // guard is needed here.
1595 let _guard = fs_probe::snapshot_guard_async().await;
1596 let temp_dir = tempfile::tempdir().unwrap();
1597 let lock_path = temp_dir.path().join("does-not-exist.lock");
1598
1599 let provider = CountingLockFileProvider::new();
1600 let cache = LockFileCache::new();
1601
1602 let err = cache.get_or_parse(&provider, &lock_path).await.unwrap_err();
1603
1604 assert!(
1605 matches!(err, DepsError::Io(_)),
1606 "expected an Io error for a missing lock file, got: {err:?}"
1607 );
1608 assert_eq!(
1609 provider.parse_count(),
1610 0,
1611 "parse_lockfile must not be called when the file cannot be stat'd"
1612 );
1613 }
1614
1615 /// A cached entry whose backing file has since been deleted must not be served as a
1616 /// stale-but-valid cache hit: the freshness check's stat fails, falls through to the
1617 /// cache-miss path, and that path's own stat also fails, surfacing as `Err` rather than
1618 /// resurrecting the old cached packages.
1619 #[tokio::test]
1620 async fn test_get_or_parse_cached_entry_with_deleted_file_returns_error() {
1621 // See the comment in `test_get_or_parse_cache_hit_does_not_reparse` on why this
1622 // guard is needed here.
1623 let _guard = fs_probe::snapshot_guard_async().await;
1624 let temp_dir = tempfile::tempdir().unwrap();
1625 let lock_path = temp_dir.path().join("test.lock");
1626 std::fs::write(&lock_path, "1.0.0").unwrap();
1627
1628 let provider = CountingLockFileProvider::new();
1629 let cache = LockFileCache::new();
1630
1631 let first = cache.get_or_parse(&provider, &lock_path).await.unwrap();
1632 assert_eq!(first.version("test-package"), Some("1.0.0"));
1633
1634 std::fs::remove_file(&lock_path).unwrap();
1635
1636 let err = cache.get_or_parse(&provider, &lock_path).await.unwrap_err();
1637
1638 assert!(
1639 matches!(err, DepsError::Io(_)),
1640 "expected an Io error once the cached file is deleted, got: {err:?}"
1641 );
1642 }
1643
1644 #[tokio::test]
1645 async fn test_get_or_parse_reparses_when_mtime_advances() {
1646 // See the comment in `test_get_or_parse_cache_hit_does_not_reparse` on why this
1647 // guard is needed here.
1648 let _guard = fs_probe::snapshot_guard_async().await;
1649 let temp_dir = tempfile::tempdir().unwrap();
1650 let lock_path = temp_dir.path().join("test.lock");
1651 std::fs::write(&lock_path, "1.0.0").unwrap();
1652
1653 let provider = CountingLockFileProvider::new();
1654 let cache = LockFileCache::new();
1655
1656 let first = cache.get_or_parse(&provider, &lock_path).await.unwrap();
1657 assert_eq!(first.version("test-package"), Some("1.0.0"));
1658
1659 std::fs::write(&lock_path, "2.0.0").unwrap();
1660 // Explicitly bump mtime into the future rather than relying on filesystem
1661 // mtime resolution (coarse on some platforms) to observe the change.
1662 let future_mtime = SystemTime::now() + std::time::Duration::from_secs(5);
1663 std::fs::OpenOptions::new()
1664 .write(true)
1665 .open(&lock_path)
1666 .unwrap()
1667 .set_modified(future_mtime)
1668 .unwrap();
1669
1670 let second = cache.get_or_parse(&provider, &lock_path).await.unwrap();
1671
1672 assert_eq!(
1673 provider.parse_count(),
1674 2,
1675 "stale mtime should trigger reparse"
1676 );
1677 assert_eq!(second.version("test-package"), Some("2.0.0"));
1678 }
1679
1680 /// Documents intentional behavior, not a bug: if a lock file's mtime moves *backward*
1681 /// relative to what is cached (clock skew, a restored backup, a `git checkout` touching
1682 /// an older tree), `get_or_parse`'s freshness check (`mtime <= cached_modified_at`) treats
1683 /// it as still fresh and serves the cached (now content-stale) packages rather than
1684 /// re-parsing. This mirrors the removed `is_lockfile_stale` default's own behavior for a
1685 /// `last_modified` timestamp set to the future, so the semantics are unchanged by its
1686 /// removal — this test exists only so the choice stays covered and visible.
1687 #[tokio::test]
1688 async fn test_get_or_parse_cache_hit_when_mtime_moves_backward() {
1689 // See the comment in `test_get_or_parse_cache_hit_does_not_reparse` on why this
1690 // guard is needed here.
1691 let _guard = fs_probe::snapshot_guard_async().await;
1692 let temp_dir = tempfile::tempdir().unwrap();
1693 let lock_path = temp_dir.path().join("test.lock");
1694 std::fs::write(&lock_path, "1.0.0").unwrap();
1695
1696 let provider = CountingLockFileProvider::new();
1697 let cache = LockFileCache::new();
1698
1699 let first = cache.get_or_parse(&provider, &lock_path).await.unwrap();
1700 assert_eq!(first.version("test-package"), Some("1.0.0"));
1701
1702 std::fs::write(&lock_path, "2.0.0").unwrap();
1703 let past_mtime = SystemTime::now() - std::time::Duration::from_secs(3600);
1704 std::fs::OpenOptions::new()
1705 .write(true)
1706 .open(&lock_path)
1707 .unwrap()
1708 .set_modified(past_mtime)
1709 .unwrap();
1710
1711 let second = cache.get_or_parse(&provider, &lock_path).await.unwrap();
1712
1713 assert_eq!(
1714 provider.parse_count(),
1715 1,
1716 "a backward-moving mtime must still be treated as a cache hit"
1717 );
1718 assert_eq!(
1719 second.version("test-package"),
1720 Some("1.0.0"),
1721 "cached (pre-rewrite) content should be served, not the new on-disk content"
1722 );
1723 }
1724
1725 /// Stub [`LockFileProvider`] that simulates a concurrent writer racing the parse:
1726 /// each `parse_lockfile` call reads the file's *current* content first, then — as
1727 /// a side effect before returning — rewrites the file to `"2.0.0"` and bumps its
1728 /// mtime into the future, then returns packages parsed from the content it read
1729 /// *before* that rewrite. This reproduces the only await point between the
1730 /// `get_or_parse` stat and cache insert, entirely under test control.
1731 struct RewritingDuringParseProvider {
1732 parse_count: std::sync::atomic::AtomicUsize,
1733 }
1734
1735 impl RewritingDuringParseProvider {
1736 fn new() -> Self {
1737 Self {
1738 parse_count: std::sync::atomic::AtomicUsize::new(0),
1739 }
1740 }
1741
1742 fn parse_count(&self) -> usize {
1743 self.parse_count.load(std::sync::atomic::Ordering::SeqCst)
1744 }
1745 }
1746
1747 impl LockFileProvider for RewritingDuringParseProvider {
1748 fn locate_lockfile(&self, _manifest_uri: &Url) -> Option<PathBuf> {
1749 None
1750 }
1751
1752 fn parse_lockfile<'a>(
1753 &'a self,
1754 lockfile_path: &'a Path,
1755 ) -> std::pin::Pin<
1756 Box<dyn std::future::Future<Output = Result<ResolvedPackages>> + Send + 'a>,
1757 > {
1758 Box::pin(async move {
1759 self.parse_count
1760 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1761 let content = read_lockfile_content(lockfile_path, "test.lock").await?;
1762
1763 // Simulate a writer rewriting the lock file mid-parse.
1764 std::fs::write(lockfile_path, "2.0.0").unwrap();
1765 let future_mtime = SystemTime::now() + std::time::Duration::from_secs(5);
1766 std::fs::OpenOptions::new()
1767 .write(true)
1768 .open(lockfile_path)
1769 .unwrap()
1770 .set_modified(future_mtime)
1771 .unwrap();
1772
1773 let mut packages = ResolvedPackages::new();
1774 packages.insert(ResolvedPackage {
1775 name: "test-package".into(),
1776 version: content.trim().to_string(),
1777 source: ResolvedSource::Path { path: ".".into() },
1778 dependencies: vec![],
1779 });
1780 Ok(packages)
1781 })
1782 }
1783 }
1784
1785 /// Regression test for #359: discriminates the stat-before-parse fix from the
1786 /// original stat-after-parse ordering.
1787 ///
1788 /// With the fix, `get_or_parse` stats the file *before* calling `parse_lockfile`,
1789 /// so the cached `modified_at` reflects the pre-rewrite mtime tied to the
1790 /// `"1.0.0"` content actually parsed. A second call then sees the file's mtime is
1791 /// newer than the cached one, so it re-parses and observes `"2.0.0"`.
1792 ///
1793 /// On the pre-fix ordering (stat after parse), the post-parse stat would pick up
1794 /// the mtime bump this same call just made, storing the *new* mtime alongside the
1795 /// *old* (`"1.0.0"`) content. The second call would then incorrectly hit cache and
1796 /// return stale `"1.0.0"` content without re-parsing — exactly the bug #359
1797 /// describes. This test would have failed on that code.
1798 #[tokio::test]
1799 async fn test_get_or_parse_detects_rewrite_during_parse() {
1800 // See the comment in `test_get_or_parse_cache_hit_does_not_reparse` on why this
1801 // guard is needed here.
1802 let _guard = fs_probe::snapshot_guard_async().await;
1803 let temp_dir = tempfile::tempdir().unwrap();
1804 let lock_path = temp_dir.path().join("test.lock");
1805 std::fs::write(&lock_path, "1.0.0").unwrap();
1806
1807 let provider = RewritingDuringParseProvider::new();
1808 let cache = LockFileCache::new();
1809
1810 let first = cache.get_or_parse(&provider, &lock_path).await.unwrap();
1811 assert_eq!(first.version("test-package"), Some("1.0.0"));
1812
1813 let second = cache.get_or_parse(&provider, &lock_path).await.unwrap();
1814
1815 assert_eq!(
1816 provider.parse_count(),
1817 2,
1818 "rewrite during first parse must be detected and trigger a reparse"
1819 );
1820 assert_eq!(second.version("test-package"), Some("2.0.0"));
1821 }
1822
1823 #[test]
1824 fn test_locate_lockfile_for_manifest_first_match_wins() {
1825 // See the comment in `test_locate_lockfile_for_manifest_same_directory` on why this
1826 // guard is needed here.
1827 let _guard = fs_probe::snapshot_guard();
1828 let temp_dir = tempfile::tempdir().unwrap();
1829 let manifest_path = temp_dir.path().join("pyproject.toml");
1830 let poetry_lock = temp_dir.path().join("poetry.lock");
1831 let uv_lock = temp_dir.path().join("uv.lock");
1832
1833 std::fs::write(&manifest_path, "[project]\nname = \"test\"").unwrap();
1834 std::fs::write(&poetry_lock, "# poetry lock").unwrap();
1835 std::fs::write(&uv_lock, "version = 1").unwrap();
1836
1837 let manifest_uri = Url::from_file_path(&manifest_path).unwrap();
1838 // Both exist, poetry.lock should be found first (listed first)
1839 let located = locate_lockfile_for_manifest(&manifest_uri, &["poetry.lock", "uv.lock"]);
1840
1841 assert!(located.is_some());
1842 assert_eq!(located.unwrap(), poetry_lock);
1843 }
1844}