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 ®istry,
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"), ¬es)
1554 .expect("create broken, non-manifest-shaped symlink");
1555
1556 let outcome = walk(
1557 std::slice::from_ref(¬es),
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}