@@ -767,6 +767,35 @@ impl FsSandbox {
767767 let mut out = resolved_ancestor;
768768 for name in tail. into_iter ( ) . rev ( ) {
769769 out. push ( name) ;
770+ // Catch symlinks in the non-existing tail — including dangling ones.
771+ if let Ok ( meta) = std:: fs:: symlink_metadata ( & out) {
772+ if meta. file_type ( ) . is_symlink ( ) {
773+ let target = std:: fs:: read_link ( & out) ?;
774+ let abs = if target. is_absolute ( ) {
775+ target
776+ } else {
777+ out. parent ( ) . unwrap_or ( & self . root ) . join ( & target)
778+ } ;
779+ // Normalise the absolute target (resolve .. etc) without
780+ // touching the filesystem, then check the prefix.
781+ let mut norm = std:: path:: PathBuf :: new ( ) ;
782+ for c in abs. components ( ) {
783+ match c {
784+ std:: path:: Component :: ParentDir => {
785+ norm. pop ( ) ;
786+ }
787+ std:: path:: Component :: CurDir => { }
788+ c => norm. push ( c) ,
789+ }
790+ }
791+ if !norm. starts_with ( & self . root ) {
792+ return Err ( anyhow ! (
793+ "symlink target escapes mount root: {:?}" ,
794+ guest_path
795+ ) ) ;
796+ }
797+ }
798+ }
770799 }
771800 Ok ( out)
772801 }
@@ -2488,6 +2517,74 @@ mod tests {
24882517 assert ! ( err. contains( "escapes mount root" ) , "{err}" ) ;
24892518 }
24902519
2520+ #[ cfg( unix) ]
2521+ #[ test]
2522+ fn test_resolve_dangling_symlink_escape ( ) {
2523+ use std:: os:: unix:: fs:: symlink;
2524+ let root = tmpdir ( "dangling-escape" ) ;
2525+ // Create a dangling symlink whose target is outside root.
2526+ symlink ( "/tmp/nonexistent-outside-root" , root. join ( "bad_link" ) ) . unwrap ( ) ;
2527+ let fs_sb = FsSandbox :: new ( & root) . unwrap ( ) ;
2528+ let err = fs_sb. resolve ( "bad_link" ) . unwrap_err ( ) . to_string ( ) ;
2529+ assert ! (
2530+ err. contains( "escapes mount root" ) ,
2531+ "expected escape error, got: {err}"
2532+ ) ;
2533+ }
2534+
2535+ #[ cfg( unix) ]
2536+ #[ test]
2537+ fn test_resolve_valid_internal_symlink ( ) {
2538+ use std:: os:: unix:: fs:: symlink;
2539+ let root = tmpdir ( "valid-internal" ) ;
2540+ fs:: write ( root. join ( "real_file.txt" ) , "hello" ) . unwrap ( ) ;
2541+ symlink ( root. join ( "real_file.txt" ) , root. join ( "good_link" ) ) . unwrap ( ) ;
2542+ let fs_sb = FsSandbox :: new ( & root) . unwrap ( ) ;
2543+ let resolved = fs_sb. resolve ( "good_link" ) . unwrap ( ) ;
2544+ // The symlink target is inside root, so resolve should succeed.
2545+ assert ! (
2546+ resolved. starts_with( & root) ,
2547+ "expected path under root, got: {resolved:?}"
2548+ ) ;
2549+ }
2550+
2551+ #[ cfg( unix) ]
2552+ #[ test]
2553+ fn test_resolve_dangling_symlink_inside_root ( ) {
2554+ use std:: os:: unix:: fs:: symlink;
2555+ let root = tmpdir ( "dangling-inside" ) ;
2556+ // Dangling symlink whose target is inside root (just doesn't exist yet).
2557+ symlink ( root. join ( "future_file.txt" ) , root. join ( "ok_link" ) ) . unwrap ( ) ;
2558+ let fs_sb = FsSandbox :: new ( & root) . unwrap ( ) ;
2559+ // This should be allowed — the target is within root even though it
2560+ // doesn't exist yet.
2561+ let resolved = fs_sb. resolve ( "ok_link" ) . unwrap ( ) ;
2562+ assert ! (
2563+ resolved. starts_with( & root) ,
2564+ "expected path under root, got: {resolved:?}"
2565+ ) ;
2566+ }
2567+
2568+ #[ cfg( unix) ]
2569+ #[ test]
2570+ fn test_resolve_symlink_chain_escape ( ) {
2571+ use std:: os:: unix:: fs:: symlink;
2572+ let root = tmpdir ( "chain-escape" ) ;
2573+ let outside = tmpdir ( "chain-outside" ) ;
2574+ // symlink B -> outside (dangling or not, target is outside root)
2575+ symlink ( & outside, root. join ( "link_b" ) ) . unwrap ( ) ;
2576+ // symlink A -> link_b
2577+ symlink ( root. join ( "link_b" ) , root. join ( "link_a" ) ) . unwrap ( ) ;
2578+ let fs_sb = FsSandbox :: new ( & root) . unwrap ( ) ;
2579+ // Resolving link_a should fail because it chains through link_b
2580+ // which points outside root.
2581+ let err = fs_sb. resolve ( "link_a" ) . unwrap_err ( ) . to_string ( ) ;
2582+ assert ! (
2583+ err. contains( "escapes mount root" ) ,
2584+ "expected escape error, got: {err}"
2585+ ) ;
2586+ }
2587+
24912588 #[ test]
24922589 fn resolve_allows_paths_under_the_root ( ) {
24932590 let root = tmpdir ( "allow" ) ;
0 commit comments