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