From 350b33acda4cbcdec7245d3c4794bc7ba6ad55db Mon Sep 17 00:00:00 2001 From: hexbinoct Date: Sat, 27 Jun 2026 09:52:14 +0500 Subject: [PATCH 1/4] Compute depth for broken symlinks so --min-depth keeps them When following links, a broken symlink is surfaced by the walker as an error that carries no depth, so fd stored it with an unknown depth and the --min-depth filter dropped it for any minimum value. Record the depth when the entry is created by counting the path components relative to the matching search root, which is what the issue discussion suggested. Broken symlinks are now filtered by depth like any other entry. Fixes #1017 --- CHANGELOG.md | 1 + src/dir_entry.rs | 20 +++++++++++--------- src/walk.rs | 28 ++++++++++++++++++++++++---- tests/tests.rs | 37 +++++++++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c522d6f23..fd18df250 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ when output goes to a terminal, to prevent terminal escape-sequence injection. Also reject a placeholder as the executable for `--exec-batch`, while still allowing it for `--exec`. +- Fix broken symlinks being incorrectly filtered out by `--min-depth` when following links (`--follow`), because their depth was not computed; see #1017 (@hexbinoct). - Handle invalid working directories gracefully when using `--full-path`, see #1900 (@Xavrir). - Fire the "search pattern contains a path separator" diagnostic for any pattern containing `/`, not just patterns that happen to name an existing directory. Preserves the legacy Windows behaviour that also flags native `\` separators when the pattern resolves to a real directory. See #1873. - Also fire the "search pattern contains a path separator" diagnostic for `--and` patterns, not only the primary positional pattern. `--and` patterns are matched against the file name just like the primary pattern, so a path separator in them silently returned zero results. See #1873. diff --git a/src/dir_entry.rs b/src/dir_entry.rs index c79c3e899..770f59cb8 100644 --- a/src/dir_entry.rs +++ b/src/dir_entry.rs @@ -11,7 +11,9 @@ use crate::filesystem::strip_current_dir; #[derive(Debug)] enum DirEntryInner { Normal(ignore::DirEntry), - BrokenSymlink(PathBuf), + // Broken symlinks are surfaced by the walker as errors that carry no depth, + // so we record the depth (relative to the search root) at creation time. + BrokenSymlink(PathBuf, usize), } #[derive(Debug)] @@ -31,9 +33,9 @@ impl DirEntry { } } - pub fn broken_symlink(path: PathBuf) -> Self { + pub fn broken_symlink(path: PathBuf, depth: usize) -> Self { Self { - inner: DirEntryInner::BrokenSymlink(path), + inner: DirEntryInner::BrokenSymlink(path, depth), metadata: OnceCell::new(), style: OnceCell::new(), } @@ -42,14 +44,14 @@ impl DirEntry { pub fn path(&self) -> &Path { match &self.inner { DirEntryInner::Normal(e) => e.path(), - DirEntryInner::BrokenSymlink(pathbuf) => pathbuf.as_path(), + DirEntryInner::BrokenSymlink(pathbuf, _) => pathbuf.as_path(), } } pub fn into_path(self) -> PathBuf { match self.inner { DirEntryInner::Normal(e) => e.into_path(), - DirEntryInner::BrokenSymlink(p) => p, + DirEntryInner::BrokenSymlink(p, _) => p, } } @@ -82,7 +84,7 @@ impl DirEntry { pub fn file_type(&self) -> Option { match &self.inner { DirEntryInner::Normal(e) => e.file_type(), - DirEntryInner::BrokenSymlink(_) => self.metadata().map(|m| m.file_type()), + DirEntryInner::BrokenSymlink(..) => self.metadata().map(|m| m.file_type()), } } @@ -90,7 +92,7 @@ impl DirEntry { self.metadata .get_or_init(|| match &self.inner { DirEntryInner::Normal(e) => e.metadata().ok(), - DirEntryInner::BrokenSymlink(path) => path.symlink_metadata().ok(), + DirEntryInner::BrokenSymlink(path, _) => path.symlink_metadata().ok(), }) .as_ref() } @@ -98,7 +100,7 @@ impl DirEntry { pub fn depth(&self) -> Option { match &self.inner { DirEntryInner::Normal(e) => Some(e.depth()), - DirEntryInner::BrokenSymlink(_) => None, + DirEntryInner::BrokenSymlink(_, depth) => Some(*depth), } } @@ -144,7 +146,7 @@ impl Colorable for DirEntry { fn file_name(&self) -> OsString { let name = match &self.inner { DirEntryInner::Normal(e) => e.file_name(), - DirEntryInner::BrokenSymlink(path) => { + DirEntryInner::BrokenSymlink(path, _) => { // Path::file_name() only works if the last component is Normal, // but we want it for all component types, so we open code it. // Copied from LsColors::style_for_path_with_metadata(). diff --git a/src/walk.rs b/src/walk.rs index 30128a0bc..f35d7bd78 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -2,7 +2,7 @@ use std::borrow::Cow; use std::ffi::OsStr; use std::io::{self, Write}; use std::mem; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, MutexGuard}; use std::thread; @@ -440,7 +440,7 @@ impl WorkerState { } /// Spawn the sender threads. - fn spawn_senders(&self, walker: WalkParallel, tx: Sender) { + fn spawn_senders(&self, walker: WalkParallel, roots: &[PathBuf], tx: Sender) { walker.run(|| { let patterns = &self.patterns; let config = &self.config; @@ -495,7 +495,8 @@ impl WorkerState { .ok() .is_some_and(|m| m.file_type().is_symlink()) => { - DirEntry::broken_symlink(path) + let depth = depth_relative_to_roots(&path, roots); + DirEntry::broken_symlink(path, depth) } Err(err) => { return match tx.send(WorkerResult::Error(err)) { @@ -640,7 +641,7 @@ impl WorkerState { let receiver = scope.spawn(|| self.receive(rx)); // Spawn the sender threads. - self.spawn_senders(walker, tx); + self.spawn_senders(walker, paths, tx); receiver.join().unwrap() }); @@ -653,6 +654,25 @@ impl WorkerState { } } +/// Compute the depth of `path` relative to the search root it was found under. +/// +/// `ignore::DirEntry::depth()` provides this for normal entries, but broken +/// symlinks are surfaced as errors that carry no depth, so we derive it from the +/// path components instead (see issue #1017). The matching root is the longest +/// search root (by component count) that `path` starts with; if none matches we +/// fall back to the full component count, which keeps the entry visible rather +/// than silently dropping it. +fn depth_relative_to_roots(path: &Path, roots: &[PathBuf]) -> usize { + let components = path.components().count(); + let root_components = roots + .iter() + .filter(|root| path.starts_with(root)) + .map(|root| root.components().count()) + .max() + .unwrap_or(0); + components.saturating_sub(root_components) +} + fn search_str_for_entry<'a>( entry_path: &'a std::path::Path, full_path_base: Option<&std::path::Path>, diff --git a/tests/tests.rs b/tests/tests.rs index 0fce5ed35..16b333584 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -1208,6 +1208,43 @@ fn test_min_depth() { ); } +/// Minimum depth with a broken symlink (regression test for #1017) +/// +/// A broken symlink, surfaced while following links, has no depth reported by +/// the walker, so --min-depth used to drop it unconditionally. +#[test] +fn test_min_depth_broken_symlink() { + let mut te = TestEnv::new(DEFAULT_DIRS, DEFAULT_FILES); + te.create_broken_symlink("one/two/broken_symlink") + .expect("Failed to create broken symlink."); + + // The broken symlink sits at depth 3, so it is kept up to that depth. + te.assert_output( + &[ + "--follow", + "--type", + "symlink", + "--min-depth", + "3", + "broken_symlink", + ], + "one/two/broken_symlink", + ); + + // A --min-depth beyond its actual depth must exclude it. + te.assert_output( + &[ + "--follow", + "--type", + "symlink", + "--min-depth", + "4", + "broken_symlink", + ], + "", + ); +} + /// Exact depth (--exact-depth) #[test] fn test_exact_depth() { From 4a89affbb4a0804c141e15abb15f20dd0cfaa13d Mon Sep 17 00:00:00 2001 From: hexbinoct Date: Mon, 29 Jun 2026 13:46:47 +0500 Subject: [PATCH 2/4] Make broken symlink depth robust under absolute paths The depth for a broken symlink was derived by stripping the search root from its path and counting the remaining components. Compare the path and the roots in absolute form before stripping, so the match holds whether or not --absolute-path has already made the roots absolute, and pick the deepest matching root. This avoids an absolute path failing to match a relative root and falling back to an inflated depth. Add a regression test that exercises the broken symlink case together with --absolute-path. --- src/walk.rs | 36 +++++++++++++++++++++++++----------- tests/tests.rs | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/src/walk.rs b/src/walk.rs index f35d7bd78..1c0623af9 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -658,19 +658,33 @@ impl WorkerState { /// /// `ignore::DirEntry::depth()` provides this for normal entries, but broken /// symlinks are surfaced as errors that carry no depth, so we derive it from the -/// path components instead (see issue #1017). The matching root is the longest -/// search root (by component count) that `path` starts with; if none matches we -/// fall back to the full component count, which keeps the entry visible rather -/// than silently dropping it. +/// path instead (see issue #1017). The walker reports every entry's path +/// prefixed by the search root it was found under, so we strip the matching root +/// and count the remaining components. Both the path and the roots are put into +/// absolute form first, so the comparison is correct regardless of whether +/// `--absolute-path` has already made the roots absolute (otherwise an absolute +/// path would fail to match a relative root and the depth would be wrong). When +/// more than one root is a prefix, the deepest (most specific) one wins. If none +/// matches, which should not happen, we fall back to the full component count, +/// keeping the entry visible rather than silently dropping it. fn depth_relative_to_roots(path: &Path, roots: &[PathBuf]) -> usize { - let components = path.components().count(); - let root_components = roots + // `Path::join` ignores the base when its argument is already absolute, so + // this leaves absolute inputs untouched and anchors relative ones at the cwd. + let cwd = std::env::current_dir().ok(); + let absolute = |p: &Path| -> PathBuf { + match &cwd { + Some(cwd) => cwd.join(p), + None => p.to_path_buf(), + } + }; + + let absolute_path = absolute(path); + roots .iter() - .filter(|root| path.starts_with(root)) - .map(|root| root.components().count()) - .max() - .unwrap_or(0); - components.saturating_sub(root_components) + .filter_map(|root| absolute_path.strip_prefix(absolute(root)).ok()) + .map(|relative| relative.components().count()) + .min() + .unwrap_or_else(|| path.components().count()) } fn search_str_for_entry<'a>( diff --git a/tests/tests.rs b/tests/tests.rs index 16b333584..0319878b1 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -1245,6 +1245,46 @@ fn test_min_depth_broken_symlink() { ); } +/// Minimum depth with a broken symlink combined with --absolute-path (#1017) +/// +/// With --absolute-path the search root is made absolute before walking, so the +/// broken symlink's depth must still be computed relative to that root rather +/// than from the absolute path's full component count. +#[test] +fn test_min_depth_broken_symlink_absolute_path() { + let (mut te, abs_path) = get_test_env_with_abs_path(DEFAULT_DIRS, DEFAULT_FILES); + te.create_broken_symlink("one/two/broken_symlink") + .expect("Failed to create broken symlink."); + + // The broken symlink sits at depth 3 relative to the (absolute) root. + te.assert_output( + &[ + "--follow", + "--absolute-path", + "--type", + "symlink", + "--min-depth", + "3", + "broken_symlink", + ], + &format!("{abs_path}/one/two/broken_symlink"), + ); + + // A --min-depth beyond its actual depth must exclude it. + te.assert_output( + &[ + "--follow", + "--absolute-path", + "--type", + "symlink", + "--min-depth", + "4", + "broken_symlink", + ], + "", + ); +} + /// Exact depth (--exact-depth) #[test] fn test_exact_depth() { From 68edfde807a06a4fe7603692b9a0da5ddc5438af Mon Sep 17 00:00:00 2001 From: hexbinoct Date: Mon, 29 Jun 2026 14:29:10 +0500 Subject: [PATCH 3/4] Use a struct-like variant for broken symlinks and test max/exact depth Address review feedback. Make DirEntryInner::BrokenSymlink a struct-like variant with named path and depth fields, so the recorded depth is clear at every use site. Add regression tests that a broken symlink is filtered by --max-depth and --exact-depth the same way a normal entry is, both through the real directory tree and through a followed symlink. --- src/dir_entry.rs | 16 +++++----- tests/tests.rs | 78 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 8 deletions(-) diff --git a/src/dir_entry.rs b/src/dir_entry.rs index 770f59cb8..bca4defd0 100644 --- a/src/dir_entry.rs +++ b/src/dir_entry.rs @@ -13,7 +13,7 @@ enum DirEntryInner { Normal(ignore::DirEntry), // Broken symlinks are surfaced by the walker as errors that carry no depth, // so we record the depth (relative to the search root) at creation time. - BrokenSymlink(PathBuf, usize), + BrokenSymlink { path: PathBuf, depth: usize }, } #[derive(Debug)] @@ -35,7 +35,7 @@ impl DirEntry { pub fn broken_symlink(path: PathBuf, depth: usize) -> Self { Self { - inner: DirEntryInner::BrokenSymlink(path, depth), + inner: DirEntryInner::BrokenSymlink { path, depth }, metadata: OnceCell::new(), style: OnceCell::new(), } @@ -44,14 +44,14 @@ impl DirEntry { pub fn path(&self) -> &Path { match &self.inner { DirEntryInner::Normal(e) => e.path(), - DirEntryInner::BrokenSymlink(pathbuf, _) => pathbuf.as_path(), + DirEntryInner::BrokenSymlink { path, .. } => path.as_path(), } } pub fn into_path(self) -> PathBuf { match self.inner { DirEntryInner::Normal(e) => e.into_path(), - DirEntryInner::BrokenSymlink(p, _) => p, + DirEntryInner::BrokenSymlink { path, .. } => path, } } @@ -84,7 +84,7 @@ impl DirEntry { pub fn file_type(&self) -> Option { match &self.inner { DirEntryInner::Normal(e) => e.file_type(), - DirEntryInner::BrokenSymlink(..) => self.metadata().map(|m| m.file_type()), + DirEntryInner::BrokenSymlink { .. } => self.metadata().map(|m| m.file_type()), } } @@ -92,7 +92,7 @@ impl DirEntry { self.metadata .get_or_init(|| match &self.inner { DirEntryInner::Normal(e) => e.metadata().ok(), - DirEntryInner::BrokenSymlink(path, _) => path.symlink_metadata().ok(), + DirEntryInner::BrokenSymlink { path, .. } => path.symlink_metadata().ok(), }) .as_ref() } @@ -100,7 +100,7 @@ impl DirEntry { pub fn depth(&self) -> Option { match &self.inner { DirEntryInner::Normal(e) => Some(e.depth()), - DirEntryInner::BrokenSymlink(_, depth) => Some(*depth), + DirEntryInner::BrokenSymlink { depth, .. } => Some(*depth), } } @@ -146,7 +146,7 @@ impl Colorable for DirEntry { fn file_name(&self) -> OsString { let name = match &self.inner { DirEntryInner::Normal(e) => e.file_name(), - DirEntryInner::BrokenSymlink(path, _) => { + DirEntryInner::BrokenSymlink { path, .. } => { // Path::file_name() only works if the last component is Normal, // but we want it for all component types, so we open code it. // Copied from LsColors::style_for_path_with_metadata(). diff --git a/tests/tests.rs b/tests/tests.rs index 0319878b1..5f8cfc6de 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -1285,6 +1285,84 @@ fn test_min_depth_broken_symlink_absolute_path() { ); } +/// Maximum depth with a broken symlink (#1017) +/// +/// A broken symlink must be filtered by --max-depth like any other entry. The +/// default environment also exposes it through the followed `symlink` directory +/// (`symlink -> one/two`), so it is reachable at depth 2 as well as depth 3. +#[test] +fn test_max_depth_broken_symlink() { + let mut te = TestEnv::new(DEFAULT_DIRS, DEFAULT_FILES); + te.create_broken_symlink("one/two/broken_symlink") + .expect("Failed to create broken symlink."); + + // --max-depth 3 keeps both routes to the broken symlink. + te.assert_output( + &[ + "--follow", + "--type", + "symlink", + "--max-depth", + "3", + "broken_symlink", + ], + "one/two/broken_symlink + symlink/broken_symlink", + ); + + // A --max-depth below either route must exclude it. + te.assert_output( + &[ + "--follow", + "--type", + "symlink", + "--max-depth", + "1", + "broken_symlink", + ], + "", + ); +} + +/// Exact depth with a broken symlink (#1017) +/// +/// A broken symlink must be kept only at its exact depth. It is reachable at +/// depth 3 (`one/two/broken_symlink`) and, through the followed `symlink` +/// directory, at depth 2 (`symlink/broken_symlink`). +#[test] +fn test_exact_depth_broken_symlink() { + let mut te = TestEnv::new(DEFAULT_DIRS, DEFAULT_FILES); + te.create_broken_symlink("one/two/broken_symlink") + .expect("Failed to create broken symlink."); + + // Only the depth-3 route matches --exact-depth 3. + te.assert_output( + &[ + "--follow", + "--type", + "symlink", + "--exact-depth", + "3", + "broken_symlink", + ], + "one/two/broken_symlink", + ); + + // Only the depth-2 route (via the followed symlink) matches --exact-depth 2, + // which confirms the depth is computed relative to the search root. + te.assert_output( + &[ + "--follow", + "--type", + "symlink", + "--exact-depth", + "2", + "broken_symlink", + ], + "symlink/broken_symlink", + ); +} + /// Exact depth (--exact-depth) #[test] fn test_exact_depth() { From b3a8ff687b0d0bc4095fb23ec8dc60ff3be1308e Mon Sep 17 00:00:00 2001 From: hexbinoct Date: Wed, 5 Aug 2026 10:13:25 +0500 Subject: [PATCH 4/4] Take the broken symlink depth from the walker The depth of a broken symlink was derived from its path by stripping the matching search root, because the walker did not report a depth on the errors that carry broken symlinks. ignore 0.4.28 fills that depth in, so read it from the error instead and drop the path arithmetic. The path based version could not express one case. When two search roots overlap, the walker visits the same broken symlink once per root at a different depth each time, and both visits carry the same path, so a single path derived answer had to serve both. Searching roots a and a/b with --min-depth 2 dropped a/b/blink even though a real file in its place was kept. --- Cargo.toml | 2 +- src/dir_entry.rs | 10 +++--- src/walk.rs | 81 ++++++++++++++++++------------------------------ tests/tests.rs | 46 +++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 56 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c9c847231..3f8f15efa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,7 @@ path = "src/main.rs" aho-corasick = "1.1" nu-ansi-term = "0.50" argmax = "0.4.0" -ignore = "0.4.25" +ignore = "0.4.28" regex = "1.12.2" regex-syntax = "0.8" ctrlc = "3.5" diff --git a/src/dir_entry.rs b/src/dir_entry.rs index bca4defd0..93aa514c0 100644 --- a/src/dir_entry.rs +++ b/src/dir_entry.rs @@ -11,9 +11,9 @@ use crate::filesystem::strip_current_dir; #[derive(Debug)] enum DirEntryInner { Normal(ignore::DirEntry), - // Broken symlinks are surfaced by the walker as errors that carry no depth, - // so we record the depth (relative to the search root) at creation time. - BrokenSymlink { path: PathBuf, depth: usize }, + // Broken symlinks reach us as walk errors rather than entries, so we carry + // over the depth the walker recorded on the error. + BrokenSymlink { path: PathBuf, depth: Option }, } #[derive(Debug)] @@ -33,7 +33,7 @@ impl DirEntry { } } - pub fn broken_symlink(path: PathBuf, depth: usize) -> Self { + pub fn broken_symlink(path: PathBuf, depth: Option) -> Self { Self { inner: DirEntryInner::BrokenSymlink { path, depth }, metadata: OnceCell::new(), @@ -100,7 +100,7 @@ impl DirEntry { pub fn depth(&self) -> Option { match &self.inner { DirEntryInner::Normal(e) => Some(e.depth()), - DirEntryInner::BrokenSymlink { depth, .. } => Some(*depth), + DirEntryInner::BrokenSymlink { depth, .. } => *depth, } } diff --git a/src/walk.rs b/src/walk.rs index 1c0623af9..1005209c5 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -440,7 +440,7 @@ impl WorkerState { } /// Spawn the sender threads. - fn spawn_senders(&self, walker: WalkParallel, roots: &[PathBuf], tx: Sender) { + fn spawn_senders(&self, walker: WalkParallel, tx: Sender) { walker.run(|| { let patterns = &self.patterns; let config = &self.config; @@ -484,25 +484,24 @@ impl WorkerState { } let entry = match entry { Ok(e) => DirEntry::normal(e), - Err(ignore::Error::WithPath { - path, - err: inner_err, - }) if inner_err - .io_error() - .is_some_and(|io_error| io_error.kind() == io::ErrorKind::NotFound) - && path - .symlink_metadata() - .ok() - .is_some_and(|m| m.file_type().is_symlink()) => - { - let depth = depth_relative_to_roots(&path, roots); - DirEntry::broken_symlink(path, depth) - } Err(err) => { - return match tx.send(WorkerResult::Error(err)) { - Ok(_) => WalkState::Continue, - Err(_) => WalkState::Quit, - }; + // The depth has to be read off the error before it is + // taken apart, since it is recorded on an inner variant. + let depth = err.depth(); + match err { + ignore::Error::WithPath { + path, + err: inner_err, + } if is_broken_symlink(&path, &inner_err) => { + DirEntry::broken_symlink(path, depth) + } + err => { + return match tx.send(WorkerResult::Error(err)) { + Ok(_) => WalkState::Continue, + Err(_) => WalkState::Quit, + }; + } + } } }; @@ -641,7 +640,7 @@ impl WorkerState { let receiver = scope.spawn(|| self.receive(rx)); // Spawn the sender threads. - self.spawn_senders(walker, paths, tx); + self.spawn_senders(walker, tx); receiver.join().unwrap() }); @@ -654,37 +653,19 @@ impl WorkerState { } } -/// Compute the depth of `path` relative to the search root it was found under. +/// Whether a walk error is really a broken symlink rather than a failure worth +/// reporting. /// -/// `ignore::DirEntry::depth()` provides this for normal entries, but broken -/// symlinks are surfaced as errors that carry no depth, so we derive it from the -/// path instead (see issue #1017). The walker reports every entry's path -/// prefixed by the search root it was found under, so we strip the matching root -/// and count the remaining components. Both the path and the roots are put into -/// absolute form first, so the comparison is correct regardless of whether -/// `--absolute-path` has already made the roots absolute (otherwise an absolute -/// path would fail to match a relative root and the depth would be wrong). When -/// more than one root is a prefix, the deepest (most specific) one wins. If none -/// matches, which should not happen, we fall back to the full component count, -/// keeping the entry visible rather than silently dropping it. -fn depth_relative_to_roots(path: &Path, roots: &[PathBuf]) -> usize { - // `Path::join` ignores the base when its argument is already absolute, so - // this leaves absolute inputs untouched and anchors relative ones at the cwd. - let cwd = std::env::current_dir().ok(); - let absolute = |p: &Path| -> PathBuf { - match &cwd { - Some(cwd) => cwd.join(p), - None => p.to_path_buf(), - } - }; - - let absolute_path = absolute(path); - roots - .iter() - .filter_map(|root| absolute_path.strip_prefix(absolute(root)).ok()) - .map(|relative| relative.components().count()) - .min() - .unwrap_or_else(|| path.components().count()) +/// A symlink whose target is missing is surfaced by the walker as a NotFound +/// error against the link's own path, so it never arrives as an entry. fd still +/// wants to match and print it (see issue #1017), which means recovering it here. +fn is_broken_symlink(path: &Path, err: &ignore::Error) -> bool { + err.io_error() + .is_some_and(|io_error| io_error.kind() == io::ErrorKind::NotFound) + && path + .symlink_metadata() + .ok() + .is_some_and(|m| m.file_type().is_symlink()) } fn search_str_for_entry<'a>( diff --git a/tests/tests.rs b/tests/tests.rs index 5f8cfc6de..d6a9e7063 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -1285,6 +1285,52 @@ fn test_min_depth_broken_symlink_absolute_path() { ); } +/// Minimum depth with a broken symlink under overlapping search roots (#1017) +/// +/// When two search roots overlap, the walker visits the same broken symlink once +/// per root, at a different depth each time. Here `one/two/broken_symlink` is at +/// depth 2 under root `one` and at depth 1 under root `one/two`, so --min-depth 2 +/// must keep the entry the walker reached via `one` and drop the one it reached +/// via `one/two`. A depth derived from the entry's path cannot distinguish the +/// two visits, since both carry the same path. +#[test] +fn test_min_depth_broken_symlink_overlapping_roots() { + let mut te = TestEnv::new(DEFAULT_DIRS, DEFAULT_FILES); + te.create_broken_symlink("one/two/broken_symlink") + .expect("Failed to create broken symlink."); + + // Only the route through `one` reaches depth 2. + te.assert_output( + &[ + "--follow", + "--type", + "symlink", + "--min-depth", + "2", + "broken_symlink", + "one", + "one/two", + ], + "one/two/broken_symlink", + ); + + // Both routes clear --min-depth 1, so it is reported once per root. + te.assert_output( + &[ + "--follow", + "--type", + "symlink", + "--min-depth", + "1", + "broken_symlink", + "one", + "one/two", + ], + "one/two/broken_symlink + one/two/broken_symlink", + ); +} + /// Maximum depth with a broken symlink (#1017) /// /// A broken symlink must be filtered by --max-depth like any other entry. The