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