From 53bdf1135b98e9c0946cbbd3cd24e174ae6fc320 Mon Sep 17 00:00:00 2001 From: Jake Abendroth Date: Wed, 30 Sep 2026 21:21:10 -0700 Subject: [PATCH] install: refuse a -t target that is not a directory with one source With a single source and no -D, `install -t X f` only installed into `X` when `X` already looked like a directory; otherwise it fell through to the file-to-file path and installed `f` as `X`. A regular file, a symlink or a fifo at `X` was replaced and a missing `X` was created as a file, all with exit status 0, where GNU refuses. Route every -t invocation through copy_files_into_dir, as multiple sources already are, so a target that is not a directory is reported instead. For -t, report it the way GNU does: "failed to access 'X': Not a directory" (or the lookup error, such as "No such file or directory"). --- src/uu/install/locales/en-US.ftl | 1 + src/uu/install/locales/fr-FR.ftl | 1 + src/uu/install/src/install.rs | 14 +++++++++++++- tests/by-util/test_install.rs | 26 ++++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/uu/install/locales/en-US.ftl b/src/uu/install/locales/en-US.ftl index fdc1726c442..0a4037ecc4e 100644 --- a/src/uu/install/locales/en-US.ftl +++ b/src/uu/install/locales/en-US.ftl @@ -39,6 +39,7 @@ install-error-invalid-user = invalid user: { $user } install-error-invalid-group = invalid group: { $group } install-error-omitting-directory = omitting directory { $path } install-error-not-a-directory = failed to access { $path }: Not a directory +install-error-target-dir-access = failed to access { $path }: { $error } install-error-existing-file-not-directory = cannot create directory { $path }: File exists install-error-override-directory-failed = cannot overwrite directory { $dir } with non-directory { $file } install-error-same-file = { $file1 } and { $file2 } are the same file diff --git a/src/uu/install/locales/fr-FR.ftl b/src/uu/install/locales/fr-FR.ftl index 6423148c95d..70ae931697b 100644 --- a/src/uu/install/locales/fr-FR.ftl +++ b/src/uu/install/locales/fr-FR.ftl @@ -39,6 +39,7 @@ install-error-invalid-user = utilisateur invalide : { $user } install-error-invalid-group = groupe invalide : { $group } install-error-omitting-directory = omission du répertoire { $path } install-error-not-a-directory = échec de l'accès à { $path } : N'est pas un répertoire +install-error-target-dir-access = échec de l'accès à { $path } : { $error } install-error-existing-file-not-directory = impossible de créer le répertoire { $path } : Le fichier existe install-error-override-directory-failed = impossible d'écraser le répertoire { $dir } avec un non-répertoire { $file } install-error-same-file = { $file1 } et { $file2 } sont le même fichier diff --git a/src/uu/install/src/install.rs b/src/uu/install/src/install.rs index 8a2e9957265..5fc9b146f49 100644 --- a/src/uu/install/src/install.rs +++ b/src/uu/install/src/install.rs @@ -117,6 +117,9 @@ enum InstallError { #[error("{}", translate!("install-error-not-a-directory", "path" => .0.quote()))] NotADirectory(PathBuf), + #[error("{}", translate!("install-error-target-dir-access", "path" => .0.quote(), "error" => strip_errno(.1)))] + TargetDirAccess(PathBuf, #[source] std::io::Error), + #[error("{}", translate!("install-error-existing-file-not-directory", "path" => .0.quote()))] ExistingFileNotADirectory(PathBuf), @@ -759,7 +762,9 @@ fn standard(mut paths: Vec, b: &Behavior) -> UResult<()> { } } - if sources.len() > 1 { + // A `-t` target is always the directory to install into, never the file + // to install as, even with a single source. + if sources.len() > 1 || b.target_dir.is_some() { copy_files_into_dir(sources, &target, b) } else { let source = sources.first().unwrap(); @@ -835,6 +840,13 @@ fn metadata_for_source(path: &Path) -> UResult { /// fn copy_files_into_dir(files: &[PathBuf], target_dir: &Path, b: &Behavior) -> UResult<()> { if !target_dir.is_dir() { + if b.target_dir.is_some() { + // GNU names why the `-t` directory cannot be used. + let reason = metadata(target_dir) + .err() + .unwrap_or_else(|| rustix::io::Errno::NOTDIR.into()); + return Err(InstallError::TargetDirAccess(target_dir.to_path_buf(), reason).into()); + } return Err(InstallError::TargetDirIsntDir(target_dir.to_path_buf()).into()); } let mut installed_destinations: HashSet = HashSet::with_capacity(files.len()); diff --git a/tests/by-util/test_install.rs b/tests/by-util/test_install.rs index 6832c6edbdb..b7cfe1beb53 100644 --- a/tests/by-util/test_install.rs +++ b/tests/by-util/test_install.rs @@ -2203,6 +2203,32 @@ fn test_target_file_ends_with_slash() { .stderr_contains("failed to access 'dir/target_file/': Not a directory"); } +/// Without `-D`, a `-t` target that is not a directory is refused, even with a +/// single source, rather than installed over or created as a file. +#[test] +fn test_install_target_dir_not_a_directory() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.write("source", "new"); + at.write("regular", "old"); + at.symlink_file("nowhere", "dangling"); + + for (target, reason) in [ + ("regular", "Not a directory"), + ("dangling", "No such file or directory"), + ("missing", "No such file or directory"), + ] { + scene + .ucmd() + .args(&["-t", target, "source"]) + .fails() + .stderr_only(format!("install: failed to access '{target}': {reason}\n")); + } + assert_eq!(at.read("regular"), "old"); + assert!(at.is_symlink("dangling")); + assert!(!at.file_exists("missing")); +} + #[test] fn test_install_root_combined() { let ts = TestScenario::new(util_name!());