Skip to content

Commit d15e43f

Browse files
committed
Fix mv <symlink>/ target for various situations. Align error message with GNU.
1 parent 0566dfc commit d15e43f

File tree

6 files changed

+143
-1
lines changed

6 files changed

+143
-1
lines changed

src/uu/mv/locales/en-US.ftl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ mv-error-dangling-symlink = can't determine symlink type, since it is dangling
3737
mv-error-no-symlink-support = your operating system does not support symlinks
3838
mv-error-permission-denied = Permission denied
3939
mv-error-inter-device-move-failed = inter-device move failed: {$from} to {$to}; unable to remove target: {$err}
40+
mv-error-cannot-move-not-directory = cannot move {$source} to {$target}: Not a directory
4041
4142
# Help messages
4243
mv-help-force = do not prompt before overwriting

src/uu/mv/locales/fr-FR.ftl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ mv-error-dangling-symlink = impossible de déterminer le type de lien symbolique
3737
mv-error-no-symlink-support = votre système d'exploitation ne prend pas en charge les liens symboliques
3838
mv-error-permission-denied = Permission refusée
3939
mv-error-inter-device-move-failed = échec du déplacement inter-périphérique : {$from} vers {$to} ; impossible de supprimer la cible : {$err}
40+
mv-error-cannot-move-not-directory = impossible de déplacer {$source} vers {$target} : N'est pas un répertoire
4041
4142
# Messages d'aide
4243
mv-help-force = ne pas demander avant d'écraser

src/uu/mv/src/error.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ pub enum MvError {
2626
TargetNotADirectory(String),
2727
#[error("{}", translate!("mv-error-failed-access-not-directory", "path" => .0.clone()))]
2828
FailedToAccessNotADirectory(String),
29+
#[error("{}", translate!("mv-error-cannot-move-not-directory", "source" => .0.clone(), "target" => .1.clone()))]
30+
CannotMoveNotADirectory(String, String),
2931
}
3032

3133
impl UError for MvError {}

