@@ -767,6 +767,41 @@ impl FsSandbox {
767767 let mut out = resolved_ancestor;
768768 for name in tail. into_iter ( ) . rev ( ) {
769769 out. push ( name) ;
770+ // Walk the symlink chain (with hop limit) to catch escapes
771+ // through dangling or chained symlinks.
772+ const MAX_SYMLINK_HOPS : usize = 40 ;
773+ let mut cursor = out. clone ( ) ;
774+ for _ in 0 ..MAX_SYMLINK_HOPS {
775+ let Ok ( meta) = std:: fs:: symlink_metadata ( & cursor) else {
776+ break ;
777+ } ;
778+ if !meta. file_type ( ) . is_symlink ( ) {
779+ break ;
780+ }
781+ let target = std:: fs:: read_link ( & cursor) ?;
782+ let abs = if target. is_absolute ( ) {
783+ target
784+ } else {
785+ cursor. parent ( ) . unwrap_or ( & self . root ) . join ( & target)
786+ } ;
787+ let mut norm = std:: path:: PathBuf :: new ( ) ;
788+ for c in abs. components ( ) {
789+ match c {
790+ std:: path:: Component :: ParentDir => {
791+ norm. pop ( ) ;
792+ }
793+ std:: path:: Component :: CurDir => { }
794+ c => norm. push ( c) ,
795+ }
796+ }
797+ if !norm. starts_with ( & self . root ) {
798+ return Err ( anyhow ! (
799+ "symlink target escapes mount root: {:?}" ,
800+ guest_path
801+ ) ) ;
802+ }
803+ cursor = norm;
804+ }
770805 }
771806 Ok ( out)
772807 }
@@ -2488,6 +2523,84 @@ mod tests {
24882523 assert ! ( err. contains( "escapes mount root" ) , "{err}" ) ;
24892524 }
24902525
2526+ #[ cfg( unix) ]
2527+ #[ test]
2528+ fn resolve_rejects_dangling_symlink_escape ( ) {
2529+ use std:: os:: unix:: fs:: symlink;
2530+ let root = tmpdir ( "dangling-escape" ) ;
2531+ let outside = tmpdir ( "dangling-escape-out" ) ;
2532+ symlink ( outside. join ( "nonexistent" ) , root. join ( "bad_link" ) ) . unwrap ( ) ;
2533+ let fs_sb = FsSandbox :: new ( & root) . unwrap ( ) ;
2534+ let err = fs_sb. resolve ( "bad_link" ) . unwrap_err ( ) . to_string ( ) ;
2535+ assert ! (
2536+ err. contains( "escapes mount root" ) ,
2537+ "expected escape error, got: {err}"
2538+ ) ;
2539+ }
2540+
2541+ #[ cfg( unix) ]
2542+ #[ test]
2543+ fn resolve_allows_valid_internal_symlink ( ) {
2544+ use std:: os:: unix:: fs:: symlink;
2545+ let root = tmpdir ( "valid-internal" ) ;
2546+ fs:: write ( root. join ( "real_file.txt" ) , "hello" ) . unwrap ( ) ;
2547+ symlink ( root. join ( "real_file.txt" ) , root. join ( "good_link" ) ) . unwrap ( ) ;
2548+ let fs_sb = FsSandbox :: new ( & root) . unwrap ( ) ;
2549+ let resolved = fs_sb. resolve ( "good_link" ) . unwrap ( ) ;
2550+ assert ! (
2551+ resolved. starts_with( & root) ,
2552+ "expected path under root, got: {resolved:?}"
2553+ ) ;
2554+ }
2555+
2556+ #[ cfg( unix) ]
2557+ #[ test]
2558+ fn resolve_allows_dangling_symlink_inside_root ( ) {
2559+ use std:: os:: unix:: fs:: symlink;
2560+ let root = tmpdir ( "dangling-inside" ) ;
2561+ symlink ( root. join ( "future_file.txt" ) , root. join ( "ok_link" ) ) . unwrap ( ) ;
2562+ let fs_sb = FsSandbox :: new ( & root) . unwrap ( ) ;
2563+ let resolved = fs_sb. resolve ( "ok_link" ) . unwrap ( ) ;
2564+ assert ! (
2565+ resolved. starts_with( & root) ,
2566+ "expected path under root, got: {resolved:?}"
2567+ ) ;
2568+ }
2569+
2570+ #[ cfg( unix) ]
2571+ #[ test]
2572+ fn resolve_rejects_symlink_chain_escape ( ) {
2573+ use std:: os:: unix:: fs:: symlink;
2574+ let root = tmpdir ( "chain-escape" ) ;
2575+ let outside = tmpdir ( "chain-outside" ) ;
2576+ symlink ( & outside, root. join ( "link_b" ) ) . unwrap ( ) ;
2577+ symlink ( root. join ( "link_b" ) , root. join ( "link_a" ) ) . unwrap ( ) ;
2578+ let fs_sb = FsSandbox :: new ( & root) . unwrap ( ) ;
2579+ let err = fs_sb. resolve ( "link_a" ) . unwrap_err ( ) . to_string ( ) ;
2580+ assert ! (
2581+ err. contains( "escapes mount root" ) ,
2582+ "expected escape error, got: {err}"
2583+ ) ;
2584+ }
2585+
2586+ #[ cfg( unix) ]
2587+ #[ test]
2588+ fn resolve_rejects_chained_dangling_symlink_escape ( ) {
2589+ use std:: os:: unix:: fs:: symlink;
2590+ let root = tmpdir ( "chain-dangling" ) ;
2591+ let outside = tmpdir ( "chain-dangling-out" ) ;
2592+ // link_b -> dangling path outside root
2593+ symlink ( outside. join ( "nonexistent" ) , root. join ( "link_b" ) ) . unwrap ( ) ;
2594+ // link_a -> link_b (which is under root, but chains outside)
2595+ symlink ( root. join ( "link_b" ) , root. join ( "link_a" ) ) . unwrap ( ) ;
2596+ let fs_sb = FsSandbox :: new ( & root) . unwrap ( ) ;
2597+ let err = fs_sb. resolve ( "link_a" ) . unwrap_err ( ) . to_string ( ) ;
2598+ assert ! (
2599+ err. contains( "escapes mount root" ) ,
2600+ "expected escape error, got: {err}"
2601+ ) ;
2602+ }
2603+
24912604 #[ test]
24922605 fn resolve_allows_paths_under_the_root ( ) {
24932606 let root = tmpdir ( "allow" ) ;
0 commit comments