From 350b33acda4cbcdec7245d3c4794bc7ba6ad55db Mon Sep 17 00:00:00 2001 From: hexbinoct Date: Sat, 27 Jun 2026 09:52:14 +0500 Subject: [PATCH] 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 c522d6f..fd18df2 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 c79c3e8..770f59c 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 30128a0..f35d7bd 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 0fce5ed..16b3335 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() {