src/uu/mv/src/mv.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ use uucore::fs::display_permissions_unix;
4343
use uucore::fs::make_fifo;
4444
use uucore::fs::{
4545
MissingHandling, ResolveMode, are_hardlinks_or_one_way_symlink_to_same_file,
46-
are_hardlinks_to_same_file, canonicalize, path_ends_with_terminator,
46+
are_hardlinks_to_same_file, canonicalize, is_symlink_with_trailing, path_ends_with_terminator,
4747
};
4848
#[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))]
4949
use uucore::fsxattr;
@@ -372,6 +372,8 @@ fn handle_two_paths(source: &Path, target: &Path, opts: &Options) -> UResult<()>
372372
)
373373
.into());
374374
}
375+
376+
// Path("symlink/").symlink_metadata() will resolve to destination of symlink
375377
if source.symlink_metadata().is_err() {
376378
return Err(if path_ends_with_terminator(source) {
377379
MvError::CannotStatNotADirectory(source.quote().to_string()).into()
@@ -387,6 +389,23 @@ fn handle_two_paths(source: &Path, target: &Path, opts: &Options) -> UResult<()>
387389
target.is_dir()
388390
};
389391

392+
if is_symlink_with_trailing(source) {
393+
if !source_is_dir {
394+
return Err(MvError::CannotStatNotADirectory(source.quote().to_string()).into());
395+
} else if target_is_dir {
396+
let target_with_source_filename = match source.file_name() {
397+
Some(name) => target.join(name),
398+
None => target.to_path_buf(),
399+
};
400+
401+
return Err(MvError::CannotMoveNotADirectory(
402+
source.quote().to_string().into(),
403+
target_with_source_filename.quote().to_string().into(),
404+
)
405+
.into());
406+
}
407+
}
408+
390409
if path_ends_with_terminator(target)
391410
&& (!target_is_dir && !source_is_dir)
392411
&& !opts.no_target_dir

src/uucore/src/lib/features/fs.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,46 @@ pub fn is_symlink_loop(path: &Path) -> bool {
630630
false
631631
}
632632

633+
#[cfg(unix)]
634+
pub fn is_symlink_with_trailing(path: &Path) -> bool {
635+
use std::os::unix::prelude::OsStrExt;
636+
637+
let bytes = path.as_os_str().as_bytes();
638+
// Not reusing path_ends_with_terminator for best performance on unix
639+
if bytes.last().is_some_and(|&last| last == b'/') {
640+
let len = bytes.len();
641+
let stripped = &bytes[..len - 1];
642+
return Path::new(OsStr::from_bytes(stripped)).is_symlink();
643+
} else {
644+
false
645+
}
646+
}
647+
648+
#[cfg(windows)]
649+
pub fn is_symlink_with_trailing(path: &Path) -> bool {
650+
if !path_ends_with_terminator(path) {
651+
return false;
652+
}
653+
654+
use std::ffi::OsString;
655+
use std::os::windows::ffi::OsStrExt;
656+
use std::os::windows::ffi::OsStringExt;
657+
658+
let mut wides: Vec<u16> = path.as_os_str().encode_wide().collect();
659+
// Handle multiple trailing separators on Windows
660+
while wides
661+
.last()
662+
.is_some_and(|&last| last == b'/'.into() || last == b'\\'.into())
663+
{
664+
wides.pop();
665+
}
666+
if wides.is_empty() {
667+
return false;
668+
}
669+
let stripped = OsString::from_wide(&wides);
670+
std::path::Path::new(&stripped).is_symlink()
671+
}
672+
633673
#[cfg(not(unix))]
634674
// Hard link comparison is not supported on non-Unix platforms
635675
pub fn are_hardlinks_to_same_file(_source: &Path, _target: &Path) -> bool {

tests/by-util/test_mv.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2856,3 +2856,82 @@ fn test_mv_no_prompt_unwriteable_file_with_no_tty() {
28562856
assert!(!at.file_exists("source_notty"));
28572857
assert!(at.file_exists("target_notty"));
28582858
}
2859+
2860+
#[test]
2861+
fn test_mv_dir_symlink_slash_to_dest_dir() {
2862+
let (at, mut ucmd) = at_and_ucmd!();
2863+
2864+
at.mkdir("foo");
2865+
at.symlink_dir("foo", "symlink");
2866+
2867+
ucmd.arg("symlink/")
2868+
.arg("foo")
2869+
.fails()
2870+
.stderr_contains("cannot move 'symlink/' to 'foo/symlink': Not a directory");
2871+
}
2872+
2873+
#[test]
2874+
fn test_mv_dir_symlink_slash_to_another_dir() {
2875+
let (at, mut ucmd) = at_and_ucmd!();
2876+
2877+
at.mkdir("foo");
2878+
at.mkdir("target");
2879+
at.symlink_dir("foo", "symlink_foo");
2880+
2881+
ucmd.arg("symlink_foo/")
2882+
.arg("target")
2883+
.fails()
2884+
.stderr_contains("cannot move 'symlink_foo/' to 'target/symlink_foo': Not a directory");
2885+
}
2886+
2887+
#[test]
2888+
fn test_mv_file_symlink_slash_to_dir() {
2889+
let (at, mut ucmd) = at_and_ucmd!();
2890+
2891+
at.touch("a");
2892+
at.mkdir("target");
2893+
at.symlink_file("a", "symlink_a");
2894+
2895+
ucmd.arg("symlink/")
2896+
.arg("target")
2897+
.fails()
2898+
.stderr_contains("cannot stat 'symlink/': Not a directory");
2899+
}
2900+
2901+
#[test]
2902+
fn test_mv_dir_symlink_slash_to_file() {
2903+
let (at, mut ucmd) = at_and_ucmd!();
2904+
2905+
at.touch("a");
2906+
at.mkdir("foo");
2907+
at.symlink_dir("foo", "symlink_foo");
2908+
2909+
ucmd.arg("symlink_foo/")
2910+
.arg("a")
2911+
.fails()
2912+
.stderr_contains("cannot overwrite non-directory 'a' with directory 'symlink_foo/'");
2913+
}
2914+
2915+
#[test]
2916+
fn test_mv_file_symlink_slash_to_dest_file() {
2917+
let (at, mut ucmd) = at_and_ucmd!();
2918+
at.touch("a");
2919+
at.symlink_dir("a", "symlink_a");
2920+
2921+
ucmd.arg("symlink_a/")
2922+
.arg("a")
2923+
.fails()
2924+
.stderr_contains("cannot stat 'symlink_a/': Not a directory");
2925+
}
2926+
#[test]
2927+
fn test_mv_file_symlink_slash_to_another_file() {
2928+
let (at, mut ucmd) = at_and_ucmd!();
2929+
at.touch("a");
2930+
at.touch("b");
2931+
at.symlink_dir("a", "symlink_a");
2932+
2933+
ucmd.arg("symlink_a/")
2934+
.arg("b")
2935+
.fails()
2936+
.stderr_contains("cannot stat 'symlink_a/': Not a directory");
2937+
}

0 commit comments

Comments
 (0)