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