From 8b60816b45ea18115118de3c9625113dcd6d2a81 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Fri, 17 Jul 2026 18:20:02 +0000 Subject: [PATCH 1/2] fix(install): treat special files as install targets (uutils #9934) Path::is_file() is false for character devices like /dev/full, so install misreported them as invalid targets. Treat any existing non-directory as a replaceable target and report Permission denied on unlink failure like GNU coreutils. Signed-off-by: Alex Chen --- src/uu/install/locales/en-US.ftl | 2 +- src/uu/install/src/install.rs | 6 +++++- tests/by-util/test_install.rs | 16 ++++++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/uu/install/locales/en-US.ftl b/src/uu/install/locales/en-US.ftl index 1b5b92e532..9279575183 100644 --- a/src/uu/install/locales/en-US.ftl +++ b/src/uu/install/locales/en-US.ftl @@ -44,7 +44,7 @@ install-error-override-directory-failed = cannot overwrite directory { $dir } wi install-error-same-file = { $file1 } and { $file2 } are the same file install-error-extra-operand = extra operand { $operand } { $usage } -install-error-not-permitted = cannot remove { $path }: Operation not permitted +install-error-not-permitted = cannot remove { $path }: Permission denied install-error-invalid-mode = Invalid mode string: { $error } install-error-mutually-exclusive-target = Options --target-directory and --no-target-directory are mutually exclusive install-error-mutually-exclusive-compare-strip = Options --compare and --strip are mutually exclusive diff --git a/src/uu/install/src/install.rs b/src/uu/install/src/install.rs index 58e16a381e..a87f0e30eb 100644 --- a/src/uu/install/src/install.rs +++ b/src/uu/install/src/install.rs @@ -776,7 +776,11 @@ fn standard(mut paths: Vec, b: &Behavior) -> UResult<()> { return Err(InstallError::SameFile(source.clone(), target.clone()).into()); } - if target.is_file() || is_new_file_path(&target) { + // Existing non-directory targets include regular files *and* special + // nodes (e.g. /dev/full). `Path::is_file()` is false for char devices, + // so without this branch we wrongly report "invalid target ... No such + // file" instead of attempting remove+install like GNU (issue #9934). + if target.is_file() || is_new_file_path(&target) || (target.exists() && !target.is_dir()) { #[cfg(unix)] if let (Some(ref parent_fd), Some(ref filename)) = (target_parent_fd, target_filename) { if b.compare && !need_copy(source, &target, b) { diff --git a/tests/by-util/test_install.rs b/tests/by-util/test_install.rs index 949c67b989..55c54db337 100644 --- a/tests/by-util/test_install.rs +++ b/tests/by-util/test_install.rs @@ -2933,6 +2933,22 @@ fn test_install_proc_self_mem_as_dst() { .stderr_contains("cannot remove"); } +#[test] +#[cfg(target_os = "linux")] +fn test_install_dev_null_to_dev_full_permission() { + // GNU reports cannot remove '/dev/full': Permission denied for char devices + // that refuse unlink, not "invalid target ... No such file" (issue #9934). + let scene = TestScenario::new(util_name!()); + scene + .ucmd() + .arg("/dev/null") + .arg("/dev/full") + .fails() + .stderr_contains("cannot remove '/dev/full'") + .stderr_contains("Permission denied") + .stderr_does_not_contain("invalid target"); +} + #[test] fn test_install_backup_nil_same_file() { let scene = TestScenario::new(util_name!()); From c4bbbba429a1f7d0871933369ec3ecb373926322 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Sat, 18 Jul 2026 04:27:10 +0000 Subject: [PATCH 2/2] fix(install): drop redundant is_file gate; exact stderr assert Address review on #13436: - admit replaceable targets via is_new_file_path || (exists && !is_dir) - assert exact GNU Permission denied line for /dev/null -> /dev/full Signed-off-by: Alex Chen --- src/uu/install/src/install.rs | 8 ++++---- tests/by-util/test_install.rs | 4 +--- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/uu/install/src/install.rs b/src/uu/install/src/install.rs index a87f0e30eb..9b4eca5968 100644 --- a/src/uu/install/src/install.rs +++ b/src/uu/install/src/install.rs @@ -777,10 +777,10 @@ fn standard(mut paths: Vec, b: &Behavior) -> UResult<()> { } // Existing non-directory targets include regular files *and* special - // nodes (e.g. /dev/full). `Path::is_file()` is false for char devices, - // so without this branch we wrongly report "invalid target ... No such - // file" instead of attempting remove+install like GNU (issue #9934). - if target.is_file() || is_new_file_path(&target) || (target.exists() && !target.is_dir()) { + // nodes (e.g. /dev/full). Rely on exists()+!is_dir() rather than + // is_file(), which is false for char devices and would wrongly report + // "invalid target ... No such file" (issue #9934). + if is_new_file_path(&target) || (target.exists() && !target.is_dir()) { #[cfg(unix)] if let (Some(ref parent_fd), Some(ref filename)) = (target_parent_fd, target_filename) { if b.compare && !need_copy(source, &target, b) { diff --git a/tests/by-util/test_install.rs b/tests/by-util/test_install.rs index 55c54db337..8b0aab61e2 100644 --- a/tests/by-util/test_install.rs +++ b/tests/by-util/test_install.rs @@ -2944,9 +2944,7 @@ fn test_install_dev_null_to_dev_full_permission() { .arg("/dev/null") .arg("/dev/full") .fails() - .stderr_contains("cannot remove '/dev/full'") - .stderr_contains("Permission denied") - .stderr_does_not_contain("invalid target"); + .stderr_is("install: cannot remove '/dev/full': Permission denied\n"); } #[test]