diff --git a/src/uu/install/src/install.rs b/src/uu/install/src/install.rs index b872e0c749e..1d0c8577945 100644 --- a/src/uu/install/src/install.rs +++ b/src/uu/install/src/install.rs @@ -657,9 +657,17 @@ fn standard(mut paths: Vec, b: &Behavior) -> UResult<()> { while trimmed_bytes.ends_with(b"/") { trimmed_bytes = &trimmed_bytes[..trimmed_bytes.len() - 1]; } - let trimmed_os_str = std::ffi::OsStr::from_bytes(trimmed_bytes); - to_create_owned = PathBuf::from(trimmed_os_str); - to_create_owned.as_path() + if trimmed_bytes.is_empty() { + // Path was entirely slashes (i.e. "/"). Stripping them + // yields "" which resolves to the current directory and + // causes a later unlink_at to delete the source file + // instead of the intended destination (#13232). + to_create + } else { + let trimmed_os_str = std::ffi::OsStr::from_bytes(trimmed_bytes); + to_create_owned = PathBuf::from(trimmed_os_str); + to_create_owned.as_path() + } } _ => to_create, }; diff --git a/tests/by-util/test_install.rs b/tests/by-util/test_install.rs index 11dfa7a4ef2..f0a8c0d8508 100644 --- a/tests/by-util/test_install.rs +++ b/tests/by-util/test_install.rs @@ -2863,3 +2863,37 @@ fn test_install_backup_nil_same_file() { assert_eq!(at.read(file), "content"); } } + +/// Regression test for #13232: `install -D src /single-component-dest` must +/// not delete the source file when the destination's parent directory is `/`. +/// +/// The slash-stripping loop turned `"/"` into `""`, which `create_dir_all_safe` +/// resolved to the current directory. A subsequent `unlink_at` then removed +/// the source file instead of the (non-existent) destination. +/// +/// We cannot write to `/` as a regular user, so we verify that install fails +/// AND that the source is left intact. +#[test] +#[cfg(unix)] +fn test_install_D_root_parent_does_not_destroy_source() { + // Skip if running as root — root can actually create /file, which would + // make the operation succeed and change the assertion below. + if unsafe { libc::getuid() } == 0 { + return; + } + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.write("src", "hello\n"); + + // /install_test_13232 lives directly under "/" — parent is "/" + scene + .ucmd() + .args(&["-D", "src", "/install_test_13232"]) + .fails(); + + // Source must survive regardless of the failure reason. + assert!(at.file_exists("src"), "source file was deleted by install -D"); + assert_eq!(at.read("src"), "hello\n"); +}