deps_cli/walk.rs
1//! Directory walk and ecosystem routing (FR-001 through FR-004).
2//!
3//! Not `.gitignore`-aware by default (issue #1109): `check` is a CI security gate over
4//! potentially untrusted input, so `.gitignore`/`.ignore` are only consulted when
5//! [`crate::cli::CheckArgs::respect_gitignore`] opts back in. A compiled-in `PRUNED_DIRECTORIES`
6//! denylist still keeps common dependency/build/VCS trees (`node_modules`, `target`, `vendor`,
7//! ...) out of the scan either way — see [`walk`]'s doc.
8
9use deps_core::{Ecosystem, EcosystemRegistry};
10use ignore::WalkBuilder;
11use std::collections::BTreeSet;
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, Mutex};
14
15/// Upper bound on the number of files a single `check` invocation inspects across every
16/// walked root (FR-004).
17///
18/// Protects against an unbounded walk over a pathological directory tree — the internal
19/// `PRUNED_DIRECTORIES` denylist keeps the common offenders (`node_modules`, `target`, ...)
20/// out of the scan already, but this cap remains a backstop for anything else pathologically
21/// large (a symlink loop `ignore` itself doesn't already guard against, an unusually large
22/// tree of a kind not on that denylist, ...). Once reached, the walk stops and
23/// [`WalkOutcome::truncated`] is set so the caller can warn instead of silently
24/// under-reporting.
25pub const MAX_WALKED_FILES: usize = 50_000;
26
27/// Directory basenames pruned from every walk, before `.gitignore`/`.ignore` are ever
28/// consulted (critic follow-up on issue #1109's default flip, S1/S2/S4). `.gitignore` was
29/// doing double duty: attacker-controlled suppression *and* the only thing keeping
30/// `node_modules/`, `target/`, `vendor/`, ... out of the scan; disabling it by default
31/// (see [`walk`]'s doc) removed both. This denylist restores the pruning without
32/// reintroducing attacker control — it is baked into the binary, so a scanned repository has
33/// no way to influence it, unlike a `.gitignore`/`.ignore` file.
34///
35/// Not exhaustive; covers the common heavy dependency/build/VCS directories across this
36/// crate's 14 supported ecosystems. Most VCS and tool-cache directories here (`.git`, `.venv`,
37/// `.gradle`, `.dart_tool`, `.build`, `.bundle`, `.tox`, ...) are already dot-prefixed and
38/// excluded by `hidden(true)` wherever that's active; they're listed again so pruning stays
39/// uniform regardless of a given walk's own `hidden` setting (e.g. the ecosystem
40/// dot-directory sub-walk, or [`detect_ignored_manifests`]'s unfiltered detection walk, both
41/// of which run with `hidden` lifted for their own reasons).
42const PRUNED_DIRECTORIES: &[&str] = &[
43 // Version control
44 ".git",
45 ".hg",
46 ".svn",
47 ".bzr",
48 // JavaScript / TypeScript / Deno
49 "node_modules",
50 "bower_components",
51 // Rust
52 "target",
53 // Go / PHP (Composer) / Ruby (Bundler, vendor/bundle)
54 "vendor",
55 // Python
56 ".venv",
57 "venv",
58 "__pycache__",
59 ".tox",
60 ".mypy_cache",
61 ".pytest_cache",
62 ".ruff_cache",
63 // Ruby (Bundler)
64 ".bundle",
65 // Dart
66 ".dart_tool",
67 // Gradle
68 ".gradle",
69 // Swift
70 ".build",
71 "Pods",
72 "DerivedData",
73 // Generic build output, used by several ecosystems (Maven, Dart, npm builds, ...)
74 "dist",
75 "build",
76];
77
78/// Returns `false` for a directory entry [`ignore::WalkBuilder::filter_entry`] should prune —
79/// its basename is in [`PRUNED_DIRECTORIES`]. Never prunes depth 0 (the walk root itself), so
80/// an explicitly-walked root that happens to be named e.g. `vendor` still works, matching the
81/// existing convention that an explicitly-given path is trusted.
82fn is_not_pruned_directory(entry: &ignore::DirEntry) -> bool {
83 entry.depth() == 0
84 || !entry
85 .file_type()
86 .is_some_and(|file_type| file_type.is_dir())
87 || !entry
88 .file_name()
89 .to_str()
90 .is_some_and(|name| PRUNED_DIRECTORIES.contains(&name))
91}
92
93/// Returns `false` for a directory entry [`ignore::WalkBuilder::filter_entry`] should prune
94/// *before descending into it* — its canonicalized real path falls outside `canonical_root`
95/// under `follow_symlinks: true`. Never prunes depth 0 (the walk root itself; already
96/// containment-checked by its own caller — see [`walk_with_limit`]'s sub-root check) or a
97/// non-directory entry (the per-file [`canonicalize_within_root`] check in [`walk_directory`]'s
98/// match loop already covers those).
99///
100/// Background code-review finding #2: without this, `follow_links(true)` lets `ignore` descend
101/// into a symlinked directory that resolves entirely outside the walked root (e.g.
102/// `evil -> /some/huge/external/tree`) before the per-file check rejects each descendant
103/// individually — on a large enough external tree this can exhaust [`MAX_WALKED_FILES`] on
104/// content outside the walked root, silently truncating the walk and dropping legitimate
105/// manifests elsewhere in the real tree, reintroducing #1112's own fail-open class through this
106/// fix's own new flag. Pruning at the directory level (mirroring [`is_not_pruned_directory`]'s
107/// existing prune-before-descend pattern) bounds the cost to one `canonicalize` call per
108/// directory rather than one per descendant file — deliberately *not* extended into the
109/// existing pruned-directory one-level-deep manifest peek
110/// ([`warn_on_pruned_directory_manifest`]), since that helper's `read_dir` is only safe because
111/// a *pruned* directory is still inside the trusted walked root; an *escaping* one is not, and
112/// must not have its contents listed at all.
113///
114/// Additive to, not a replacement for, the per-file [`canonicalize_within_root`] check: a leaf
115/// file symlink that individually escapes the root without its parent directory itself being a
116/// symlink is not a directory-level escape, so this check does not fire for it and the per-file
117/// check remains the only guard for that case.
118fn is_not_escaping_directory(
119 entry: &ignore::DirEntry,
120 follow_symlinks: bool,
121 canonical_root: Option<&Path>,
122) -> bool {
123 if !follow_symlinks || entry.depth() == 0 {
124 return true;
125 }
126 if !entry
127 .file_type()
128 .is_some_and(|file_type| file_type.is_dir())
129 {
130 return true;
131 }
132 let Some(canonical_root) = canonical_root else {
133 return false;
134 };
135 let Ok(canonical_path) = std::fs::canonicalize(entry.path()) else {
136 return false;
137 };
138 canonical_path.starts_with(canonical_root)
139}
140
141/// One manifest discovered by [`walk`], already routed to its owning ecosystem.
142pub struct DiscoveredManifest {
143 /// Absolute (or walk-root-relative, when the walked root itself was relative)
144 /// filesystem path to read the manifest's content from — for a symlinked manifest under
145 /// `--follow-symlinks`, this is the resolved, canonicalized real path (FR-007), never the
146 /// symlink itself.
147 pub path: PathBuf,
148 /// Absolute filesystem path used to derive the manifest's URI for parsing and lockfile/
149 /// in-use-version discovery (review finding M2). This must stay the manifest's *encountered*
150 /// path — the symlink's own location, not its resolved target's — because lockfile lookup
151 /// searches ancestor directories starting from this URI: a manifest symlinked into
152 /// directory A, whose target lives in directory B, must find `A`'s adjacent lockfile, not
153 /// `B`'s (or `B`'s absence of one), matching US-002's own shared-manifest scenario. Identical
154 /// to [`path`](Self::path) for every non-symlinked (or `--follow-symlinks`-disabled)
155 /// manifest.
156 pub uri_path: PathBuf,
157 /// The manifest path as it should be displayed/reported — relative to the walked root
158 /// when possible, matching [`crate::report::CheckFinding::manifest_path`].
159 pub display_path: PathBuf,
160 /// The ecosystem [`deps_core::EcosystemRegistry::for_uri`] routed this file to.
161 pub ecosystem: Arc<dyn Ecosystem>,
162}
163
164/// The result of walking one or more roots.
165#[derive(Default)]
166pub struct WalkOutcome {
167 /// Every manifest discovered and routed to an ecosystem.
168 pub manifests: Vec<DiscoveredManifest>,
169 /// Paths that `ignore` could not read (permission error, broken symlink, ...) — reported
170 /// as warnings, never fatal (FR-002's "no ecosystem recognizes / cannot be read" edge
171 /// case).
172 pub walk_errors: Vec<String>,
173 /// Whether [`MAX_WALKED_FILES`] was reached before the walk finished.
174 pub truncated: bool,
175 /// A `root` that was itself a single file (not a directory) but that no ecosystem's
176 /// `manifest_filenames`/`manifest_patterns`/`manifest_extensions`/
177 /// `manifest_directory_patterns` claimed (M4, spec 062 review) — spec §6 requires a
178 /// warning line for exactly this case: an explicitly-given path, unlike an unmatched file
179 /// encountered while walking a directory (the overwhelming majority of files in any real
180 /// tree, never worth a warning each).
181 pub unrecognized_explicit_paths: Vec<PathBuf>,
182 /// Manifest-shaped files excluded from the scan without the caller asking for that
183 /// exclusion — either an ignore rule while `respect_gitignore` was enabled (issue #1109),
184 /// or a `PRUNED_DIRECTORIES` match in *any* mode (reviewer follow-up: an unusual monorepo
185 /// layout can have a real subproject's manifest sitting directly inside a directory named
186 /// `vendor`/`build`/`dist`/...). Unlike
187 /// [`unrecognized_explicit_paths`](Self::unrecognized_explicit_paths), this is a warning
188 /// about data loss (a real manifest silently skipped), not a benign non-match.
189 pub ignored_manifests: Vec<PathBuf>,
190}
191
192/// Walks every path in `roots`.
193///
194/// Uses the `ignore` crate, routing every regular file through `registry.for_uri` (FR-002)
195/// unchanged from the LSP's own routing.
196///
197/// A `root` that is itself a single file (not a directory) is checked directly against the
198/// registry, bypassing the directory walk — this is what lets `deps-cli check Cargo.toml`
199/// work without needing `.gitignore` semantics at all.
200///
201/// Every directory root is walked twice (spec 062 review, background code-review fix 1):
202/// once with `ignore`'s default hidden-file filtering intact (so `.git` — a potentially huge
203/// tree in a real checkout, and never a source of manifests — is never even descended into,
204/// not merely filtered from the results), and once more per registered ecosystem's own
205/// dot-prefixed [`deps_core::Ecosystem::manifest_directory_patterns`] entry (`.github`,
206/// `.gitlab`, ...) with hidden-ness lifted for that one subtree specifically. This is derived
207/// from the live registry rather than a hardcoded `[".github", ".gitlab"]` list, so a future
208/// ecosystem introducing a new dot-directory pattern is picked up automatically.
209///
210/// **`respect_gitignore` (issue #1109)**: when `false` (the `check` subcommand's default —
211/// see [`crate::cli::CheckArgs::respect_gitignore`]), `.gitignore` and `.ignore` files are
212/// never consulted, because in a CI security-gate invocation (`git checkout && deps-cli check
213/// .` against an untrusted fork PR) both are attacker-controlled input: a one-line addition
214/// anywhere in the tree would otherwise silently remove a manifest from the scan. `.git`
215/// itself is still never descended into (that is `hidden`-filtering, an unrelated concern —
216/// see above), and `.git/info/exclude` / the user's global gitignore are always honored
217/// regardless of this flag, since neither travels with a cloned/fetched PR and both are
218/// operator-, not attacker-, controlled. When `true`, standard `.gitignore`/`.ignore`
219/// awareness is restored (matching `git`'s own behavior), and any manifest-shaped file that
220/// awareness excludes is additionally reported via [`WalkOutcome::ignored_manifests`].
221/// The internal `PRUNED_DIRECTORIES` denylist is applied regardless of `respect_gitignore` —
222/// it is compiled into the binary, not attacker-controlled input, so pruning
223/// `node_modules`/`target`/`vendor`/... does not reopen the fail-open gap this flag closes. A
224/// manifest sitting directly at the root of a pruned directory (an unusual but real monorepo
225/// layout, e.g. a genuine subproject named `vendor`) is still reported via
226/// [`WalkOutcome::ignored_manifests`] in every mode, so pruning stays visible rather than a
227/// second, narrower silent-omission bug.
228///
229/// One asymmetry is deliberate rather than accidental: in the default (`respect_gitignore:
230/// false`) mode, a manifest excluded only by the always-on `.git/info/exclude` or global
231/// gitignore is never diffed against (that costly double-walk only runs under
232/// `respect_gitignore`), so such a suppression is silent beyond [`WalkOutcome::manifests`]
233/// coming back emptier than expected — acceptable because both sources are
234/// operator-, not attacker-, controlled (see above).
235///
236/// **`follow_symlinks` (issue #1112)**: a directory entry that is itself a symlink to a
237/// manifest-shaped file is always detected, regardless of this flag — its path is reported via
238/// [`WalkOutcome::ignored_manifests`], the same sink a pruned or `.gitignore`-excluded manifest
239/// already uses, so a symlinked manifest can never silently vanish from the report. Detection
240/// alone never reads the target's content. When `follow_symlinks` is `true`, such a symlink is
241/// additionally resolved and routed like any other manifest (appearing in
242/// [`WalkOutcome::manifests`] instead, with [`DiscoveredManifest::path`] set to the resolved
243/// real path used for reading and [`DiscoveredManifest::display_path`] kept as the symlink's
244/// own encountered path). Every routed entry under `follow_symlinks: true` — not only ones
245/// where the leaf itself is a symlink, since an entry reached by descending into a followed
246/// symlinked *directory* is otherwise indistinguishable from an ordinary one — is canonicalized
247/// and checked against the walked root's own canonicalized absolute path; an entry that
248/// resolves outside the root is never routed, and is reported via `ignored_manifests` only when
249/// it is itself manifest-shaped (an arbitrary out-of-root symlink to a non-manifest file is
250/// silently skipped, matching detection's own never-warn-on-non-manifests invariant). This
251/// containment check applies to every registered ecosystem's own dot-directory sub-root (e.g.
252/// `.github`) too, and — because `ignore`/`walkdir` always follows a walk's own *root* symlink
253/// regardless of `follow_links` — that sub-root containment check runs in every mode, not only
254/// under `follow_symlinks`. A symlink loop is detected by the underlying `ignore` crate and
255/// surfaced via [`WalkOutcome::walk_errors`].
256#[must_use]
257pub fn walk(
258 roots: &[PathBuf],
259 registry: &EcosystemRegistry,
260 respect_gitignore: bool,
261 follow_symlinks: bool,
262) -> WalkOutcome {
263 walk_with_limit(
264 roots,
265 registry,
266 MAX_WALKED_FILES,
267 respect_gitignore,
268 follow_symlinks,
269 )
270}
271
272/// [`walk`]'s implementation, parameterized over the walked-entry cap so a test can exercise
273/// truncation against a small fixture instead of needing a real `MAX_WALKED_FILES`-sized tree
274/// (spec 062 review, tester gap 2).
275///
276/// The cap counts every entry the walk visits (S1, spec 062 review) — files, directories, and
277/// unreadable entries alike — not just the subset that matched an ecosystem, so
278/// [`WalkOutcome::truncated`] actually bounds the walk's own cost against a pathological tree
279/// (a huge `node_modules` not excluded by `.gitignore`, a symlink loop) rather than only the
280/// count of manifests found. Applies uniformly to the `root.is_file()` explicit-path branch
281/// too (background code-review fix 2, spec 062 review) — that branch previously incremented
282/// the counter but never checked it, so `WalkOutcome::truncated` could never be set from an
283/// explicit path list.
284fn walk_with_limit(
285 roots: &[PathBuf],
286 registry: &EcosystemRegistry,
287 limit: usize,
288 respect_gitignore: bool,
289 follow_symlinks: bool,
290) -> WalkOutcome {
291 let mut ctx = WalkCtx {
292 registry,
293 limit,
294 entries_walked: 0,
295 outcome: WalkOutcome::default(),
296 };
297 let hidden_ecosystem_dirs = hidden_ecosystem_directories(registry);
298
299 'roots: for root in roots {
300 if ctx.outcome.truncated {
301 break;
302 }
303 // Absolutized (issue #1108): `url::Url::from_file_path` (in `route_file`) and
304 // `ignore::WalkBuilder` both require an absolute root to produce absolute entries —
305 // a relative root (e.g. `.`, the CLI's own default) otherwise made every discovered
306 // file fail `from_file_path` silently, so `deps-cli check` with no arguments always
307 // reported zero manifests. `display_path`s below are still derived relative to
308 // `root` as given, so reported paths stay exactly as the caller typed them.
309 let Ok(absolute_root) = std::path::absolute(root) else {
310 ctx.outcome
311 .walk_errors
312 .push(format!("could not resolve path: {}", root.display()));
313 continue;
314 };
315
316 if root.is_file() {
317 if ctx.entries_walked >= ctx.limit {
318 ctx.outcome.truncated = true;
319 tracing::warn!(
320 limit,
321 "walk truncated: reached the maximum number of entries per run"
322 );
323 break;
324 }
325 ctx.entries_walked += 1;
326 let matched_before = ctx.outcome.manifests.len();
327 route_file(
328 &absolute_root,
329 &absolute_root,
330 root,
331 registry,
332 &mut ctx.outcome,
333 );
334 if ctx.outcome.manifests.len() == matched_before {
335 ctx.outcome.unrecognized_explicit_paths.push(root.clone());
336 }
337 continue;
338 }
339
340 // FR-004: the escape-prevention baseline, canonicalized once per root (not per entry).
341 // Computed unconditionally, not only when `follow_symlinks` is set (critic finding S1):
342 // `ignore`/`walkdir` always follows a *root* symlink when starting a walk, regardless
343 // of `follow_links`, so a symlinked hidden-ecosystem sub-root (e.g. a repository's own
344 // `.github` replaced by a symlink) can escape the walked root in every mode, not only
345 // under `--follow-symlinks` — this baseline is reused below to close that gap too.
346 // `canonicalize` failing here (a root that itself cannot be resolved) is not fatal to
347 // the walk: it just means containment can never be proven for anything under this root,
348 // so every symlink target falls back to `ignored_manifests` (or the sub-root is simply
349 // not walked) rather than being routed.
350 let canonical_root = std::fs::canonicalize(&absolute_root).ok();
351
352 // Always hidden-filtered: `hidden(true)` filters entries by their own basename as the
353 // walk descends, so `walk_root` itself is never excluded even if its name starts with
354 // `.` (e.g. a tempfile dir on macOS) — that previously caused a regression.
355 if !walk_directory(
356 &absolute_root,
357 &absolute_root,
358 true,
359 respect_gitignore,
360 follow_symlinks,
361 canonical_root.as_deref(),
362 &mut ctx,
363 ) {
364 break 'roots;
365 }
366
367 for dir_name in &hidden_ecosystem_dirs {
368 let sub_root = absolute_root.join(dir_name);
369 if !sub_root.is_dir() {
370 continue;
371 }
372 // S1: `sub_root` (e.g. `<root>/.github`) may itself be a symlink escaping the
373 // walked root — `ignore`/`walkdir` always follows a walk's own root symlink, so
374 // this containment check applies regardless of `follow_symlinks`. A root that
375 // could not be canonicalized above means containment can never be proven, so the
376 // sub-root is skipped entirely rather than walked unchecked.
377 match (
378 canonical_root.as_deref(),
379 std::fs::canonicalize(&sub_root).ok(),
380 ) {
381 (Some(canonical_root), Some(canonical_sub_root))
382 if canonical_sub_root.starts_with(canonical_root) => {}
383 _ => continue,
384 }
385 if !walk_directory(
386 &sub_root,
387 &absolute_root,
388 false,
389 respect_gitignore,
390 follow_symlinks,
391 canonical_root.as_deref(),
392 &mut ctx,
393 ) {
394 break 'roots;
395 }
396 }
397 }
398
399 ctx.outcome
400}
401
402/// Bundles the state threaded through every helper in a single walk run, so adding a new
403/// piece of shared state doesn't grow each helper's own argument list
404/// (`clippy::too_many_arguments`).
405struct WalkCtx<'a> {
406 registry: &'a EcosystemRegistry,
407 limit: usize,
408 entries_walked: usize,
409 outcome: WalkOutcome,
410}
411
412/// Walks `walk_root` (a directory), routing every regular file relative to `display_root` —
413/// the outer `root` [`walk_with_limit`] was given, so a file under a dot-directory sub-root
414/// (e.g. `<repo>/.github`) still displays relative to the repository root, not to `.github`
415/// itself. Returns `false` once `limit` is reached (the caller must stop the whole walk, not
416/// just this directory); `true` otherwise.
417fn walk_directory(
418 walk_root: &Path,
419 display_root: &Path,
420 hidden: bool,
421 respect_gitignore: bool,
422 follow_symlinks: bool,
423 canonical_root: Option<&Path>,
424 ctx: &mut WalkCtx<'_>,
425) -> bool {
426 // `.gitignore`/`.ignore` toggle by `respect_gitignore` (issue #1109) — both are
427 // attacker-controlled in a CI scan of untrusted input. `git_exclude` (`.git/info/exclude`)
428 // and `git_global` (the user's global gitignore) stay on unconditionally: neither travels
429 // with a cloned/fetched PR, so neither is part of the attack surface this flag closes.
430 let pruned_dirs: Arc<Mutex<Vec<PathBuf>>> = Arc::new(Mutex::new(Vec::new()));
431 let mut builder = WalkBuilder::new(walk_root);
432 builder
433 .hidden(hidden)
434 .parents(true)
435 .ignore(respect_gitignore)
436 .git_ignore(respect_gitignore)
437 .git_global(true)
438 .git_exclude(true)
439 .follow_links(follow_symlinks);
440 {
441 let pruned_dirs = Arc::clone(&pruned_dirs);
442 let canonical_root_owned = canonical_root.map(Path::to_path_buf);
443 builder.filter_entry(move |entry| {
444 if !is_not_pruned_directory(entry) {
445 pruned_dirs
446 .lock()
447 .unwrap_or_else(std::sync::PoisonError::into_inner)
448 .push(entry.path().to_path_buf());
449 return false;
450 }
451 is_not_escaping_directory(entry, follow_symlinks, canonical_root_owned.as_deref())
452 });
453 }
454
455 let mut visited: BTreeSet<PathBuf> = BTreeSet::new();
456 for entry in builder.build() {
457 if ctx.entries_walked >= ctx.limit {
458 ctx.outcome.truncated = true;
459 tracing::warn!(
460 limit = ctx.limit,
461 "walk truncated: reached the maximum number of entries per run"
462 );
463 return false;
464 }
465 ctx.entries_walked += 1;
466 match entry {
467 Ok(entry) if entry.file_type().is_some_and(|t| t.is_file()) => {
468 let path = entry.path();
469 let display = path
470 .strip_prefix(display_root)
471 .unwrap_or(path)
472 .to_path_buf();
473 if respect_gitignore {
474 visited.insert(display.clone());
475 }
476 if follow_symlinks {
477 // FR-004/FR-007 (critic finding C1): every entry is canonicalized and
478 // containment-checked here, not only ones where the leaf itself is a
479 // symlink — see `canonicalize_within_root`'s doc for why a leaf-only check
480 // misses an entry reached through a followed symlinked *directory*. The
481 // resolved path doubles as the real path routed for reading (FR-007).
482 match canonicalize_within_root(path, canonical_root) {
483 Some(canonical_path) => {
484 // Routing (basename/pattern matching) still goes through `path`
485 // (the encountered, possibly-symlinked path) — only the content
486 // read later uses the resolved `canonical_path` (FR-007).
487 route_file(
488 path,
489 &canonical_path,
490 &display,
491 ctx.registry,
492 &mut ctx.outcome,
493 );
494 }
495 None => {
496 // S4: only report as an excluded manifest when the escaping/
497 // unresolvable path is itself manifest-shaped — an arbitrary
498 // out-of-root symlink (e.g. `notes.txt -> /etc/hosts`) must not
499 // produce a false "looks like a manifest" warning.
500 if symlink_is_manifest_shaped(path, ctx.registry) {
501 ctx.outcome.ignored_manifests.push(display);
502 }
503 }
504 }
505 } else {
506 route_file(path, path, &display, ctx.registry, &mut ctx.outcome);
507 }
508 }
509 Ok(entry) => {
510 // Issue #1112: `file_type()` reports the symlink's own type, not its target's,
511 // so a manifest reachable only through a symlink otherwise falls through here
512 // silently. Detection alone (this arm) is always on; actually resolving and
513 // scanning the target is opt-in via `follow_symlinks` (handled by `ignore`'s
514 // own `WalkBuilder::follow_links`, wired above).
515 if entry.path_is_symlink() && symlink_is_manifest_shaped(entry.path(), ctx.registry)
516 {
517 let path = entry.path();
518 let display = path
519 .strip_prefix(display_root)
520 .unwrap_or(path)
521 .to_path_buf();
522 // Background code-review finding #1: must mirror the `is_file()` arm's
523 // `visited` insert above — otherwise `detect_ignored_manifests`' separate
524 // unfiltered walk (run when `respect_gitignore` is true) finds this same
525 // symlink again, sees it missing from `visited`, and pushes a second,
526 // duplicate `ignored_manifests` entry for it.
527 if respect_gitignore {
528 visited.insert(display.clone());
529 }
530 ctx.outcome.ignored_manifests.push(display);
531 }
532 }
533 Err(error) => ctx.outcome.walk_errors.push(error.to_string()),
534 }
535 }
536
537 // Reviewer follow-up (#2): visible regardless of `respect_gitignore` — pruning is always
538 // on, so its (rare) false positives must always be reported, not only under the opt-in
539 // ignore-aware mode.
540 for pruned_dir in pruned_dirs
541 .lock()
542 .unwrap_or_else(std::sync::PoisonError::into_inner)
543 .iter()
544 {
545 warn_on_pruned_directory_manifest(pruned_dir, display_root, ctx);
546 }
547
548 if respect_gitignore {
549 detect_ignored_manifests(walk_root, display_root, hidden, ctx, &visited);
550 }
551 true
552}
553
554/// Checks a directory [`is_not_pruned_directory`] excluded (its basename matched
555/// [`PRUNED_DIRECTORIES`]) for a manifest sitting directly at its own root, and records any
556/// found onto [`WalkOutcome::ignored_manifests`] (reviewer follow-up on issue #1109's
557/// pruning fix: an unusual monorepo layout can have a *real* subproject's manifest directly
558/// inside a directory named `vendor`/`build`/`dist`/...).
559///
560/// Deliberately one level deep only — a full recursive re-walk of the pruned directory would
561/// reintroduce the exact cost [`PRUNED_DIRECTORIES`] exists to avoid for a large vendored tree
562/// (a `node_modules` with hundreds of packages has no manifest directly at its own root, only
563/// nested under each package directory, so this check costs one cheap `read_dir` per pruned
564/// directory and stays silent for it). A manifest nested two or more levels inside a pruned
565/// directory remains a known, accepted limitation of this check, not a regression — it was
566/// never discovered before this fix either.
567fn warn_on_pruned_directory_manifest(
568 pruned_dir: &Path,
569 display_root: &Path,
570 ctx: &mut WalkCtx<'_>,
571) {
572 let Ok(read_dir) = std::fs::read_dir(pruned_dir) else {
573 return;
574 };
575 for entry in read_dir.flatten() {
576 let path = entry.path();
577 if !symlink_is_manifest_shaped(&path, ctx.registry) {
578 continue;
579 }
580 let display = path
581 .strip_prefix(display_root)
582 .unwrap_or(&path)
583 .to_path_buf();
584 ctx.outcome.ignored_manifests.push(display);
585 }
586}
587
588/// Diffs an unfiltered (but still [`PRUNED_DIRECTORIES`]-pruned) walk of `walk_root` against
589/// `visited` (the file set an ignore-aware walk already found) and records any
590/// *manifest-shaped* file present only in the unfiltered walk onto
591/// [`WalkOutcome::ignored_manifests`] (issue #1109) — reusing [`EcosystemRegistry::for_uri`]
592/// (the same routing [`route_file`] uses) rather than reimplementing manifest matching, and
593/// never warning on a non-manifest file so this stays silent for the overwhelming majority of
594/// ordinary `.gitignore` entries. `git_global`/`git_exclude` are kept `true` here too — the
595/// same as the filtered walk (critic S3) — so a file excluded only by the operator-controlled
596/// `.git/info/exclude` or global gitignore (out of scope for `respect_gitignore`, see [`walk`]'s
597/// doc) is present in *both* walks and never misattributed to `.gitignore`/`.ignore`.
598///
599/// Only invoked when `respect_gitignore` is `true` — the default (`respect_gitignore: false`)
600/// already never consults `.gitignore`/`.ignore`, so there is nothing to diff against. Uses its
601/// own entry budget, independent of `ctx.entries_walked`/`ctx.limit` (reviewer follow-up #3):
602/// this is a best-effort diagnostic pass over a walk whose *primary* manifest list is already
603/// complete by the time this runs, so exhausting its budget must never set
604/// [`WalkOutcome::truncated`] (which would misleadingly claim the primary walk, not this
605/// diagnostic one, is incomplete) or otherwise affect the primary walk's own truncation state.
606fn detect_ignored_manifests(
607 walk_root: &Path,
608 display_root: &Path,
609 hidden: bool,
610 ctx: &mut WalkCtx<'_>,
611 visited: &BTreeSet<PathBuf>,
612) {
613 let mut builder = WalkBuilder::new(walk_root);
614 builder
615 .hidden(hidden)
616 .ignore(false)
617 .git_ignore(false)
618 .git_global(true)
619 .git_exclude(true)
620 .filter_entry(is_not_pruned_directory);
621 for (detection_entries, entry) in builder.build().enumerate() {
622 if detection_entries >= ctx.limit {
623 tracing::warn!(
624 limit = ctx.limit,
625 "ignored-manifest detection walk truncated: reached the maximum number of \
626 entries per run; some .gitignore/.ignore exclusions may go unreported"
627 );
628 return;
629 }
630 // Reviewer follow-up (#4): both failure paths below now match their counterparts in
631 // `walk_directory`/`route_file` — an unreadable entry or an unconvertible path is
632 // recorded, not silently skipped, so two structurally identical failure points added
633 // by the same change behave identically.
634 let entry = match entry {
635 Ok(entry) => entry,
636 Err(error) => {
637 ctx.outcome.walk_errors.push(error.to_string());
638 continue;
639 }
640 };
641 if !entry.file_type().is_some_and(|t| t.is_file()) {
642 // #1112/M5: a symlinked manifest excluded by `.gitignore`/`.ignore` never appears
643 // in the primary (filtered) walk at all — `ignore` skips a gitignored entry before
644 // yielding it, so it also never lands in `visited`. This unfiltered pass is the
645 // only place that still sees it; without this branch it silently vanished from
646 // both `manifests` and `ignored_manifests` under `--respect-gitignore`, the same
647 // fail-open class `--respect-gitignore` closes for ordinary files.
648 let path = entry.path();
649 if entry.path_is_symlink() && symlink_is_manifest_shaped(path, ctx.registry) {
650 let display = path
651 .strip_prefix(display_root)
652 .unwrap_or(path)
653 .to_path_buf();
654 if !visited.contains(&display) {
655 ctx.outcome.ignored_manifests.push(display);
656 }
657 }
658 continue;
659 }
660 let path = entry.path();
661 let display = path
662 .strip_prefix(display_root)
663 .unwrap_or(path)
664 .to_path_buf();
665 if visited.contains(&display) {
666 continue;
667 }
668 match url::Url::from_file_path(path) {
669 Ok(uri) => {
670 if ctx.registry.for_uri(&uri).is_some() {
671 ctx.outcome.ignored_manifests.push(display);
672 }
673 }
674 Err(()) => {
675 ctx.outcome.walk_errors.push(format!(
676 "could not convert to a file URI while checking for ignore-suppressed \
677 manifests, skipping: {}",
678 display.display()
679 ));
680 }
681 }
682 }
683}
684
685/// The set of top-level dot-directory names (`.github`, `.gitlab`, ...) that at least one
686/// registered ecosystem's [`deps_core::Ecosystem::manifest_directory_patterns`] names — the
687/// only dot-directories [`walk_with_limit`] walks with hidden-file filtering lifted. Derived
688/// from the live registry (not a hardcoded list) so a future ecosystem's own dot-directory
689/// pattern is picked up automatically; `.git` is never a match here, since no ecosystem's
690/// directory pattern names it.
691fn hidden_ecosystem_directories(registry: &EcosystemRegistry) -> BTreeSet<String> {
692 let mut dirs = BTreeSet::new();
693 for id in registry.ecosystem_ids() {
694 let Some(ecosystem) = registry.get(id) else {
695 continue;
696 };
697 for (dir_pattern, _suffix) in ecosystem.manifest_directory_patterns() {
698 if let Some(first) = dir_pattern.split('/').next()
699 && first.starts_with('.')
700 {
701 dirs.insert(first.to_string());
702 }
703 }
704 }
705 dirs
706}
707
708/// FR-004/FR-007: resolves `path` to its canonicalized real path and returns it only when that
709/// path is contained within `canonical_root`. `canonical_root` being `None` (symlink-following
710/// disabled, or the root itself failed to canonicalize) always yields `None` — a safe default,
711/// never routing an entry whose containment cannot be proven. A `canonicalize` failure on `path`
712/// itself (e.g. a race between the walk's stat and this check) is likewise treated as "outside
713/// root", never as "inside".
714///
715/// Called for **every** routed file entry under `follow_symlinks: true`, not only ones where
716/// the leaf itself is a symlink (critic finding C1): `ignore`/`walkdir` only sets
717/// `DirEntry::path_is_symlink()` on the entry actually named as a symlink, so an entry reached
718/// by *descending into* a followed symlinked directory reports `path_is_symlink() == false` for
719/// its own leaf while still resolving to a real path outside the walked root — canonicalizing
720/// unconditionally (rather than gating on `path_is_symlink()`) closes that gap for both leaf
721/// symlinks and symlinked ancestors alike.
722///
723/// The returned path also satisfies FR-007: it is the resolved real path
724/// [`DiscoveredManifest::path`] must use for reading, computed once here rather than a second
725/// time at read-time, which would otherwise leave a TOCTOU window between this containment
726/// check and the actual read.
727fn canonicalize_within_root(path: &Path, canonical_root: Option<&Path>) -> Option<PathBuf> {
728 let canonical_root = canonical_root?;
729 let canonical_path = std::fs::canonicalize(path).ok()?;
730 canonical_path
731 .starts_with(canonical_root)
732 .then_some(canonical_path)
733}
734
735/// Resolves `path` (which failed the regular `is_file()` check) as a possible symlink to a
736/// manifest-shaped file, without reading its content. Returns `false` for anything that isn't
737/// a symlink resolving to a manifest-shaped regular file — a broken symlink, a symlink to a
738/// directory, or a target no ecosystem's `for_uri` claims (issue #1112, FR-001).
739fn symlink_is_manifest_shaped(path: &Path, registry: &EcosystemRegistry) -> bool {
740 let Ok(metadata) = std::fs::metadata(path) else {
741 return false;
742 };
743 if !metadata.is_file() {
744 return false;
745 }
746 let Ok(uri) = url::Url::from_file_path(path) else {
747 return false;
748 };
749 registry.for_uri(&uri).is_some()
750}
751
752/// Routes one already-discovered file through `registry.for_uri`, pushing a
753/// [`DiscoveredManifest`] onto `outcome` when an ecosystem claims it.
754///
755/// `route_path` and `read_path` are the same path for every caller except the
756/// `follow_symlinks: true` branch of `walk_directory` (FR-007): ecosystem routing is a
757/// basename/pattern match (`manifest_filenames`, `manifest_patterns`, ...), so it must always
758/// go through the *encountered* path — a symlink named `Cargo.toml` routes as `Cargo.toml`
759/// regardless of what its target is named — while [`DiscoveredManifest::path`] (used later to
760/// read the manifest's content) must be the resolved real path a symlink was already
761/// containment-checked against, not the symlink itself. `route_path` is also stored as
762/// [`DiscoveredManifest::uri_path`] (review finding M2): lockfile/in-use-version discovery
763/// must anchor its ancestor-directory search at the symlink's own location, not its target's.
764///
765/// `route_path` is expected to already be absolute (every caller absolutizes its walk root
766/// first — see [`walk_with_limit`]) so `url::Url::from_file_path` should never fail in
767/// practice; if it somehow does (issue #1108), that is recorded as a warning rather than
768/// silently dropping the file, so a future regression in the absolutization degrades loudly
769/// instead of quietly reintroducing the "walk finds zero manifests" bug.
770fn route_file(
771 route_path: &Path,
772 read_path: &Path,
773 display_path: &Path,
774 registry: &EcosystemRegistry,
775 outcome: &mut WalkOutcome,
776) {
777 let Ok(uri) = url::Url::from_file_path(route_path) else {
778 outcome.walk_errors.push(format!(
779 "could not convert to a file URI, skipping: {}",
780 display_path.display()
781 ));
782 return;
783 };
784 if let Some(ecosystem) = registry.for_uri(&uri) {
785 outcome.manifests.push(DiscoveredManifest {
786 path: read_path.to_path_buf(),
787 uri_path: route_path.to_path_buf(),
788 display_path: display_path.to_path_buf(),
789 ecosystem,
790 });
791 }
792}
793
794#[cfg(test)]
795mod tests {
796 use super::*;
797 use std::fs;
798 use std::sync::Mutex;
799
800 /// Serializes tests that call `std::env::set_current_dir` — the process CWD is global
801 /// state shared across every test in this binary, which otherwise run concurrently.
802 static CWD_LOCK: Mutex<()> = Mutex::new(());
803
804 /// Chdirs into `dir` and restores the original cwd on drop — including on an early return
805 /// via a panicking assertion mid-test (critic M3), which a plain "restore at the end of the
806 /// function" cannot do. Holds `CWD_LOCK` for its own lifetime.
807 struct CwdGuard {
808 original: PathBuf,
809 _lock: std::sync::MutexGuard<'static, ()>,
810 }
811
812 impl CwdGuard {
813 fn chdir(dir: &Path) -> Self {
814 let lock = CWD_LOCK
815 .lock()
816 .unwrap_or_else(std::sync::PoisonError::into_inner);
817 let original = std::env::current_dir().expect("read cwd");
818 std::env::set_current_dir(dir).expect("chdir");
819 Self {
820 original,
821 _lock: lock,
822 }
823 }
824 }
825
826 impl Drop for CwdGuard {
827 fn drop(&mut self) {
828 let _ = std::env::set_current_dir(&self.original);
829 }
830 }
831
832 fn test_registry() -> EcosystemRegistry {
833 let registry = EcosystemRegistry::new();
834 let runtime = deps_engine::setup::EcosystemRuntime::from_policy(
835 &deps_core::policy_config::PolicyConfig::default(),
836 );
837 deps_engine::setup::register_ecosystems(
838 ®istry,
839 Arc::new(deps_core::HttpCache::new()),
840 &runtime,
841 );
842 registry
843 }
844
845 #[test]
846 fn test_walk_empty_directory_finds_nothing() {
847 let dir = tempfile::tempdir().expect("create temp dir");
848 let outcome = walk(&[dir.path().to_path_buf()], &test_registry(), false, false);
849 assert!(outcome.manifests.is_empty());
850 assert!(!outcome.truncated);
851 }
852
853 #[test]
854 fn test_walk_finds_cargo_toml() {
855 let dir = tempfile::tempdir().expect("create temp dir");
856 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n")
857 .expect("write manifest");
858 let outcome = walk(&[dir.path().to_path_buf()], &test_registry(), false, false);
859 assert_eq!(outcome.manifests.len(), 1);
860 assert_eq!(
861 outcome.manifests[0].display_path,
862 PathBuf::from("Cargo.toml")
863 );
864 assert_eq!(outcome.manifests[0].ecosystem.id(), "cargo");
865 }
866
867 /// With `respect_gitignore: true` (the opt-in, pre-#1109-fix behavior), a `.gitignore`
868 /// entry still suppresses a manifest — this is intentional for a caller who explicitly
869 /// asked to restore `git`'s own semantics.
870 #[test]
871 fn test_walk_respect_gitignore_true_skips_gitignored_manifest() {
872 let dir = tempfile::tempdir().expect("create temp dir");
873 // `ignore`'s `.gitignore` support only activates inside a git repo by default
874 // (`require_git`); an empty `.git` marker is enough for detection.
875 fs::create_dir(dir.path().join(".git")).expect("create .git marker");
876 fs::write(dir.path().join(".gitignore"), "ignored/\n").expect("write gitignore");
877 fs::create_dir(dir.path().join("ignored")).expect("mkdir");
878 fs::write(dir.path().join("ignored").join("Cargo.toml"), "[package]\n")
879 .expect("write manifest");
880 let outcome = walk(&[dir.path().to_path_buf()], &test_registry(), true, false);
881 assert!(outcome.manifests.is_empty());
882 assert_eq!(
883 outcome.ignored_manifests,
884 vec![PathBuf::from("ignored").join("Cargo.toml")]
885 );
886 }
887
888 /// Issue #1109 repro 1: a `.gitignore` entry must NOT suppress a manifest under the
889 /// default (`respect_gitignore: false`) `check` behavior — this is the fail-open gap the
890 /// issue reports (attacker-controlled `.gitignore` silently defeating the CI gate).
891 #[test]
892 fn test_walk_default_does_not_respect_gitignore() {
893 let dir = tempfile::tempdir().expect("create temp dir");
894 fs::create_dir(dir.path().join(".git")).expect("create .git marker");
895 fs::write(dir.path().join(".gitignore"), "Cargo.toml\n").expect("write gitignore");
896 fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
897 let outcome = walk(&[dir.path().to_path_buf()], &test_registry(), false, false);
898 assert_eq!(outcome.manifests.len(), 1);
899 assert!(outcome.ignored_manifests.is_empty());
900 }
901
902 /// Issue #1109 repro 2: a nested `sub/.gitignore` (not just a top-level one) must not
903 /// suppress a manifest under the default behavior either.
904 #[test]
905 fn test_walk_default_ignores_nested_gitignore() {
906 let dir = tempfile::tempdir().expect("create temp dir");
907 fs::create_dir(dir.path().join(".git")).expect("create .git marker");
908 fs::create_dir(dir.path().join("sub")).expect("mkdir sub");
909 fs::write(dir.path().join("sub").join(".gitignore"), "Cargo.toml\n")
910 .expect("write nested gitignore");
911 fs::write(dir.path().join("sub").join("Cargo.toml"), "[package]\n")
912 .expect("write manifest");
913 let outcome = walk(&[dir.path().to_path_buf()], &test_registry(), false, false);
914 assert_eq!(outcome.manifests.len(), 1);
915 }
916
917 /// Issue #1109 repro 3: an `.ignore` file (no `.git` directory at all) must not suppress a
918 /// manifest under the default behavior.
919 #[test]
920 fn test_walk_default_ignores_dot_ignore_file_without_git_repo() {
921 let dir = tempfile::tempdir().expect("create temp dir");
922 fs::write(dir.path().join(".ignore"), "Cargo.toml\n").expect("write .ignore");
923 fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
924 let outcome = walk(&[dir.path().to_path_buf()], &test_registry(), false, false);
925 assert_eq!(outcome.manifests.len(), 1);
926 }
927
928 /// Critic S1 (post-#1109 default-flip regression): disabling `.gitignore`/`.ignore` by
929 /// default must not turn a vendored `node_modules/` tree back into hundreds of scanned
930 /// manifests — the compiled-in [`PRUNED_DIRECTORIES`] denylist must prune it regardless.
931 #[test]
932 fn test_walk_default_prunes_node_modules() {
933 let dir = tempfile::tempdir().expect("create temp dir");
934 fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
935 fs::create_dir_all(dir.path().join("node_modules").join("left-pad"))
936 .expect("mkdir node_modules/left-pad");
937 fs::write(
938 dir.path()
939 .join("node_modules")
940 .join("left-pad")
941 .join("package.json"),
942 "{}",
943 )
944 .expect("write vendored manifest");
945
946 let outcome = walk(&[dir.path().to_path_buf()], &test_registry(), false, false);
947
948 assert_eq!(outcome.manifests.len(), 1);
949 assert_eq!(
950 outcome.manifests[0].display_path,
951 PathBuf::from("Cargo.toml")
952 );
953 }
954
955 /// Reviewer follow-up #2: an unusual monorepo layout can have a *real* subproject's
956 /// manifest sitting directly under a directory named `vendor`/`build`/`dist`/... —
957 /// `PRUNED_DIRECTORIES` still excludes it from the primary scan, but the omission must be
958 /// visible via `WalkOutcome::ignored_manifests`, in every mode (not only under
959 /// `--respect-gitignore`).
960 #[test]
961 fn test_walk_default_warns_on_manifest_directly_inside_pruned_directory() {
962 let dir = tempfile::tempdir().expect("create temp dir");
963 fs::create_dir(dir.path().join("vendor")).expect("mkdir vendor");
964 fs::write(dir.path().join("vendor").join("Cargo.toml"), "[package]\n")
965 .expect("write manifest directly under pruned dir");
966
967 let outcome = walk(&[dir.path().to_path_buf()], &test_registry(), false, false);
968
969 assert!(
970 outcome.manifests.is_empty(),
971 "still pruned from the primary scan"
972 );
973 assert_eq!(
974 outcome.ignored_manifests,
975 vec![PathBuf::from("vendor").join("Cargo.toml")]
976 );
977 }
978
979 /// Companion to the above: a manifest nested two or more levels inside a pruned directory
980 /// remains a documented, accepted limitation (checking only the pruned directory's own
981 /// root avoids reintroducing the cost a full recursive re-walk would bring back for a
982 /// large vendored tree) — not a regression, since it was never found before this fix.
983 #[test]
984 fn test_walk_manifest_nested_two_levels_inside_pruned_directory_remains_unreported() {
985 let dir = tempfile::tempdir().expect("create temp dir");
986 fs::create_dir_all(dir.path().join("vendor").join("sub")).expect("mkdir vendor/sub");
987 fs::write(
988 dir.path().join("vendor").join("sub").join("Cargo.toml"),
989 "[package]\n",
990 )
991 .expect("write nested manifest");
992
993 let outcome = walk(&[dir.path().to_path_buf()], &test_registry(), false, false);
994
995 assert!(outcome.manifests.is_empty());
996 assert!(outcome.ignored_manifests.is_empty());
997 }
998
999 /// Reviewer follow-up #3: `detect_ignored_manifests`'s diagnostic pass must use its own
1000 /// entry budget, independent of the primary walk's `ctx.entries_walked`/`ctx.limit` — a
1001 /// small limit that comfortably covers the primary (filtered) walk but not the
1002 /// diagnostic (unfiltered) pass over an ignored, non-manifest-shaped `noise/` directory
1003 /// must exhaust only the diagnostic pass, never set `WalkOutcome::truncated`, and never
1004 /// affect the primary walk's own (already-complete) manifest list.
1005 #[test]
1006 fn test_detect_ignored_manifests_uses_its_own_budget_and_does_not_set_truncated() {
1007 let dir = tempfile::tempdir().expect("create temp dir");
1008 fs::create_dir(dir.path().join(".git")).expect("create .git marker");
1009 fs::write(dir.path().join(".gitignore"), "noise/\n").expect("write gitignore");
1010 fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
1011 fs::create_dir(dir.path().join("noise")).expect("mkdir noise");
1012 for i in 0..20 {
1013 fs::write(dir.path().join("noise").join(format!("f{i}.txt")), "")
1014 .expect("write noise file");
1015 }
1016
1017 // Comfortably covers the primary walk (root + .gitignore + Cargo.toml == 3 entries,
1018 // `noise/` itself is `.gitignore`-excluded there) but not the diagnostic pass, which
1019 // ignores `.gitignore` and must descend into `noise/`'s 20 files.
1020 let outcome = walk_with_limit(
1021 &[dir.path().to_path_buf()],
1022 &test_registry(),
1023 5,
1024 true,
1025 false,
1026 );
1027
1028 assert!(
1029 !outcome.truncated,
1030 "the diagnostic pass' own budget exhaustion must not mark the primary walk truncated"
1031 );
1032 assert_eq!(
1033 outcome.manifests.len(),
1034 1,
1035 "primary walk result must still be complete"
1036 );
1037 }
1038
1039 /// Critic S2: with `respect_gitignore: true`, a `node_modules/` excluded by an ordinary,
1040 /// non-security-relevant `.gitignore` entry must not be reported via
1041 /// [`WalkOutcome::ignored_manifests`] — [`PRUNED_DIRECTORIES`] removes it from both the
1042 /// filtered and unfiltered walk, so there is nothing to diff.
1043 #[test]
1044 fn test_walk_respect_gitignore_does_not_warn_on_pruned_directory() {
1045 let dir = tempfile::tempdir().expect("create temp dir");
1046 fs::create_dir(dir.path().join(".git")).expect("create .git marker");
1047 fs::write(dir.path().join(".gitignore"), "node_modules/\n").expect("write gitignore");
1048 fs::create_dir_all(dir.path().join("node_modules").join("left-pad"))
1049 .expect("mkdir node_modules/left-pad");
1050 fs::write(
1051 dir.path()
1052 .join("node_modules")
1053 .join("left-pad")
1054 .join("package.json"),
1055 "{}",
1056 )
1057 .expect("write vendored manifest");
1058
1059 let outcome = walk(&[dir.path().to_path_buf()], &test_registry(), true, false);
1060
1061 assert!(outcome.ignored_manifests.is_empty());
1062 }
1063
1064 /// Critic S3: a manifest excluded only by the always-on, operator-controlled
1065 /// `.git/info/exclude` must not be misattributed to `.gitignore`/`.ignore` — both walks in
1066 /// [`detect_ignored_manifests`] must apply `git_exclude` identically, so such a file is
1067 /// absent from *both* and never diffed into [`WalkOutcome::ignored_manifests`].
1068 #[test]
1069 fn test_walk_git_info_exclude_suppression_not_misreported_as_ignored_manifest() {
1070 let dir = tempfile::tempdir().expect("create temp dir");
1071 fs::create_dir_all(dir.path().join(".git").join("info")).expect("mkdir .git/info");
1072 fs::write(
1073 dir.path().join(".git").join("info").join("exclude"),
1074 "Cargo.toml\n",
1075 )
1076 .expect("write git info/exclude");
1077 fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
1078
1079 let outcome = walk(&[dir.path().to_path_buf()], &test_registry(), true, false);
1080
1081 assert!(outcome.manifests.is_empty());
1082 assert!(
1083 outcome.ignored_manifests.is_empty(),
1084 ".git/info/exclude is operator-controlled, not a .gitignore/.ignore rule"
1085 );
1086 }
1087
1088 #[test]
1089 fn test_walk_single_file_path_bypasses_gitignore() {
1090 let dir = tempfile::tempdir().expect("create temp dir");
1091 fs::write(dir.path().join(".gitignore"), "Cargo.toml\n").expect("write gitignore");
1092 let manifest = dir.path().join("Cargo.toml");
1093 fs::write(&manifest, "[package]\n").expect("write manifest");
1094 let outcome = walk(&[manifest], &test_registry(), true, false);
1095 assert_eq!(outcome.manifests.len(), 1);
1096 }
1097
1098 /// Regression test for #1108: a *relative* walk root must still find manifests —
1099 /// `url::Url::from_file_path` (used to route a discovered file) rejects relative paths, so
1100 /// before the fix every file under a relative root was silently dropped, and `deps-cli
1101 /// check`'s own no-argument default (`.`) always reported zero findings.
1102 ///
1103 /// `std::env::set_current_dir` mutates process-global state, so this test (and any other
1104 /// test doing the same) is serialized via [`CwdGuard`]/[`CWD_LOCK`], which also restores
1105 /// the original cwd even if an assertion below panics.
1106 #[test]
1107 fn test_walk_relative_root_finds_manifest() {
1108 let dir = tempfile::tempdir().expect("create temp dir");
1109 fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
1110 let _guard = CwdGuard::chdir(dir.path());
1111
1112 let outcome = walk(&[PathBuf::from(".")], &test_registry(), false, false);
1113
1114 assert_eq!(outcome.manifests.len(), 1);
1115 assert!(outcome.walk_errors.is_empty());
1116 assert_eq!(
1117 outcome.manifests[0].display_path,
1118 PathBuf::from("Cargo.toml")
1119 );
1120 }
1121
1122 /// Companion to [`test_walk_relative_root_finds_manifest`]: an explicitly-given *relative*
1123 /// file path must resolve to the real path-conversion issue being fixed, not report the
1124 /// file as unrecognized by any ecosystem (the previous, misleading failure mode).
1125 #[test]
1126 fn test_walk_relative_explicit_file_path_is_found() {
1127 let dir = tempfile::tempdir().expect("create temp dir");
1128 fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
1129 let _guard = CwdGuard::chdir(dir.path());
1130
1131 let outcome = walk(
1132 &[PathBuf::from("Cargo.toml")],
1133 &test_registry(),
1134 false,
1135 false,
1136 );
1137
1138 assert_eq!(outcome.manifests.len(), 1);
1139 assert!(outcome.unrecognized_explicit_paths.is_empty());
1140 }
1141
1142 /// Regression test for S1 (spec 062 review): the cap must count every walked entry, not
1143 /// just matched manifests — a small fixture plus a small `limit` proves truncation fires
1144 /// without needing a real `MAX_WALKED_FILES`-sized tree.
1145 #[test]
1146 fn test_walk_with_limit_truncates_on_walked_entries_not_just_manifests() {
1147 let dir = tempfile::tempdir().expect("create temp dir");
1148 for i in 0..5 {
1149 fs::write(dir.path().join(format!("noise-{i}.txt")), "").expect("write noise file");
1150 }
1151 fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
1152
1153 let outcome = walk_with_limit(
1154 &[dir.path().to_path_buf()],
1155 &test_registry(),
1156 2,
1157 false,
1158 false,
1159 );
1160 assert!(
1161 outcome.truncated,
1162 "a 2-entry limit against a 6-entry tree must truncate"
1163 );
1164 }
1165
1166 #[test]
1167 fn test_walk_with_limit_does_not_truncate_when_under_the_cap() {
1168 let dir = tempfile::tempdir().expect("create temp dir");
1169 fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
1170 let outcome = walk_with_limit(
1171 &[dir.path().to_path_buf()],
1172 &test_registry(),
1173 100,
1174 false,
1175 false,
1176 );
1177 assert!(!outcome.truncated);
1178 assert_eq!(outcome.manifests.len(), 1);
1179 }
1180
1181 /// Regression test for M4 (spec 062 review): an explicitly-given path no ecosystem
1182 /// recognizes must be reported, not silently dropped.
1183 #[test]
1184 fn test_walk_explicit_unrecognized_path_is_reported() {
1185 let dir = tempfile::tempdir().expect("create temp dir");
1186 let unknown = dir.path().join("notes.txt");
1187 fs::write(&unknown, "not a manifest").expect("write file");
1188 let outcome = walk(
1189 std::slice::from_ref(&unknown),
1190 &test_registry(),
1191 false,
1192 false,
1193 );
1194 assert!(outcome.manifests.is_empty());
1195 assert_eq!(outcome.unrecognized_explicit_paths, vec![unknown]);
1196 }
1197
1198 /// A file encountered while walking a directory (as opposed to given explicitly) must
1199 /// never be reported this way — nearly every file in a real tree doesn't match any
1200 /// ecosystem, and warning on each would be useless noise.
1201 #[test]
1202 fn test_walk_unrecognized_file_found_during_directory_walk_is_not_reported() {
1203 let dir = tempfile::tempdir().expect("create temp dir");
1204 fs::write(dir.path().join("notes.txt"), "not a manifest").expect("write file");
1205 let outcome = walk(&[dir.path().to_path_buf()], &test_registry(), false, false);
1206 assert!(outcome.unrecognized_explicit_paths.is_empty());
1207 }
1208
1209 #[test]
1210 fn test_walk_multiple_ecosystems_in_one_tree() {
1211 let dir = tempfile::tempdir().expect("create temp dir");
1212 fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write cargo manifest");
1213 fs::write(dir.path().join("package.json"), "{}").expect("write npm manifest");
1214 let outcome = walk(&[dir.path().to_path_buf()], &test_registry(), false, false);
1215 assert_eq!(outcome.manifests.len(), 2);
1216 }
1217
1218 /// Regression test for background code-review fix 1 (spec 062 review): `.git`'s contents
1219 /// must never be walked, even though `.github`/`.gitlab` need hidden-ness lifted for the
1220 /// same tree. Deterministic regardless of directory-enumeration order: `.git` holds 100
1221 /// dummy files against a `limit` of 3 (comfortable margin over the handful of entries —
1222 /// the walk root, `.git`'s own directory entry, `Cargo.toml` — a correctly-hidden-filtered
1223 /// walk actually visits) — if `.git`'s contents were descended into at all, `entries_walked`
1224 /// would blow past 3 long before reaching `Cargo.toml`, truncating the walk.
1225 ///
1226 /// This test caught a real bug during review: an earlier version of this fix special-cased
1227 /// "the walk root's own basename starts with `.`" to mean "un-hide it, the user chose this
1228 /// dot-directory on purpose" — but `tempfile::tempdir()` itself creates dot-prefixed
1229 /// directories on macOS, so *every* test using a tempdir root silently hit that special
1230 /// case and disabled hidden-file filtering entirely, `.git` included. The fix removes that
1231 /// special-casing; `hidden(true)` never filters `walk_root` itself regardless of its name
1232 /// (only entries encountered *while descending*, by their own basename), so it was never
1233 /// needed in the first place.
1234 #[test]
1235 fn test_walk_never_descends_into_dot_git() {
1236 let dir = tempfile::tempdir().expect("create temp dir");
1237 fs::create_dir(dir.path().join(".git")).expect("create .git marker");
1238 for i in 0..100 {
1239 fs::write(dir.path().join(".git").join(format!("object-{i}")), "")
1240 .expect("write dummy git-internal file");
1241 }
1242 fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
1243
1244 let outcome = walk_with_limit(
1245 &[dir.path().to_path_buf()],
1246 &test_registry(),
1247 3,
1248 false,
1249 false,
1250 );
1251 assert!(
1252 !outcome.truncated,
1253 ".git's 100 dummy files must never be walked, so a limit of 3 must suffice"
1254 );
1255 assert_eq!(outcome.manifests.len(), 1);
1256 assert_eq!(
1257 outcome.manifests[0].display_path,
1258 PathBuf::from("Cargo.toml")
1259 );
1260 }
1261
1262 /// Companion test: `.github/workflows/*.yml` must still be found in the same tree that
1263 /// excludes `.git` — proves the fix distinguishes the two rather than just re-hiding
1264 /// everything dot-prefixed again.
1265 #[test]
1266 fn test_walk_still_finds_github_workflows_alongside_excluded_dot_git() {
1267 let dir = tempfile::tempdir().expect("create temp dir");
1268 fs::create_dir(dir.path().join(".git")).expect("create .git marker");
1269 fs::create_dir_all(dir.path().join(".github").join("workflows")).expect("mkdir");
1270 fs::write(
1271 dir.path().join(".github").join("workflows").join("ci.yml"),
1272 "on: push\njobs:\n x:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n",
1273 )
1274 .expect("write workflow");
1275
1276 let outcome = walk(&[dir.path().to_path_buf()], &test_registry(), false, false);
1277 assert_eq!(outcome.manifests.len(), 1);
1278 assert_eq!(
1279 outcome.manifests[0].display_path,
1280 PathBuf::from(".github").join("workflows").join("ci.yml")
1281 );
1282 assert_eq!(outcome.manifests[0].ecosystem.id(), "github-actions");
1283 }
1284
1285 /// Regression test for background code-review fix 2 (spec 062 review): the cap must be
1286 /// enforced for explicit file-path arguments too, not only for a directory walk — this
1287 /// was previously incrementing `entries_walked` without ever checking it in that branch.
1288 #[test]
1289 fn test_walk_with_limit_truncates_on_explicit_path_list() {
1290 let dir = tempfile::tempdir().expect("create temp dir");
1291 // Each manifest needs its own subdirectory — matched by exact filename, not a
1292 // pattern, so `Cargo0.toml`..`Cargo4.toml` siblings would never route to any ecosystem.
1293 let paths: Vec<PathBuf> = (0..5)
1294 .map(|i| {
1295 let subdir = dir.path().join(format!("pkg{i}"));
1296 fs::create_dir(&subdir).expect("mkdir");
1297 let path = subdir.join("Cargo.toml");
1298 fs::write(&path, "[package]\n").expect("write manifest");
1299 path
1300 })
1301 .collect();
1302
1303 let outcome = walk_with_limit(&paths, &test_registry(), 2, false, false);
1304 assert!(
1305 outcome.truncated,
1306 "a 2-entry limit against 5 explicit paths must truncate"
1307 );
1308 assert_eq!(outcome.manifests.len(), 2);
1309 }
1310
1311 /// Issue #1112, US-001: a symlinked manifest must never silently vanish from the scan
1312 /// under the default (`follow_symlinks: false`) mode — it is reported via
1313 /// `ignored_manifests`, not `manifests`.
1314 #[cfg(unix)]
1315 #[test]
1316 fn test_walk_default_detects_symlinked_manifest_without_following() {
1317 let dir = tempfile::tempdir().expect("create temp dir");
1318 let real = dir.path().join("real").join("manifest-data");
1319 fs::create_dir(dir.path().join("real")).expect("mkdir real");
1320 fs::write(&real, "[package]\n").expect("write real manifest");
1321 std::os::unix::fs::symlink(&real, dir.path().join("Cargo.toml")).expect("create symlink");
1322
1323 let outcome = walk(&[dir.path().to_path_buf()], &test_registry(), false, false);
1324
1325 assert!(outcome.manifests.is_empty());
1326 assert_eq!(outcome.ignored_manifests, vec![PathBuf::from("Cargo.toml")]);
1327 }
1328
1329 /// A broken symlink is not manifest-shaped by definition — no `ignored_manifests` entry.
1330 #[cfg(unix)]
1331 #[test]
1332 fn test_walk_broken_symlink_is_not_reported_as_manifest() {
1333 let dir = tempfile::tempdir().expect("create temp dir");
1334 std::os::unix::fs::symlink(
1335 dir.path().join("does-not-exist"),
1336 dir.path().join("Cargo.toml"),
1337 )
1338 .expect("create broken symlink");
1339
1340 let outcome = walk(&[dir.path().to_path_buf()], &test_registry(), false, false);
1341
1342 assert!(outcome.manifests.is_empty());
1343 assert!(outcome.ignored_manifests.is_empty());
1344 }
1345
1346 /// A symlink to a directory is not manifest-shaped either.
1347 #[cfg(unix)]
1348 #[test]
1349 fn test_walk_symlink_to_directory_is_not_reported_as_manifest() {
1350 let dir = tempfile::tempdir().expect("create temp dir");
1351 fs::create_dir(dir.path().join("real_dir")).expect("mkdir real_dir");
1352 std::os::unix::fs::symlink(dir.path().join("real_dir"), dir.path().join("link_dir"))
1353 .expect("create symlink to directory");
1354
1355 let outcome = walk(&[dir.path().to_path_buf()], &test_registry(), false, false);
1356
1357 assert!(outcome.manifests.is_empty());
1358 assert!(outcome.ignored_manifests.is_empty());
1359 }
1360
1361 /// Spec §6 edge case: a symlink to a manifest-shaped file sitting directly at a
1362 /// `PRUNED_DIRECTORIES`-excluded directory's own root is reported via `ignored_manifests`,
1363 /// mirroring the existing regular-file case
1364 /// (`test_walk_default_warns_on_manifest_directly_inside_pruned_directory`).
1365 #[cfg(unix)]
1366 #[test]
1367 fn test_walk_pruned_directory_symlinked_manifest_is_still_warned() {
1368 let dir = tempfile::tempdir().expect("create temp dir");
1369 let real = dir.path().join("real-cargo.toml");
1370 fs::write(&real, "[package]\n").expect("write real manifest");
1371 fs::create_dir(dir.path().join("vendor")).expect("mkdir vendor");
1372 std::os::unix::fs::symlink(&real, dir.path().join("vendor").join("Cargo.toml"))
1373 .expect("create symlink inside pruned directory");
1374
1375 let outcome = walk(&[dir.path().to_path_buf()], &test_registry(), false, false);
1376
1377 assert!(
1378 outcome.manifests.is_empty(),
1379 "still pruned from the primary scan"
1380 );
1381 assert_eq!(
1382 outcome.ignored_manifests,
1383 vec![PathBuf::from("vendor").join("Cargo.toml")]
1384 );
1385 }
1386
1387 /// Issue #1112, US-002 (FR-003): with `follow_symlinks: true`, a symlinked manifest inside
1388 /// the walked root is resolved and routed, appearing in `manifests` rather than only
1389 /// `ignored_manifests`.
1390 #[cfg(unix)]
1391 #[test]
1392 fn test_walk_follow_symlinks_resolves_and_routes_manifest() {
1393 let dir = tempfile::tempdir().expect("create temp dir");
1394 let real = dir.path().join("real").join("manifest-data");
1395 fs::create_dir(dir.path().join("real")).expect("mkdir real");
1396 fs::write(&real, "[package]\n").expect("write real manifest");
1397 std::os::unix::fs::symlink(&real, dir.path().join("Cargo.toml")).expect("create symlink");
1398
1399 let outcome = walk(&[dir.path().to_path_buf()], &test_registry(), false, true);
1400
1401 assert_eq!(outcome.manifests.len(), 1);
1402 assert!(outcome.ignored_manifests.is_empty());
1403 assert_eq!(outcome.manifests[0].ecosystem.id(), "cargo");
1404 // S3/FR-007: `path` (used for reading) is the resolved real path, not the symlink.
1405 assert_eq!(
1406 outcome.manifests[0]
1407 .path
1408 .canonicalize()
1409 .expect("canonicalize actual path"),
1410 real.canonicalize()
1411 .expect("canonicalize expected real path")
1412 );
1413 // Review finding M3: canonicalizing both sides above can't actually distinguish
1414 // "symlink path" from "real path" on its own (both would resolve to the same real
1415 // file) — assert the *raw*, non-canonicalized paths differ too, proving `path` is
1416 // genuinely the resolved target, not the symlink verbatim.
1417 assert_ne!(
1418 outcome.manifests[0].path,
1419 dir.path().join("Cargo.toml"),
1420 "path must be the resolved real path, not the symlink's own raw path"
1421 );
1422 }
1423
1424 /// FR-002/US-001: the same symlinked-manifest fixture behaves oppositely depending on the
1425 /// flag — `follow_symlinks: false` never reads the target (empty `manifests`, populated
1426 /// `ignored_manifests`), `follow_symlinks: true` resolves and routes it (populated
1427 /// `manifests`, empty `ignored_manifests`) — proving the flag actually gates reading, not
1428 /// just two independently-plausible outcomes.
1429 #[cfg(unix)]
1430 #[test]
1431 fn test_walk_follow_symlinks_toggles_between_ignored_and_routed() {
1432 let dir = tempfile::tempdir().expect("create temp dir");
1433 let real = dir.path().join("real").join("manifest-data");
1434 fs::create_dir(dir.path().join("real")).expect("mkdir real");
1435 fs::write(&real, "[package]\n").expect("write real manifest");
1436 std::os::unix::fs::symlink(&real, dir.path().join("Cargo.toml")).expect("create symlink");
1437
1438 let disabled = walk(&[dir.path().to_path_buf()], &test_registry(), false, false);
1439 assert!(disabled.manifests.is_empty());
1440 assert_eq!(
1441 disabled.ignored_manifests,
1442 vec![PathBuf::from("Cargo.toml")]
1443 );
1444
1445 let enabled = walk(&[dir.path().to_path_buf()], &test_registry(), false, true);
1446 assert_eq!(enabled.manifests.len(), 1);
1447 assert!(enabled.ignored_manifests.is_empty());
1448 }
1449
1450 /// FR-007: `display_path` reflects the symlink's own encountered path, not the resolved
1451 /// target's real path.
1452 #[cfg(unix)]
1453 #[test]
1454 fn test_walk_follow_symlinks_display_path_is_symlink_path_not_target() {
1455 let dir = tempfile::tempdir().expect("create temp dir");
1456 let real = dir.path().join("real").join("manifest-data");
1457 fs::create_dir(dir.path().join("real")).expect("mkdir real");
1458 fs::write(&real, "[package]\n").expect("write real manifest");
1459 std::os::unix::fs::symlink(&real, dir.path().join("Cargo.toml")).expect("create symlink");
1460
1461 let outcome = walk(&[dir.path().to_path_buf()], &test_registry(), false, true);
1462
1463 assert_eq!(outcome.manifests.len(), 1);
1464 assert_eq!(
1465 outcome.manifests[0].display_path,
1466 PathBuf::from("Cargo.toml")
1467 );
1468 }
1469
1470 /// FR-006: `follow_symlinks: true` does not defeat `PRUNED_DIRECTORIES` pruning, even when
1471 /// the manifest inside the pruned directory is itself reachable through a symlink.
1472 #[cfg(unix)]
1473 #[test]
1474 fn test_walk_follow_symlinks_still_prunes_node_modules() {
1475 let dir = tempfile::tempdir().expect("create temp dir");
1476 fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
1477 let real_vendored = dir.path().join("real-vendored-manifest");
1478 fs::write(&real_vendored, "{}").expect("write real vendored manifest");
1479 fs::create_dir_all(dir.path().join("node_modules").join("left-pad"))
1480 .expect("mkdir node_modules/left-pad");
1481 std::os::unix::fs::symlink(
1482 &real_vendored,
1483 dir.path()
1484 .join("node_modules")
1485 .join("left-pad")
1486 .join("package.json"),
1487 )
1488 .expect("symlink vendored manifest inside pruned directory");
1489
1490 let outcome = walk(&[dir.path().to_path_buf()], &test_registry(), false, true);
1491
1492 assert_eq!(
1493 outcome.manifests.len(),
1494 1,
1495 "a symlinked manifest inside a pruned directory must still be pruned, not routed"
1496 );
1497 assert_eq!(
1498 outcome.manifests[0].display_path,
1499 PathBuf::from("Cargo.toml")
1500 );
1501 }
1502
1503 /// FR-006: `follow_symlinks: true` still respects `walk_with_limit`'s cap when a symlinked
1504 /// directory inflates the number of entries the walk must visit.
1505 #[cfg(unix)]
1506 #[test]
1507 fn test_walk_follow_symlinks_still_enforces_max_walked_files() {
1508 let dir = tempfile::tempdir().expect("create temp dir");
1509 // Review finding M4: the noise source is a *hidden* (dot-prefixed) directory, so the
1510 // default `hidden(true)` filter excludes it from ever being walked directly by its own
1511 // name — the only way to reach its 5 files is by following `noise-link`, making the
1512 // symlink genuinely load-bearing for this test. `limit` is chosen to comfortably cover
1513 // the baseline tree (walk root + `Cargo.toml` + the `noise-link` entry itself = 3
1514 // entries, `.noise-source` never yielded at all) but not baseline-plus-5-noise-files —
1515 // with `follow_symlinks: false` this same fixture and limit does *not* truncate,
1516 // proving the symlink (not just the raw entry count) is what pushes the walk over.
1517 let noise_target = dir.path().join(".noise-source");
1518 fs::create_dir(&noise_target).expect("mkdir .noise-source");
1519 for i in 0..5 {
1520 fs::write(noise_target.join(format!("noise-{i}.txt")), "").expect("write noise file");
1521 }
1522 std::os::unix::fs::symlink(&noise_target, dir.path().join("noise-link"))
1523 .expect("symlink noise directory");
1524 fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
1525
1526 let outcome = walk_with_limit(
1527 &[dir.path().to_path_buf()],
1528 &test_registry(),
1529 4,
1530 false,
1531 true,
1532 );
1533 assert!(
1534 outcome.truncated,
1535 "a 4-entry limit against a tree inflated by a symlinked directory must truncate"
1536 );
1537 }
1538
1539 /// FR-004, US-003: a symlink inside the walked root pointing to a manifest-shaped file
1540 /// *outside* the walked root is never routed, even with `follow_symlinks: true`.
1541 #[cfg(unix)]
1542 #[test]
1543 fn test_walk_follow_symlinks_rejects_target_outside_root() {
1544 let outside = tempfile::tempdir().expect("create outside temp dir");
1545 let outside_manifest = outside.path().join("Cargo.toml");
1546 fs::write(&outside_manifest, "[package]\n").expect("write outside manifest");
1547
1548 let root = tempfile::tempdir().expect("create walked root");
1549 std::os::unix::fs::symlink(&outside_manifest, root.path().join("Cargo.toml"))
1550 .expect("create symlink escaping the walked root");
1551
1552 let outcome = walk(&[root.path().to_path_buf()], &test_registry(), false, true);
1553
1554 assert!(
1555 outcome.manifests.is_empty(),
1556 "must never route a symlink target outside the walked root"
1557 );
1558 assert_eq!(outcome.ignored_manifests, vec![PathBuf::from("Cargo.toml")]);
1559 }
1560
1561 /// FR-005, US-003: a symlink loop must not hang or crash the walk; it is reported via
1562 /// `walk_errors` and the run's other manifests are still found.
1563 #[cfg(unix)]
1564 #[test]
1565 fn test_walk_follow_symlinks_reports_symlink_loop_via_walk_errors() {
1566 let dir = tempfile::tempdir().expect("create temp dir");
1567 fs::create_dir_all(dir.path().join("a")).expect("mkdir a");
1568 fs::create_dir_all(dir.path().join("b")).expect("mkdir b");
1569 std::os::unix::fs::symlink(dir.path().join("b"), dir.path().join("a").join("loop"))
1570 .expect("create a/loop -> b");
1571 std::os::unix::fs::symlink(dir.path().join("a"), dir.path().join("b").join("loop"))
1572 .expect("create b/loop -> a");
1573 fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
1574
1575 let outcome = walk(&[dir.path().to_path_buf()], &test_registry(), false, true);
1576
1577 assert!(
1578 outcome
1579 .walk_errors
1580 .iter()
1581 .any(|error| error.to_lowercase().contains("loop")),
1582 "a symlink loop must be reported via walk_errors naming the loop, not just any \
1583 error, and must not hang or crash: {:?}",
1584 outcome.walk_errors
1585 );
1586 assert!(
1587 outcome
1588 .manifests
1589 .iter()
1590 .any(|m| m.display_path == Path::new("Cargo.toml")),
1591 "other manifests in the same tree must still be found"
1592 );
1593 }
1594
1595 /// Spec §6 edge case: `--follow-symlinks` and `--respect-gitignore` are independent flags —
1596 /// a symlinked manifest excluded by `.gitignore` is still reported via the existing
1597 /// `.gitignore`-suppression path once resolved, exactly as a non-symlinked manifest would
1598 /// be, rather than `follow_symlinks` bypassing the ignore rule.
1599 #[cfg(unix)]
1600 #[test]
1601 fn test_walk_follow_symlinks_and_respect_gitignore_together_still_honors_gitignore() {
1602 let dir = tempfile::tempdir().expect("create temp dir");
1603 fs::create_dir(dir.path().join(".git")).expect("create .git marker");
1604 fs::write(dir.path().join(".gitignore"), "Cargo.toml\n").expect("write gitignore");
1605 let real = dir.path().join("real").join("manifest-data");
1606 fs::create_dir(dir.path().join("real")).expect("mkdir real");
1607 fs::write(&real, "[package]\n").expect("write real manifest");
1608 std::os::unix::fs::symlink(&real, dir.path().join("Cargo.toml")).expect("create symlink");
1609
1610 let outcome = walk(&[dir.path().to_path_buf()], &test_registry(), true, true);
1611
1612 assert!(
1613 outcome.manifests.is_empty(),
1614 "a .gitignore-excluded symlinked manifest must not be routed even under \
1615 --follow-symlinks"
1616 );
1617 assert_eq!(outcome.ignored_manifests, vec![PathBuf::from("Cargo.toml")]);
1618 }
1619
1620 /// Critic finding C1 regression: a symlinked *directory* (not a symlinked leaf file) must
1621 /// not let `--follow-symlinks` escape the walked root — the leaf entry inside it
1622 /// (`evil/Cargo.toml`) is not itself a symlink, so a containment check keyed only on
1623 /// `path_is_symlink()` would miss it.
1624 #[cfg(unix)]
1625 #[test]
1626 fn test_walk_follow_symlinks_rejects_directory_symlink_escaping_root() {
1627 let outside = tempfile::tempdir().expect("create outside temp dir");
1628 fs::write(outside.path().join("Cargo.toml"), "[package]\n")
1629 .expect("write outside manifest");
1630
1631 let root = tempfile::tempdir().expect("create walked root");
1632 fs::write(root.path().join("Cargo.toml"), "[package]\n").expect("write root manifest");
1633 std::os::unix::fs::symlink(outside.path(), root.path().join("evil"))
1634 .expect("symlink a directory escaping the walked root");
1635
1636 let outcome = walk(&[root.path().to_path_buf()], &test_registry(), false, true);
1637
1638 assert!(
1639 outcome
1640 .manifests
1641 .iter()
1642 .all(|m| m.display_path != PathBuf::from("evil").join("Cargo.toml")),
1643 "a manifest reached only by descending into a symlinked directory outside the \
1644 walked root must never be routed: {:?}",
1645 outcome
1646 .manifests
1647 .iter()
1648 .map(|m| &m.display_path)
1649 .collect::<Vec<_>>()
1650 );
1651 assert_eq!(
1652 outcome.manifests.len(),
1653 1,
1654 "the root's own, non-escaping Cargo.toml must still be found"
1655 );
1656 }
1657
1658 /// Background code-review finding #2: an escaping symlinked directory must be pruned
1659 /// *before* `ignore`/`walkdir` descends into it, not walked entry-by-entry and rejected
1660 /// individually — otherwise a large external tree behind the symlink can exhaust
1661 /// `MAX_WALKED_FILES` on content outside the walked root, silently truncating the walk and
1662 /// dropping legitimate manifests elsewhere in the real tree (the exact #1112 fail-open
1663 /// class, reopened through this fix's own flag). Proven by pointing the escaping symlink at
1664 /// a directory with far more entries than a deliberately tiny `limit`, and asserting the
1665 /// walk still finds the root's own manifest without truncating.
1666 #[cfg(unix)]
1667 #[test]
1668 fn test_walk_follow_symlinks_escaping_directory_does_not_exhaust_the_budget() {
1669 let outside = tempfile::tempdir().expect("create outside temp dir");
1670 for i in 0..50 {
1671 fs::write(outside.path().join(format!("noise-{i}.txt")), "")
1672 .expect("write outside noise file");
1673 }
1674
1675 let root = tempfile::tempdir().expect("create walked root");
1676 fs::write(root.path().join("Cargo.toml"), "[package]\n").expect("write root manifest");
1677 std::os::unix::fs::symlink(outside.path(), root.path().join("evil"))
1678 .expect("symlink a directory escaping the walked root");
1679
1680 // Comfortably covers the walked root's own 2 entries (root dir + Cargo.toml) plus the
1681 // pruned `evil` entry itself, but is far smaller than the 50 files behind it — if the
1682 // escaping directory were walked instead of pruned, this would truncate long before
1683 // `Cargo.toml` is guaranteed to be counted.
1684 let outcome = walk_with_limit(
1685 &[root.path().to_path_buf()],
1686 &test_registry(),
1687 5,
1688 false,
1689 true,
1690 );
1691
1692 assert!(
1693 !outcome.truncated,
1694 "pruning the escaping directory before descent must keep the walk well under the \
1695 budget, not exhaust it on external content"
1696 );
1697 assert_eq!(
1698 outcome.manifests.len(),
1699 1,
1700 "the root's own manifest must still be found"
1701 );
1702 }
1703
1704 /// Background code-review finding #1: a symlinked manifest that is not itself
1705 /// `.gitignore`-excluded must be reported exactly once in `ignored_manifests`, not twice.
1706 /// Root cause was the primary walk's symlink-detection arm never inserting into `visited`
1707 /// (only the `is_file()` arm did), so `detect_ignored_manifests`'s separate unfiltered walk
1708 /// (run because `respect_gitignore` is true) found the same symlink again and double-counted
1709 /// it.
1710 #[cfg(unix)]
1711 #[test]
1712 fn test_walk_respect_gitignore_does_not_duplicate_symlinked_manifest_warning() {
1713 let dir = tempfile::tempdir().expect("create temp dir");
1714 fs::create_dir(dir.path().join(".git")).expect("create .git marker");
1715 // Present but irrelevant to this symlink — proves the duplication wasn't specific to
1716 // an empty .gitignore file being absent.
1717 fs::write(dir.path().join(".gitignore"), "*.log\n").expect("write gitignore");
1718 let real = dir.path().join("real").join("manifest-data");
1719 fs::create_dir(dir.path().join("real")).expect("mkdir real");
1720 fs::write(&real, "[package]\n").expect("write real manifest");
1721 std::os::unix::fs::symlink(&real, dir.path().join("Cargo.toml")).expect("create symlink");
1722
1723 let outcome = walk(&[dir.path().to_path_buf()], &test_registry(), true, false);
1724
1725 assert_eq!(
1726 outcome.ignored_manifests,
1727 vec![PathBuf::from("Cargo.toml")],
1728 "a non-gitignored symlinked manifest must be reported exactly once"
1729 );
1730 }
1731
1732 /// Critic finding S1 regression: a registered ecosystem's own hidden dot-directory (e.g.
1733 /// `.github`) escaping the walked root via a symlink must not be scanned, regardless of
1734 /// `follow_symlinks` — `ignore`/`walkdir` always follows a walk's own root symlink, so this
1735 /// containment check must apply in the default mode too.
1736 #[cfg(unix)]
1737 #[test]
1738 fn test_walk_default_rejects_symlinked_hidden_ecosystem_directory_escaping_root() {
1739 let outside = tempfile::tempdir().expect("create outside temp dir");
1740 fs::create_dir_all(outside.path().join("workflows")).expect("mkdir workflows");
1741 fs::write(
1742 outside.path().join("workflows").join("ci.yml"),
1743 "on: push\njobs:\n x:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n",
1744 )
1745 .expect("write workflow");
1746
1747 let root = tempfile::tempdir().expect("create walked root");
1748 std::os::unix::fs::symlink(outside.path(), root.path().join(".github"))
1749 .expect("symlink .github escaping the walked root");
1750
1751 let outcome = walk(&[root.path().to_path_buf()], &test_registry(), false, false);
1752
1753 assert!(
1754 outcome.manifests.is_empty(),
1755 "a symlinked .github escaping the walked root must never be scanned, even in the \
1756 default mode: {:?}",
1757 outcome
1758 .manifests
1759 .iter()
1760 .map(|m| &m.display_path)
1761 .collect::<Vec<_>>()
1762 );
1763 }
1764}