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