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 std::fmt::Debug for LockFileCache {
656 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
657 f.debug_struct("LockFileCache")
658 .field("capacity", &self.capacity)
659 .field("len", &self.entries.len())
660 .finish_non_exhaustive()
661 }
662}
663
664impl LockFileCache {
665 /// Creates a new empty lock file cache, capped at [`DEFAULT_MAX_CACHED_LOCKFILES`]
666 /// entries.
667 #[must_use]
668 pub fn new() -> Self {
669 Self::with_capacity(DEFAULT_MAX_CACHED_LOCKFILES)
670 }
671
672 /// Creates a new empty lock file cache, capped at `capacity` entries.
673 ///
674 /// # Examples
675 ///
676 /// ```
677 /// use deps_core::lockfile::LockFileCache;
678 ///
679 /// let cache = LockFileCache::with_capacity(64);
680 /// assert!(cache.is_empty());
681 /// ```
682 #[must_use]
683 pub fn with_capacity(capacity: usize) -> Self {
684 Self {
685 entries: DashMap::new(),
686 capacity,
687 }
688 }
689
690 /// Evicts the least-recently-used entry, making room for one new insert.
691 ///
692 /// A full scan of `entries` by [`CachedLockFile::parsed_at`] — acceptable at this
693 /// cache's scale (bounded by `capacity`, [`DEFAULT_MAX_CACHED_LOCKFILES`] by default),
694 /// unlike [`crate::osv`]'s cache, which is sized an order of magnitude larger and batches
695 /// its eviction accordingly. Logs at `warn` when eviction actually runs, mirroring
696 /// [`crate::mtime_cache::MtimeFileCache`]'s own at-capacity warning.
697 fn evict_oldest(&self) {
698 let oldest = self
699 .entries
700 .iter()
701 .min_by_key(|entry| entry.parsed_at)
702 .map(|entry| entry.key().clone());
703 if let Some(key) = oldest {
704 tracing::warn!(
705 path = %key.display(),
706 capacity = self.capacity,
707 "lock file cache capacity reached; evicting least-recently-used entry"
708 );
709 self.entries.remove(&key);
710 }
711 }
712
713 /// Gets parsed packages from cache or parses the lock file.
714 ///
715 /// Checks file modification time to detect changes. If the file
716 /// has been modified since last parse, re-parses it. Otherwise,
717 /// returns the cached result.
718 ///
719 /// # Arguments
720 ///
721 /// * `provider` - Lock file provider implementation
722 /// * `lockfile_path` - Path to the lock file
723 ///
724 /// # Returns
725 ///
726 /// Resolved packages on success
727 ///
728 /// # Errors
729 ///
730 /// Returns error if file cannot be read or parsed
731 pub async fn get_or_parse(
732 &self,
733 provider: &dyn LockFileProvider,
734 lockfile_path: &Path,
735 ) -> Result<ResolvedPackages> {
736 // Extract owned data from the cache entry before awaiting: the DashMap shard
737 // `Ref` returned by `entries.get` must not be held across `.await`, or a
738 // concurrent access to the same key blocks for the duration (#350, same hazard
739 // class as #333).
740 let cached = self
741 .entries
742 .get(lockfile_path)
743 .map(|entry| (entry.modified_at, entry.packages.clone()));
744
745 if let Some((cached_modified_at, cached_packages)) = cached
746 && let Ok(metadata) = tokio::fs::metadata(lockfile_path).await
747 && let Ok(mtime) = metadata.modified()
748 && mtime <= cached_modified_at
749 {
750 tracing::debug!("Lock file cache hit: {}", lockfile_path.display());
751 // Refresh the recency marker on a hit too, not only on (re)parse: otherwise
752 // eviction is FIFO-by-parse-time rather than LRU, evicting a stable,
753 // frequently-read lock file before one that keeps changing and reparsing.
754 if let Some(mut entry) = self.entries.get_mut(lockfile_path) {
755 entry.parsed_at = Instant::now();
756 }
757 return Ok(cached_packages);
758 }
759
760 // Stat *before* parsing and use that pre-parse mtime as the cache key's freshness
761 // marker: stat'ing after would let a concurrent rewrite mid-parse store content from
762 // the old version under the new version's mtime, looking fresh when stale (#359).
763 tracing::debug!("Lock file cache miss: {}", lockfile_path.display());
764 let metadata = tokio::fs::metadata(lockfile_path).await?;
765 let modified_at = metadata.modified()?;
766
767 let packages = provider.parse_lockfile(lockfile_path).await?;
768
769 if !self.entries.contains_key(lockfile_path) && self.entries.len() >= self.capacity {
770 self.evict_oldest();
771 }
772 self.entries.insert(
773 lockfile_path.to_path_buf(),
774 CachedLockFile {
775 packages: packages.clone(),
776 modified_at,
777 parsed_at: Instant::now(),
778 },
779 );
780
781 Ok(packages)
782 }
783
784 /// Invalidates cached entry for a lock file.
785 ///
786 /// Forces next access to re-parse the file. Use when you know
787 /// the file has changed but modification time might not reflect it.
788 pub fn invalidate(&self, lockfile_path: &Path) {
789 self.entries.remove(lockfile_path);
790 }
791
792 /// Returns the number of cached lock files.
793 pub fn len(&self) -> usize {
794 self.entries.len()
795 }
796
797 /// Returns true if the cache is empty.
798 pub fn is_empty(&self) -> bool {
799 self.entries.is_empty()
800 }
801}
802
803impl Default for LockFileCache {
804 fn default() -> Self {
805 Self::new()
806 }
807}
808
809#[cfg(test)]
810mod tests {
811 use super::*;
812
813 /// Builds a URI whose path component is `manifest_path`'s real, absolute path, with the
814 /// `file://` prefix `Url::from_file_path` would produce replaced by `prefix` (e.g.
815 /// `"untitled:"` or `"file://attacker.example"`).
816 ///
817 /// Building this via `format!("{prefix}{}", manifest_path.display())` would panic on
818 /// Windows: `Path::display()` there uses `\` separators and an unescaped drive letter,
819 /// neither of which is a legal URI path character, so `.parse::<Url>()` fails. Routing
820 /// through `Url::from_file_path` first reuses its own drive-letter/separator/percent-
821 /// encoding handling, so the resulting string parses on every platform.
822 fn non_file_uri(prefix: &str, manifest_path: &std::path::Path) -> Url {
823 let file_uri = Url::from_file_path(manifest_path).unwrap();
824 let path_part = file_uri.as_str().strip_prefix("file://").unwrap();
825 format!("{prefix}{path_part}").parse().unwrap()
826 }
827
828 #[tokio::test]
829 async fn test_read_lockfile_content_success() {
830 // Held per `fs_probe::snapshot_guard`'s doc: `read_lockfile_content` transitively
831 // touches fs_probe, and this test runs in the same binary as other modules' (e.g.
832 // `mtime_cache`'s) diffing tests.
833 let _guard = fs_probe::snapshot_guard_async().await;
834 let temp_dir = tempfile::tempdir().unwrap();
835 let lock_path = temp_dir.path().join("Cargo.lock");
836 std::fs::write(&lock_path, "version = 4").unwrap();
837
838 let content = read_lockfile_content(&lock_path, "Cargo.lock")
839 .await
840 .unwrap();
841
842 assert_eq!(content, "version = 4");
843 }
844
845 /// An oversized lock file must be rejected by the capped read rather than read into
846 /// memory in full (CWE-400) — `read_lockfile_content` previously had no size gate at
847 /// all (#607). Uses a sparse file (`set_len`, all-zero bytes, valid UTF-8) so the test
848 /// does not need to write `MAX_LOCKFILE_BYTES` of real content to disk — `unwrap_err()`
849 /// panics outright if the cap is not actually applied, since a sparse file's all-NUL
850 /// content is otherwise perfectly valid `String` content for the success path to return.
851 #[tokio::test]
852 async fn test_read_lockfile_content_rejects_oversized_file() {
853 // See the comment in `test_read_lockfile_content_success` on why this guard is
854 // needed here.
855 let _guard = fs_probe::snapshot_guard_async().await;
856 let temp_dir = tempfile::tempdir().unwrap();
857 let lock_path = temp_dir.path().join("Cargo.lock");
858 let file = std::fs::File::create(&lock_path).unwrap();
859 file.set_len(MAX_LOCKFILE_BYTES + 1).unwrap();
860
861 let err = read_lockfile_content(&lock_path, "Cargo.lock")
862 .await
863 .unwrap_err();
864
865 match err {
866 DepsError::ParseError { file_type, .. } => {
867 assert_eq!(file_type, format!("Cargo.lock at {}", lock_path.display()));
868 }
869 other => panic!("Expected ParseError, got: {other:?}"),
870 }
871 }
872
873 /// A non-regular file (a directory here, portable across platforms unlike a FIFO) at
874 /// the lock file path must be rejected by the `is_file` stat pre-filter, not handed to
875 /// `File::open`/`read_to_string_capped` — the same class of hazard `fs_probe::is_file`'s
876 /// own doc warns about (a FIFO would block the read indefinitely).
877 #[tokio::test]
878 async fn test_read_lockfile_content_rejects_non_regular_file() {
879 // See the comment in `test_read_lockfile_content_success` on why this guard is
880 // needed here.
881 let _guard = fs_probe::snapshot_guard_async().await;
882 let temp_dir = tempfile::tempdir().unwrap();
883 let lock_path = temp_dir.path().join("Cargo.lock");
884 std::fs::create_dir(&lock_path).unwrap();
885
886 let err = read_lockfile_content(&lock_path, "Cargo.lock")
887 .await
888 .unwrap_err();
889
890 match err {
891 DepsError::ParseError { file_type, .. } => {
892 assert_eq!(file_type, format!("Cargo.lock at {}", lock_path.display()));
893 }
894 other => panic!("Expected ParseError, got: {other:?}"),
895 }
896 }
897
898 #[tokio::test]
899 async fn test_read_lockfile_content_missing_file_wraps_error() {
900 // See the comment in `test_read_lockfile_content_success` on why this guard is
901 // needed here.
902 let _guard = fs_probe::snapshot_guard_async().await;
903 let temp_dir = tempfile::tempdir().unwrap();
904 let lock_path = temp_dir.path().join("Cargo.lock");
905
906 let err = read_lockfile_content(&lock_path, "Cargo.lock")
907 .await
908 .unwrap_err();
909
910 match err {
911 DepsError::ParseError { file_type, .. } => {
912 assert_eq!(file_type, format!("Cargo.lock at {}", lock_path.display()));
913 }
914 other => panic!("Expected ParseError, got: {other:?}"),
915 }
916 }
917
918 /// The `spawn_blocking` `JoinError`→`DepsError::ParseError` mapping is issue #723's core
919 /// concern (a panicking/blocking parse must not silently misbehave or unwind the caller),
920 /// but had zero executable coverage — only the `no_run` doctest, which never runs. A
921 /// panicking `parse` closure must surface as `Err(DepsError::ParseError)`, labeled the
922 /// same way as every other `read_and_parse_lockfile` failure (`"{file_type} at {path}"`),
923 /// with the panic message preserved in `source` (tokio's `JoinError: Display` carries it).
924 #[tokio::test]
925 async fn test_read_and_parse_lockfile_panic_in_parse_becomes_parse_error() {
926 // See the comment in `test_read_lockfile_content_success` on why this guard is
927 // needed here.
928 let _guard = fs_probe::snapshot_guard_async().await;
929 let temp_dir = tempfile::tempdir().unwrap();
930 let lock_path = temp_dir.path().join("Cargo.lock");
931 std::fs::write(&lock_path, "version = 4").unwrap();
932
933 let err = read_and_parse_lockfile(&lock_path, "Cargo.lock", |_content| -> Result<()> {
934 panic!("boom")
935 })
936 .await
937 .unwrap_err();
938
939 match err {
940 DepsError::ParseError { file_type, source } => {
941 assert_eq!(file_type, format!("Cargo.lock at {}", lock_path.display()));
942 assert!(
943 source.to_string().contains("boom"),
944 "panic message should be preserved in the error source, got: {source}"
945 );
946 }
947 other => panic!("Expected ParseError, got: {other:?}"),
948 }
949 }
950
951 /// Proves `parse` actually runs off the calling (async) thread via `spawn_blocking`, not
952 /// merely that the return type happens to line up — the whole point of the fix is that
953 /// CPU-bound parsing no longer executes on the tokio worker driving the LSP request.
954 #[tokio::test]
955 async fn test_read_and_parse_lockfile_runs_parse_off_calling_thread() {
956 // See the comment in `test_read_lockfile_content_success` on why this guard is
957 // needed here.
958 let _guard = fs_probe::snapshot_guard_async().await;
959 let temp_dir = tempfile::tempdir().unwrap();
960 let lock_path = temp_dir.path().join("Cargo.lock");
961 std::fs::write(&lock_path, "version = 4").unwrap();
962
963 let calling_thread = std::thread::current().id();
964
965 let content = read_and_parse_lockfile(&lock_path, "Cargo.lock", move |content| {
966 assert_ne!(
967 std::thread::current().id(),
968 calling_thread,
969 "parse must run on the blocking pool, not the calling thread"
970 );
971 Ok(content)
972 })
973 .await
974 .unwrap();
975
976 assert_eq!(content, "version = 4");
977 }
978
979 #[test]
980 fn test_resolved_packages_new() {
981 let packages = ResolvedPackages::new();
982 assert!(packages.is_empty());
983 assert_eq!(packages.len(), 0);
984 }
985
986 #[test]
987 fn test_resolved_packages_insert_and_get() {
988 let mut packages = ResolvedPackages::new();
989
990 let pkg = ResolvedPackage {
991 name: "serde".into(),
992 version: "1.0.195".into(),
993 source: ResolvedSource::Registry {
994 url: "https://github.com/rust-lang/crates.io-index".into(),
995 checksum: "abc123".into(),
996 },
997 dependencies: vec!["serde_derive".into()],
998 };
999
1000 packages.insert(pkg);
1001
1002 assert_eq!(packages.len(), 1);
1003 assert!(!packages.is_empty());
1004 assert_eq!(packages.version("serde"), Some("1.0.195"));
1005
1006 let retrieved = packages.get("serde");
1007 assert!(retrieved.is_some());
1008 assert_eq!(retrieved.unwrap().name, "serde");
1009 assert_eq!(retrieved.unwrap().dependencies.len(), 1);
1010 }
1011
1012 #[test]
1013 fn test_resolved_packages_get_nonexistent() {
1014 let packages = ResolvedPackages::new();
1015 assert_eq!(packages.get("nonexistent"), None);
1016 assert_eq!(packages.version("nonexistent"), None);
1017 }
1018
1019 #[test]
1020 fn test_resolved_packages_replace() {
1021 let mut packages = ResolvedPackages::new();
1022
1023 packages.insert(ResolvedPackage {
1024 name: "serde".into(),
1025 version: "1.0.0".into(),
1026 source: ResolvedSource::Registry {
1027 url: "test".into(),
1028 checksum: "old".into(),
1029 },
1030 dependencies: vec![],
1031 });
1032
1033 packages.insert(ResolvedPackage {
1034 name: "serde".into(),
1035 version: "1.0.195".into(),
1036 source: ResolvedSource::Registry {
1037 url: "test".into(),
1038 checksum: "new".into(),
1039 },
1040 dependencies: vec![],
1041 });
1042
1043 // Both versions stored, but len counts unique names
1044 assert_eq!(packages.len(), 1);
1045 assert_eq!(packages.version("serde"), Some("1.0.195"));
1046 // Both versions accessible via get_all
1047 assert_eq!(packages.all("serde").unwrap().len(), 2);
1048 }
1049
1050 #[test]
1051 fn test_resolved_packages_multiple_versions() {
1052 let mut packages = ResolvedPackages::new();
1053
1054 packages.insert(ResolvedPackage {
1055 name: "serde".into(),
1056 version: "1.0.195".into(),
1057 source: ResolvedSource::Registry {
1058 url: "test".into(),
1059 checksum: "a".into(),
1060 },
1061 dependencies: vec![],
1062 });
1063
1064 packages.insert(ResolvedPackage {
1065 name: "serde".into(),
1066 version: "0.9.0".into(),
1067 source: ResolvedSource::Registry {
1068 url: "test".into(),
1069 checksum: "b".into(),
1070 },
1071 dependencies: vec![],
1072 });
1073
1074 packages.insert(ResolvedPackage {
1075 name: "serde".into(),
1076 version: "2.0.0-beta.1".into(),
1077 source: ResolvedSource::Registry {
1078 url: "test".into(),
1079 checksum: "c".into(),
1080 },
1081 dependencies: vec![],
1082 });
1083
1084 assert_eq!(packages.len(), 1);
1085 assert_eq!(packages.version("serde"), Some("2.0.0-beta.1"));
1086 assert_eq!(packages.all("serde").unwrap().len(), 3);
1087 }
1088
1089 #[test]
1090 fn test_resolved_packages_non_semver_fallback() {
1091 let mut packages = ResolvedPackages::new();
1092
1093 packages.insert(ResolvedPackage {
1094 name: "weird".into(),
1095 version: "abc".into(),
1096 source: ResolvedSource::Path { path: ".".into() },
1097 dependencies: vec![],
1098 });
1099
1100 packages.insert(ResolvedPackage {
1101 name: "weird".into(),
1102 version: "xyz".into(),
1103 source: ResolvedSource::Path { path: ".".into() },
1104 dependencies: vec![],
1105 });
1106
1107 // Falls back to string comparison: "xyz" > "abc"
1108 assert_eq!(packages.version("weird"), Some("xyz"));
1109 }
1110
1111 #[test]
1112 fn test_resolved_packages_semver_preferred_over_non_semver() {
1113 let mut packages = ResolvedPackages::new();
1114
1115 packages.insert(ResolvedPackage {
1116 name: "mixed".into(),
1117 version: "not-a-version".into(),
1118 source: ResolvedSource::Path { path: ".".into() },
1119 dependencies: vec![],
1120 });
1121
1122 packages.insert(ResolvedPackage {
1123 name: "mixed".into(),
1124 version: "1.0.0".into(),
1125 source: ResolvedSource::Path { path: ".".into() },
1126 dependencies: vec![],
1127 });
1128
1129 // Parseable semver is preferred over non-parseable
1130 assert_eq!(packages.version("mixed"), Some("1.0.0"));
1131 }
1132
1133 #[test]
1134 fn test_resolved_source_equality() {
1135 let source1 = ResolvedSource::Registry {
1136 url: "https://test.com".into(),
1137 checksum: "abc".into(),
1138 };
1139 let source2 = ResolvedSource::Registry {
1140 url: "https://test.com".into(),
1141 checksum: "abc".into(),
1142 };
1143 let source3 = ResolvedSource::Git {
1144 url: "https://github.com/test".into(),
1145 rev: "abc123".into(),
1146 };
1147
1148 assert_eq!(source1, source2);
1149 assert_ne!(source1, source3);
1150 }
1151
1152 #[test]
1153 fn test_resolved_packages_iter() {
1154 let mut packages = ResolvedPackages::new();
1155
1156 packages.insert(ResolvedPackage {
1157 name: "serde".into(),
1158 version: "1.0.0".into(),
1159 source: ResolvedSource::Registry {
1160 url: "test".into(),
1161 checksum: "a".into(),
1162 },
1163 dependencies: vec![],
1164 });
1165
1166 packages.insert(ResolvedPackage {
1167 name: "tokio".into(),
1168 version: "1.0.0".into(),
1169 source: ResolvedSource::Registry {
1170 url: "test".into(),
1171 checksum: "b".into(),
1172 },
1173 dependencies: vec![],
1174 });
1175
1176 let count = packages.iter().count();
1177 assert_eq!(count, 2);
1178
1179 let names: Vec<_> = packages.iter().map(|(name, _)| name.as_str()).collect();
1180 assert!(names.contains(&"serde"));
1181 assert!(names.contains(&"tokio"));
1182 }
1183
1184 #[test]
1185 fn test_resolved_packages_into_map() {
1186 let mut packages = ResolvedPackages::new();
1187
1188 packages.insert(ResolvedPackage {
1189 name: "serde".into(),
1190 version: "1.0.0".into(),
1191 source: ResolvedSource::Registry {
1192 url: "test".into(),
1193 checksum: "a".into(),
1194 },
1195 dependencies: vec![],
1196 });
1197
1198 let map = packages.into_map();
1199 assert_eq!(map.len(), 1);
1200 assert!(map.contains_key("serde"));
1201 }
1202
1203 #[test]
1204 fn test_lockfile_cache_new() {
1205 let cache = LockFileCache::new();
1206 assert!(cache.is_empty());
1207 assert_eq!(cache.len(), 0);
1208 assert_eq!(
1209 cache.capacity, DEFAULT_MAX_CACHED_LOCKFILES,
1210 "new() must use the documented default capacity"
1211 );
1212 }
1213
1214 #[test]
1215 fn test_lockfile_cache_invalidate() {
1216 let cache = LockFileCache::new();
1217 let test_path = PathBuf::from("/test/Cargo.lock");
1218
1219 cache.entries.insert(
1220 test_path.clone(),
1221 CachedLockFile {
1222 packages: ResolvedPackages::new(),
1223 modified_at: SystemTime::now(),
1224 parsed_at: Instant::now(),
1225 },
1226 );
1227
1228 assert_eq!(cache.len(), 1);
1229
1230 cache.invalidate(&test_path);
1231 assert_eq!(cache.len(), 0);
1232 assert!(cache.is_empty());
1233 }
1234
1235 #[test]
1236 fn test_lockfile_cache_with_capacity() {
1237 let cache = LockFileCache::with_capacity(64);
1238 assert_eq!(cache.capacity, 64);
1239 assert!(cache.is_empty());
1240 }
1241
1242 /// #962 regression: a [`LockFileCache`] at capacity must evict the least-recently-parsed
1243 /// entry before inserting a new key, never grow past its configured bound.
1244 #[tokio::test]
1245 async fn test_get_or_parse_evicts_oldest_entry_at_capacity() {
1246 // See the comment in `test_get_or_parse_cache_hit_does_not_reparse` on why this
1247 // guard is needed here.
1248 let _guard = fs_probe::snapshot_guard_async().await;
1249 let temp_dir = tempfile::tempdir().unwrap();
1250 let lock_a = temp_dir.path().join("a.lock");
1251 let lock_b = temp_dir.path().join("b.lock");
1252 let lock_c = temp_dir.path().join("c.lock");
1253 std::fs::write(&lock_a, "a").unwrap();
1254 std::fs::write(&lock_b, "b").unwrap();
1255 std::fs::write(&lock_c, "c").unwrap();
1256
1257 let provider = CountingLockFileProvider::new();
1258 let cache = LockFileCache::with_capacity(2);
1259
1260 cache.get_or_parse(&provider, &lock_a).await.unwrap();
1261 cache.get_or_parse(&provider, &lock_b).await.unwrap();
1262 assert_eq!(
1263 cache.len(),
1264 2,
1265 "cache should hold exactly `capacity` entries"
1266 );
1267
1268 // `a` is now the oldest by `parsed_at`; inserting a third distinct key must evict
1269 // it rather than growing the cache past its capacity.
1270 cache.get_or_parse(&provider, &lock_c).await.unwrap();
1271 assert_eq!(
1272 cache.len(),
1273 2,
1274 "cache must not grow past its configured capacity"
1275 );
1276 assert!(
1277 cache.entries.contains_key(&lock_b),
1278 "the more recently parsed entry must survive eviction"
1279 );
1280 assert!(
1281 cache.entries.contains_key(&lock_c),
1282 "the newly inserted entry must be present"
1283 );
1284 assert!(
1285 !cache.entries.contains_key(&lock_a),
1286 "the least-recently-parsed entry must have been evicted"
1287 );
1288
1289 // Re-fetching the evicted `a` must trigger a fresh parse, not a cache hit.
1290 let parses_before = provider.parse_count();
1291 cache.get_or_parse(&provider, &lock_a).await.unwrap();
1292 assert_eq!(
1293 provider.parse_count(),
1294 parses_before + 1,
1295 "an evicted entry must be re-parsed on next access"
1296 );
1297 }
1298
1299 /// A repeat `get_or_parse` for an already-cached key at capacity must not trigger
1300 /// eviction — the cache-hit path returns before `entries.insert` is ever reached.
1301 #[tokio::test]
1302 async fn test_get_or_parse_cache_hit_at_capacity_does_not_evict() {
1303 // See the comment in `test_get_or_parse_cache_hit_does_not_reparse` on why this
1304 // guard is needed here.
1305 let _guard = fs_probe::snapshot_guard_async().await;
1306 let temp_dir = tempfile::tempdir().unwrap();
1307 let lock_a = temp_dir.path().join("a.lock");
1308 let lock_b = temp_dir.path().join("b.lock");
1309 std::fs::write(&lock_a, "a").unwrap();
1310 std::fs::write(&lock_b, "b").unwrap();
1311
1312 let provider = CountingLockFileProvider::new();
1313 let cache = LockFileCache::with_capacity(2);
1314
1315 cache.get_or_parse(&provider, &lock_a).await.unwrap();
1316 cache.get_or_parse(&provider, &lock_b).await.unwrap();
1317
1318 // Re-fetch `a` (unchanged mtime): a cache hit, must not evict `b`.
1319 cache.get_or_parse(&provider, &lock_a).await.unwrap();
1320 assert_eq!(cache.len(), 2);
1321 assert!(cache.entries.contains_key(&lock_a));
1322 assert!(cache.entries.contains_key(&lock_b));
1323 }
1324
1325 #[test]
1326 fn test_locate_lockfile_for_manifest_same_directory() {
1327 // Held per `fs_probe::snapshot_guard`'s doc: `locate_lockfile_for_manifest`
1328 // transitively touches fs_probe (via `fs_probe::is_file`), and this test runs in the
1329 // same binary as other modules' (e.g. `mtime_cache`'s) diffing tests.
1330 let _guard = fs_probe::snapshot_guard();
1331 let temp_dir = tempfile::tempdir().unwrap();
1332 let manifest_path = temp_dir.path().join("Cargo.toml");
1333 let lock_path = temp_dir.path().join("Cargo.lock");
1334
1335 std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
1336 std::fs::write(&lock_path, "version = 4").unwrap();
1337
1338 let manifest_uri = Url::from_file_path(&manifest_path).unwrap();
1339 let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
1340
1341 assert!(located.is_some());
1342 assert_eq!(located.unwrap(), lock_path);
1343 }
1344
1345 #[test]
1346 fn test_locate_lockfile_for_manifest_workspace_root() {
1347 // See the comment in `test_locate_lockfile_for_manifest_same_directory` on why this
1348 // guard is needed here.
1349 let _guard = fs_probe::snapshot_guard();
1350 let temp_dir = tempfile::tempdir().unwrap();
1351 let workspace_lock = temp_dir.path().join("Cargo.lock");
1352 let member_dir = temp_dir.path().join("crates").join("member");
1353 std::fs::create_dir_all(&member_dir).unwrap();
1354 let member_manifest = member_dir.join("Cargo.toml");
1355
1356 std::fs::write(&workspace_lock, "version = 4").unwrap();
1357 std::fs::write(&member_manifest, "[package]\nname = \"member\"").unwrap();
1358
1359 let manifest_uri = Url::from_file_path(&member_manifest).unwrap();
1360 let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
1361
1362 assert!(located.is_some());
1363 assert_eq!(located.unwrap(), workspace_lock);
1364 }
1365
1366 /// A directory named `Cargo.lock` (portable stand-in for a FIFO, which
1367 /// `std::fs::exists`-style checks would also wrongly treat as "found") must not be
1368 /// returned as a located lock file — `fs_probe::is_file` rejects anything but a
1369 /// regular file, unlike the plain `Path::exists()` this locator used before the fix.
1370 #[test]
1371 fn test_locate_lockfile_for_manifest_skips_non_regular_file() {
1372 // See the comment in `test_locate_lockfile_for_manifest_same_directory` on why this
1373 // guard is needed here.
1374 let _guard = fs_probe::snapshot_guard();
1375 let temp_dir = tempfile::tempdir().unwrap();
1376 let manifest_path = temp_dir.path().join("Cargo.toml");
1377 let lock_path = temp_dir.path().join("Cargo.lock");
1378
1379 std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
1380 std::fs::create_dir(&lock_path).unwrap();
1381
1382 let manifest_uri = Url::from_file_path(&manifest_path).unwrap();
1383 let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
1384
1385 assert!(
1386 located.is_none(),
1387 "a directory at the lock file path must not be treated as a found lock file"
1388 );
1389 }
1390
1391 /// #1084 regression: `url::Url::to_file_path` does not check the URI's scheme, so a
1392 /// hierarchical non-`file:` URI shaped like a real path (e.g. VS Code's `untitled:`
1393 /// scheme) must be rejected before any filesystem resolution is attempted, never
1394 /// resolved against the real filesystem.
1395 #[test]
1396 fn test_locate_lockfile_for_manifest_rejects_non_file_uri() {
1397 // See the comment in `test_locate_lockfile_for_manifest_same_directory` on why this
1398 // guard is needed here.
1399 let _guard = fs_probe::snapshot_guard();
1400 // A real manifest + lockfile pair on disk: without the scheme guard, the unguarded
1401 // `to_file_path` would still find this lockfile since the path component is real —
1402 // unlike a nonexistent-directory URI, which would return `None` for an unrelated
1403 // reason regardless.
1404 let temp_dir = tempfile::tempdir().unwrap();
1405 let manifest_path = temp_dir.path().join("Cargo.toml");
1406 let lock_path = temp_dir.path().join("Cargo.lock");
1407 std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
1408 std::fs::write(&lock_path, "version = 4").unwrap();
1409
1410 let manifest_uri = non_file_uri("untitled:", &manifest_path);
1411
1412 let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
1413
1414 assert!(
1415 located.is_none(),
1416 "a non-file-scheme URI must never resolve to a filesystem path, even when that \
1417 path names a real, existing lock file"
1418 );
1419 }
1420
1421 /// #1084 S3: a `file://` URI carrying a non-`localhost` host must be rejected by
1422 /// [`resolve_manifest_file_path`], not resolved against the local filesystem as if the
1423 /// host were not there. `url::Url::to_file_path` already refuses this internally
1424 /// (verified empirically: it checks cannot-be-a-base and host), but the explicit host
1425 /// check in `resolve_manifest_file_path` pins the behavior at this call site regardless
1426 /// of that internal detail, so this test protects against either implementation
1427 /// drifting silently.
1428 ///
1429 /// `#[cfg(unix)]`: the WHATWG URL Standard's file-host parsing unconditionally drops a
1430 /// `file://` URL's host whenever the path immediately following it looks like a Windows
1431 /// drive letter (`url` crate's `parser.rs`: "For file URLs that have a host and whose
1432 /// path starts with the windows drive letter we just remove the host" — this is
1433 /// spec-mandated parsing behavior, not an OS-gated code path, but it only *triggers* on
1434 /// a real Windows machine, where `tempfile::tempdir()`'s absolute path is drive-lettered;
1435 /// on Unix the path never looks like a drive letter, so the host survives parsing).
1436 /// Concretely: `file://attacker.example/C:/x` parses with `host: None`, not
1437 /// `Some("attacker.example")`, on Windows — the malicious host is discarded before
1438 /// `resolve_manifest_file_path` ever sees it, and the URI resolves to the local
1439 /// drive-letter path directly, same as if no host had been specified at all. This is
1440 /// safe (the "remote host" never actually gets treated as a target), just not what this
1441 /// test's `assert!(located.is_none())` expects, so it is Unix-only rather than adjusted
1442 /// to a different, weaker assertion guessed at without a Windows environment to verify
1443 /// the exact resulting behavior against.
1444 #[cfg(unix)]
1445 #[test]
1446 fn test_locate_lockfile_for_manifest_rejects_remote_host_file_uri() {
1447 // See the comment in `test_locate_lockfile_for_manifest_same_directory` on why this
1448 // guard is needed here.
1449 let _guard = fs_probe::snapshot_guard();
1450 let temp_dir = tempfile::tempdir().unwrap();
1451 let manifest_path = temp_dir.path().join("Cargo.toml");
1452 let lock_path = temp_dir.path().join("Cargo.lock");
1453 std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
1454 std::fs::write(&lock_path, "version = 4").unwrap();
1455
1456 let manifest_uri = non_file_uri("file://attacker.example", &manifest_path);
1457
1458 let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
1459
1460 assert!(
1461 located.is_none(),
1462 "a file: URI carrying a non-localhost host must never resolve to a filesystem path"
1463 );
1464 }
1465
1466 /// A `file:` URI with an empty or `localhost` host must still resolve normally — the
1467 /// host guard in [`resolve_manifest_file_path`] must not reject the common, safe case.
1468 ///
1469 /// `#[cfg(unix)]`: this crate's other Windows-path tests (see `deps_core::test_util::
1470 /// test_uri` and the `#1071` migration handoffs) establish that `url::Url::to_file_path`
1471 /// needs a drive-letter-shaped first path segment to resolve on Windows at all; a bare
1472 /// `file://localhost/tmp/...`-style fixture with no drive letter is not a portable test
1473 /// input there, so this is kept Unix-only rather than guessed at without a Windows
1474 /// environment to verify against.
1475 #[cfg(unix)]
1476 #[test]
1477 fn test_locate_lockfile_for_manifest_accepts_localhost_file_uri() {
1478 // See the comment in `test_locate_lockfile_for_manifest_same_directory` on why this
1479 // guard is needed here.
1480 let _guard = fs_probe::snapshot_guard();
1481 let temp_dir = tempfile::tempdir().unwrap();
1482 let manifest_path = temp_dir.path().join("Cargo.toml");
1483 let lock_path = temp_dir.path().join("Cargo.lock");
1484 std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
1485 std::fs::write(&lock_path, "version = 4").unwrap();
1486
1487 let manifest_uri = non_file_uri("file://localhost", &manifest_path);
1488
1489 let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
1490
1491 assert_eq!(located, Some(lock_path));
1492 }
1493
1494 #[test]
1495 fn test_locate_lockfile_for_manifest_not_found() {
1496 // See the comment in `test_locate_lockfile_for_manifest_same_directory` on why this
1497 // guard is needed here.
1498 let _guard = fs_probe::snapshot_guard();
1499 let temp_dir = tempfile::tempdir().unwrap();
1500 let manifest_path = temp_dir.path().join("Cargo.toml");
1501 std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap();
1502
1503 let manifest_uri = Url::from_file_path(&manifest_path).unwrap();
1504 let located = locate_lockfile_for_manifest(&manifest_uri, &["Cargo.lock"]);
1505
1506 assert!(located.is_none());
1507 }
1508
1509 #[test]
1510 fn test_locate_lockfile_for_manifest_multiple_names() {
1511 // See the comment in `test_locate_lockfile_for_manifest_same_directory` on why this
1512 // guard is needed here.
1513 let _guard = fs_probe::snapshot_guard();
1514 let temp_dir = tempfile::tempdir().unwrap();
1515 let manifest_path = temp_dir.path().join("pyproject.toml");
1516 let uv_lock = temp_dir.path().join("uv.lock");
1517
1518 std::fs::write(&manifest_path, "[project]\nname = \"test\"").unwrap();
1519 std::fs::write(&uv_lock, "version = 1").unwrap();
1520
1521 let manifest_uri = Url::from_file_path(&manifest_path).unwrap();
1522 // poetry.lock doesn't exist, but uv.lock does - should find uv.lock
1523 let located = locate_lockfile_for_manifest(&manifest_uri, &["poetry.lock", "uv.lock"]);
1524
1525 assert!(located.is_some());
1526 assert_eq!(located.unwrap(), uv_lock);
1527 }
1528
1529 /// Stub [`LockFileProvider`] that counts `parse_lockfile` invocations and returns
1530 /// a package whose version is the lock file's trimmed content, so tests can
1531 /// observe both call count and which content was actually parsed.
1532 struct CountingLockFileProvider {
1533 parse_count: std::sync::atomic::AtomicUsize,
1534 }
1535
1536 impl CountingLockFileProvider {
1537 fn new() -> Self {
1538 Self {
1539 parse_count: std::sync::atomic::AtomicUsize::new(0),
1540 }
1541 }
1542
1543 fn parse_count(&self) -> usize {
1544 self.parse_count.load(std::sync::atomic::Ordering::SeqCst)
1545 }
1546 }
1547
1548 impl LockFileProvider for CountingLockFileProvider {
1549 fn locate_lockfile(&self, _manifest_uri: &Url) -> Option<PathBuf> {
1550 None
1551 }
1552
1553 fn parse_lockfile<'a>(
1554 &'a self,
1555 lockfile_path: &'a Path,
1556 ) -> std::pin::Pin<
1557 Box<dyn std::future::Future<Output = Result<ResolvedPackages>> + Send + 'a>,
1558 > {
1559 Box::pin(async move {
1560 self.parse_count
1561 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1562 let content = read_lockfile_content(lockfile_path, "test.lock").await?;
1563
1564 let mut packages = ResolvedPackages::new();
1565 packages.insert(ResolvedPackage {
1566 name: "test-package".into(),
1567 version: content.trim().to_string(),
1568 source: ResolvedSource::Path { path: ".".into() },
1569 dependencies: vec![],
1570 });
1571 Ok(packages)
1572 })
1573 }
1574 }
1575
1576 #[tokio::test]
1577 async fn test_get_or_parse_cache_hit_does_not_reparse() {
1578 // Held per `fs_probe::snapshot_guard`'s doc: the stub provider's `parse_lockfile`
1579 // calls `read_lockfile_content`, which transitively touches fs_probe.
1580 let _guard = fs_probe::snapshot_guard_async().await;
1581 let temp_dir = tempfile::tempdir().unwrap();
1582 let lock_path = temp_dir.path().join("test.lock");
1583 std::fs::write(&lock_path, "1.0.0").unwrap();
1584
1585 let provider = CountingLockFileProvider::new();
1586 let cache = LockFileCache::new();
1587
1588 let first = cache.get_or_parse(&provider, &lock_path).await.unwrap();
1589 let second = cache.get_or_parse(&provider, &lock_path).await.unwrap();
1590
1591 assert_eq!(provider.parse_count(), 1, "second call should hit cache");
1592 assert_eq!(first.version("test-package"), Some("1.0.0"));
1593 assert_eq!(second.version("test-package"), Some("1.0.0"));
1594 }
1595
1596 /// A lock file that does not exist (never cached, or deleted since) must surface as an
1597 /// `Err`, not panic or silently return empty/stale data — `get_or_parse` has no dedicated
1598 /// missing-file branch of its own; its cache-miss path's `tokio::fs::metadata` stat simply
1599 /// propagates the failure via `?`.
1600 #[tokio::test]
1601 async fn test_get_or_parse_missing_file_returns_error() {
1602 // See the comment in `test_get_or_parse_cache_hit_does_not_reparse` on why this
1603 // guard is needed here.
1604 let _guard = fs_probe::snapshot_guard_async().await;
1605 let temp_dir = tempfile::tempdir().unwrap();
1606 let lock_path = temp_dir.path().join("does-not-exist.lock");
1607
1608 let provider = CountingLockFileProvider::new();
1609 let cache = LockFileCache::new();
1610
1611 let err = cache.get_or_parse(&provider, &lock_path).await.unwrap_err();
1612
1613 assert!(
1614 matches!(err, DepsError::Io(_)),
1615 "expected an Io error for a missing lock file, got: {err:?}"
1616 );
1617 assert_eq!(
1618 provider.parse_count(),
1619 0,
1620 "parse_lockfile must not be called when the file cannot be stat'd"
1621 );
1622 }
1623
1624 /// A cached entry whose backing file has since been deleted must not be served as a
1625 /// stale-but-valid cache hit: the freshness check's stat fails, falls through to the
1626 /// cache-miss path, and that path's own stat also fails, surfacing as `Err` rather than
1627 /// resurrecting the old cached packages.
1628 #[tokio::test]
1629 async fn test_get_or_parse_cached_entry_with_deleted_file_returns_error() {
1630 // See the comment in `test_get_or_parse_cache_hit_does_not_reparse` on why this
1631 // guard is needed here.
1632 let _guard = fs_probe::snapshot_guard_async().await;
1633 let temp_dir = tempfile::tempdir().unwrap();
1634 let lock_path = temp_dir.path().join("test.lock");
1635 std::fs::write(&lock_path, "1.0.0").unwrap();
1636
1637 let provider = CountingLockFileProvider::new();
1638 let cache = LockFileCache::new();
1639
1640 let first = cache.get_or_parse(&provider, &lock_path).await.unwrap();
1641 assert_eq!(first.version("test-package"), Some("1.0.0"));
1642
1643 std::fs::remove_file(&lock_path).unwrap();
1644
1645 let err = cache.get_or_parse(&provider, &lock_path).await.unwrap_err();
1646
1647 assert!(
1648 matches!(err, DepsError::Io(_)),
1649 "expected an Io error once the cached file is deleted, got: {err:?}"
1650 );
1651 }
1652
1653 #[tokio::test]
1654 async fn test_get_or_parse_reparses_when_mtime_advances() {
1655 // See the comment in `test_get_or_parse_cache_hit_does_not_reparse` on why this
1656 // guard is needed here.
1657 let _guard = fs_probe::snapshot_guard_async().await;
1658 let temp_dir = tempfile::tempdir().unwrap();
1659 let lock_path = temp_dir.path().join("test.lock");
1660 std::fs::write(&lock_path, "1.0.0").unwrap();
1661
1662 let provider = CountingLockFileProvider::new();
1663 let cache = LockFileCache::new();
1664
1665 let first = cache.get_or_parse(&provider, &lock_path).await.unwrap();
1666 assert_eq!(first.version("test-package"), Some("1.0.0"));
1667
1668 std::fs::write(&lock_path, "2.0.0").unwrap();
1669 // Explicitly bump mtime into the future rather than relying on filesystem
1670 // mtime resolution (coarse on some platforms) to observe the change.
1671 let future_mtime = SystemTime::now() + std::time::Duration::from_secs(5);
1672 std::fs::OpenOptions::new()
1673 .write(true)
1674 .open(&lock_path)
1675 .unwrap()
1676 .set_modified(future_mtime)
1677 .unwrap();
1678
1679 let second = cache.get_or_parse(&provider, &lock_path).await.unwrap();
1680
1681 assert_eq!(
1682 provider.parse_count(),
1683 2,
1684 "stale mtime should trigger reparse"
1685 );
1686 assert_eq!(second.version("test-package"), Some("2.0.0"));
1687 }
1688
1689 /// Documents intentional behavior, not a bug: if a lock file's mtime moves *backward*
1690 /// relative to what is cached (clock skew, a restored backup, a `git checkout` touching
1691 /// an older tree), `get_or_parse`'s freshness check (`mtime <= cached_modified_at`) treats
1692 /// it as still fresh and serves the cached (now content-stale) packages rather than
1693 /// re-parsing. This mirrors the removed `is_lockfile_stale` default's own behavior for a
1694 /// `last_modified` timestamp set to the future, so the semantics are unchanged by its
1695 /// removal — this test exists only so the choice stays covered and visible.
1696 #[tokio::test]
1697 async fn test_get_or_parse_cache_hit_when_mtime_moves_backward() {
1698 // See the comment in `test_get_or_parse_cache_hit_does_not_reparse` on why this
1699 // guard is needed here.
1700 let _guard = fs_probe::snapshot_guard_async().await;
1701 let temp_dir = tempfile::tempdir().unwrap();
1702 let lock_path = temp_dir.path().join("test.lock");
1703 std::fs::write(&lock_path, "1.0.0").unwrap();
1704
1705 let provider = CountingLockFileProvider::new();
1706 let cache = LockFileCache::new();
1707
1708 let first = cache.get_or_parse(&provider, &lock_path).await.unwrap();
1709 assert_eq!(first.version("test-package"), Some("1.0.0"));
1710
1711 std::fs::write(&lock_path, "2.0.0").unwrap();
1712 let past_mtime = SystemTime::now() - std::time::Duration::from_secs(3600);
1713 std::fs::OpenOptions::new()
1714 .write(true)
1715 .open(&lock_path)
1716 .unwrap()
1717 .set_modified(past_mtime)
1718 .unwrap();
1719
1720 let second = cache.get_or_parse(&provider, &lock_path).await.unwrap();
1721
1722 assert_eq!(
1723 provider.parse_count(),
1724 1,
1725 "a backward-moving mtime must still be treated as a cache hit"
1726 );
1727 assert_eq!(
1728 second.version("test-package"),
1729 Some("1.0.0"),
1730 "cached (pre-rewrite) content should be served, not the new on-disk content"
1731 );
1732 }
1733
1734 /// Stub [`LockFileProvider`] that simulates a concurrent writer racing the parse:
1735 /// each `parse_lockfile` call reads the file's *current* content first, then — as
1736 /// a side effect before returning — rewrites the file to `"2.0.0"` and bumps its
1737 /// mtime into the future, then returns packages parsed from the content it read
1738 /// *before* that rewrite. This reproduces the only await point between the
1739 /// `get_or_parse` stat and cache insert, entirely under test control.
1740 struct RewritingDuringParseProvider {
1741 parse_count: std::sync::atomic::AtomicUsize,
1742 }
1743
1744 impl RewritingDuringParseProvider {
1745 fn new() -> Self {
1746 Self {
1747 parse_count: std::sync::atomic::AtomicUsize::new(0),
1748 }
1749 }
1750
1751 fn parse_count(&self) -> usize {
1752 self.parse_count.load(std::sync::atomic::Ordering::SeqCst)
1753 }
1754 }
1755
1756 impl LockFileProvider for RewritingDuringParseProvider {
1757 fn locate_lockfile(&self, _manifest_uri: &Url) -> Option<PathBuf> {
1758 None
1759 }
1760
1761 fn parse_lockfile<'a>(
1762 &'a self,
1763 lockfile_path: &'a Path,
1764 ) -> std::pin::Pin<
1765 Box<dyn std::future::Future<Output = Result<ResolvedPackages>> + Send + 'a>,
1766 > {
1767 Box::pin(async move {
1768 self.parse_count
1769 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1770 let content = read_lockfile_content(lockfile_path, "test.lock").await?;
1771
1772 // Simulate a writer rewriting the lock file mid-parse.
1773 std::fs::write(lockfile_path, "2.0.0").unwrap();
1774 let future_mtime = SystemTime::now() + std::time::Duration::from_secs(5);
1775 std::fs::OpenOptions::new()
1776 .write(true)
1777 .open(lockfile_path)
1778 .unwrap()
1779 .set_modified(future_mtime)
1780 .unwrap();
1781
1782 let mut packages = ResolvedPackages::new();
1783 packages.insert(ResolvedPackage {
1784 name: "test-package".into(),
1785 version: content.trim().to_string(),
1786 source: ResolvedSource::Path { path: ".".into() },
1787 dependencies: vec![],
1788 });
1789 Ok(packages)
1790 })
1791 }
1792 }
1793
1794 /// Regression test for #359: discriminates the stat-before-parse fix from the
1795 /// original stat-after-parse ordering.
1796 ///
1797 /// With the fix, `get_or_parse` stats the file *before* calling `parse_lockfile`,
1798 /// so the cached `modified_at` reflects the pre-rewrite mtime tied to the
1799 /// `"1.0.0"` content actually parsed. A second call then sees the file's mtime is
1800 /// newer than the cached one, so it re-parses and observes `"2.0.0"`.
1801 ///
1802 /// On the pre-fix ordering (stat after parse), the post-parse stat would pick up
1803 /// the mtime bump this same call just made, storing the *new* mtime alongside the
1804 /// *old* (`"1.0.0"`) content. The second call would then incorrectly hit cache and
1805 /// return stale `"1.0.0"` content without re-parsing — exactly the bug #359
1806 /// describes. This test would have failed on that code.
1807 #[tokio::test]
1808 async fn test_get_or_parse_detects_rewrite_during_parse() {
1809 // See the comment in `test_get_or_parse_cache_hit_does_not_reparse` on why this
1810 // guard is needed here.
1811 let _guard = fs_probe::snapshot_guard_async().await;
1812 let temp_dir = tempfile::tempdir().unwrap();
1813 let lock_path = temp_dir.path().join("test.lock");
1814 std::fs::write(&lock_path, "1.0.0").unwrap();
1815
1816 let provider = RewritingDuringParseProvider::new();
1817 let cache = LockFileCache::new();
1818
1819 let first = cache.get_or_parse(&provider, &lock_path).await.unwrap();
1820 assert_eq!(first.version("test-package"), Some("1.0.0"));
1821
1822 let second = cache.get_or_parse(&provider, &lock_path).await.unwrap();
1823
1824 assert_eq!(
1825 provider.parse_count(),
1826 2,
1827 "rewrite during first parse must be detected and trigger a reparse"
1828 );
1829 assert_eq!(second.version("test-package"), Some("2.0.0"));
1830 }
1831
1832 #[test]
1833 fn test_locate_lockfile_for_manifest_first_match_wins() {
1834 // See the comment in `test_locate_lockfile_for_manifest_same_directory` on why this
1835 // guard is needed here.
1836 let _guard = fs_probe::snapshot_guard();
1837 let temp_dir = tempfile::tempdir().unwrap();
1838 let manifest_path = temp_dir.path().join("pyproject.toml");
1839 let poetry_lock = temp_dir.path().join("poetry.lock");
1840 let uv_lock = temp_dir.path().join("uv.lock");
1841
1842 std::fs::write(&manifest_path, "[project]\nname = \"test\"").unwrap();
1843 std::fs::write(&poetry_lock, "# poetry lock").unwrap();
1844 std::fs::write(&uv_lock, "version = 1").unwrap();
1845
1846 let manifest_uri = Url::from_file_path(&manifest_path).unwrap();
1847 // Both exist, poetry.lock should be found first (listed first)
1848 let located = locate_lockfile_for_manifest(&manifest_uri, &["poetry.lock", "uv.lock"]);
1849
1850 assert!(located.is_some());
1851 assert_eq!(located.unwrap(), poetry_lock);
1852 }
1853}