@@ -12,6 +12,7 @@ use std::cmp::Reverse;
1212use std:: collections:: BinaryHeap ;
1313use std:: num:: NonZero ;
1414use std:: path:: Path ;
15+ use std:: path:: PathBuf ;
1516use std:: sync:: Arc ;
1617use std:: sync:: atomic:: AtomicBool ;
1718use std:: sync:: atomic:: AtomicUsize ;
@@ -143,6 +144,7 @@ pub fn run(
143144 respect_gitignore : bool ,
144145) -> anyhow:: Result < FileSearchResults > {
145146 let pattern = create_pattern ( pattern_text) ;
147+ let canonical_root = canonicalize_path ( search_directory) ;
146148 // Create one BestMatchesList per worker thread so that each worker can
147149 // operate independently. The results across threads will be merged when
148150 // the traversal is complete.
@@ -199,6 +201,7 @@ pub fn run(
199201 let index = index_counter. fetch_add ( 1 , Ordering :: Relaxed ) ;
200202 let best_list_ptr = best_matchers_per_worker[ index] . get ( ) ;
201203 let best_list = unsafe { & mut * best_list_ptr } ;
204+ let canonical_root = canonical_root. clone ( ) ;
202205
203206 // Each worker keeps a local counter so we only read the atomic flag
204207 // every N entries which is cheaper than checking on every file.
@@ -207,7 +210,19 @@ pub fn run(
207210
208211 let cancel = cancel_flag. clone ( ) ;
209212
210- Box :: new ( move |entry| {
213+ Box :: new ( move |entry_result| {
214+ let entry = match entry_result {
215+ Ok ( entry) => entry,
216+ Err ( _) => return ignore:: WalkState :: Continue ,
217+ } ;
218+ if symlink_outside_root ( & entry, & canonical_root) {
219+ return if entry. file_type ( ) . is_some_and ( |ft| ft. is_dir ( ) ) {
220+ ignore:: WalkState :: Skip
221+ } else {
222+ ignore:: WalkState :: Continue
223+ } ;
224+ }
225+
211226 if let Some ( path) = get_file_path ( & entry, search_directory) {
212227 best_list. insert ( path) ;
213228 }
@@ -222,13 +237,9 @@ pub fn run(
222237 } ) ;
223238
224239 fn get_file_path < ' a > (
225- entry_result : & ' a Result < ignore:: DirEntry , ignore :: Error > ,
240+ entry : & ' a ignore:: DirEntry ,
226241 search_directory : & std:: path:: Path ,
227242 ) -> Option < & ' a str > {
228- let entry = match entry_result {
229- Ok ( e) => e,
230- Err ( _) => return None ,
231- } ;
232243 if entry. file_type ( ) . is_some_and ( |ft| ft. is_dir ( ) ) {
233244 return None ;
234245 }
@@ -408,6 +419,21 @@ fn create_pattern(pattern: &str) -> Pattern {
408419 )
409420}
410421
422+ fn canonicalize_path ( path : & Path ) -> PathBuf {
423+ dunce:: canonicalize ( path) . unwrap_or_else ( |_| path. to_path_buf ( ) )
424+ }
425+
426+ fn symlink_outside_root ( entry : & ignore:: DirEntry , canonical_root : & Path ) -> bool {
427+ if !entry. path_is_symlink ( ) {
428+ return false ;
429+ }
430+ let resolved = dunce:: canonicalize ( entry. path ( ) ) . ok ( ) ;
431+ let Some ( resolved) = resolved else {
432+ return true ;
433+ } ;
434+ !resolved. starts_with ( canonical_root)
435+ }
436+
411437#[ cfg( test) ]
412438mod tests {
413439 use super :: * ;
@@ -453,4 +479,59 @@ mod tests {
453479 fn file_name_from_path_falls_back_to_full_path ( ) {
454480 assert_eq ! ( file_name_from_path( "" ) , "" ) ;
455481 }
482+
483+ #[ cfg( unix) ]
484+ #[ test]
485+ fn file_search_skips_symlink_outside_root ( ) -> anyhow:: Result < ( ) > {
486+ use std:: fs;
487+ use std:: os:: unix:: fs:: symlink;
488+ use std:: sync:: Arc ;
489+ use std:: sync:: atomic:: AtomicBool ;
490+
491+ let temp = tempfile:: tempdir ( ) ?;
492+ let root = temp. path ( ) . join ( "root" ) ;
493+ let outside = temp. path ( ) . join ( "outside" ) ;
494+ fs:: create_dir ( & root) ?;
495+ fs:: create_dir ( & outside) ?;
496+ fs:: write ( root. join ( "inside.txt" ) , "inside" ) ?;
497+ fs:: write ( outside. join ( "outside.txt" ) , "outside" ) ?;
498+ symlink ( & outside, root. join ( "link" ) ) ?;
499+
500+ let limit = NonZero :: new ( 10 ) . unwrap ( ) ;
501+ let threads = NonZero :: new ( 1 ) . unwrap ( ) ;
502+
503+ let results_inside = run (
504+ "inside" ,
505+ limit,
506+ & root,
507+ Vec :: new ( ) ,
508+ threads,
509+ Arc :: new ( AtomicBool :: new ( false ) ) ,
510+ false ,
511+ true ,
512+ ) ?;
513+ let paths_inside: Vec < String > =
514+ results_inside. matches . into_iter ( ) . map ( |m| m. path ) . collect ( ) ;
515+ assert_eq ! ( paths_inside, vec![ "inside.txt" . to_string( ) ] ) ;
516+
517+ let results_outside = run (
518+ "outside" ,
519+ limit,
520+ & root,
521+ Vec :: new ( ) ,
522+ threads,
523+ Arc :: new ( AtomicBool :: new ( false ) ) ,
524+ false ,
525+ true ,
526+ ) ?;
527+ let paths_outside: Vec < String > = results_outside
528+ . matches
529+ . into_iter ( )
530+ . map ( |m| m. path )
531+ . collect ( ) ;
532+ assert_eq ! ( paths_outside, Vec :: <String >:: new( ) ) ;
533+
534+ Ok ( ( ) )
535+ }
456536}
537+
0 commit comments