From a999c25023e0852b80aa77c57eeda1edefd1a8b0 Mon Sep 17 00:00:00 2001 From: Kanishk Sachan Date: Thu, 2 Jul 2026 20:50:47 +0100 Subject: [PATCH] install: fix -D data-loss when destination parent is the root directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `install -D src /file` strips trailing slashes from the destination's parent directory before calling `create_dir_all_safe`. For a parent of `"/"` the loop removes ALL bytes and produces `""`, which `find_existing_ancestor` (inside `create_dir_all_safe`) maps to `"."` — the current directory. The resulting `DirFd` points at CWD, so the subsequent `unlink_at(cwd, "file")` deletes the source file, and the copy fails leaving the user with no file at all. Fix: when the post-stripping result would be empty (i.e. the original path was entirely slashes), keep the path unchanged so the root directory is opened as the parent fd instead of the current directory. Adds a regression test that verifies the source file is preserved when `install -D src /single-component-dest` fails (as a non-root user). Fixes #13232 --- src/uu/install/src/install.rs | 14 +++++++++++--- tests/by-util/test_install.rs | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) 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"); +}