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