From 4414bdfeab83d00963695c970fe22f386369c35f Mon Sep 17 00:00:00 2001 From: Ahmet Enes Kaymak Date: Tue, 27 Oct 2026 00:45:54 +0300 Subject: [PATCH] ln: refuse to replace a directory with --backup, -f or -i With a backup option, ln renamed an existing directory at the link name to its backup name and created the link in its place, exiting 0. GNU ln refuses with "cannot overwrite directory" and leaves it untouched; it does the same for -f and -i, where uutils failed with "Is a directory" (after a prompt for -i). Check for a real directory before backing up or prompting. A symlink to a directory is still backed up as a link, as GNU does. Plain ln is unchanged and still reports "File exists". Fixes #15788 --- src/uu/ln/locales/en-US.ftl | 1 + src/uu/ln/locales/fr-FR.ftl | 1 + src/uu/ln/src/ln.rs | 10 +++++++ tests/by-util/test_ln.rs | 57 +++++++++++++++++++++++++++++++++++++ 4 files changed, 69 insertions(+) diff --git a/src/uu/ln/locales/en-US.ftl b/src/uu/ln/locales/en-US.ftl index 0ff8f8a34bd..44b8d187829 100644 --- a/src/uu/ln/locales/en-US.ftl +++ b/src/uu/ln/locales/en-US.ftl @@ -30,6 +30,7 @@ ln-error-extra-operand = extra operand {$operand} Try '{$program} --help' for more information. ln-error-could-not-update = Could not update {$target}: {$error} ln-error-will-not-overwrite = will not overwrite just-created {$target} with {$source} +ln-error-cannot-overwrite-directory = {$dest}: cannot overwrite directory ln-prompt-replace = replace {$file}? ln-cannot-backup = cannot backup {$file} ln-failed-to-access = failed to access {$file} diff --git a/src/uu/ln/locales/fr-FR.ftl b/src/uu/ln/locales/fr-FR.ftl index 0c3234f9a2d..1a4f6df0cad 100644 --- a/src/uu/ln/locales/fr-FR.ftl +++ b/src/uu/ln/locales/fr-FR.ftl @@ -31,6 +31,7 @@ ln-error-extra-operand = opérande supplémentaire {$operand} Essayez « {$program} --help » pour plus d'informations. ln-error-could-not-update = Impossible de mettre à jour {$target} : {$error} ln-error-will-not-overwrite = ne remplacera pas le fichier {$target} qui vient d'être créé par {$source} +ln-error-cannot-overwrite-directory = {$dest} : impossible d'écraser le répertoire ln-prompt-replace = remplacer {$file} ? ln-cannot-backup = impossible de sauvegarder {$file} ln-failed-to-access = échec d'accès à {$file} diff --git a/src/uu/ln/src/ln.rs b/src/uu/ln/src/ln.rs index f717294532d..9d38188bd9c 100644 --- a/src/uu/ln/src/ln.rs +++ b/src/uu/ln/src/ln.rs @@ -71,6 +71,9 @@ pub enum LnError { #[error("{}", translate!("ln-failed-to-create-hard-link-dir", "source" => _0.to_string_lossy()))] FailedToCreateHardLinkDir(PathBuf), + + #[error("{}", translate!("ln-error-cannot-overwrite-directory", "dest" => _0.maybe_quote()))] + CannotOverwriteDirectory(PathBuf), } impl UError for LnError { @@ -405,6 +408,13 @@ pub fn link(src: &Path, dst: &Path, settings: &Settings) -> LnResult<()> { }; if dst.is_symlink() || dst.exists() { + // Never move a directory aside or replace it to make room for a link, + // matching GNU. A symlink to a directory is a link and is replaced. + if (settings.backup != BackupMode::None || settings.overwrite != OverwriteMode::NoClobber) + && dst.symlink_metadata().is_ok_and(|m| m.is_dir()) + { + return Err(LnError::CannotOverwriteDirectory(dst.to_owned())); + } backup_path = backup_control::get_backup_path(settings.backup, dst, &settings.suffix); if settings.backup != BackupMode::None && !settings.symbolic { // `ln --backup f f`: the backup would move `f` away before the diff --git a/tests/by-util/test_ln.rs b/tests/by-util/test_ln.rs index 7c940069706..a27744c6bc1 100644 --- a/tests/by-util/test_ln.rs +++ b/tests/by-util/test_ln.rs @@ -1091,6 +1091,63 @@ fn test_backup_simple_numbered_same_file() { } } +#[test] +fn test_backup_force_interactive_refuse_directory() { + for args in [ + vec!["-s", "-b", "-t", "site", "src/static"], + vec!["-s", "--backup=numbered", "-t", "site", "src/static"], + vec!["-s", "-S", ".bak", "-t", "site", "src/static"], + vec!["-s", "-f", "-b", "-t", "site", "src/static"], + vec!["-s", "-r", "-b", "-t", "site", "src/static"], + vec!["-s", "-f", "-t", "site", "src/static"], + vec!["-s", "-i", "-t", "site", "src/static"], + vec!["-s", "-b", "-T", "src/static", "site/static"], + vec!["-b", "-t", "site", "static"], + ] { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir_all("site/static"); + at.write("site/static/app.js", "original"); + at.mkdir_all("src/static"); + at.touch("static"); + + // ln refuses before reading the answer, so it may exit before the + // input is written. + ucmd.args(&args) + .pipe_in("y\n") + .ignore_stdin_write_error() + .fails_with_code(1) + .stderr_contains("static: cannot overwrite directory"); + + assert!(at.dir_exists("site/static")); + assert_eq!(at.read("site/static/app.js"), "original"); + assert!(!at.dir_exists("site/static~")); + assert!(!at.dir_exists("site/static.~1~")); + assert!(!at.dir_exists("site/static.bak")); + } +} + +#[test] +fn test_backup_symlink_to_directory() { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir_all("site"); + at.mkdir_all("src/static"); + at.mkdir("other"); + at.relative_symlink_dir("../other", "site/static"); + + ucmd.args(&["-s", "-b", "-t", "site", "src/static"]) + .succeeds() + .no_stderr(); + + assert_eq!( + at.read_symlink("site/static"), + "src/static".replace('/', MAIN_SEPARATOR_STR) + ); + assert_eq!( + at.read_symlink("site/static~"), + "../other".replace('/', MAIN_SEPARATOR_STR) + ); +} + #[test] fn test_backup_simple_same_file_into_directory() { let (at, mut ucmd) = at_and_ucmd!();