Skip to main content

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}