Skip to main content

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            &registry,
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}