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}