diff --git a/src/uu/mv/locales/en-US.ftl b/src/uu/mv/locales/en-US.ftl index 4bb5339fe32..b832f2348f7 100644 --- a/src/uu/mv/locales/en-US.ftl +++ b/src/uu/mv/locales/en-US.ftl @@ -37,6 +37,7 @@ mv-error-dangling-symlink = can't determine symlink type, since it is dangling mv-error-no-symlink-support = your operating system does not support symlinks mv-error-permission-denied = Permission denied mv-error-inter-device-move-failed = inter-device move failed: {$from} to {$to}; unable to remove target: {$err} +mv-error-cannot-move-not-directory = cannot move {$source} to {$target}: Not a directory # Help messages mv-help-force = do not prompt before overwriting diff --git a/src/uu/mv/locales/fr-FR.ftl b/src/uu/mv/locales/fr-FR.ftl index 9ea2f2114b1..0dde1befbd5 100644 --- a/src/uu/mv/locales/fr-FR.ftl +++ b/src/uu/mv/locales/fr-FR.ftl @@ -37,6 +37,7 @@ mv-error-dangling-symlink = impossible de déterminer le type de lien symbolique mv-error-no-symlink-support = votre système d'exploitation ne prend pas en charge les liens symboliques mv-error-permission-denied = Permission refusée mv-error-inter-device-move-failed = échec du déplacement inter-périphérique : {$from} vers {$to} ; impossible de supprimer la cible : {$err} +mv-error-cannot-move-not-directory = impossible de déplacer {$source} vers {$target} : N'est pas un répertoire # Messages d'aide mv-help-force = ne pas demander avant d'écraser diff --git a/src/uu/mv/src/error.rs b/src/uu/mv/src/error.rs index 721cc799cc9..8fcc65e6746 100644 --- a/src/uu/mv/src/error.rs +++ b/src/uu/mv/src/error.rs @@ -26,6 +26,8 @@ pub enum MvError { TargetNotADirectory(String), #[error("{}", translate!("mv-error-failed-access-not-directory", "path" => .0.clone()))] FailedToAccessNotADirectory(String), + #[error("{}", translate!("mv-error-cannot-move-not-directory", "source" => .0.clone(), "target" => .1.clone()))] + CannotMoveNotADirectory(String, String), } impl UError for MvError {} diff --git a/src/uu/mv/src/mv.rs b/src/uu/mv/src/mv.rs index aa34a6294ae..8d6dbc42792 100644 --- a/src/uu/mv/src/mv.rs +++ b/src/uu/mv/src/mv.rs @@ -43,7 +43,7 @@ use uucore::fs::display_permissions_unix; use uucore::fs::make_fifo; use uucore::fs::{ MissingHandling, ResolveMode, are_hardlinks_or_one_way_symlink_to_same_file, - are_hardlinks_to_same_file, canonicalize, path_ends_with_terminator, + are_hardlinks_to_same_file, canonicalize, is_symlink_with_trailing, path_ends_with_terminator, }; #[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))] use uucore::fsxattr; @@ -372,6 +372,8 @@ fn handle_two_paths(source: &Path, target: &Path, opts: &Options) -> UResult<()> ) .into()); } + + // Path("symlink/").symlink_metadata() will resolve to destination of symlink if source.symlink_metadata().is_err() { return Err(if path_ends_with_terminator(source) { MvError::CannotStatNotADirectory(source.quote().to_string()).into() @@ -387,6 +389,23 @@ fn handle_two_paths(source: &Path, target: &Path, opts: &Options) -> UResult<()> target.is_dir() }; + if is_symlink_with_trailing(source) { + if !source_is_dir { + return Err(MvError::CannotStatNotADirectory(source.quote().to_string()).into()); + } else if target_is_dir { + let target_with_source_filename = match source.file_name() { + Some(name) => target.join(name), + None => target.to_path_buf(), + }; + + return Err(MvError::CannotMoveNotADirectory( + source.quote().to_string(), + target_with_source_filename.quote().to_string(), + ) + .into()); + } + } + if path_ends_with_terminator(target) && (!target_is_dir && !source_is_dir) && !opts.no_target_dir diff --git a/src/uucore/src/lib/features/fs.rs b/src/uucore/src/lib/features/fs.rs index 16de054a3c2..26b17ea1f36 100644 --- a/src/uucore/src/lib/features/fs.rs +++ b/src/uucore/src/lib/features/fs.rs @@ -630,6 +630,46 @@ pub fn is_symlink_loop(path: &Path) -> bool { false } +#[cfg(unix)] +pub fn is_symlink_with_trailing(path: &Path) -> bool { + use std::os::unix::prelude::OsStrExt; + + let bytes = path.as_os_str().as_bytes(); + // Not reusing path_ends_with_terminator for best performance on unix + if bytes.last().is_some_and(|&last| last == b'/') { + let len = bytes.len(); + let stripped = &bytes[..len - 1]; + Path::new(OsStr::from_bytes(stripped)).is_symlink() + } else { + false + } +} + +#[cfg(windows)] +pub fn is_symlink_with_trailing(path: &Path) -> bool { + if !path_ends_with_terminator(path) { + return false; + } + + use std::ffi::OsString; + use std::os::windows::ffi::OsStrExt; + use std::os::windows::ffi::OsStringExt; + + let mut wides: Vec = path.as_os_str().encode_wide().collect(); + // Handle multiple trailing separators on Windows + while wides + .last() + .is_some_and(|&last| last == b'/'.into() || last == b'\\'.into()) + { + wides.pop(); + } + if wides.is_empty() { + return false; + } + let stripped = OsString::from_wide(&wides); + std::path::Path::new(&stripped).is_symlink() +} + #[cfg(not(unix))] // Hard link comparison is not supported on non-Unix platforms pub fn are_hardlinks_to_same_file(_source: &Path, _target: &Path) -> bool { diff --git a/tests/by-util/test_mv.rs b/tests/by-util/test_mv.rs index 3c69d65a78d..46d65a59e3f 100644 --- a/tests/by-util/test_mv.rs +++ b/tests/by-util/test_mv.rs @@ -2856,3 +2856,129 @@ fn test_mv_no_prompt_unwriteable_file_with_no_tty() { assert!(!at.file_exists("source_notty")); assert!(at.file_exists("target_notty")); } + +#[test] +#[cfg(unix)] +fn test_mv_dir_symlink_slash_to_dest_dir() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.mkdir("foo"); + at.symlink_dir("foo", "symlink"); + + ucmd.arg("symlink/") + .arg("foo") + .fails() + .stderr_contains("cannot move 'symlink/' to 'foo/symlink': Not a directory"); +} + +#[test] +#[cfg(unix)] +fn test_mv_dir_symlink_slash_to_another_dir() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.mkdir("foo"); + at.mkdir("target"); + at.symlink_dir("foo", "symlink_foo"); + + ucmd.arg("symlink_foo/") + .arg("target") + .fails() + .stderr_contains("cannot move 'symlink_foo/' to 'target/symlink_foo': Not a directory"); +} + +#[test] +fn test_mv_file_symlink_slash_to_dir() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.touch("a"); + at.mkdir("target"); + at.symlink_file("a", "symlink_a"); + + ucmd.arg("symlink/") + .arg("target") + .fails() + .stderr_contains("cannot stat 'symlink/': Not a directory"); +} + +#[test] +#[cfg(unix)] +fn test_mv_dir_symlink_slash_to_file() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.touch("a"); + at.mkdir("foo"); + at.symlink_dir("foo", "symlink_foo"); + + ucmd.arg("symlink_foo/") + .arg("a") + .fails() + .stderr_contains("cannot overwrite non-directory 'a' with directory 'symlink_foo/'"); +} + +#[test] +#[cfg(windows)] +fn test_mv_dir_symlink_slash_to_dest_dir() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.mkdir("foo"); + at.symlink_dir("foo", "symlink"); + + ucmd.arg("symlink/") + .arg("foo") + .fails() + .stderr_contains("cannot stat 'symlink/': Not a directory"); +} + +#[test] +#[cfg(windows)] +fn test_mv_dir_symlink_slash_to_another_dir() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.mkdir("foo"); + at.mkdir("target"); + at.symlink_dir("foo", "symlink_foo"); + + ucmd.arg("symlink_foo/") + .arg("target") + .fails() + .stderr_contains("cannot stat 'symlink_foo/': Not a directory"); +} + +#[test] +#[cfg(windows)] +fn test_mv_dir_symlink_slash_to_file() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.touch("a"); + at.mkdir("foo"); + at.symlink_dir("foo", "symlink_foo"); + + ucmd.arg("symlink_foo/") + .arg("a") + .fails() + .stderr_contains("cannot stat 'symlink_foo/': Not a directory"); +} + +#[test] +fn test_mv_file_symlink_slash_to_dest_file() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("a"); + at.symlink_dir("a", "symlink_a"); + + ucmd.arg("symlink_a/") + .arg("a") + .fails() + .stderr_contains("cannot stat 'symlink_a/': Not a directory"); +} +#[test] +fn test_mv_file_symlink_slash_to_another_file() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("a"); + at.touch("b"); + at.symlink_dir("a", "symlink_a"); + + ucmd.arg("symlink_a/") + .arg("b") + .fails() + .stderr_contains("cannot stat 'symlink_a/': Not a directory"); +}