Skip to content

Commit dce0a26

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

File tree

6 files changed

+192
-1
lines changed

6 files changed

+192
-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(),
403+
target_with_source_filename.quote().to_string(),
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+
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: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2856,3 +2856,131 @@ 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+
#[cfg(unix)]
2862+
fn test_mv_dir_symlink_slash_to_dest_dir() {
2863+
let (at, mut ucmd) = at_and_ucmd!();
2864+
2865+
at.mkdir("foo");
2866+
at.symlink_dir("foo", "symlink");
2867+
2868+
ucmd.arg("symlink/")
2869+
.arg("foo")
2870+
.fails()
2871+
.stderr_contains("cannot move 'symlink/' to 'foo/symlink': Not a directory");
2872+
}
2873+
2874+
#[test]
2875+
#[cfg(unix)]
2876+
fn test_mv_dir_symlink_slash_to_another_dir() {
2877+
let (at, mut ucmd) = at_and_ucmd!();
2878+
2879+
at.mkdir("foo");
2880+
at.mkdir("target");
2881+
at.symlink_dir("foo", "symlink_foo");
2882+
2883+
ucmd.arg("symlink_foo/")
2884+
.arg("target")
2885+
.fails()
2886+
.stderr_contains("cannot move 'symlink_foo/' to 'target/symlink_foo': Not a directory");
2887+
}
2888+
2889+
#[test]
2890+
fn test_mv_file_symlink_slash_to_dir() {
2891+
let (at, mut ucmd) = at_and_ucmd!();
2892+
2893+
at.touch("a");
2894+
at.mkdir("target");
2895+
at.symlink_file("a", "symlink_a");
2896+
2897+
ucmd.arg("symlink/")
2898+
.arg("target")
2899+
.fails()
2900+
.stderr_contains("cannot stat 'symlink/': Not a directory");
2901+
}
2902+
2903+
#[test]
2904+
#[cfg(unix)]
2905+
fn test_mv_dir_symlink_slash_to_file() {
2906+
let (at, mut ucmd) = at_and_ucmd!();
2907+
2908+
at.touch("a");
2909+
at.mkdir("foo");
2910+
at.symlink_dir("foo", "symlink_foo");
2911+
2912+
ucmd.arg("symlink_foo/")
2913+
.arg("a")
2914+
.fails()
2915+
.stderr_contains("cannot overwrite non-directory 'a' with directory 'symlink_foo/'");
2916+
}
2917+
2918+
#[test]
2919+
#[cfg(windows)]
2920+
fn test_mv_dir_symlink_slash_to_dest_dir() {
2921+
let (at, mut ucmd) = at_and_ucmd!();
2922+
2923+
at.mkdir("foo");
2924+
at.symlink_dir("foo", "symlink");
2925+
2926+
ucmd.arg("symlink/")
2927+
.arg("foo")
2928+
.fails()
2929+
.stderr_contains("cannot stat 'symlink/': Not a directory");
2930+
}
2931+
2932+
#[test]
2933+
#[cfg(windows)]
2934+
fn test_mv_dir_symlink_slash_to_another_dir() {
2935+
let (at, mut ucmd) = at_and_ucmd!();
2936+
2937+
at.mkdir("foo");
2938+
at.mkdir("target");
2939+
at.symlink_dir("foo", "symlink_foo");
2940+
2941+
ucmd.arg("symlink_foo/")
2942+
.arg("target")
2943+
.fails()
2944+
.stderr_contains("cannot stat 'symlink_foo/': Not a directory");
2945+
}
2946+
2947+
#[test]
2948+
#[cfg(windows)]
2949+
fn test_mv_dir_symlink_slash_to_file() {
2950+
let (at, mut ucmd) = at_and_ucmd!();
2951+
2952+
at.touch("a");
2953+
at.mkdir("foo");
2954+
at.symlink_dir("foo", "symlink_foo");
2955+
2956+
ucmd.arg("symlink_foo/")
2957+
.arg("a")
2958+
.fails()
2959+
.stderr_contains("cannot stat 'symlink_foo/': Not a directory");
2960+
}
2961+
2962+
#[test]
2963+
fn test_mv_file_symlink_slash_to_dest_file() {
2964+
let (at, mut ucmd) = at_and_ucmd!();
2965+
at.touch("a");
2966+
at.symlink_dir("a", "symlink_a");
2967+
2968+
ucmd.arg("symlink_a/")
2969+
.arg("a")
2970+
.fails()
2971+
.stderr_contains("cannot stat 'symlink_a/': Not a directory");
2972+
}
2973+
#[test]
2974+
fn test_mv_file_symlink_slash_to_another_file() {
2975+
let (at, mut ucmd) = at_and_ucmd!();
2976+
at.touch("a");
2977+
at.touch("b");
2978+
at.symlink_dir("a", "symlink_a");
2979+
2980+
ucmd.arg("symlink_a/")
2981+
.arg("b")
2982+
.fails()
2983+
.stderr_contains("cannot stat 'symlink_a/': Not a directory");
2984+
}
2985+
2986+

0 commit comments

Comments
 (0)