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 [`GitignorePolicy::Respect`] was in effect
184    /// (issue #1109),
185    /// or a `PRUNED_DIRECTORIES` match in *any* mode (reviewer follow-up: an unusual monorepo
186    /// layout can have a real subproject's manifest sitting directly inside a directory named
187    /// `vendor`/`build`/`dist`/...). Unlike
188    /// [`unrecognized_explicit_paths`](Self::unrecognized_explicit_paths), this is a warning
189    /// about data loss (a real manifest silently skipped), not a benign non-match.
190    pub ignored_manifests: Vec<PathBuf>,
191    /// A manifest-shaped symlink whose target is not a manifest — unresolvable (dangling,
192    /// broken chain, unreadable) or resolves to a non-regular-file (issue #1124). Distinct from
193    /// [`ignored_manifests`](Self::ignored_manifests): that means "a real file existed and we
194    /// chose not to read it" (unfollowed symlink, `.gitignore`, pruned directory); this means
195    /// the manifest-shaped path produced no manifest at all, a stronger tampering signal.
196    /// Reported regardless of `--follow-symlinks`/`--respect-gitignore`, except a structural
197    /// ancestor loop under `--follow-symlinks`, which surfaces via `walk_errors` instead (still
198    /// a non-zero exit, just a generic message rather than this specific one).
199    pub broken_manifest_symlinks: Vec<PathBuf>,
200}
201
202/// Whether `.gitignore`/`.ignore` rules exclude manifests from the walk (issue #1109).
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub enum GitignorePolicy {
205    /// `.gitignore`/`.ignore` are consulted, matching `git`'s own behavior.
206    Respect,
207    /// `.gitignore`/`.ignore` are never consulted.
208    Ignore,
209}
210
211/// Whether symlinked manifests and directories are resolved and walked into (issue #1112).
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub enum SymlinkPolicy {
214    /// Symlinks are resolved and walked into.
215    Follow,
216    /// Symlinks are detected but never resolved or descended into.
217    Skip,
218}
219
220/// Walks every path in `roots`.
221///
222/// Uses the `ignore` crate, routing every regular file through `registry.for_uri` (FR-002)
223/// unchanged from the LSP's own routing.
224///
225/// A `root` that is itself a single file (not a directory) is checked directly against the
226/// registry, bypassing the directory walk — this is what lets `deps-cli check Cargo.toml`
227/// work without needing `.gitignore` semantics at all.
228///
229/// Every directory root is walked twice (spec 062 review, background code-review fix 1):
230/// once with `ignore`'s default hidden-file filtering intact (so `.git` — a potentially huge
231/// tree in a real checkout, and never a source of manifests — is never even descended into,
232/// not merely filtered from the results), and once more per registered ecosystem's own
233/// dot-prefixed [`deps_core::Ecosystem::manifest_directory_patterns`] entry (`.github`,
234/// `.gitlab`, ...) with hidden-ness lifted for that one subtree specifically. This is derived
235/// from the live registry rather than a hardcoded `[".github", ".gitlab"]` list, so a future
236/// ecosystem introducing a new dot-directory pattern is picked up automatically.
237///
238/// **`gitignore_policy` (issue #1109)**: when [`GitignorePolicy::Ignore`] (the `check`
239/// subcommand's default — see [`crate::cli::CheckArgs::gitignore_policy`]), `.gitignore` and
240/// `.ignore` files are never consulted, because in a CI security-gate invocation (`git
241/// checkout && deps-cli check .` against an untrusted fork PR) both are attacker-controlled
242/// input: a one-line addition anywhere in the tree would otherwise silently remove a manifest
243/// from the scan. `.git` itself is still never descended into (that is `hidden`-filtering, an
244/// unrelated concern — see above), and `.git/info/exclude` / the user's global gitignore are
245/// always honored regardless of this policy, since neither travels with a cloned/fetched PR and
246/// both are operator-, not attacker-, controlled. When [`GitignorePolicy::Respect`], standard
247/// `.gitignore`/`.ignore` awareness is restored (matching `git`'s own behavior), and any
248/// manifest-shaped file that awareness excludes is additionally reported via
249/// [`WalkOutcome::ignored_manifests`]. The internal `PRUNED_DIRECTORIES` denylist is applied
250/// regardless of `gitignore_policy` — it is compiled into the binary, not attacker-controlled
251/// input, so pruning `node_modules`/`target`/`vendor`/... does not reopen the fail-open gap
252/// this policy closes. A manifest sitting directly at the root of a pruned directory (an
253/// unusual but real monorepo layout, e.g. a genuine subproject named `vendor`) is still
254/// reported via [`WalkOutcome::ignored_manifests`] in every mode, so pruning stays visible
255/// rather than a second, narrower silent-omission bug.
256///
257/// One asymmetry is deliberate rather than accidental: in the default
258/// ([`GitignorePolicy::Ignore`]) mode, a manifest excluded only by the always-on
259/// `.git/info/exclude` or global gitignore is never diffed against (that costly double-walk
260/// only runs under [`GitignorePolicy::Respect`]), so such a suppression is silent beyond
261/// [`WalkOutcome::manifests`] coming back emptier than expected — acceptable because both
262/// sources are operator-, not attacker-, controlled (see above).
263///
264/// **`symlink_policy` (issue #1112)**: a directory entry that is itself a symlink to a
265/// manifest-shaped file is always detected, regardless of this policy — its path is reported
266/// via [`WalkOutcome::ignored_manifests`], the same sink a pruned or `.gitignore`-excluded
267/// manifest already uses, so a symlinked manifest can never silently vanish from the report.
268/// Detection alone never reads the target's content. When `symlink_policy` is
269/// [`SymlinkPolicy::Follow`], such a symlink is additionally resolved and routed like any other
270/// manifest (appearing in [`WalkOutcome::manifests`] instead, with [`DiscoveredManifest::path`]
271/// set to the resolved real path used for reading and [`DiscoveredManifest::display_path`] kept
272/// as the symlink's own encountered path). Every routed entry under [`SymlinkPolicy::Follow`] —
273/// not only ones where the leaf itself is a symlink, since an entry reached by descending into
274/// a followed symlinked *directory* is otherwise indistinguishable from an ordinary one — is
275/// canonicalized and checked against the walked root's own canonicalized absolute path; an
276/// entry that resolves outside the root is never routed, and is reported via
277/// `ignored_manifests` only when it is itself manifest-shaped (an arbitrary out-of-root symlink
278/// to a non-manifest file is silently skipped, matching detection's own never-warn-on-non-manifests
279/// invariant). This containment check applies to every registered ecosystem's own dot-directory
280/// sub-root (e.g. `.github`) too, and — because `ignore`/`walkdir` always follows a walk's own
281/// *root* symlink regardless of `follow_links` — that sub-root containment check runs in every
282/// mode, not only under [`SymlinkPolicy::Follow`]. A symlink loop is detected by the underlying
283/// `ignore` crate and surfaced via [`WalkOutcome::walk_errors`].
284///
285/// **Broken symlinks (issue #1124)**: a symlink whose target is not a manifest (unresolvable,
286/// or a non-regular-file such as a directory) is classified by its own filename, not its
287/// target, and reported via [`WalkOutcome::broken_manifest_symlinks`] instead of
288/// `ignored_manifests` — unconditional across every mode, same as the resolvable case, except a
289/// structural ancestor loop under `--follow-symlinks`, which reports via `walk_errors` instead.
290#[must_use]
291pub fn walk(
292    roots: &[PathBuf],
293    registry: &EcosystemRegistry,
294    gitignore_policy: GitignorePolicy,
295    symlink_policy: SymlinkPolicy,
296) -> WalkOutcome {
297    walk_with_limit(
298        roots,
299        registry,
300        MAX_WALKED_FILES,
301        gitignore_policy,
302        symlink_policy,
303    )
304}
305
306/// [`walk`]'s implementation, parameterized over the walked-entry cap so a test can exercise
307/// truncation against a small fixture instead of needing a real `MAX_WALKED_FILES`-sized tree
308/// (spec 062 review, tester gap 2).
309///
310/// The cap counts every entry the walk visits (S1, spec 062 review) — files, directories, and
311/// unreadable entries alike — not just the subset that matched an ecosystem, so
312/// [`WalkOutcome::truncated`] actually bounds the walk's own cost against a pathological tree
313/// (a huge `node_modules` not excluded by `.gitignore`, a symlink loop) rather than only the
314/// count of manifests found. Applies uniformly to the `root.is_file()` explicit-path branch
315/// too (background code-review fix 2, spec 062 review) — that branch previously incremented
316/// the counter but never checked it, so `WalkOutcome::truncated` could never be set from an
317/// explicit path list.
318fn walk_with_limit(
319    roots: &[PathBuf],
320    registry: &EcosystemRegistry,
321    limit: usize,
322    gitignore_policy: GitignorePolicy,
323    symlink_policy: SymlinkPolicy,
324) -> WalkOutcome {
325    let mut ctx = WalkCtx {
326        registry,
327        limit,
328        entries_walked: 0,
329        outcome: WalkOutcome::default(),
330    };
331    let hidden_ecosystem_dirs = hidden_ecosystem_directories(registry);
332    let options = WalkOptions {
333        respect_gitignore: matches!(gitignore_policy, GitignorePolicy::Respect),
334        follow_symlinks: matches!(symlink_policy, SymlinkPolicy::Follow),
335    };
336
337    'roots: for root in roots {
338        if ctx.outcome.truncated {
339            break;
340        }
341        // Absolutized (issue #1108): `url::Url::from_file_path` (in `route_file`) and
342        // `ignore::WalkBuilder` both require an absolute root to produce absolute entries —
343        // a relative root (e.g. `.`, the CLI's own default) otherwise made every discovered
344        // file fail `from_file_path` silently, so `deps-cli check` with no arguments always
345        // reported zero manifests. `display_path`s below are still derived relative to
346        // `root` as given, so reported paths stay exactly as the caller typed them.
347        let Ok(absolute_root) = std::path::absolute(root) else {
348            ctx.outcome
349                .walk_errors
350                .push(format!("could not resolve path: {}", root.display()));
351            continue;
352        };
353
354        if root.is_file() {
355            if ctx.entries_walked >= ctx.limit {
356                ctx.outcome.truncated = true;
357                tracing::warn!(
358                    limit,
359                    "walk truncated: reached the maximum number of entries per run"
360                );
361                break;
362            }
363            ctx.entries_walked += 1;
364            let matched_before = ctx.outcome.manifests.len();
365            route_file(
366                &absolute_root,
367                &absolute_root,
368                root,
369                registry,
370                &mut ctx.outcome,
371            );
372            if ctx.outcome.manifests.len() == matched_before {
373                ctx.outcome.unrecognized_explicit_paths.push(root.clone());
374            }
375            continue;
376        }
377
378        // Short-circuit a broken/non-file-target manifest-shaped symlink root via the same
379        // `classify_symlink` the walk uses, so it can't both fall through as a directory walk
380        // and get independently re-flagged as `Broken` at the root entry (S1/S3 contradiction).
381        if is_symlink(root) {
382            let sink = match classify_symlink(&absolute_root, registry) {
383                SymlinkClassification::Broken => Some(&mut ctx.outcome.broken_manifest_symlinks),
384                SymlinkClassification::Irrelevant if std::fs::metadata(root).is_err() => {
385                    Some(&mut ctx.outcome.unrecognized_explicit_paths)
386                }
387                SymlinkClassification::Resolvable | SymlinkClassification::Irrelevant => None,
388            };
389            if let Some(sink) = sink {
390                if ctx.entries_walked >= ctx.limit {
391                    ctx.outcome.truncated = true;
392                    tracing::warn!(
393                        limit,
394                        "walk truncated: reached the maximum number of entries per run"
395                    );
396                    break;
397                }
398                ctx.entries_walked += 1;
399                sink.push(root.clone());
400                continue;
401            }
402        }
403
404        // FR-004: the escape-prevention baseline, canonicalized once per root (not per entry).
405        // Computed unconditionally, not only when `follow_symlinks` is set (critic finding S1):
406        // `ignore`/`walkdir` always follows a *root* symlink when starting a walk, regardless
407        // of `follow_links`, so a symlinked hidden-ecosystem sub-root (e.g. a repository's own
408        // `.github` replaced by a symlink) can escape the walked root in every mode, not only
409        // under `--follow-symlinks` — this baseline is reused below to close that gap too.
410        // `canonicalize` failing here (a root that itself cannot be resolved) is not fatal to
411        // the walk: it just means containment can never be proven for anything under this root,
412        // so every symlink target falls back to `ignored_manifests` (or the sub-root is simply
413        // not walked) rather than being routed.
414        let canonical_root = std::fs::canonicalize(&absolute_root).ok();
415
416        // Always hidden-filtered: `hidden(true)` filters entries by their own basename as the
417        // walk descends, so `walk_root` itself is never excluded even if its name starts with
418        // `.` (e.g. a tempfile dir on macOS) — that previously caused a regression.
419        if !walk_directory(
420            &absolute_root,
421            &absolute_root,
422            DotDirs::Skip,
423            options,
424            canonical_root.as_deref(),
425            &mut ctx,
426        ) {
427            break 'roots;
428        }
429
430        for dir_name in &hidden_ecosystem_dirs {
431            let sub_root = absolute_root.join(dir_name);
432            if !sub_root.is_dir() {
433                continue;
434            }
435            // S1: `sub_root` (e.g. `<root>/.github`) may itself be a symlink escaping the
436            // walked root — `ignore`/`walkdir` always follows a walk's own root symlink, so
437            // this containment check applies regardless of `follow_symlinks`. A root that
438            // could not be canonicalized above means containment can never be proven, so the
439            // sub-root is skipped entirely rather than walked unchecked.
440            match (
441                canonical_root.as_deref(),
442                std::fs::canonicalize(&sub_root).ok(),
443            ) {
444                (Some(canonical_root), Some(canonical_sub_root))
445                    if canonical_sub_root.starts_with(canonical_root) => {}
446                _ => continue,
447            }
448            if !walk_directory(
449                &sub_root,
450                &absolute_root,
451                DotDirs::Descend,
452                options,
453                canonical_root.as_deref(),
454                &mut ctx,
455            ) {
456                break 'roots;
457            }
458        }
459    }
460
461    ctx.outcome
462}
463
464/// Bundles the state threaded through every helper in a single walk run, so adding a new
465/// piece of shared state doesn't grow each helper's own argument list
466/// (`clippy::too_many_arguments`).
467struct WalkCtx<'a> {
468    registry: &'a EcosystemRegistry,
469    limit: usize,
470    entries_walked: usize,
471    outcome: WalkOutcome,
472}
473
474/// The `respect_gitignore`/`follow_symlinks` pair, bundled so the internal walk chain (issue
475/// #1135) threads one named value instead of two positional bools whose order a call site can
476/// silently transpose.
477#[derive(Clone, Copy)]
478struct WalkOptions {
479    respect_gitignore: bool,
480    follow_symlinks: bool,
481}
482
483/// Whether a walk skips or descends into dot-prefixed entries — replaces `walk_directory`'s
484/// former positional `hidden: bool` parameter (issue #1135), whose meaning is the inverse of
485/// what a reader expects: `ignore::WalkBuilder::hidden(true)` means "skip hidden entries".
486#[derive(Clone, Copy)]
487enum DotDirs {
488    /// Dot-prefixed entries are filtered out of the walk.
489    Skip,
490    /// Dot-prefixed entries are walked normally.
491    Descend,
492}
493
494impl DotDirs {
495    /// The `ignore::WalkBuilder::hidden` argument this variant corresponds to — the single
496    /// point where `DotDirs` is converted back to the crate's own inverted-bool convention.
497    fn skip_hidden(self) -> bool {
498        matches!(self, Self::Skip)
499    }
500}
501
502/// Walks `walk_root` (a directory), routing every regular file relative to `display_root` —
503/// the outer `root` [`walk_with_limit`] was given, so a file under a dot-directory sub-root
504/// (e.g. `<repo>/.github`) still displays relative to the repository root, not to `.github`
505/// itself. Returns `false` once `limit` is reached (the caller must stop the whole walk, not
506/// just this directory); `true` otherwise.
507fn walk_directory(
508    walk_root: &Path,
509    display_root: &Path,
510    dot_dirs: DotDirs,
511    options: WalkOptions,
512    canonical_root: Option<&Path>,
513    ctx: &mut WalkCtx<'_>,
514) -> bool {
515    // `.gitignore`/`.ignore` toggle by `respect_gitignore` (issue #1109) — both are
516    // attacker-controlled in a CI scan of untrusted input. `git_exclude` (`.git/info/exclude`)
517    // and `git_global` (the user's global gitignore) stay on unconditionally: neither travels
518    // with a cloned/fetched PR, so neither is part of the attack surface this flag closes.
519    let hidden = dot_dirs.skip_hidden();
520    let pruned_dirs: Arc<Mutex<Vec<PathBuf>>> = Arc::new(Mutex::new(Vec::new()));
521    let mut builder = WalkBuilder::new(walk_root);
522    builder
523        .hidden(hidden)
524        .parents(true)
525        .ignore(options.respect_gitignore)
526        .git_ignore(options.respect_gitignore)
527        .git_global(true)
528        .git_exclude(true)
529        .follow_links(options.follow_symlinks);
530    {
531        let pruned_dirs = Arc::clone(&pruned_dirs);
532        let canonical_root_owned = canonical_root.map(Path::to_path_buf);
533        let follow_symlinks = options.follow_symlinks;
534        builder.filter_entry(move |entry| {
535            if !is_not_pruned_directory(entry) {
536                pruned_dirs
537                    .lock()
538                    .unwrap_or_else(std::sync::PoisonError::into_inner)
539                    .push(entry.path().to_path_buf());
540                return false;
541            }
542            is_not_escaping_directory(entry, follow_symlinks, canonical_root_owned.as_deref())
543        });
544    }
545
546    let mut visited: BTreeSet<PathBuf> = BTreeSet::new();
547    for entry in builder.build() {
548        if ctx.entries_walked >= ctx.limit {
549            ctx.outcome.truncated = true;
550            tracing::warn!(
551                limit = ctx.limit,
552                "walk truncated: reached the maximum number of entries per run"
553            );
554            return false;
555        }
556        ctx.entries_walked += 1;
557        match entry {
558            Ok(entry) if entry.file_type().is_some_and(|t| t.is_file()) => {
559                let path = entry.path();
560                let display = display_relative_path(path, display_root);
561                if options.respect_gitignore {
562                    visited.insert(display.clone());
563                }
564                if options.follow_symlinks {
565                    // FR-004/FR-007 (critic C1): canonicalize+containment-check every entry,
566                    // not only leaf symlinks — a followed symlinked *directory* ancestor needs
567                    // the same check. Resolved path doubles as the real read path (FR-007).
568                    match canonicalize_within_root(path, canonical_root) {
569                        Some(canonical_path) => {
570                            route_file(
571                                path,
572                                &canonical_path,
573                                &display,
574                                ctx.registry,
575                                &mut ctx.outcome,
576                            );
577                        }
578                        // S4: reachable only via a rare TOCTOU race (metadata failed here after
579                        // `is_file()` succeeded above); routed like any other unresolvable path.
580                        None => {
581                            classify_symlink(path, ctx.registry).record(&mut ctx.outcome, display);
582                        }
583                    }
584                } else {
585                    route_file(path, path, &display, ctx.registry, &mut ctx.outcome);
586                }
587            }
588            // #1112/#1124: `file_type()` reports the symlink's own (unresolved) type under
589            // `follow_symlinks: false`, or `None` under `follow_symlinks: true` when the
590            // target can't be stat'd (a broken symlink) — either way this arm, not the one
591            // above, is where detection happens.
592            Ok(entry) => {
593                if entry.path_is_symlink() {
594                    let path = entry.path();
595                    let classification = classify_symlink(path, ctx.registry);
596                    if !matches!(classification, SymlinkClassification::Irrelevant) {
597                        let display = display_relative_path(path, display_root);
598                        // Mirrors the `is_file()` arm's `visited` insert — otherwise
599                        // `detect_ignored_manifests`'s separate unfiltered walk double-reports.
600                        if options.respect_gitignore {
601                            visited.insert(display.clone());
602                        }
603                        classification.record(&mut ctx.outcome, display);
604                    }
605                }
606            }
607            Err(error) => {
608                // #1124: under `follow_symlinks: true`, a broken symlink surfaces as an `Err`
609                // (walkdir must stat to resolve type) instead of the arm above. `is_io()`
610                // excludes a structurally different error sharing this path-carrying variant
611                // (e.g. a symlink `Loop`); `is_symlink` (S2) excludes a non-symlink permission
612                // error from being misreported as tampering.
613                let classified_as_broken = if let ignore::Error::WithPath { path, err } = &error
614                    && err.is_io()
615                    && is_symlink(path)
616                    && is_manifest_shaped_by_name(path, ctx.registry)
617                {
618                    let display = display_relative_path(path, display_root);
619                    if options.respect_gitignore {
620                        visited.insert(display.clone());
621                    }
622                    ctx.outcome.broken_manifest_symlinks.push(display);
623                    true
624                } else {
625                    false
626                };
627                // Bug 3 (background review): don't also emit the generic IO error once the
628                // specific broken-manifest warning already covers this path.
629                if !classified_as_broken {
630                    ctx.outcome.walk_errors.push(error.to_string());
631                }
632            }
633        }
634    }
635
636    // Reviewer follow-up (#2): visible regardless of `respect_gitignore` — pruning is always
637    // on, so its (rare) false positives must always be reported, not only under the opt-in
638    // ignore-aware mode.
639    for pruned_dir in pruned_dirs
640        .lock()
641        .unwrap_or_else(std::sync::PoisonError::into_inner)
642        .iter()
643    {
644        warn_on_pruned_directory_manifest(pruned_dir, display_root, ctx);
645    }
646
647    if options.respect_gitignore {
648        detect_ignored_manifests(walk_root, display_root, hidden, ctx, &visited);
649    }
650    true
651}
652
653/// Checks a directory [`is_not_pruned_directory`] excluded (its basename matched
654/// [`PRUNED_DIRECTORIES`]) for a manifest sitting directly at its own root, and records any
655/// found onto [`WalkOutcome::ignored_manifests`] or [`WalkOutcome::broken_manifest_symlinks`]
656/// (reviewer follow-up on issue #1109's pruning fix, extended for #1124: an unusual monorepo
657/// layout can have a *real* subproject's manifest — or a manifest-shaped, possibly broken,
658/// symlink — directly inside a directory named `vendor`/`build`/`dist`/...).
659///
660/// Deliberately one level deep only — a full recursive re-walk of the pruned directory would
661/// reintroduce the exact cost [`PRUNED_DIRECTORIES`] exists to avoid for a large vendored tree
662/// (a `node_modules` with hundreds of packages has no manifest directly at its own root, only
663/// nested under each package directory, so this check costs one cheap `read_dir` per pruned
664/// directory and stays silent for it). A manifest nested two or more levels inside a pruned
665/// directory remains a known, accepted limitation of this check, not a regression — it was
666/// never discovered before this fix either.
667fn warn_on_pruned_directory_manifest(
668    pruned_dir: &Path,
669    display_root: &Path,
670    ctx: &mut WalkCtx<'_>,
671) {
672    let Ok(read_dir) = std::fs::read_dir(pruned_dir) else {
673        return;
674    };
675    for entry in read_dir.flatten() {
676        let path = entry.path();
677        let display = display_relative_path(&path, display_root);
678        classify_symlink(&path, ctx.registry).record(&mut ctx.outcome, display);
679    }
680}
681
682/// Diffs an unfiltered (but still [`PRUNED_DIRECTORIES`]-pruned) walk of `walk_root` against
683/// `visited` (the file set an ignore-aware walk already found) and records any
684/// *manifest-shaped* file present only in the unfiltered walk onto
685/// [`WalkOutcome::ignored_manifests`] (issue #1109) — reusing [`EcosystemRegistry::for_uri`]
686/// (the same routing [`route_file`] uses) rather than reimplementing manifest matching, and
687/// never warning on a non-manifest file so this stays silent for the overwhelming majority of
688/// ordinary `.gitignore` entries. `git_global`/`git_exclude` are kept `true` here too — the
689/// same as the filtered walk (critic S3) — so a file excluded only by the operator-controlled
690/// `.git/info/exclude` or global gitignore (out of scope for `respect_gitignore`, see [`walk`]'s
691/// doc) is present in *both* walks and never misattributed to `.gitignore`/`.ignore`.
692///
693/// Only invoked when `respect_gitignore` is `true` — the default (`respect_gitignore: false`)
694/// already never consults `.gitignore`/`.ignore`, so there is nothing to diff against. Uses its
695/// own entry budget, independent of `ctx.entries_walked`/`ctx.limit` (reviewer follow-up #3):
696/// this is a best-effort diagnostic pass over a walk whose *primary* manifest list is already
697/// complete by the time this runs, so exhausting its budget must never set
698/// [`WalkOutcome::truncated`] (which would misleadingly claim the primary walk, not this
699/// diagnostic one, is incomplete) or otherwise affect the primary walk's own truncation state.
700fn detect_ignored_manifests(
701    walk_root: &Path,
702    display_root: &Path,
703    hidden: bool,
704    ctx: &mut WalkCtx<'_>,
705    visited: &BTreeSet<PathBuf>,
706) {
707    let mut builder = WalkBuilder::new(walk_root);
708    builder
709        .hidden(hidden)
710        .ignore(false)
711        .git_ignore(false)
712        .git_global(true)
713        .git_exclude(true)
714        .filter_entry(is_not_pruned_directory);
715    for (detection_entries, entry) in builder.build().enumerate() {
716        if detection_entries >= ctx.limit {
717            tracing::warn!(
718                limit = ctx.limit,
719                "ignored-manifest detection walk truncated: reached the maximum number of \
720                 entries per run; some .gitignore/.ignore exclusions may go unreported"
721            );
722            return;
723        }
724        // Reviewer follow-up (#4): both failure paths below now match their counterparts in
725        // `walk_directory`/`route_file` — an unreadable entry or an unconvertible path is
726        // recorded, not silently skipped, so two structurally identical failure points added
727        // by the same change behave identically.
728        let entry = match entry {
729            Ok(entry) => entry,
730            Err(error) => {
731                ctx.outcome.walk_errors.push(error.to_string());
732                continue;
733            }
734        };
735        if !entry.file_type().is_some_and(|t| t.is_file()) {
736            // #1112/M5/#1124: a gitignored symlinked (possibly broken) manifest never appears
737            // in the primary filtered walk at all, so this unfiltered pass is the only place
738            // that still sees it under `--respect-gitignore`.
739            let path = entry.path();
740            if entry.path_is_symlink() {
741                let display = display_relative_path(path, display_root);
742                if !visited.contains(&display) {
743                    classify_symlink(path, ctx.registry).record(&mut ctx.outcome, display);
744                }
745            }
746            continue;
747        }
748        let path = entry.path();
749        let display = display_relative_path(path, display_root);
750        if visited.contains(&display) {
751            continue;
752        }
753        match url::Url::from_file_path(path) {
754            Ok(uri) => {
755                if ctx.registry.for_uri(&uri).is_some() {
756                    ctx.outcome.ignored_manifests.push(display);
757                }
758            }
759            Err(()) => {
760                ctx.outcome.walk_errors.push(format!(
761                    "could not convert to a file URI while checking for ignore-suppressed \
762                     manifests, skipping: {}",
763                    display.display()
764                ));
765            }
766        }
767    }
768}
769
770/// The set of top-level dot-directory names (`.github`, `.gitlab`, ...) that at least one
771/// registered ecosystem's [`deps_core::Ecosystem::manifest_directory_patterns`] names — the
772/// only dot-directories [`walk_with_limit`] walks with hidden-file filtering lifted. Derived
773/// from the live registry (not a hardcoded list) so a future ecosystem's own dot-directory
774/// pattern is picked up automatically; `.git` is never a match here, since no ecosystem's
775/// directory pattern names it.
776fn hidden_ecosystem_directories(registry: &EcosystemRegistry) -> BTreeSet<String> {
777    let mut dirs = BTreeSet::new();
778    for id in registry.ecosystem_ids() {
779        let Some(ecosystem) = registry.get(id) else {
780            continue;
781        };
782        for (dir_pattern, _suffix) in ecosystem.manifest_directory_patterns() {
783            if let Some(first) = dir_pattern.split('/').next()
784                && first.starts_with('.')
785            {
786                dirs.insert(first.to_string());
787            }
788        }
789    }
790    dirs
791}
792
793/// FR-004/FR-007: resolves `path` to its canonicalized real path and returns it only when that
794/// path is contained within `canonical_root`. `canonical_root` being `None` (symlink-following
795/// disabled, or the root itself failed to canonicalize) always yields `None` — a safe default,
796/// never routing an entry whose containment cannot be proven. A `canonicalize` failure on `path`
797/// itself (e.g. a race between the walk's stat and this check) is likewise treated as "outside
798/// root", never as "inside".
799///
800/// Called for **every** routed file entry under `follow_symlinks: true`, not only ones where
801/// the leaf itself is a symlink (critic finding C1): `ignore`/`walkdir` only sets
802/// `DirEntry::path_is_symlink()` on the entry actually named as a symlink, so an entry reached
803/// by *descending into* a followed symlinked directory reports `path_is_symlink() == false` for
804/// its own leaf while still resolving to a real path outside the walked root — canonicalizing
805/// unconditionally (rather than gating on `path_is_symlink()`) closes that gap for both leaf
806/// symlinks and symlinked ancestors alike.
807///
808/// The returned path also satisfies FR-007: it is the resolved real path
809/// [`DiscoveredManifest::path`] must use for reading, computed once here rather than a second
810/// time at read-time, which would otherwise leave a TOCTOU window between this containment
811/// check and the actual read.
812fn canonicalize_within_root(path: &Path, canonical_root: Option<&Path>) -> Option<PathBuf> {
813    let canonical_root = canonical_root?;
814    let canonical_path = std::fs::canonicalize(path).ok()?;
815    canonical_path
816        .starts_with(canonical_root)
817        .then_some(canonical_path)
818}
819
820/// The result of [`classify_symlink`] (issue #1124).
821enum SymlinkClassification {
822    /// Target resolved to a manifest-shaped regular file — not followed by choice, or resolved
823    /// but excluded for another reason (e.g. it falls outside the walked root, FR-004).
824    Resolvable,
825    /// Manifest-shaped by name, but the target is not a manifest: unresolvable (dangling,
826    /// broken chain hop, unreadable), or resolves to a non-regular-file (directory, fifo,
827    /// socket, ...) — the latter is just as much a substitute for the real manifest as a
828    /// dangling target, so it is not treated as safe merely because it "resolves".
829    Broken,
830    /// Not manifest-shaped by name — never worth a warning.
831    Irrelevant,
832}
833
834impl SymlinkClassification {
835    /// Routes `display` to the matching `outcome` sink (no-op for `Irrelevant`) — the one place
836    /// this Resolvable/Broken/Irrelevant → push/push/noop mapping lives.
837    fn record(self, outcome: &mut WalkOutcome, display: PathBuf) {
838        match self {
839            Self::Resolvable => outcome.ignored_manifests.push(display),
840            Self::Broken => outcome.broken_manifest_symlinks.push(display),
841            Self::Irrelevant => {}
842        }
843    }
844}
845
846/// Classifies `path` without reading its content — gates `Broken` on `path` actually being a
847/// symlink (lstat), not merely non-regular-file, so an ordinary permission-denied directory or
848/// file that happens to share a manifest's name is never misreported as tampering.
849fn classify_symlink(path: &Path, registry: &EcosystemRegistry) -> SymlinkClassification {
850    if !is_manifest_shaped_by_name(path, registry) {
851        return SymlinkClassification::Irrelevant;
852    }
853    match std::fs::metadata(path) {
854        Ok(metadata) if metadata.is_file() => SymlinkClassification::Resolvable,
855        _ if is_symlink(path) => SymlinkClassification::Broken,
856        _ => SymlinkClassification::Irrelevant,
857    }
858}
859
860/// `symlink_metadata` (lstat, never follows) reporting `path` itself as a symlink — succeeds
861/// even for a dangling target, unlike [`std::fs::metadata`]/`Path::is_file`.
862fn is_symlink(path: &Path) -> bool {
863    std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink())
864}
865
866/// `path` relative to `display_root`; falls back to `path` itself when `strip_prefix` fails or
867/// succeeds empty (the latter whenever `path == display_root`, e.g. an explicitly-given root).
868fn display_relative_path(path: &Path, display_root: &Path) -> PathBuf {
869    let stripped = path.strip_prefix(display_root).unwrap_or(path);
870    if stripped.as_os_str().is_empty() {
871        path.to_path_buf()
872    } else {
873        stripped.to_path_buf()
874    }
875}
876
877/// `EcosystemRegistry::for_uri` on `path`'s own name, never on a resolved target.
878fn is_manifest_shaped_by_name(path: &Path, registry: &EcosystemRegistry) -> bool {
879    let Ok(uri) = url::Url::from_file_path(path) else {
880        return false;
881    };
882    registry.for_uri(&uri).is_some()
883}
884
885/// Routes one already-discovered file through `registry.for_uri`, pushing a
886/// [`DiscoveredManifest`] onto `outcome` when an ecosystem claims it.
887///
888/// `route_path` and `read_path` are the same path for every caller except the
889/// `follow_symlinks: true` branch of `walk_directory` (FR-007): ecosystem routing is a
890/// basename/pattern match (`manifest_filenames`, `manifest_patterns`, ...), so it must always
891/// go through the *encountered* path — a symlink named `Cargo.toml` routes as `Cargo.toml`
892/// regardless of what its target is named — while [`DiscoveredManifest::path`] (used later to
893/// read the manifest's content) must be the resolved real path a symlink was already
894/// containment-checked against, not the symlink itself. `route_path` is also stored as
895/// [`DiscoveredManifest::uri_path`] (review finding M2): lockfile/in-use-version discovery
896/// must anchor its ancestor-directory search at the symlink's own location, not its target's.
897///
898/// `route_path` is expected to already be absolute (every caller absolutizes its walk root
899/// first — see [`walk_with_limit`]) so `url::Url::from_file_path` should never fail in
900/// practice; if it somehow does (issue #1108), that is recorded as a warning rather than
901/// silently dropping the file, so a future regression in the absolutization degrades loudly
902/// instead of quietly reintroducing the "walk finds zero manifests" bug.
903fn route_file(
904    route_path: &Path,
905    read_path: &Path,
906    display_path: &Path,
907    registry: &EcosystemRegistry,
908    outcome: &mut WalkOutcome,
909) {
910    let Ok(uri) = url::Url::from_file_path(route_path) else {
911        outcome.walk_errors.push(format!(
912            "could not convert to a file URI, skipping: {}",
913            display_path.display()
914        ));
915        return;
916    };
917    if let Some(ecosystem) = registry.for_uri(&uri) {
918        outcome.manifests.push(DiscoveredManifest {
919            path: read_path.to_path_buf(),
920            uri_path: route_path.to_path_buf(),
921            display_path: display_path.to_path_buf(),
922            ecosystem,
923        });
924    }
925}
926
927#[cfg(test)]
928mod tests {
929    use super::*;
930    use std::fs;
931    use std::sync::Mutex;
932
933    /// Serializes tests that call `std::env::set_current_dir` — the process CWD is global
934    /// state shared across every test in this binary, which otherwise run concurrently.
935    static CWD_LOCK: Mutex<()> = Mutex::new(());
936
937    /// Chdirs into `dir` and restores the original cwd on drop — including on an early return
938    /// via a panicking assertion mid-test (critic M3), which a plain "restore at the end of the
939    /// function" cannot do. Holds `CWD_LOCK` for its own lifetime.
940    struct CwdGuard {
941        original: PathBuf,
942        _lock: std::sync::MutexGuard<'static, ()>,
943    }
944
945    impl CwdGuard {
946        fn chdir(dir: &Path) -> Self {
947            let lock = CWD_LOCK
948                .lock()
949                .unwrap_or_else(std::sync::PoisonError::into_inner);
950            let original = std::env::current_dir().expect("read cwd");
951            std::env::set_current_dir(dir).expect("chdir");
952            Self {
953                original,
954                _lock: lock,
955            }
956        }
957    }
958
959    impl Drop for CwdGuard {
960        fn drop(&mut self) {
961            let _ = std::env::set_current_dir(&self.original);
962        }
963    }
964
965    fn test_registry() -> EcosystemRegistry {
966        let registry = EcosystemRegistry::new();
967        let runtime = deps_engine::setup::EcosystemRuntime::from_policy(
968            &deps_core::policy_config::PolicyConfig::default(),
969        );
970        deps_engine::setup::register_ecosystems(
971            &registry,
972            Arc::new(deps_core::HttpCache::new()),
973            &runtime,
974        );
975        registry
976    }
977
978    #[test]
979    fn test_walk_empty_directory_finds_nothing() {
980        let dir = tempfile::tempdir().expect("create temp dir");
981        let outcome = walk(
982            &[dir.path().to_path_buf()],
983            &test_registry(),
984            GitignorePolicy::Ignore,
985            SymlinkPolicy::Skip,
986        );
987        assert!(outcome.manifests.is_empty());
988        assert!(!outcome.truncated);
989    }
990
991    #[test]
992    fn test_walk_finds_cargo_toml() {
993        let dir = tempfile::tempdir().expect("create temp dir");
994        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n")
995            .expect("write manifest");
996        let outcome = walk(
997            &[dir.path().to_path_buf()],
998            &test_registry(),
999            GitignorePolicy::Ignore,
1000            SymlinkPolicy::Skip,
1001        );
1002        assert_eq!(outcome.manifests.len(), 1);
1003        assert_eq!(
1004            outcome.manifests[0].display_path,
1005            PathBuf::from("Cargo.toml")
1006        );
1007        assert_eq!(outcome.manifests[0].ecosystem.id(), "cargo");
1008    }
1009
1010    /// With `respect_gitignore: true` (the opt-in, pre-#1109-fix behavior), a `.gitignore`
1011    /// entry still suppresses a manifest — this is intentional for a caller who explicitly
1012    /// asked to restore `git`'s own semantics.
1013    #[test]
1014    fn test_walk_respect_gitignore_true_skips_gitignored_manifest() {
1015        let dir = tempfile::tempdir().expect("create temp dir");
1016        // `ignore`'s `.gitignore` support only activates inside a git repo by default
1017        // (`require_git`); an empty `.git` marker is enough for detection.
1018        fs::create_dir(dir.path().join(".git")).expect("create .git marker");
1019        fs::write(dir.path().join(".gitignore"), "ignored/\n").expect("write gitignore");
1020        fs::create_dir(dir.path().join("ignored")).expect("mkdir");
1021        fs::write(dir.path().join("ignored").join("Cargo.toml"), "[package]\n")
1022            .expect("write manifest");
1023        let outcome = walk(
1024            &[dir.path().to_path_buf()],
1025            &test_registry(),
1026            GitignorePolicy::Respect,
1027            SymlinkPolicy::Skip,
1028        );
1029        assert!(outcome.manifests.is_empty());
1030        assert_eq!(
1031            outcome.ignored_manifests,
1032            vec![PathBuf::from("ignored").join("Cargo.toml")]
1033        );
1034    }
1035
1036    /// Issue #1109 repro 1: a `.gitignore` entry must NOT suppress a manifest under the
1037    /// default (`respect_gitignore: false`) `check` behavior — this is the fail-open gap the
1038    /// issue reports (attacker-controlled `.gitignore` silently defeating the CI gate).
1039    #[test]
1040    fn test_walk_default_does_not_respect_gitignore() {
1041        let dir = tempfile::tempdir().expect("create temp dir");
1042        fs::create_dir(dir.path().join(".git")).expect("create .git marker");
1043        fs::write(dir.path().join(".gitignore"), "Cargo.toml\n").expect("write gitignore");
1044        fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
1045        let outcome = walk(
1046            &[dir.path().to_path_buf()],
1047            &test_registry(),
1048            GitignorePolicy::Ignore,
1049            SymlinkPolicy::Skip,
1050        );
1051        assert_eq!(outcome.manifests.len(), 1);
1052        assert!(outcome.ignored_manifests.is_empty());
1053    }
1054
1055    /// Issue #1109 repro 2: a nested `sub/.gitignore` (not just a top-level one) must not
1056    /// suppress a manifest under the default behavior either.
1057    #[test]
1058    fn test_walk_default_ignores_nested_gitignore() {
1059        let dir = tempfile::tempdir().expect("create temp dir");
1060        fs::create_dir(dir.path().join(".git")).expect("create .git marker");
1061        fs::create_dir(dir.path().join("sub")).expect("mkdir sub");
1062        fs::write(dir.path().join("sub").join(".gitignore"), "Cargo.toml\n")
1063            .expect("write nested gitignore");
1064        fs::write(dir.path().join("sub").join("Cargo.toml"), "[package]\n")
1065            .expect("write manifest");
1066        let outcome = walk(
1067            &[dir.path().to_path_buf()],
1068            &test_registry(),
1069            GitignorePolicy::Ignore,
1070            SymlinkPolicy::Skip,
1071        );
1072        assert_eq!(outcome.manifests.len(), 1);
1073    }
1074
1075    /// Issue #1109 repro 3: an `.ignore` file (no `.git` directory at all) must not suppress a
1076    /// manifest under the default behavior.
1077    #[test]
1078    fn test_walk_default_ignores_dot_ignore_file_without_git_repo() {
1079        let dir = tempfile::tempdir().expect("create temp dir");
1080        fs::write(dir.path().join(".ignore"), "Cargo.toml\n").expect("write .ignore");
1081        fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
1082        let outcome = walk(
1083            &[dir.path().to_path_buf()],
1084            &test_registry(),
1085            GitignorePolicy::Ignore,
1086            SymlinkPolicy::Skip,
1087        );
1088        assert_eq!(outcome.manifests.len(), 1);
1089    }
1090
1091    /// Critic S1 (post-#1109 default-flip regression): disabling `.gitignore`/`.ignore` by
1092    /// default must not turn a vendored `node_modules/` tree back into hundreds of scanned
1093    /// manifests — the compiled-in [`PRUNED_DIRECTORIES`] denylist must prune it regardless.
1094    #[test]
1095    fn test_walk_default_prunes_node_modules() {
1096        let dir = tempfile::tempdir().expect("create temp dir");
1097        fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
1098        fs::create_dir_all(dir.path().join("node_modules").join("left-pad"))
1099            .expect("mkdir node_modules/left-pad");
1100        fs::write(
1101            dir.path()
1102                .join("node_modules")
1103                .join("left-pad")
1104                .join("package.json"),
1105            "{}",
1106        )
1107        .expect("write vendored manifest");
1108
1109        let outcome = walk(
1110            &[dir.path().to_path_buf()],
1111            &test_registry(),
1112            GitignorePolicy::Ignore,
1113            SymlinkPolicy::Skip,
1114        );
1115
1116        assert_eq!(outcome.manifests.len(), 1);
1117        assert_eq!(
1118            outcome.manifests[0].display_path,
1119            PathBuf::from("Cargo.toml")
1120        );
1121    }
1122
1123    /// Reviewer follow-up #2: an unusual monorepo layout can have a *real* subproject's
1124    /// manifest sitting directly under a directory named `vendor`/`build`/`dist`/... —
1125    /// `PRUNED_DIRECTORIES` still excludes it from the primary scan, but the omission must be
1126    /// visible via `WalkOutcome::ignored_manifests`, in every mode (not only under
1127    /// `--respect-gitignore`).
1128    #[test]
1129    fn test_walk_default_warns_on_manifest_directly_inside_pruned_directory() {
1130        let dir = tempfile::tempdir().expect("create temp dir");
1131        fs::create_dir(dir.path().join("vendor")).expect("mkdir vendor");
1132        fs::write(dir.path().join("vendor").join("Cargo.toml"), "[package]\n")
1133            .expect("write manifest directly under pruned dir");
1134
1135        let outcome = walk(
1136            &[dir.path().to_path_buf()],
1137            &test_registry(),
1138            GitignorePolicy::Ignore,
1139            SymlinkPolicy::Skip,
1140        );
1141
1142        assert!(
1143            outcome.manifests.is_empty(),
1144            "still pruned from the primary scan"
1145        );
1146        assert_eq!(
1147            outcome.ignored_manifests,
1148            vec![PathBuf::from("vendor").join("Cargo.toml")]
1149        );
1150    }
1151
1152    /// Companion to the above: a manifest nested two or more levels inside a pruned directory
1153    /// remains a documented, accepted limitation (checking only the pruned directory's own
1154    /// root avoids reintroducing the cost a full recursive re-walk would bring back for a
1155    /// large vendored tree) — not a regression, since it was never found before this fix.
1156    #[test]
1157    fn test_walk_manifest_nested_two_levels_inside_pruned_directory_remains_unreported() {
1158        let dir = tempfile::tempdir().expect("create temp dir");
1159        fs::create_dir_all(dir.path().join("vendor").join("sub")).expect("mkdir vendor/sub");
1160        fs::write(
1161            dir.path().join("vendor").join("sub").join("Cargo.toml"),
1162            "[package]\n",
1163        )
1164        .expect("write nested manifest");
1165
1166        let outcome = walk(
1167            &[dir.path().to_path_buf()],
1168            &test_registry(),
1169            GitignorePolicy::Ignore,
1170            SymlinkPolicy::Skip,
1171        );
1172
1173        assert!(outcome.manifests.is_empty());
1174        assert!(outcome.ignored_manifests.is_empty());
1175    }
1176
1177    /// Reviewer follow-up #3: `detect_ignored_manifests`'s diagnostic pass must use its own
1178    /// entry budget, independent of the primary walk's `ctx.entries_walked`/`ctx.limit` — a
1179    /// small limit that comfortably covers the primary (filtered) walk but not the
1180    /// diagnostic (unfiltered) pass over an ignored, non-manifest-shaped `noise/` directory
1181    /// must exhaust only the diagnostic pass, never set `WalkOutcome::truncated`, and never
1182    /// affect the primary walk's own (already-complete) manifest list.
1183    #[test]
1184    fn test_detect_ignored_manifests_uses_its_own_budget_and_does_not_set_truncated() {
1185        let dir = tempfile::tempdir().expect("create temp dir");
1186        fs::create_dir(dir.path().join(".git")).expect("create .git marker");
1187        fs::write(dir.path().join(".gitignore"), "noise/\n").expect("write gitignore");
1188        fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
1189        fs::create_dir(dir.path().join("noise")).expect("mkdir noise");
1190        for i in 0..20 {
1191            fs::write(dir.path().join("noise").join(format!("f{i}.txt")), "")
1192                .expect("write noise file");
1193        }
1194
1195        // Comfortably covers the primary walk (root + .gitignore + Cargo.toml == 3 entries,
1196        // `noise/` itself is `.gitignore`-excluded there) but not the diagnostic pass, which
1197        // ignores `.gitignore` and must descend into `noise/`'s 20 files.
1198        let outcome = walk_with_limit(
1199            &[dir.path().to_path_buf()],
1200            &test_registry(),
1201            5,
1202            GitignorePolicy::Respect,
1203            SymlinkPolicy::Skip,
1204        );
1205
1206        assert!(
1207            !outcome.truncated,
1208            "the diagnostic pass' own budget exhaustion must not mark the primary walk truncated"
1209        );
1210        assert_eq!(
1211            outcome.manifests.len(),
1212            1,
1213            "primary walk result must still be complete"
1214        );
1215    }
1216
1217    /// Critic S2: with `respect_gitignore: true`, a `node_modules/` excluded by an ordinary,
1218    /// non-security-relevant `.gitignore` entry must not be reported via
1219    /// [`WalkOutcome::ignored_manifests`] — [`PRUNED_DIRECTORIES`] removes it from both the
1220    /// filtered and unfiltered walk, so there is nothing to diff.
1221    #[test]
1222    fn test_walk_respect_gitignore_does_not_warn_on_pruned_directory() {
1223        let dir = tempfile::tempdir().expect("create temp dir");
1224        fs::create_dir(dir.path().join(".git")).expect("create .git marker");
1225        fs::write(dir.path().join(".gitignore"), "node_modules/\n").expect("write gitignore");
1226        fs::create_dir_all(dir.path().join("node_modules").join("left-pad"))
1227            .expect("mkdir node_modules/left-pad");
1228        fs::write(
1229            dir.path()
1230                .join("node_modules")
1231                .join("left-pad")
1232                .join("package.json"),
1233            "{}",
1234        )
1235        .expect("write vendored manifest");
1236
1237        let outcome = walk(
1238            &[dir.path().to_path_buf()],
1239            &test_registry(),
1240            GitignorePolicy::Respect,
1241            SymlinkPolicy::Skip,
1242        );
1243
1244        assert!(outcome.ignored_manifests.is_empty());
1245    }
1246
1247    /// Critic S3: a manifest excluded only by the always-on, operator-controlled
1248    /// `.git/info/exclude` must not be misattributed to `.gitignore`/`.ignore` — both walks in
1249    /// [`detect_ignored_manifests`] must apply `git_exclude` identically, so such a file is
1250    /// absent from *both* and never diffed into [`WalkOutcome::ignored_manifests`].
1251    #[test]
1252    fn test_walk_git_info_exclude_suppression_not_misreported_as_ignored_manifest() {
1253        let dir = tempfile::tempdir().expect("create temp dir");
1254        fs::create_dir_all(dir.path().join(".git").join("info")).expect("mkdir .git/info");
1255        fs::write(
1256            dir.path().join(".git").join("info").join("exclude"),
1257            "Cargo.toml\n",
1258        )
1259        .expect("write git info/exclude");
1260        fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
1261
1262        let outcome = walk(
1263            &[dir.path().to_path_buf()],
1264            &test_registry(),
1265            GitignorePolicy::Respect,
1266            SymlinkPolicy::Skip,
1267        );
1268
1269        assert!(outcome.manifests.is_empty());
1270        assert!(
1271            outcome.ignored_manifests.is_empty(),
1272            ".git/info/exclude is operator-controlled, not a .gitignore/.ignore rule"
1273        );
1274    }
1275
1276    #[test]
1277    fn test_walk_single_file_path_bypasses_gitignore() {
1278        let dir = tempfile::tempdir().expect("create temp dir");
1279        fs::write(dir.path().join(".gitignore"), "Cargo.toml\n").expect("write gitignore");
1280        let manifest = dir.path().join("Cargo.toml");
1281        fs::write(&manifest, "[package]\n").expect("write manifest");
1282        let outcome = walk(
1283            &[manifest],
1284            &test_registry(),
1285            GitignorePolicy::Respect,
1286            SymlinkPolicy::Skip,
1287        );
1288        assert_eq!(outcome.manifests.len(), 1);
1289    }
1290
1291    /// Critic finding S3: issue #1124's own repro command shape is `deps-cli check
1292    /// path/to/Cargo.toml` where that path is itself a broken symlink — the natural
1293    /// single-manifest CI-gate invocation. Before the fix, `root.is_file()` (which follows
1294    /// symlinks and so is `false` here) let this fall into the directory-walk branch with
1295    /// `walk_root == display_root == root`, producing an empty `display_path` once
1296    /// `strip_prefix` trivially succeeded against itself. Must report the manifest's own given
1297    /// path, not an empty one.
1298    #[cfg(unix)]
1299    #[test]
1300    fn test_walk_explicit_broken_symlink_manifest_path_reports_the_given_path() {
1301        let dir = tempfile::tempdir().expect("create temp dir");
1302        let manifest = dir.path().join("Cargo.toml");
1303        std::os::unix::fs::symlink(dir.path().join("does-not-exist"), &manifest)
1304            .expect("create broken symlink");
1305
1306        let outcome = walk(
1307            std::slice::from_ref(&manifest),
1308            &test_registry(),
1309            GitignorePolicy::Ignore,
1310            SymlinkPolicy::Skip,
1311        );
1312
1313        assert!(outcome.manifests.is_empty());
1314        assert!(outcome.ignored_manifests.is_empty());
1315        assert!(outcome.unrecognized_explicit_paths.is_empty());
1316        assert_eq!(outcome.broken_manifest_symlinks, vec![manifest]);
1317    }
1318
1319    /// Companion to the above: an explicit root that is a symlink to a real *directory* must
1320    /// still be walked as a directory (existing, legitimate use), not intercepted by S3's fix —
1321    /// only a symlink whose target fails to resolve at all is a broken-manifest candidate.
1322    #[cfg(unix)]
1323    #[test]
1324    fn test_walk_explicit_symlink_to_directory_root_is_still_walked() {
1325        let real_dir = tempfile::tempdir().expect("create real dir");
1326        fs::write(real_dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
1327
1328        let link_parent = tempfile::tempdir().expect("create link parent");
1329        let link = link_parent.path().join("link-to-real");
1330        std::os::unix::fs::symlink(real_dir.path(), &link)
1331            .expect("create symlink to a real directory");
1332
1333        let outcome = walk(
1334            std::slice::from_ref(&link),
1335            &test_registry(),
1336            GitignorePolicy::Ignore,
1337            SymlinkPolicy::Skip,
1338        );
1339
1340        assert_eq!(outcome.manifests.len(), 1);
1341        assert!(outcome.broken_manifest_symlinks.is_empty());
1342    }
1343
1344    /// Critic S4 + background-review Bug 1: a manifest-shaped symlink resolving to an existing
1345    /// *directory*, passed as an explicit root (`deps-cli check tree/Cargo.toml`), must be
1346    /// reported with a non-empty path — and, critically, must NOT also be walked as a
1347    /// directory: `real_dir` contains a real, findable manifest, so if the old fallthrough
1348    /// behavior regressed, `manifests` would be non-empty here at the same time
1349    /// `broken_manifest_symlinks` is populated — a self-contradictory report (found earlier by
1350    /// background code review: S1's widened policy and S3's directory-walk fallthrough used to
1351    /// fire simultaneously for this exact path).
1352    #[cfg(unix)]
1353    #[test]
1354    fn test_walk_explicit_manifest_shaped_symlink_to_directory_root_reports_a_non_empty_path() {
1355        let real_dir = tempfile::tempdir().expect("create real dir");
1356        fs::write(real_dir.path().join("package.json"), "{}").expect("write real manifest");
1357
1358        let link_parent = tempfile::tempdir().expect("create link parent");
1359        let link = link_parent.path().join("Cargo.toml");
1360        std::os::unix::fs::symlink(real_dir.path(), &link)
1361            .expect("create manifest-shaped symlink to a real directory");
1362
1363        let outcome = walk(
1364            std::slice::from_ref(&link),
1365            &test_registry(),
1366            GitignorePolicy::Ignore,
1367            SymlinkPolicy::Skip,
1368        );
1369
1370        assert!(
1371            outcome.manifests.is_empty(),
1372            "must not also walk the target directory's contents: {:?}",
1373            outcome
1374                .manifests
1375                .iter()
1376                .map(|m| &m.display_path)
1377                .collect::<Vec<_>>()
1378        );
1379        assert!(outcome.ignored_manifests.is_empty());
1380        assert_eq!(outcome.broken_manifest_symlinks.len(), 1);
1381        assert!(
1382            !outcome.broken_manifest_symlinks[0].as_os_str().is_empty(),
1383            "must never report an empty display path"
1384        );
1385        assert_eq!(
1386            outcome.broken_manifest_symlinks[0].file_name(),
1387            Some(std::ffi::OsStr::new("Cargo.toml")),
1388            "reported path must still name the manifest: {:?}",
1389            outcome.broken_manifest_symlinks
1390        );
1391    }
1392
1393    /// Explicit root that is a broken symlink whose own name is *not* manifest-shaped must be
1394    /// reported as unrecognized, not as tampering — mirrors the never-warn-on-non-manifests
1395    /// invariant `classify_symlink` already applies to a discovered (non-root) entry.
1396    #[cfg(unix)]
1397    #[test]
1398    fn test_walk_explicit_broken_symlink_non_manifest_path_is_unrecognized() {
1399        let dir = tempfile::tempdir().expect("create temp dir");
1400        let notes = dir.path().join("notes.txt");
1401        std::os::unix::fs::symlink(dir.path().join("does-not-exist"), &notes)
1402            .expect("create broken, non-manifest-shaped symlink");
1403
1404        let outcome = walk(
1405            std::slice::from_ref(&notes),
1406            &test_registry(),
1407            GitignorePolicy::Ignore,
1408            SymlinkPolicy::Skip,
1409        );
1410
1411        assert!(outcome.broken_manifest_symlinks.is_empty());
1412        assert_eq!(outcome.unrecognized_explicit_paths, vec![notes]);
1413    }
1414
1415    /// Regression test for #1108: a *relative* walk root must still find manifests —
1416    /// `url::Url::from_file_path` (used to route a discovered file) rejects relative paths, so
1417    /// before the fix every file under a relative root was silently dropped, and `deps-cli
1418    /// check`'s own no-argument default (`.`) always reported zero findings.
1419    ///
1420    /// `std::env::set_current_dir` mutates process-global state, so this test (and any other
1421    /// test doing the same) is serialized via [`CwdGuard`]/[`CWD_LOCK`], which also restores
1422    /// the original cwd even if an assertion below panics.
1423    #[test]
1424    fn test_walk_relative_root_finds_manifest() {
1425        let dir = tempfile::tempdir().expect("create temp dir");
1426        fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
1427        let _guard = CwdGuard::chdir(dir.path());
1428
1429        let outcome = walk(
1430            &[PathBuf::from(".")],
1431            &test_registry(),
1432            GitignorePolicy::Ignore,
1433            SymlinkPolicy::Skip,
1434        );
1435
1436        assert_eq!(outcome.manifests.len(), 1);
1437        assert!(outcome.walk_errors.is_empty());
1438        assert_eq!(
1439            outcome.manifests[0].display_path,
1440            PathBuf::from("Cargo.toml")
1441        );
1442    }
1443
1444    /// Companion to [`test_walk_relative_root_finds_manifest`]: an explicitly-given *relative*
1445    /// file path must resolve to the real path-conversion issue being fixed, not report the
1446    /// file as unrecognized by any ecosystem (the previous, misleading failure mode).
1447    #[test]
1448    fn test_walk_relative_explicit_file_path_is_found() {
1449        let dir = tempfile::tempdir().expect("create temp dir");
1450        fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
1451        let _guard = CwdGuard::chdir(dir.path());
1452
1453        let outcome = walk(
1454            &[PathBuf::from("Cargo.toml")],
1455            &test_registry(),
1456            GitignorePolicy::Ignore,
1457            SymlinkPolicy::Skip,
1458        );
1459
1460        assert_eq!(outcome.manifests.len(), 1);
1461        assert!(outcome.unrecognized_explicit_paths.is_empty());
1462    }
1463
1464    /// Regression test for S1 (spec 062 review): the cap must count every walked entry, not
1465    /// just matched manifests — a small fixture plus a small `limit` proves truncation fires
1466    /// without needing a real `MAX_WALKED_FILES`-sized tree.
1467    #[test]
1468    fn test_walk_with_limit_truncates_on_walked_entries_not_just_manifests() {
1469        let dir = tempfile::tempdir().expect("create temp dir");
1470        for i in 0..5 {
1471            fs::write(dir.path().join(format!("noise-{i}.txt")), "").expect("write noise file");
1472        }
1473        fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
1474
1475        let outcome = walk_with_limit(
1476            &[dir.path().to_path_buf()],
1477            &test_registry(),
1478            2,
1479            GitignorePolicy::Ignore,
1480            SymlinkPolicy::Skip,
1481        );
1482        assert!(
1483            outcome.truncated,
1484            "a 2-entry limit against a 6-entry tree must truncate"
1485        );
1486    }
1487
1488    #[test]
1489    fn test_walk_with_limit_does_not_truncate_when_under_the_cap() {
1490        let dir = tempfile::tempdir().expect("create temp dir");
1491        fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
1492        let outcome = walk_with_limit(
1493            &[dir.path().to_path_buf()],
1494            &test_registry(),
1495            100,
1496            GitignorePolicy::Ignore,
1497            SymlinkPolicy::Skip,
1498        );
1499        assert!(!outcome.truncated);
1500        assert_eq!(outcome.manifests.len(), 1);
1501    }
1502
1503    /// Regression test for M4 (spec 062 review): an explicitly-given path no ecosystem
1504    /// recognizes must be reported, not silently dropped.
1505    #[test]
1506    fn test_walk_explicit_unrecognized_path_is_reported() {
1507        let dir = tempfile::tempdir().expect("create temp dir");
1508        let unknown = dir.path().join("notes.txt");
1509        fs::write(&unknown, "not a manifest").expect("write file");
1510        let outcome = walk(
1511            std::slice::from_ref(&unknown),
1512            &test_registry(),
1513            GitignorePolicy::Ignore,
1514            SymlinkPolicy::Skip,
1515        );
1516        assert!(outcome.manifests.is_empty());
1517        assert_eq!(outcome.unrecognized_explicit_paths, vec![unknown]);
1518    }
1519
1520    /// A file encountered while walking a directory (as opposed to given explicitly) must
1521    /// never be reported this way — nearly every file in a real tree doesn't match any
1522    /// ecosystem, and warning on each would be useless noise.
1523    #[test]
1524    fn test_walk_unrecognized_file_found_during_directory_walk_is_not_reported() {
1525        let dir = tempfile::tempdir().expect("create temp dir");
1526        fs::write(dir.path().join("notes.txt"), "not a manifest").expect("write file");
1527        let outcome = walk(
1528            &[dir.path().to_path_buf()],
1529            &test_registry(),
1530            GitignorePolicy::Ignore,
1531            SymlinkPolicy::Skip,
1532        );
1533        assert!(outcome.unrecognized_explicit_paths.is_empty());
1534    }
1535
1536    #[test]
1537    fn test_walk_multiple_ecosystems_in_one_tree() {
1538        let dir = tempfile::tempdir().expect("create temp dir");
1539        fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write cargo manifest");
1540        fs::write(dir.path().join("package.json"), "{}").expect("write npm manifest");
1541        let outcome = walk(
1542            &[dir.path().to_path_buf()],
1543            &test_registry(),
1544            GitignorePolicy::Ignore,
1545            SymlinkPolicy::Skip,
1546        );
1547        assert_eq!(outcome.manifests.len(), 2);
1548    }
1549
1550    /// Regression test for background code-review fix 1 (spec 062 review): `.git`'s contents
1551    /// must never be walked, even though `.github`/`.gitlab` need hidden-ness lifted for the
1552    /// same tree. Deterministic regardless of directory-enumeration order: `.git` holds 100
1553    /// dummy files against a `limit` of 3 (comfortable margin over the handful of entries —
1554    /// the walk root, `.git`'s own directory entry, `Cargo.toml` — a correctly-hidden-filtered
1555    /// walk actually visits) — if `.git`'s contents were descended into at all, `entries_walked`
1556    /// would blow past 3 long before reaching `Cargo.toml`, truncating the walk.
1557    ///
1558    /// This test caught a real bug during review: an earlier version of this fix special-cased
1559    /// "the walk root's own basename starts with `.`" to mean "un-hide it, the user chose this
1560    /// dot-directory on purpose" — but `tempfile::tempdir()` itself creates dot-prefixed
1561    /// directories on macOS, so *every* test using a tempdir root silently hit that special
1562    /// case and disabled hidden-file filtering entirely, `.git` included. The fix removes that
1563    /// special-casing; `hidden(true)` never filters `walk_root` itself regardless of its name
1564    /// (only entries encountered *while descending*, by their own basename), so it was never
1565    /// needed in the first place.
1566    #[test]
1567    fn test_walk_never_descends_into_dot_git() {
1568        let dir = tempfile::tempdir().expect("create temp dir");
1569        fs::create_dir(dir.path().join(".git")).expect("create .git marker");
1570        for i in 0..100 {
1571            fs::write(dir.path().join(".git").join(format!("object-{i}")), "")
1572                .expect("write dummy git-internal file");
1573        }
1574        fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
1575
1576        let outcome = walk_with_limit(
1577            &[dir.path().to_path_buf()],
1578            &test_registry(),
1579            3,
1580            GitignorePolicy::Ignore,
1581            SymlinkPolicy::Skip,
1582        );
1583        assert!(
1584            !outcome.truncated,
1585            ".git's 100 dummy files must never be walked, so a limit of 3 must suffice"
1586        );
1587        assert_eq!(outcome.manifests.len(), 1);
1588        assert_eq!(
1589            outcome.manifests[0].display_path,
1590            PathBuf::from("Cargo.toml")
1591        );
1592    }
1593
1594    /// Companion test: `.github/workflows/*.yml` must still be found in the same tree that
1595    /// excludes `.git` — proves the fix distinguishes the two rather than just re-hiding
1596    /// everything dot-prefixed again.
1597    #[test]
1598    fn test_walk_still_finds_github_workflows_alongside_excluded_dot_git() {
1599        let dir = tempfile::tempdir().expect("create temp dir");
1600        fs::create_dir(dir.path().join(".git")).expect("create .git marker");
1601        fs::create_dir_all(dir.path().join(".github").join("workflows")).expect("mkdir");
1602        fs::write(
1603            dir.path().join(".github").join("workflows").join("ci.yml"),
1604            "on: push\njobs:\n  x:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n",
1605        )
1606        .expect("write workflow");
1607
1608        let outcome = walk(
1609            &[dir.path().to_path_buf()],
1610            &test_registry(),
1611            GitignorePolicy::Ignore,
1612            SymlinkPolicy::Skip,
1613        );
1614        assert_eq!(outcome.manifests.len(), 1);
1615        assert_eq!(
1616            outcome.manifests[0].display_path,
1617            PathBuf::from(".github").join("workflows").join("ci.yml")
1618        );
1619        assert_eq!(outcome.manifests[0].ecosystem.id(), "github-actions");
1620    }
1621
1622    /// Regression test for issue #1165: pins the `DotDirs::Descend` wiring at the
1623    /// hidden-ecosystem sub-root call site (e.g. `.github`) in [`walk_with_limit`].
1624    /// `hidden(true)` vs `hidden(false)` only differs on *nested* dot-prefixed entries — the
1625    /// sub-root itself is never filtered either way (depth 0) — so a manifest nested under a
1626    /// dot-prefixed directory inside `.github` is only discovered under `Descend`. If that call
1627    /// site were accidentally flipped to `DotDirs::Skip`, this manifest would be silently
1628    /// dropped and this test would fail.
1629    #[test]
1630    fn test_walk_descends_into_nested_dot_dir_under_github_sub_root() {
1631        let dir = tempfile::tempdir().expect("create temp dir");
1632        fs::create_dir_all(dir.path().join(".github").join(".hidden")).expect("mkdir");
1633        fs::write(
1634            dir.path()
1635                .join(".github")
1636                .join(".hidden")
1637                .join("Cargo.toml"),
1638            "[package]\n",
1639        )
1640        .expect("write nested manifest");
1641
1642        let outcome = walk(
1643            &[dir.path().to_path_buf()],
1644            &test_registry(),
1645            GitignorePolicy::Ignore,
1646            SymlinkPolicy::Skip,
1647        );
1648
1649        assert_eq!(outcome.manifests.len(), 1);
1650        assert_eq!(
1651            outcome.manifests[0].display_path,
1652            PathBuf::from(".github").join(".hidden").join("Cargo.toml")
1653        );
1654    }
1655
1656    /// Regression test for background code-review fix 2 (spec 062 review): the cap must be
1657    /// enforced for explicit file-path arguments too, not only for a directory walk — this
1658    /// was previously incrementing `entries_walked` without ever checking it in that branch.
1659    #[test]
1660    fn test_walk_with_limit_truncates_on_explicit_path_list() {
1661        let dir = tempfile::tempdir().expect("create temp dir");
1662        // Each manifest needs its own subdirectory — matched by exact filename, not a
1663        // pattern, so `Cargo0.toml`..`Cargo4.toml` siblings would never route to any ecosystem.
1664        let paths: Vec<PathBuf> = (0..5)
1665            .map(|i| {
1666                let subdir = dir.path().join(format!("pkg{i}"));
1667                fs::create_dir(&subdir).expect("mkdir");
1668                let path = subdir.join("Cargo.toml");
1669                fs::write(&path, "[package]\n").expect("write manifest");
1670                path
1671            })
1672            .collect();
1673
1674        let outcome = walk_with_limit(
1675            &paths,
1676            &test_registry(),
1677            2,
1678            GitignorePolicy::Ignore,
1679            SymlinkPolicy::Skip,
1680        );
1681        assert!(
1682            outcome.truncated,
1683            "a 2-entry limit against 5 explicit paths must truncate"
1684        );
1685        assert_eq!(outcome.manifests.len(), 2);
1686    }
1687
1688    /// Issue #1112, US-001: a symlinked manifest must never silently vanish from the scan
1689    /// under the default (`follow_symlinks: false`) mode — it is reported via
1690    /// `ignored_manifests`, not `manifests`.
1691    #[cfg(unix)]
1692    #[test]
1693    fn test_walk_default_detects_symlinked_manifest_without_following() {
1694        let dir = tempfile::tempdir().expect("create temp dir");
1695        let real = dir.path().join("real").join("manifest-data");
1696        fs::create_dir(dir.path().join("real")).expect("mkdir real");
1697        fs::write(&real, "[package]\n").expect("write real manifest");
1698        std::os::unix::fs::symlink(&real, dir.path().join("Cargo.toml")).expect("create symlink");
1699
1700        let outcome = walk(
1701            &[dir.path().to_path_buf()],
1702            &test_registry(),
1703            GitignorePolicy::Ignore,
1704            SymlinkPolicy::Skip,
1705        );
1706
1707        assert!(outcome.manifests.is_empty());
1708        assert_eq!(outcome.ignored_manifests, vec![PathBuf::from("Cargo.toml")]);
1709    }
1710
1711    /// Issue #1124's own repro: a manifest-shaped broken symlink is reported via the
1712    /// dedicated `broken_manifest_symlinks` sink, not `ignored_manifests` — the two carry
1713    /// different signal (see `WalkOutcome::broken_manifest_symlinks`'s doc).
1714    #[cfg(unix)]
1715    #[test]
1716    fn test_walk_default_detects_broken_symlinked_manifest() {
1717        let dir = tempfile::tempdir().expect("create temp dir");
1718        std::os::unix::fs::symlink(
1719            dir.path().join("does-not-exist"),
1720            dir.path().join("Cargo.toml"),
1721        )
1722        .expect("create broken symlink");
1723
1724        let outcome = walk(
1725            &[dir.path().to_path_buf()],
1726            &test_registry(),
1727            GitignorePolicy::Ignore,
1728            SymlinkPolicy::Skip,
1729        );
1730
1731        assert!(outcome.manifests.is_empty());
1732        assert!(outcome.ignored_manifests.is_empty());
1733        assert_eq!(
1734            outcome.broken_manifest_symlinks,
1735            vec![PathBuf::from("Cargo.toml")]
1736        );
1737    }
1738
1739    /// A symlink whose own filename is not manifest-shaped stays silent even when broken —
1740    /// the never-warn-on-non-manifests invariant applies to `broken_manifest_symlinks` too.
1741    #[cfg(unix)]
1742    #[test]
1743    fn test_walk_broken_symlink_not_manifest_shaped_is_not_reported() {
1744        let dir = tempfile::tempdir().expect("create temp dir");
1745        std::os::unix::fs::symlink(
1746            dir.path().join("does-not-exist"),
1747            dir.path().join("notes.txt"),
1748        )
1749        .expect("create broken, non-manifest-shaped symlink");
1750
1751        let outcome = walk(
1752            &[dir.path().to_path_buf()],
1753            &test_registry(),
1754            GitignorePolicy::Ignore,
1755            SymlinkPolicy::Skip,
1756        );
1757
1758        assert!(outcome.manifests.is_empty());
1759        assert!(outcome.ignored_manifests.is_empty());
1760        assert!(outcome.broken_manifest_symlinks.is_empty());
1761    }
1762
1763    /// `--follow-symlinks` does not defeat #1124's detection — a broken symlink still can't be
1764    /// resolved regardless of the flag, so it must still land in `broken_manifest_symlinks`,
1765    /// not silently vanish the way it did before this fix. Exercises a different code path
1766    /// than the default-mode test above: under `follow_symlinks: true`, `ignore`/`walkdir`
1767    /// must stat the entry to resolve its type and yields an `Err` for a broken target instead
1768    /// of an `Ok(entry)` with an unresolved type — see the `Err(error)` arm's own doc in
1769    /// `walk_directory`.
1770    #[cfg(unix)]
1771    #[test]
1772    fn test_walk_follow_symlinks_still_detects_broken_symlinked_manifest() {
1773        let dir = tempfile::tempdir().expect("create temp dir");
1774        std::os::unix::fs::symlink(
1775            dir.path().join("does-not-exist"),
1776            dir.path().join("Cargo.toml"),
1777        )
1778        .expect("create broken symlink");
1779
1780        let outcome = walk(
1781            &[dir.path().to_path_buf()],
1782            &test_registry(),
1783            GitignorePolicy::Ignore,
1784            SymlinkPolicy::Follow,
1785        );
1786
1787        assert!(outcome.manifests.is_empty());
1788        assert!(outcome.ignored_manifests.is_empty());
1789        assert_eq!(
1790            outcome.broken_manifest_symlinks,
1791            vec![PathBuf::from("Cargo.toml")]
1792        );
1793        assert!(
1794            outcome.walk_errors.is_empty(),
1795            "Bug 3 (background code review): a mid-walk broken symlink already classified must \
1796             not also emit a duplicate generic IO walk error: {:?}",
1797            outcome.walk_errors
1798        );
1799    }
1800
1801    /// A broken hop partway through a symlink chain (`Cargo.toml -> intermediate -> nothing`)
1802    /// is detected the same way a directly-broken symlink is — `std::fs::metadata` follows the
1803    /// whole chain, so no separate per-hop handling is needed.
1804    #[cfg(unix)]
1805    #[test]
1806    fn test_walk_broken_symlink_chain_is_detected() {
1807        let dir = tempfile::tempdir().expect("create temp dir");
1808        let missing = dir.path().join("does-not-exist");
1809        let intermediate = dir.path().join("intermediate-link");
1810        std::os::unix::fs::symlink(&missing, &intermediate)
1811            .expect("create intermediate broken symlink");
1812        std::os::unix::fs::symlink(&intermediate, dir.path().join("Cargo.toml"))
1813            .expect("create Cargo.toml -> intermediate-link -> does-not-exist");
1814
1815        let outcome = walk(
1816            &[dir.path().to_path_buf()],
1817            &test_registry(),
1818            GitignorePolicy::Ignore,
1819            SymlinkPolicy::Skip,
1820        );
1821
1822        assert!(outcome.manifests.is_empty());
1823        assert!(outcome.ignored_manifests.is_empty());
1824        assert_eq!(
1825            outcome.broken_manifest_symlinks,
1826            vec![PathBuf::from("Cargo.toml")]
1827        );
1828    }
1829
1830    /// A symlink target unreadable because of an ancestor directory's permissions (`EACCES`,
1831    /// not `ENOENT`) is detected the same way a dangling symlink is — `std::fs::metadata`
1832    /// fails identically for both. Skips its own assertions (rather than failing) when running
1833    /// with a privilege that bypasses directory permission checks (e.g. root in some CI
1834    /// containers), since the permission-denied precondition this test needs doesn't hold there.
1835    #[cfg(unix)]
1836    #[test]
1837    fn test_walk_symlink_target_permission_denied_is_detected_as_broken() {
1838        use std::os::unix::fs::PermissionsExt;
1839
1840        let dir = tempfile::tempdir().expect("create temp dir");
1841        let blocked = dir.path().join("blocked");
1842        fs::create_dir(&blocked).expect("mkdir blocked");
1843        let target = blocked.join("manifest-data");
1844        fs::write(&target, "[package]\n").expect("write target");
1845        fs::set_permissions(&blocked, fs::Permissions::from_mode(0o000)).expect("chmod 000");
1846        std::os::unix::fs::symlink(&target, dir.path().join("Cargo.toml"))
1847            .expect("create symlink into a permission-denied directory");
1848
1849        let outcome = walk(
1850            &[dir.path().to_path_buf()],
1851            &test_registry(),
1852            GitignorePolicy::Ignore,
1853            SymlinkPolicy::Skip,
1854        );
1855        let permission_check_effective = std::fs::metadata(&target).is_err();
1856        fs::set_permissions(&blocked, fs::Permissions::from_mode(0o755)).expect("restore perms");
1857
1858        if !permission_check_effective {
1859            eprintln!(
1860                "skipping: directory permissions did not block metadata (likely running as root)"
1861            );
1862            return;
1863        }
1864
1865        assert!(outcome.manifests.is_empty());
1866        assert!(outcome.ignored_manifests.is_empty());
1867        assert_eq!(
1868            outcome.broken_manifest_symlinks,
1869            vec![PathBuf::from("Cargo.toml")]
1870        );
1871    }
1872
1873    /// Spec 065 §6 edge case, extended for #1124: a broken, manifest-shaped symlink directly
1874    /// at a `PRUNED_DIRECTORIES`-excluded directory's own root is reported via
1875    /// `broken_manifest_symlinks`, mirroring the existing resolvable-symlink case
1876    /// (`test_walk_pruned_directory_symlinked_manifest_is_still_warned`).
1877    #[cfg(unix)]
1878    #[test]
1879    fn test_walk_pruned_directory_broken_symlinked_manifest_is_reported_as_broken() {
1880        let dir = tempfile::tempdir().expect("create temp dir");
1881        fs::create_dir(dir.path().join("vendor")).expect("mkdir vendor");
1882        std::os::unix::fs::symlink(
1883            dir.path().join("vendor").join("does-not-exist"),
1884            dir.path().join("vendor").join("Cargo.toml"),
1885        )
1886        .expect("create broken symlink inside pruned directory");
1887
1888        let outcome = walk(
1889            &[dir.path().to_path_buf()],
1890            &test_registry(),
1891            GitignorePolicy::Ignore,
1892            SymlinkPolicy::Skip,
1893        );
1894
1895        assert!(
1896            outcome.manifests.is_empty(),
1897            "still pruned from the primary scan"
1898        );
1899        assert!(outcome.ignored_manifests.is_empty());
1900        assert_eq!(
1901            outcome.broken_manifest_symlinks,
1902            vec![PathBuf::from("vendor").join("Cargo.toml")]
1903        );
1904    }
1905
1906    /// A broken symlink excluded by `.gitignore` under `--respect-gitignore` must still be
1907    /// detected — mirroring #1112/M5's own gitignored-resolvable-symlink case
1908    /// (`test_walk_respect_gitignore_does_not_duplicate_symlinked_manifest_warning`), this is
1909    /// only visible via `detect_ignored_manifests`'s unfiltered diff pass since the primary
1910    /// (filtered) walk never yields a gitignored entry at all.
1911    #[cfg(unix)]
1912    #[test]
1913    fn test_walk_respect_gitignore_detects_gitignored_broken_symlinked_manifest() {
1914        let dir = tempfile::tempdir().expect("create temp dir");
1915        fs::create_dir(dir.path().join(".git")).expect("create .git marker");
1916        fs::write(dir.path().join(".gitignore"), "Cargo.toml\n").expect("write gitignore");
1917        std::os::unix::fs::symlink(
1918            dir.path().join("does-not-exist"),
1919            dir.path().join("Cargo.toml"),
1920        )
1921        .expect("create broken symlink");
1922
1923        let outcome = walk(
1924            &[dir.path().to_path_buf()],
1925            &test_registry(),
1926            GitignorePolicy::Respect,
1927            SymlinkPolicy::Skip,
1928        );
1929
1930        assert!(outcome.manifests.is_empty());
1931        assert!(outcome.ignored_manifests.is_empty());
1932        assert_eq!(
1933            outcome.broken_manifest_symlinks,
1934            vec![PathBuf::from("Cargo.toml")]
1935        );
1936    }
1937
1938    /// Companion to the above: a broken symlinked manifest that is *not* gitignored must be
1939    /// reported exactly once even when `--respect-gitignore`'s extra unfiltered diff pass also
1940    /// runs — mirrors the existing resolvable-symlink dedup test
1941    /// (`test_walk_respect_gitignore_does_not_duplicate_symlinked_manifest_warning`).
1942    #[cfg(unix)]
1943    #[test]
1944    fn test_walk_respect_gitignore_does_not_duplicate_broken_symlink_warning() {
1945        let dir = tempfile::tempdir().expect("create temp dir");
1946        fs::create_dir(dir.path().join(".git")).expect("create .git marker");
1947        fs::write(dir.path().join(".gitignore"), "*.log\n").expect("write gitignore");
1948        std::os::unix::fs::symlink(
1949            dir.path().join("does-not-exist"),
1950            dir.path().join("Cargo.toml"),
1951        )
1952        .expect("create broken symlink");
1953
1954        let outcome = walk(
1955            &[dir.path().to_path_buf()],
1956            &test_registry(),
1957            GitignorePolicy::Respect,
1958            SymlinkPolicy::Skip,
1959        );
1960
1961        assert_eq!(
1962            outcome.broken_manifest_symlinks,
1963            vec![PathBuf::from("Cargo.toml")],
1964            "a non-gitignored broken symlinked manifest must be reported exactly once"
1965        );
1966    }
1967
1968    /// A symlink to a directory whose own name isn't manifest-shaped is silent — unaffected by
1969    /// critic finding S1, since the never-warn-on-non-manifests invariant is orthogonal to it.
1970    #[cfg(unix)]
1971    #[test]
1972    fn test_walk_symlink_to_directory_is_not_reported_as_manifest() {
1973        let dir = tempfile::tempdir().expect("create temp dir");
1974        fs::create_dir(dir.path().join("real_dir")).expect("mkdir real_dir");
1975        std::os::unix::fs::symlink(dir.path().join("real_dir"), dir.path().join("link_dir"))
1976            .expect("create symlink to directory");
1977
1978        let outcome = walk(
1979            &[dir.path().to_path_buf()],
1980            &test_registry(),
1981            GitignorePolicy::Ignore,
1982            SymlinkPolicy::Skip,
1983        );
1984
1985        assert!(outcome.manifests.is_empty());
1986        assert!(outcome.ignored_manifests.is_empty());
1987        assert!(outcome.broken_manifest_symlinks.is_empty());
1988    }
1989
1990    /// Critic finding S1: a manifest-shaped symlink resolving to an existing *directory* is a
1991    /// zero-cost substitute for a dangling symlink from an attacker's perspective — both are
1992    /// "a manifest-shaped path that produces no manifest" — so it must land in
1993    /// `broken_manifest_symlinks`, not silently pass as `Irrelevant` just because the target
1994    /// technically resolves.
1995    #[cfg(unix)]
1996    #[test]
1997    fn test_walk_symlink_to_directory_with_manifest_shaped_name_is_reported_as_broken() {
1998        let dir = tempfile::tempdir().expect("create temp dir");
1999        fs::create_dir(dir.path().join("real_dir")).expect("mkdir real_dir");
2000        std::os::unix::fs::symlink(dir.path().join("real_dir"), dir.path().join("Cargo.toml"))
2001            .expect("create manifest-shaped symlink to a directory");
2002
2003        let outcome = walk(
2004            &[dir.path().to_path_buf()],
2005            &test_registry(),
2006            GitignorePolicy::Ignore,
2007            SymlinkPolicy::Skip,
2008        );
2009
2010        assert!(outcome.manifests.is_empty());
2011        assert!(outcome.ignored_manifests.is_empty());
2012        assert_eq!(
2013            outcome.broken_manifest_symlinks,
2014            vec![PathBuf::from("Cargo.toml")]
2015        );
2016    }
2017
2018    /// Companion to the above: a manifest-shaped symlink resolving to a non-regular,
2019    /// non-directory target (a fifo) is the same class of substitution attack and must be
2020    /// reported identically. Uses the `mkfifo` binary rather than unsafe `libc::mkfifo` (this
2021    /// workspace forbids `unsafe_code`); skips gracefully if the binary isn't on `PATH`.
2022    #[cfg(unix)]
2023    #[test]
2024    fn test_walk_symlink_to_fifo_with_manifest_shaped_name_is_reported_as_broken() {
2025        let dir = tempfile::tempdir().expect("create temp dir");
2026        let fifo = dir.path().join("a-fifo");
2027        let mkfifo_ok = std::process::Command::new("mkfifo")
2028            .arg(&fifo)
2029            .status()
2030            .is_ok_and(|status| status.success());
2031        if !mkfifo_ok {
2032            eprintln!("skipping: `mkfifo` binary not available on PATH");
2033            return;
2034        }
2035        std::os::unix::fs::symlink(&fifo, dir.path().join("Cargo.toml"))
2036            .expect("create manifest-shaped symlink to a fifo");
2037
2038        let outcome = walk(
2039            &[dir.path().to_path_buf()],
2040            &test_registry(),
2041            GitignorePolicy::Ignore,
2042            SymlinkPolicy::Skip,
2043        );
2044
2045        assert!(outcome.manifests.is_empty());
2046        assert!(outcome.ignored_manifests.is_empty());
2047        assert_eq!(
2048            outcome.broken_manifest_symlinks,
2049            vec![PathBuf::from("Cargo.toml")]
2050        );
2051    }
2052
2053    /// Critic finding S2: an ordinary, non-symlink directory that merely shares a manifest's
2054    /// name, made unreadable by permissions, must never be reported as symlink tampering — the
2055    /// `is_symlink` gate in both `classify_symlink` and the walk's `Err(error)` arm must
2056    /// exclude it. Skips its own assertions when running with a privilege that bypasses
2057    /// directory permission checks (mirrors the existing EACCES symlink-target test).
2058    #[cfg(unix)]
2059    #[test]
2060    fn test_walk_permission_denied_non_symlink_manifest_named_directory_is_not_reported() {
2061        use std::os::unix::fs::PermissionsExt;
2062
2063        let dir = tempfile::tempdir().expect("create temp dir");
2064        let blocked = dir.path().join("app.csproj");
2065        fs::create_dir(&blocked).expect("mkdir app.csproj");
2066        fs::set_permissions(&blocked, fs::Permissions::from_mode(0o000)).expect("chmod 000");
2067
2068        let outcome = walk(
2069            &[dir.path().to_path_buf()],
2070            &test_registry(),
2071            GitignorePolicy::Ignore,
2072            SymlinkPolicy::Skip,
2073        );
2074        // Background-review test-gap finding: `metadata()`/`stat()` on `blocked` needs only
2075        // execute permission on its *ancestors*, not on `blocked` itself, so it always succeeds
2076        // here regardless of the chmod above — checking it (as an earlier version of this test
2077        // did) made the self-skip fire unconditionally, giving this test zero real coverage.
2078        // `read_dir()` on `blocked` does need its own execute bit, matching the operation the
2079        // walker actually performs (and fails) when it tries to descend into this entry.
2080        let permission_check_effective = std::fs::read_dir(&blocked).is_err();
2081        fs::set_permissions(&blocked, fs::Permissions::from_mode(0o755)).expect("restore perms");
2082
2083        if !permission_check_effective {
2084            eprintln!(
2085                "skipping: directory permissions did not block read_dir (likely running as root)"
2086            );
2087            return;
2088        }
2089
2090        assert!(
2091            !outcome.walk_errors.is_empty(),
2092            "sanity check: the walker's own descent into `blocked` must have failed for this \
2093             test to exercise anything"
2094        );
2095        assert!(outcome.ignored_manifests.is_empty());
2096        assert!(
2097            outcome.broken_manifest_symlinks.is_empty(),
2098            "a non-symlink, permission-denied directory must never be reported as symlink \
2099             tampering: {:?}",
2100            outcome.broken_manifest_symlinks
2101        );
2102    }
2103
2104    /// Spec §6 edge case: a symlink to a manifest-shaped file sitting directly at a
2105    /// `PRUNED_DIRECTORIES`-excluded directory's own root is reported via `ignored_manifests`,
2106    /// mirroring the existing regular-file case
2107    /// (`test_walk_default_warns_on_manifest_directly_inside_pruned_directory`).
2108    #[cfg(unix)]
2109    #[test]
2110    fn test_walk_pruned_directory_symlinked_manifest_is_still_warned() {
2111        let dir = tempfile::tempdir().expect("create temp dir");
2112        let real = dir.path().join("real-cargo.toml");
2113        fs::write(&real, "[package]\n").expect("write real manifest");
2114        fs::create_dir(dir.path().join("vendor")).expect("mkdir vendor");
2115        std::os::unix::fs::symlink(&real, dir.path().join("vendor").join("Cargo.toml"))
2116            .expect("create symlink inside pruned directory");
2117
2118        let outcome = walk(
2119            &[dir.path().to_path_buf()],
2120            &test_registry(),
2121            GitignorePolicy::Ignore,
2122            SymlinkPolicy::Skip,
2123        );
2124
2125        assert!(
2126            outcome.manifests.is_empty(),
2127            "still pruned from the primary scan"
2128        );
2129        assert_eq!(
2130            outcome.ignored_manifests,
2131            vec![PathBuf::from("vendor").join("Cargo.toml")]
2132        );
2133    }
2134
2135    /// Issue #1112, US-002 (FR-003): with `follow_symlinks: true`, a symlinked manifest inside
2136    /// the walked root is resolved and routed, appearing in `manifests` rather than only
2137    /// `ignored_manifests`.
2138    #[cfg(unix)]
2139    #[test]
2140    fn test_walk_follow_symlinks_resolves_and_routes_manifest() {
2141        let dir = tempfile::tempdir().expect("create temp dir");
2142        let real = dir.path().join("real").join("manifest-data");
2143        fs::create_dir(dir.path().join("real")).expect("mkdir real");
2144        fs::write(&real, "[package]\n").expect("write real manifest");
2145        std::os::unix::fs::symlink(&real, dir.path().join("Cargo.toml")).expect("create symlink");
2146
2147        let outcome = walk(
2148            &[dir.path().to_path_buf()],
2149            &test_registry(),
2150            GitignorePolicy::Ignore,
2151            SymlinkPolicy::Follow,
2152        );
2153
2154        assert_eq!(outcome.manifests.len(), 1);
2155        assert!(outcome.ignored_manifests.is_empty());
2156        assert_eq!(outcome.manifests[0].ecosystem.id(), "cargo");
2157        // S3/FR-007: `path` (used for reading) is the resolved real path, not the symlink.
2158        assert_eq!(
2159            outcome.manifests[0]
2160                .path
2161                .canonicalize()
2162                .expect("canonicalize actual path"),
2163            real.canonicalize()
2164                .expect("canonicalize expected real path")
2165        );
2166        // Review finding M3: canonicalizing both sides above can't actually distinguish
2167        // "symlink path" from "real path" on its own (both would resolve to the same real
2168        // file) — assert the *raw*, non-canonicalized paths differ too, proving `path` is
2169        // genuinely the resolved target, not the symlink verbatim.
2170        assert_ne!(
2171            outcome.manifests[0].path,
2172            dir.path().join("Cargo.toml"),
2173            "path must be the resolved real path, not the symlink's own raw path"
2174        );
2175    }
2176
2177    /// FR-002/US-001: the same symlinked-manifest fixture behaves oppositely depending on the
2178    /// flag — `follow_symlinks: false` never reads the target (empty `manifests`, populated
2179    /// `ignored_manifests`), `follow_symlinks: true` resolves and routes it (populated
2180    /// `manifests`, empty `ignored_manifests`) — proving the flag actually gates reading, not
2181    /// just two independently-plausible outcomes.
2182    #[cfg(unix)]
2183    #[test]
2184    fn test_walk_follow_symlinks_toggles_between_ignored_and_routed() {
2185        let dir = tempfile::tempdir().expect("create temp dir");
2186        let real = dir.path().join("real").join("manifest-data");
2187        fs::create_dir(dir.path().join("real")).expect("mkdir real");
2188        fs::write(&real, "[package]\n").expect("write real manifest");
2189        std::os::unix::fs::symlink(&real, dir.path().join("Cargo.toml")).expect("create symlink");
2190
2191        let disabled = walk(
2192            &[dir.path().to_path_buf()],
2193            &test_registry(),
2194            GitignorePolicy::Ignore,
2195            SymlinkPolicy::Skip,
2196        );
2197        assert!(disabled.manifests.is_empty());
2198        assert_eq!(
2199            disabled.ignored_manifests,
2200            vec![PathBuf::from("Cargo.toml")]
2201        );
2202
2203        let enabled = walk(
2204            &[dir.path().to_path_buf()],
2205            &test_registry(),
2206            GitignorePolicy::Ignore,
2207            SymlinkPolicy::Follow,
2208        );
2209        assert_eq!(enabled.manifests.len(), 1);
2210        assert!(enabled.ignored_manifests.is_empty());
2211    }
2212
2213    /// FR-007: `display_path` reflects the symlink's own encountered path, not the resolved
2214    /// target's real path.
2215    #[cfg(unix)]
2216    #[test]
2217    fn test_walk_follow_symlinks_display_path_is_symlink_path_not_target() {
2218        let dir = tempfile::tempdir().expect("create temp dir");
2219        let real = dir.path().join("real").join("manifest-data");
2220        fs::create_dir(dir.path().join("real")).expect("mkdir real");
2221        fs::write(&real, "[package]\n").expect("write real manifest");
2222        std::os::unix::fs::symlink(&real, dir.path().join("Cargo.toml")).expect("create symlink");
2223
2224        let outcome = walk(
2225            &[dir.path().to_path_buf()],
2226            &test_registry(),
2227            GitignorePolicy::Ignore,
2228            SymlinkPolicy::Follow,
2229        );
2230
2231        assert_eq!(outcome.manifests.len(), 1);
2232        assert_eq!(
2233            outcome.manifests[0].display_path,
2234            PathBuf::from("Cargo.toml")
2235        );
2236    }
2237
2238    /// FR-006: `follow_symlinks: true` does not defeat `PRUNED_DIRECTORIES` pruning, even when
2239    /// the manifest inside the pruned directory is itself reachable through a symlink.
2240    #[cfg(unix)]
2241    #[test]
2242    fn test_walk_follow_symlinks_still_prunes_node_modules() {
2243        let dir = tempfile::tempdir().expect("create temp dir");
2244        fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
2245        let real_vendored = dir.path().join("real-vendored-manifest");
2246        fs::write(&real_vendored, "{}").expect("write real vendored manifest");
2247        fs::create_dir_all(dir.path().join("node_modules").join("left-pad"))
2248            .expect("mkdir node_modules/left-pad");
2249        std::os::unix::fs::symlink(
2250            &real_vendored,
2251            dir.path()
2252                .join("node_modules")
2253                .join("left-pad")
2254                .join("package.json"),
2255        )
2256        .expect("symlink vendored manifest inside pruned directory");
2257
2258        let outcome = walk(
2259            &[dir.path().to_path_buf()],
2260            &test_registry(),
2261            GitignorePolicy::Ignore,
2262            SymlinkPolicy::Follow,
2263        );
2264
2265        assert_eq!(
2266            outcome.manifests.len(),
2267            1,
2268            "a symlinked manifest inside a pruned directory must still be pruned, not routed"
2269        );
2270        assert_eq!(
2271            outcome.manifests[0].display_path,
2272            PathBuf::from("Cargo.toml")
2273        );
2274    }
2275
2276    /// FR-006: `follow_symlinks: true` still respects `walk_with_limit`'s cap when a symlinked
2277    /// directory inflates the number of entries the walk must visit.
2278    #[cfg(unix)]
2279    #[test]
2280    fn test_walk_follow_symlinks_still_enforces_max_walked_files() {
2281        let dir = tempfile::tempdir().expect("create temp dir");
2282        // Review finding M4: the noise source is a *hidden* (dot-prefixed) directory, so the
2283        // default `hidden(true)` filter excludes it from ever being walked directly by its own
2284        // name — the only way to reach its 5 files is by following `noise-link`, making the
2285        // symlink genuinely load-bearing for this test. `limit` is chosen to comfortably cover
2286        // the baseline tree (walk root + `Cargo.toml` + the `noise-link` entry itself = 3
2287        // entries, `.noise-source` never yielded at all) but not baseline-plus-5-noise-files —
2288        // with `follow_symlinks: false` this same fixture and limit does *not* truncate,
2289        // proving the symlink (not just the raw entry count) is what pushes the walk over.
2290        let noise_target = dir.path().join(".noise-source");
2291        fs::create_dir(&noise_target).expect("mkdir .noise-source");
2292        for i in 0..5 {
2293            fs::write(noise_target.join(format!("noise-{i}.txt")), "").expect("write noise file");
2294        }
2295        std::os::unix::fs::symlink(&noise_target, dir.path().join("noise-link"))
2296            .expect("symlink noise directory");
2297        fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
2298
2299        let outcome = walk_with_limit(
2300            &[dir.path().to_path_buf()],
2301            &test_registry(),
2302            4,
2303            GitignorePolicy::Ignore,
2304            SymlinkPolicy::Follow,
2305        );
2306        assert!(
2307            outcome.truncated,
2308            "a 4-entry limit against a tree inflated by a symlinked directory must truncate"
2309        );
2310    }
2311
2312    /// FR-004, US-003: a symlink inside the walked root pointing to a manifest-shaped file
2313    /// *outside* the walked root is never routed, even with `follow_symlinks: true`.
2314    #[cfg(unix)]
2315    #[test]
2316    fn test_walk_follow_symlinks_rejects_target_outside_root() {
2317        let outside = tempfile::tempdir().expect("create outside temp dir");
2318        let outside_manifest = outside.path().join("Cargo.toml");
2319        fs::write(&outside_manifest, "[package]\n").expect("write outside manifest");
2320
2321        let root = tempfile::tempdir().expect("create walked root");
2322        std::os::unix::fs::symlink(&outside_manifest, root.path().join("Cargo.toml"))
2323            .expect("create symlink escaping the walked root");
2324
2325        let outcome = walk(
2326            &[root.path().to_path_buf()],
2327            &test_registry(),
2328            GitignorePolicy::Ignore,
2329            SymlinkPolicy::Follow,
2330        );
2331
2332        assert!(
2333            outcome.manifests.is_empty(),
2334            "must never route a symlink target outside the walked root"
2335        );
2336        assert_eq!(outcome.ignored_manifests, vec![PathBuf::from("Cargo.toml")]);
2337    }
2338
2339    /// FR-005, US-003: a symlink loop must not hang or crash the walk; it is reported via
2340    /// `walk_errors` and the run's other manifests are still found.
2341    #[cfg(unix)]
2342    #[test]
2343    fn test_walk_follow_symlinks_reports_symlink_loop_via_walk_errors() {
2344        let dir = tempfile::tempdir().expect("create temp dir");
2345        fs::create_dir_all(dir.path().join("a")).expect("mkdir a");
2346        fs::create_dir_all(dir.path().join("b")).expect("mkdir b");
2347        std::os::unix::fs::symlink(dir.path().join("b"), dir.path().join("a").join("loop"))
2348            .expect("create a/loop -> b");
2349        std::os::unix::fs::symlink(dir.path().join("a"), dir.path().join("b").join("loop"))
2350            .expect("create b/loop -> a");
2351        fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("write manifest");
2352
2353        let outcome = walk(
2354            &[dir.path().to_path_buf()],
2355            &test_registry(),
2356            GitignorePolicy::Ignore,
2357            SymlinkPolicy::Follow,
2358        );
2359
2360        assert!(
2361            outcome
2362                .walk_errors
2363                .iter()
2364                .any(|error| error.to_lowercase().contains("loop")),
2365            "a symlink loop must be reported via walk_errors naming the loop, not just any \
2366             error, and must not hang or crash: {:?}",
2367            outcome.walk_errors
2368        );
2369        assert!(
2370            outcome
2371                .manifests
2372                .iter()
2373                .any(|m| m.display_path == Path::new("Cargo.toml")),
2374            "other manifests in the same tree must still be found"
2375        );
2376    }
2377
2378    /// M2 (critic follow-up): a self-referential manifest-shaped symlink (`Cargo.toml ->
2379    /// Cargo.toml`) resolves via a plain OS-level `ELOOP` (`io::Error`), distinct from the
2380    /// structural ancestor/descendant directory cycle `ignore`'s own `Error::Loop` variant
2381    /// detects during descent (`test_walk_follow_symlinks_reports_symlink_loop_via_walk_errors`
2382    /// above, whose names aren't manifest-shaped and whose `Error::Loop` isn't `is_io()`
2383    /// either way). Since this *is* an IO error on a manifest-shaped symlink, it lands in
2384    /// `broken_manifest_symlinks`, and — per Bug 3 (background code review) — the generic
2385    /// `walk_errors` push is skipped once that specific classification already fired, so no
2386    /// hang/crash but also no duplicate warning.
2387    #[cfg(unix)]
2388    #[test]
2389    fn test_walk_follow_symlinks_self_referential_manifest_symlink_is_reported_as_broken() {
2390        let dir = tempfile::tempdir().expect("create temp dir");
2391        std::os::unix::fs::symlink(dir.path().join("Cargo.toml"), dir.path().join("Cargo.toml"))
2392            .expect("create self-referential Cargo.toml -> Cargo.toml");
2393
2394        let outcome = walk(
2395            &[dir.path().to_path_buf()],
2396            &test_registry(),
2397            GitignorePolicy::Ignore,
2398            SymlinkPolicy::Follow,
2399        );
2400
2401        assert_eq!(
2402            outcome.broken_manifest_symlinks,
2403            vec![PathBuf::from("Cargo.toml")]
2404        );
2405        assert!(
2406            outcome.walk_errors.is_empty(),
2407            "must not duplicate the specific broken-manifest classification with a generic \
2408             IO walk error: {:?}",
2409            outcome.walk_errors
2410        );
2411    }
2412
2413    /// M2 (critic follow-up): `--respect-gitignore` and `--follow-symlinks` combined must still
2414    /// detect a broken, gitignored manifest symlink — no untested interaction between the two
2415    /// opt-in flags for the broken case specifically (the resolvable case already has
2416    /// `test_walk_follow_symlinks_and_respect_gitignore_together_still_honors_gitignore`).
2417    #[cfg(unix)]
2418    #[test]
2419    fn test_walk_respect_gitignore_and_follow_symlinks_together_detect_broken_manifest() {
2420        let dir = tempfile::tempdir().expect("create temp dir");
2421        fs::create_dir(dir.path().join(".git")).expect("create .git marker");
2422        fs::write(dir.path().join(".gitignore"), "Cargo.toml\n").expect("write gitignore");
2423        std::os::unix::fs::symlink(
2424            dir.path().join("does-not-exist"),
2425            dir.path().join("Cargo.toml"),
2426        )
2427        .expect("create broken symlink");
2428
2429        let outcome = walk(
2430            &[dir.path().to_path_buf()],
2431            &test_registry(),
2432            GitignorePolicy::Respect,
2433            SymlinkPolicy::Follow,
2434        );
2435
2436        assert!(outcome.manifests.is_empty());
2437        assert!(outcome.ignored_manifests.is_empty());
2438        assert_eq!(
2439            outcome.broken_manifest_symlinks,
2440            vec![PathBuf::from("Cargo.toml")]
2441        );
2442    }
2443
2444    /// Spec §6 edge case: `--follow-symlinks` and `--respect-gitignore` are independent flags —
2445    /// a symlinked manifest excluded by `.gitignore` is still reported via the existing
2446    /// `.gitignore`-suppression path once resolved, exactly as a non-symlinked manifest would
2447    /// be, rather than `follow_symlinks` bypassing the ignore rule.
2448    #[cfg(unix)]
2449    #[test]
2450    fn test_walk_follow_symlinks_and_respect_gitignore_together_still_honors_gitignore() {
2451        let dir = tempfile::tempdir().expect("create temp dir");
2452        fs::create_dir(dir.path().join(".git")).expect("create .git marker");
2453        fs::write(dir.path().join(".gitignore"), "Cargo.toml\n").expect("write gitignore");
2454        let real = dir.path().join("real").join("manifest-data");
2455        fs::create_dir(dir.path().join("real")).expect("mkdir real");
2456        fs::write(&real, "[package]\n").expect("write real manifest");
2457        std::os::unix::fs::symlink(&real, dir.path().join("Cargo.toml")).expect("create symlink");
2458
2459        let outcome = walk(
2460            &[dir.path().to_path_buf()],
2461            &test_registry(),
2462            GitignorePolicy::Respect,
2463            SymlinkPolicy::Follow,
2464        );
2465
2466        assert!(
2467            outcome.manifests.is_empty(),
2468            "a .gitignore-excluded symlinked manifest must not be routed even under \
2469             --follow-symlinks"
2470        );
2471        assert_eq!(outcome.ignored_manifests, vec![PathBuf::from("Cargo.toml")]);
2472    }
2473
2474    /// Critic finding C1 regression: a symlinked *directory* (not a symlinked leaf file) must
2475    /// not let `--follow-symlinks` escape the walked root — the leaf entry inside it
2476    /// (`evil/Cargo.toml`) is not itself a symlink, so a containment check keyed only on
2477    /// `path_is_symlink()` would miss it.
2478    #[cfg(unix)]
2479    #[test]
2480    fn test_walk_follow_symlinks_rejects_directory_symlink_escaping_root() {
2481        let outside = tempfile::tempdir().expect("create outside temp dir");
2482        fs::write(outside.path().join("Cargo.toml"), "[package]\n")
2483            .expect("write outside manifest");
2484
2485        let root = tempfile::tempdir().expect("create walked root");
2486        fs::write(root.path().join("Cargo.toml"), "[package]\n").expect("write root manifest");
2487        std::os::unix::fs::symlink(outside.path(), root.path().join("evil"))
2488            .expect("symlink a directory escaping the walked root");
2489
2490        let outcome = walk(
2491            &[root.path().to_path_buf()],
2492            &test_registry(),
2493            GitignorePolicy::Ignore,
2494            SymlinkPolicy::Follow,
2495        );
2496
2497        assert!(
2498            outcome
2499                .manifests
2500                .iter()
2501                .all(|m| m.display_path != PathBuf::from("evil").join("Cargo.toml")),
2502            "a manifest reached only by descending into a symlinked directory outside the \
2503             walked root must never be routed: {:?}",
2504            outcome
2505                .manifests
2506                .iter()
2507                .map(|m| &m.display_path)
2508                .collect::<Vec<_>>()
2509        );
2510        assert_eq!(
2511            outcome.manifests.len(),
2512            1,
2513            "the root's own, non-escaping Cargo.toml must still be found"
2514        );
2515    }
2516
2517    /// Background code-review finding #2: an escaping symlinked directory must be pruned
2518    /// *before* `ignore`/`walkdir` descends into it, not walked entry-by-entry and rejected
2519    /// individually — otherwise a large external tree behind the symlink can exhaust
2520    /// `MAX_WALKED_FILES` on content outside the walked root, silently truncating the walk and
2521    /// dropping legitimate manifests elsewhere in the real tree (the exact #1112 fail-open
2522    /// class, reopened through this fix's own flag). Proven by pointing the escaping symlink at
2523    /// a directory with far more entries than a deliberately tiny `limit`, and asserting the
2524    /// walk still finds the root's own manifest without truncating.
2525    #[cfg(unix)]
2526    #[test]
2527    fn test_walk_follow_symlinks_escaping_directory_does_not_exhaust_the_budget() {
2528        let outside = tempfile::tempdir().expect("create outside temp dir");
2529        for i in 0..50 {
2530            fs::write(outside.path().join(format!("noise-{i}.txt")), "")
2531                .expect("write outside noise file");
2532        }
2533
2534        let root = tempfile::tempdir().expect("create walked root");
2535        fs::write(root.path().join("Cargo.toml"), "[package]\n").expect("write root manifest");
2536        std::os::unix::fs::symlink(outside.path(), root.path().join("evil"))
2537            .expect("symlink a directory escaping the walked root");
2538
2539        // Comfortably covers the walked root's own 2 entries (root dir + Cargo.toml) plus the
2540        // pruned `evil` entry itself, but is far smaller than the 50 files behind it — if the
2541        // escaping directory were walked instead of pruned, this would truncate long before
2542        // `Cargo.toml` is guaranteed to be counted.
2543        let outcome = walk_with_limit(
2544            &[root.path().to_path_buf()],
2545            &test_registry(),
2546            5,
2547            GitignorePolicy::Ignore,
2548            SymlinkPolicy::Follow,
2549        );
2550
2551        assert!(
2552            !outcome.truncated,
2553            "pruning the escaping directory before descent must keep the walk well under the \
2554             budget, not exhaust it on external content"
2555        );
2556        assert_eq!(
2557            outcome.manifests.len(),
2558            1,
2559            "the root's own manifest must still be found"
2560        );
2561    }
2562
2563    /// Background code-review finding #1: a symlinked manifest that is not itself
2564    /// `.gitignore`-excluded must be reported exactly once in `ignored_manifests`, not twice.
2565    /// Root cause was the primary walk's symlink-detection arm never inserting into `visited`
2566    /// (only the `is_file()` arm did), so `detect_ignored_manifests`'s separate unfiltered walk
2567    /// (run because `respect_gitignore` is true) found the same symlink again and double-counted
2568    /// it.
2569    #[cfg(unix)]
2570    #[test]
2571    fn test_walk_respect_gitignore_does_not_duplicate_symlinked_manifest_warning() {
2572        let dir = tempfile::tempdir().expect("create temp dir");
2573        fs::create_dir(dir.path().join(".git")).expect("create .git marker");
2574        // Present but irrelevant to this symlink — proves the duplication wasn't specific to
2575        // an empty .gitignore file being absent.
2576        fs::write(dir.path().join(".gitignore"), "*.log\n").expect("write gitignore");
2577        let real = dir.path().join("real").join("manifest-data");
2578        fs::create_dir(dir.path().join("real")).expect("mkdir real");
2579        fs::write(&real, "[package]\n").expect("write real manifest");
2580        std::os::unix::fs::symlink(&real, dir.path().join("Cargo.toml")).expect("create symlink");
2581
2582        let outcome = walk(
2583            &[dir.path().to_path_buf()],
2584            &test_registry(),
2585            GitignorePolicy::Respect,
2586            SymlinkPolicy::Skip,
2587        );
2588
2589        assert_eq!(
2590            outcome.ignored_manifests,
2591            vec![PathBuf::from("Cargo.toml")],
2592            "a non-gitignored symlinked manifest must be reported exactly once"
2593        );
2594    }
2595
2596    /// Critic finding S1 regression: a registered ecosystem's own hidden dot-directory (e.g.
2597    /// `.github`) escaping the walked root via a symlink must not be scanned, regardless of
2598    /// `follow_symlinks` — `ignore`/`walkdir` always follows a walk's own root symlink, so this
2599    /// containment check must apply in the default mode too.
2600    #[cfg(unix)]
2601    #[test]
2602    fn test_walk_default_rejects_symlinked_hidden_ecosystem_directory_escaping_root() {
2603        let outside = tempfile::tempdir().expect("create outside temp dir");
2604        fs::create_dir_all(outside.path().join("workflows")).expect("mkdir workflows");
2605        fs::write(
2606            outside.path().join("workflows").join("ci.yml"),
2607            "on: push\njobs:\n  x:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n",
2608        )
2609        .expect("write workflow");
2610
2611        let root = tempfile::tempdir().expect("create walked root");
2612        std::os::unix::fs::symlink(outside.path(), root.path().join(".github"))
2613            .expect("symlink .github escaping the walked root");
2614
2615        let outcome = walk(
2616            &[root.path().to_path_buf()],
2617            &test_registry(),
2618            GitignorePolicy::Ignore,
2619            SymlinkPolicy::Skip,
2620        );
2621
2622        assert!(
2623            outcome.manifests.is_empty(),
2624            "a symlinked .github escaping the walked root must never be scanned, even in the \
2625             default mode: {:?}",
2626            outcome
2627                .manifests
2628                .iter()
2629                .map(|m| &m.display_path)
2630                .collect::<Vec<_>>()
2631        );
2632    }
2633}