@@ -2573,90 +2573,154 @@ fn test_install_normal_file_replaces_symlink() {
25732573#[ test]
25742574#[ cfg( unix) ]
25752575fn test_install_d_symlink_race_condition ( ) {
2576- // Test for symlink race condition fix (issue #10013)
2577- // Verifies that pre-existing symlinks in path are handled safely
2576+ // Test that pre-existing symlinks in the path are followed (GNU coreutils behavior).
2577+ // install -D should traverse symlink components rather than replacing them.
25782578 use std:: os:: unix:: fs:: symlink;
25792579
25802580 let scene = TestScenario :: new ( util_name ! ( ) ) ;
25812581 let at = & scene. fixtures ;
25822582
2583- // Create test directories
25842583 at. mkdir ( "target" ) ;
2585-
2586- // Create source file
25872584 at. write ( "source_file" , "test content" ) ;
25882585
2589- // Set up a pre-existing symlink attack scenario
25902586 at. mkdir_all ( "testdir/a" ) ;
2591- let intermediate_dir = at. plus ( "testdir/a/b" ) ;
2592- symlink ( at. plus ( "target" ) , & intermediate_dir) . unwrap ( ) ;
2587+ symlink ( at. plus ( "target" ) , at. plus ( "testdir/a/b" ) ) . unwrap ( ) ;
25932588
2594- // Run install -D which should detect and handle the symlink
2595- let result = scene
2589+ // install -D should follow the symlink and write into the symlink target
2590+ scene
25962591 . ucmd ( )
25972592 . arg ( "-D" )
25982593 . arg ( at. plus ( "source_file" ) )
25992594 . arg ( at. plus ( "testdir/a/b/c/file" ) )
2600- . run ( ) ;
2601-
2602- let wrong_location = at. plus ( "target/c/file" ) ;
2595+ . succeeds ( ) ;
26032596
2604- // The critical assertion: file must NOT be in symlink target (race prevented)
2597+ // File must be written through the symlink, i.e. inside the real target dir
26052598 assert ! (
2606- !wrong_location. exists( ) ,
2607- "RACE CONDITION NOT PREVENTED: File was created in symlink target"
2599+ at. plus( "target/c/file" ) . exists( ) ,
2600+ "File should be written through the symlink into the real target directory"
2601+ ) ;
2602+ assert_eq ! (
2603+ fs:: read_to_string( at. plus( "target/c/file" ) ) . unwrap( ) ,
2604+ "test content"
26082605 ) ;
26092606
2610- // If the command succeeded, verify the file is in the correct location
2611- if result. succeeded ( ) {
2612- assert ! ( at. file_exists( "testdir/a/b/c/file" ) ) ;
2613- assert_eq ! ( at. read( "testdir/a/b/c/file" ) , "test content" ) ;
2614- // The symlink should have been replaced with a real directory
2615- assert ! (
2616- at. plus( "testdir/a/b" ) . is_dir( ) && !at. plus( "testdir/a/b" ) . is_symlink( ) ,
2617- "Intermediate path should be a real directory, not a symlink"
2618- ) ;
2619- }
2607+ // The symlink must not have been replaced with a real directory
2608+ assert ! (
2609+ at. plus( "testdir/a/b" ) . is_symlink( ) ,
2610+ "Intermediate symlink should be preserved, not replaced with a real directory"
2611+ ) ;
26202612}
26212613
26222614#[ test]
26232615#[ cfg( unix) ]
26242616fn test_install_d_symlink_race_condition_concurrent ( ) {
2625- // Test pre-existing symlinks in intermediate paths are handled correctly
2617+ // Verify symlink-following behavior is consistent (companion to the test above).
26262618 use std:: os:: unix:: fs:: symlink;
26272619
26282620 let scene = TestScenario :: new ( util_name ! ( ) ) ;
26292621 let at = & scene. fixtures ;
26302622
2631- // Create test directories and source file using testing framework
26322623 at. mkdir ( "target2" ) ;
26332624 at. write ( "source_file2" , "test content 2" ) ;
26342625
2635- // Set up intermediate directory with symlink
26362626 at. mkdir_all ( "testdir2/a" ) ;
26372627 symlink ( at. plus ( "target2" ) , at. plus ( "testdir2/a/b" ) ) . unwrap ( ) ;
26382628
2639- // Run install -D
26402629 scene
26412630 . ucmd ( )
26422631 . arg ( "-D" )
26432632 . arg ( at. plus ( "source_file2" ) )
26442633 . arg ( at. plus ( "testdir2/a/b/c/file" ) )
26452634 . succeeds ( ) ;
26462635
2647- // Verify file was created at the intended destination
2648- assert ! ( at. file_exists( "testdir2/a/b/c/file" ) ) ;
2649- assert_eq ! ( at. read( "testdir2/a/b/c/file" ) , "test content 2" ) ;
2636+ // File should be in the real target directory (symlink was followed)
2637+ assert ! (
2638+ at. plus( "target2/c/file" ) . exists( ) ,
2639+ "File should be written through the symlink into the real target directory"
2640+ ) ;
2641+ assert_eq ! (
2642+ fs:: read_to_string( at. plus( "target2/c/file" ) ) . unwrap( ) ,
2643+ "test content 2"
2644+ ) ;
26502645
2651- // Verify file was NOT created in symlink target
2646+ // Symlink should be preserved
26522647 assert ! (
2653- ! at. plus( "target2/c/file " ) . exists ( ) ,
2654- "File should NOT be in symlink target location "
2648+ at. plus( "testdir2/a/b " ) . is_symlink ( ) ,
2649+ "Intermediate symlink should be preserved, not replaced with a real directory "
26552650 ) ;
2651+ }
2652+
2653+ #[ test]
2654+ #[ cfg( unix) ]
2655+ fn test_install_d_follows_symlink_prefix ( ) {
2656+ // Regression test for: install -D replaces symlink components instead of following them.
2657+ // Reproduces the exact scenario from the bug report: a symlinked install prefix
2658+ // (common in BOSH, Homebrew, Nix, stow) must be followed, not destroyed.
2659+ use std:: os:: unix:: fs:: symlink;
2660+
2661+ let scene = TestScenario :: new ( util_name ! ( ) ) ;
2662+ let at = & scene. fixtures ;
2663+
2664+ // Simulate: ln -s /tmp/target /tmp/link
2665+ at. mkdir ( "target" ) ;
2666+ symlink ( at. plus ( "target" ) , at. plus ( "link" ) ) . unwrap ( ) ;
2667+
2668+ at. write ( "file.txt" , "hello" ) ;
26562669
2657- // Verify intermediate path is now a real directory
2670+ // install -D -m 644 file.txt link/subdir/file.txt
2671+ scene
2672+ . ucmd ( )
2673+ . args ( & [ "-D" , "-m" , "644" ] )
2674+ . arg ( at. plus ( "file.txt" ) )
2675+ . arg ( at. plus ( "link/subdir/file.txt" ) )
2676+ . succeeds ( ) ;
2677+
2678+ // GNU expected: /tmp/link remains a symlink, file written to /tmp/target/subdir/file.txt
2679+ assert ! (
2680+ at. plus( "link" ) . is_symlink( ) ,
2681+ "The symlinked prefix must remain a symlink"
2682+ ) ;
2683+ assert ! (
2684+ at. plus( "target/subdir/file.txt" ) . exists( ) ,
2685+ "File must be written into the real target directory via the symlink"
2686+ ) ;
2687+ assert_eq ! (
2688+ fs:: read_to_string( at. plus( "target/subdir/file.txt" ) ) . unwrap( ) ,
2689+ "hello"
2690+ ) ;
2691+ }
2692+
2693+ #[ test]
2694+ #[ cfg( unix) ]
2695+ fn test_install_d_dangling_symlink_in_path_errors ( ) {
2696+ // A dangling symlink as a path component must not be silently replaced with a
2697+ // real directory. GNU coreutils errors out in this case; we should too.
2698+ use std:: os:: unix:: fs:: symlink;
2699+
2700+ let scene = TestScenario :: new ( util_name ! ( ) ) ;
2701+ let at = & scene. fixtures ;
2702+
2703+ // Create a symlink pointing to a nonexistent target (dangling)
2704+ symlink ( at. plus ( "nonexistent" ) , at. plus ( "dangling" ) ) . unwrap ( ) ;
2705+ assert ! ( at. plus( "dangling" ) . is_symlink( ) ) ;
2706+
2707+ at. write ( "file.txt" , "hello" ) ;
2708+
2709+ // install -D file.txt dangling/subdir/file.txt should fail
2710+ scene
2711+ . ucmd ( )
2712+ . args ( & [ "-D" , "-m" , "644" ] )
2713+ . arg ( at. plus ( "file.txt" ) )
2714+ . arg ( at. plus ( "dangling/subdir/file.txt" ) )
2715+ . fails ( ) ;
2716+
2717+ // The dangling symlink must not have been replaced with a real directory
2718+ assert ! (
2719+ at. plus( "dangling" ) . is_symlink( ) ,
2720+ "Dangling symlink must not be replaced with a real directory"
2721+ ) ;
26582722 assert ! (
2659- at . plus ( "testdir2/a/b" ) . is_dir ( ) && !at. plus( "testdir2/a/b " ) . is_symlink ( ) ,
2660- "Intermediate directory should be a real directory, not a symlink "
2723+ !at. plus( "nonexistent " ) . exists ( ) ,
2724+ "The symlink target must not have been created "
26612725 ) ;
26622726}
0 commit comments