From 7179c4d7feccae80259c3d150082ae2528b39767 Mon Sep 17 00:00:00 2001 From: Jake Wallin Date: Thu, 15 Oct 2026 05:43:25 +0000 Subject: [PATCH] install: report why a target cannot be used as a directory --- src/uu/install/locales/en-US.ftl | 4 ++- src/uu/install/locales/fr-FR.ftl | 2 +- src/uu/install/src/install.rs | 47 ++++++++++++++++++++------ tests/by-util/test_install.rs | 57 +++++++++++++++++++++++++++++--- 4 files changed, 93 insertions(+), 17 deletions(-) diff --git a/src/uu/install/locales/en-US.ftl b/src/uu/install/locales/en-US.ftl index dd28a0766c5..160b4c904eb 100644 --- a/src/uu/install/locales/en-US.ftl +++ b/src/uu/install/locales/en-US.ftl @@ -28,7 +28,9 @@ install-error-chmod-failed = failed to chmod { $path } install-error-chmod-failed-detailed = { $path }: chmod failed with error { $error } install-error-chown-failed = failed to chown { $path }: { $error } install-error-invalid-target = invalid target { $path }: No such file or directory -install-error-target-not-dir = target { $path } is not a directory +install-error-target-not-dir = target { $path }: { $error } +install-error-target-stat = cannot stat { $path }: { $error } +install-error-create-regular-file = cannot create regular file { $path }: { $error } install-error-backup-failed = cannot backup { $from }: { $error } install-error-install-failed = cannot install { $from } to { $to }: { $error } install-error-strip-failed = strip program failed: { $error } diff --git a/src/uu/install/locales/fr-FR.ftl b/src/uu/install/locales/fr-FR.ftl index 972d40bddbf..c2bdce876b9 100644 --- a/src/uu/install/locales/fr-FR.ftl +++ b/src/uu/install/locales/fr-FR.ftl @@ -28,7 +28,7 @@ install-error-chmod-failed = échec du chmod { $path } install-error-chmod-failed-detailed = { $path } : échec du chmod avec l'erreur { $error } install-error-chown-failed = échec du chown { $path } : { $error } install-error-invalid-target = cible invalide { $path } : Aucun fichier ou répertoire de ce type -install-error-target-not-dir = la cible { $path } n'est pas un répertoire +install-error-target-not-dir = la cible { $path } : { $error } install-error-backup-failed = impossible de sauvegarder { $from } : { $error } install-error-install-failed = impossible d'installer { $from } vers { $to }: { $error } install-error-strip-failed = échec du programme strip : { $error } diff --git a/src/uu/install/src/install.rs b/src/uu/install/src/install.rs index cb938bf0f2b..c1d9bfbd914 100644 --- a/src/uu/install/src/install.rs +++ b/src/uu/install/src/install.rs @@ -83,8 +83,14 @@ enum InstallError { #[error("{}", translate!("install-error-invalid-target", "path" => .0.quote()))] InvalidTarget(PathBuf), - #[error("{}", translate!("install-error-target-not-dir", "path" => .0.quote()))] - TargetDirIsntDir(PathBuf), + #[error("{}", translate!("install-error-target-not-dir", "path" => .0.quote(), "error" => strip_errno(.1)))] + TargetDirIsntDir(PathBuf, #[source] std::io::Error), + + #[error("{}", translate!("install-error-target-stat", "path" => .0.quote(), "error" => strip_errno(.1)))] + TargetStatFailed(PathBuf, #[source] std::io::Error), + + #[error("{}", translate!("install-error-create-regular-file", "path" => .0.quote(), "error" => strip_errno(.1)))] + CreateRegularFileFailed(PathBuf, #[source] std::io::Error), #[error("{}", translate!("install-error-backup-failed", "from" => .0.quote(), "error" => strip_errno(.1)))] BackupFailed(PathBuf, #[source] std::io::Error), @@ -660,9 +666,17 @@ fn standard(mut paths: Vec, b: &Behavior) -> UResult<()> { None }; - // If -t is used, check if target exists as a file before trying to create directories - if b.target_dir.is_some() && target.exists() && !target.is_dir() { - return Err(InstallError::NotADirectory(target).into()); + // Reject inaccessible -t targets before trying to create directories. + if b.target_dir.is_some() { + match metadata(&target) { + Ok(meta) if !meta.is_dir() => { + return Err(InstallError::NotADirectory(target).into()); + } + Err(e) if e.kind() != std::io::ErrorKind::NotFound => { + return Err(InstallError::TargetDirAccess(target, e).into()); + } + _ => {} + } } if let Some(to_create) = to_create { @@ -840,14 +854,27 @@ 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() { + let reason = metadata(target_dir) + .err() + .unwrap_or_else(|| rustix::io::Errno::NOTDIR.into()); 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()); + if files.len() > 1 { + return Err(InstallError::TargetDirIsntDir(target_dir.to_path_buf(), reason).into()); + } + if reason.kind() != std::io::ErrorKind::NotFound { + return Err(InstallError::TargetStatFailed(target_dir.to_path_buf(), reason).into()); + } + // A trailing slash requires a directory even when its parent exists. + let create_error = if is_new_file_path(target_dir) { + rustix::io::Errno::NOTDIR.into() + } else { + reason + }; + return Err( + InstallError::CreateRegularFileFailed(target_dir.to_path_buf(), create_error).into(), + ); } let mut installed_destinations: HashSet = HashSet::with_capacity(files.len()); for sourcepath in files { diff --git a/tests/by-util/test_install.rs b/tests/by-util/test_install.rs index 63fd335ae24..c45862e5691 100644 --- a/tests/by-util/test_install.rs +++ b/tests/by-util/test_install.rs @@ -85,7 +85,7 @@ fn test_install_failing_not_dir() { .arg(file2) .arg(file3) .fails() - .stderr_contains("not a directory"); + .stderr_contains("Not a directory"); } #[test] @@ -685,7 +685,7 @@ fn test_install_failing_omitting_directory() { .arg(dir1) .arg(no_dir2) .fails() - .stderr_contains("is not a directory"); + .stderr_contains("No such file or directory"); // file1 will be copied before install fails on dir1 scene @@ -1107,7 +1107,7 @@ fn test_install_creating_leading_dirs_with_single_source_and_target_dir() { .arg(source1) .arg(at.plus(target_dir)) .fails() - .stderr_contains("missing_target_dir/' is not a directory"); + .stderr_contains("missing_target_dir/': Not a directory"); assert!(!at.dir_exists(target_dir)); @@ -1143,7 +1143,7 @@ fn test_install_creating_leading_dirs_with_multiple_sources_and_target_dir() { .arg(source2) .arg(at.plus(target_dir)) .fails() - .stderr_contains("missing_target_dir' is not a directory"); + .stderr_contains("missing_target_dir': No such file or directory"); assert!(!at.dir_exists(target_dir)); @@ -2243,6 +2243,53 @@ fn test_install_target_dir_not_a_directory() { assert!(!at.file_exists("missing")); } +#[test] +fn test_install_unusable_target_diagnostics() { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.write("source", "new"); + at.write("other", "other source"); + at.write("regular", "old"); + + for (args, message) in [ + ( + vec!["source", "other", "regular"], + "target 'regular': Not a directory", + ), + ( + vec!["source", "other", "missing"], + "target 'missing': No such file or directory", + ), + ( + vec!["source", "regular/"], + "cannot stat 'regular/': Not a directory", + ), + ( + vec!["source", "missing/"], + "cannot create regular file 'missing/': Not a directory", + ), + ( + vec!["source", "missing/sub/"], + "cannot create regular file 'missing/sub/': No such file or directory", + ), + ( + vec!["-D", "-t", "regular/sub", "source"], + "failed to access 'regular/sub': Not a directory", + ), + ] { + scene + .ucmd() + .args(&args) + .fails_with_code(1) + .stderr_only(format!("install: {message}\n")); + } + assert_eq!(at.read("source"), "new"); + assert_eq!(at.read("other"), "other source"); + assert_eq!(at.read("regular"), "old"); + assert!(!at.dir_exists("missing")); + assert!(!at.file_exists("missing")); +} + #[test] fn test_install_root_combined() { let ts = TestScenario::new(util_name!()); @@ -2435,7 +2482,7 @@ fn test_install_no_target_directory_creating_leading_dirs_with_single_source_and .arg(source1) .arg(at.plus(target_dir)) .fails() - .stderr_contains("missing_target_dir/' is not a directory"); + .stderr_contains("missing_target_dir/': Not a directory"); assert!(!at.dir_exists(target_dir)); }