Skip to content

Commit b60befe

Browse files
committed
mv: improve move-to-self error handling
- improve move-to-self detection, so this errors without data loss: ```diff mkdir mydir mv mydir mydir/subdir -mv: No such file or directory (os error 2) +mv: cannot move 'mydir' to a subdirectory of itself, 'mydir/subdir' ``` - align "cannot move source to a subdirectory of itself" and "same file" errors more closely with coreutils: ```diff mkdir mydir mv mydir/ mydir/.. -mv: cannot move 'mydir/' to a subdirectory of itself, 'mydir/../mydir/' +mv: 'mydir/' and 'mydir/../mydir' are the same file ```
1 parent b4cdc36 commit b60befe

File tree

3 files changed

+140
-111
lines changed

3 files changed

+140
-111
lines changed

src/uu/mv/src/error.rs

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ pub enum MvError {
1212
NoSuchFile(String),
1313
CannotStatNotADirectory(String),
1414
SameFile(String, String),
15-
SelfSubdirectory(String),
1615
SelfTargetSubdirectory(String, String),
1716
DirectoryToNonDirectory(String),
1817
NonDirectoryToDirectory(String, String),
@@ -29,14 +28,9 @@ impl Display for MvError {
2928
Self::NoSuchFile(s) => write!(f, "cannot stat {s}: No such file or directory"),
3029
Self::CannotStatNotADirectory(s) => write!(f, "cannot stat {s}: Not a directory"),
3130
Self::SameFile(s, t) => write!(f, "{s} and {t} are the same file"),
32-
Self::SelfSubdirectory(s) => write!(
33-
f,
34-
"cannot move '{s}' to a subdirectory of itself, '{s}/{s}'"
35-
),
36-
Self::SelfTargetSubdirectory(s, t) => write!(
37-
f,
38-
"cannot move '{s}' to a subdirectory of itself, '{t}/{s}'"
39-
),
31+
Self::SelfTargetSubdirectory(s, t) => {
32+
write!(f, "cannot move {s} to a subdirectory of itself, {t}")
33+
}
4034
Self::DirectoryToNonDirectory(t) => {
4135
write!(f, "cannot overwrite directory {t} with non-directory")
4236
}

src/uu/mv/src/mv.rs

Lines changed: 90 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,13 @@ use std::io;
1919
use std::os::unix;
2020
#[cfg(windows)]
2121
use std::os::windows;
22-
use std::path::{Path, PathBuf};
22+
use std::path::{absolute, Path, PathBuf};
2323
use uucore::backup_control::{self, source_is_target_backup};
2424
use uucore::display::Quotable;
2525
use uucore::error::{set_exit_code, FromIo, UResult, USimpleError, UUsageError};
2626
use uucore::fs::{
27-
are_hardlinks_or_one_way_symlink_to_same_file, are_hardlinks_to_same_file,
28-
path_ends_with_terminator,
27+
are_hardlinks_or_one_way_symlink_to_same_file, are_hardlinks_to_same_file, canonicalize,
28+
path_ends_with_terminator, MissingHandling, ResolveMode,
2929
};
3030
#[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))]
3131
use uucore::fsxattr;
@@ -322,20 +322,6 @@ fn handle_two_paths(source: &Path, target: &Path, opts: &Options) -> UResult<()>
322322
});
323323
}
324324

325-
if (source.eq(target)
326-
|| are_hardlinks_to_same_file(source, target)
327-
|| are_hardlinks_or_one_way_symlink_to_same_file(source, target))
328-
&& opts.backup == BackupMode::NoBackup
329-
{
330-
if source.eq(Path::new(".")) || source.ends_with("/.") || source.is_file() {
331-
return Err(
332-
MvError::SameFile(source.quote().to_string(), target.quote().to_string()).into(),
333-
);
334-
} else {
335-
return Err(MvError::SelfSubdirectory(source.display().to_string()).into());
336-
}
337-
}
338-
339325
let target_is_dir = target.is_dir();
340326
let source_is_dir = source.is_dir();
341327

@@ -347,6 +333,8 @@ fn handle_two_paths(source: &Path, target: &Path, opts: &Options) -> UResult<()>
347333
return Err(MvError::FailedToAccessNotADirectory(target.quote().to_string()).into());
348334
}
349335

336+
assert_not_same_file(source, target, target_is_dir, opts)?;
337+
350338
if target_is_dir {
351339
if opts.no_target_dir {
352340
if source.is_dir() {
@@ -356,14 +344,6 @@ fn handle_two_paths(source: &Path, target: &Path, opts: &Options) -> UResult<()>
356344
} else {
357345
Err(MvError::DirectoryToNonDirectory(target.quote().to_string()).into())
358346
}
359-
// Check that source & target do not contain same subdir/dir when both exist
360-
// mkdir dir1/dir2; mv dir1 dir1/dir2
361-
} else if target.starts_with(source) {
362-
Err(MvError::SelfTargetSubdirectory(
363-
source.display().to_string(),
364-
target.display().to_string(),
365-
)
366-
.into())
367347
} else {
368348
move_files_into_dir(&[source.to_path_buf()], target, opts)
369349
}
@@ -387,6 +367,88 @@ fn handle_two_paths(source: &Path, target: &Path, opts: &Options) -> UResult<()>
387367
}
388368
}
389369

370+
fn assert_not_same_file(
371+
source: &Path,
372+
target: &Path,
373+
target_is_dir: bool,
374+
opts: &Options,
375+
) -> UResult<()> {
376+
// we'll compare canonicalized_source and canonicalized_target for same file detection
377+
let canonicalized_source = match canonicalize(
378+
absolute(source)?,
379+
MissingHandling::Normal,
380+
ResolveMode::Logical,
381+
) {
382+
Ok(source) if source.exists() => source,
383+
_ => absolute(source)?, // file or symlink target doesn't exist but its absolute path is still used for comparison
384+
};
385+
386+
// special case if the target exists, is a directory, and the `-T` flag wasn't used
387+
let target_is_dir = target_is_dir && !opts.no_target_dir;
388+
let canonicalized_target = if target_is_dir {
389+
// `mv source_file target_dir` => target_dir/source_file
390+
// canonicalize the path that exists (target directory) and join the source file name
391+
canonicalize(
392+
absolute(target)?,
393+
MissingHandling::Normal,
394+
ResolveMode::Logical,
395+
)?
396+
.join(source.file_name().unwrap_or_default())
397+
} else {
398+
// `mv source target_dir/target` => target_dir/target
399+
// we canonicalize target_dir and join /target
400+
match absolute(target)?.parent() {
401+
Some(parent) if parent.to_str() != Some("") => {
402+
canonicalize(parent, MissingHandling::Normal, ResolveMode::Logical)?
403+
.join(target.file_name().unwrap_or_default())
404+
}
405+
// path.parent() returns Some("") or None if there's no parent
406+
_ => absolute(target)?, // absolute paths should always have a parent, but we'll fall back just in case
407+
}
408+
};
409+
410+
let same_file = (canonicalized_source.eq(&canonicalized_target)
411+
|| are_hardlinks_to_same_file(source, target)
412+
|| are_hardlinks_or_one_way_symlink_to_same_file(source, target))
413+
&& opts.backup == BackupMode::NoBackup;
414+
415+
// get the expected target path to show in errors
416+
// this is based on the argument and not canonicalized
417+
let target_display = match source.file_name() {
418+
Some(file_name) if target_is_dir => {
419+
// join target_dir/source_file in a platform-independent manner
420+
let mut path = target
421+
.display()
422+
.to_string()
423+
.trim_end_matches("/")
424+
.to_owned();
425+
426+
path.push('/');
427+
path.push_str(&file_name.to_string_lossy());
428+
429+
path.quote().to_string()
430+
}
431+
_ => target.quote().to_string(),
432+
};
433+
434+
if same_file
435+
&& (canonicalized_source.eq(&canonicalized_target)
436+
|| source.eq(Path::new("."))
437+
|| source.ends_with("/.")
438+
|| source.is_file())
439+
{
440+
return Err(MvError::SameFile(source.quote().to_string(), target_display).into());
441+
} else if (same_file || canonicalized_target.starts_with(canonicalized_source))
442+
// don't error if we're moving a symlink of a directory into itself
443+
&& !source.is_symlink()
444+
{
445+
return Err(
446+
MvError::SelfTargetSubdirectory(source.quote().to_string(), target_display).into(),
447+
);
448+
}
449+
Ok(())
450+
}
451+
390452
fn handle_multiple_paths(paths: &[PathBuf], opts: &Options) -> UResult<()> {
391453
if opts.no_target_dir {
392454
return Err(UUsageError::new(
@@ -425,10 +487,6 @@ fn move_files_into_dir(files: &[PathBuf], target_dir: &Path, options: &Options)
425487
return Err(MvError::NotADirectory(target_dir.quote().to_string()).into());
426488
}
427489

428-
let canonicalized_target_dir = target_dir
429-
.canonicalize()
430-
.unwrap_or_else(|_| target_dir.to_path_buf());
431-
432490
let multi_progress = options.progress_bar.then(MultiProgress::new);
433491

434492
let count_progress = if let Some(ref multi_progress) = multi_progress {
@@ -479,24 +537,9 @@ fn move_files_into_dir(files: &[PathBuf], target_dir: &Path, options: &Options)
479537

480538
// Check if we have mv dir1 dir2 dir2
481539
// And generate an error if this is the case
482-
if let Ok(canonicalized_source) = sourcepath.canonicalize() {
483-
if canonicalized_source == canonicalized_target_dir {
484-
// User tried to move directory to itself, warning is shown
485-
// and process of moving files is continued.
486-
show!(USimpleError::new(
487-
1,
488-
format!(
489-
"cannot move '{}' to a subdirectory of itself, '{}/{}'",
490-
sourcepath.display(),
491-
uucore::fs::normalize_path(target_dir).display(),
492-
canonicalized_target_dir.components().last().map_or_else(
493-
|| target_dir.display().to_string(),
494-
|dir| { PathBuf::from(dir.as_os_str()).display().to_string() }
495-
)
496-
)
497-
));
498-
continue;
499-
}
540+
if let Err(e) = assert_not_same_file(sourcepath, target_dir, true, options) {
541+
show!(e);
542+
continue;
500543
}
501544

502545
match rename(sourcepath, &targetpath, options, multi_progress.as_ref()) {

tests/by-util/test_mv.rs

Lines changed: 47 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
// spell-checker:ignore mydir
77
use crate::common::util::TestScenario;
88
use filetime::FileTime;
9+
use rstest::rstest;
910
use std::io::Write;
1011

1112
#[test]
@@ -467,7 +468,31 @@ fn test_mv_same_symlink() {
467468
.arg(file_c)
468469
.arg(file_a)
469470
.fails()
470-
.stderr_is(format!("mv: '{file_c}' and '{file_a}' are the same file\n",));
471+
.stderr_is(format!("mv: '{file_c}' and '{file_a}' are the same file\n"));
472+
}
473+
474+
#[test]
475+
#[cfg(all(unix, not(target_os = "android")))]
476+
fn test_mv_same_broken_symlink() {
477+
let (at, mut ucmd) = at_and_ucmd!();
478+
479+
at.symlink_file("missing-target", "broken");
480+
481+
ucmd.arg("broken")
482+
.arg("broken")
483+
.fails()
484+
.stderr_is("mv: 'broken' and 'broken' are the same file\n");
485+
}
486+
487+
#[test]
488+
#[cfg(all(unix, not(target_os = "android")))]
489+
fn test_mv_symlink_into_target() {
490+
let (at, mut ucmd) = at_and_ucmd!();
491+
492+
at.mkdir("dir");
493+
at.symlink_file("dir", "dir-link");
494+
495+
ucmd.arg("dir-link").arg("dir").succeeds();
471496
}
472497

473498
#[test]
@@ -1389,24 +1414,6 @@ fn test_mv_interactive_error() {
13891414
.is_empty());
13901415
}
13911416

1392-
#[test]
1393-
fn test_mv_into_self() {
1394-
let scene = TestScenario::new(util_name!());
1395-
let at = &scene.fixtures;
1396-
let dir1 = "dir1";
1397-
let dir2 = "dir2";
1398-
at.mkdir(dir1);
1399-
at.mkdir(dir2);
1400-
1401-
scene
1402-
.ucmd()
1403-
.arg(dir1)
1404-
.arg(dir2)
1405-
.arg(dir2)
1406-
.fails()
1407-
.stderr_contains("mv: cannot move 'dir2' to a subdirectory of itself, 'dir2/dir2'");
1408-
}
1409-
14101417
#[test]
14111418
fn test_mv_arg_interactive_skipped() {
14121419
let (at, mut ucmd) = at_and_ucmd!();
@@ -1456,27 +1463,32 @@ fn test_mv_into_self_data() {
14561463
assert!(!at.file_exists(file1));
14571464
}
14581465

1459-
#[test]
1460-
fn test_mv_directory_into_subdirectory_of_itself_fails() {
1466+
#[rstest]
1467+
#[case(vec!["mydir"], vec!["mydir", "mydir"], "mv: cannot move 'mydir' to a subdirectory of itself, 'mydir/mydir'")]
1468+
#[case(vec!["mydir"], vec!["mydir/", "mydir/"], "mv: cannot move 'mydir/' to a subdirectory of itself, 'mydir/mydir'")]
1469+
#[case(vec!["mydir"], vec!["./mydir", "mydir", "mydir/"], "mv: cannot move './mydir' to a subdirectory of itself, 'mydir/mydir'")]
1470+
#[case(vec!["mydir"], vec!["mydir/", "mydir/mydir_2/"], "mv: cannot move 'mydir/' to a subdirectory of itself, 'mydir/mydir_2/'")]
1471+
#[case(vec!["mydir/mydir_2"], vec!["mydir", "mydir/mydir_2"], "mv: cannot move 'mydir' to a subdirectory of itself, 'mydir/mydir_2/mydir'\n")]
1472+
#[case(vec!["mydir/mydir_2"], vec!["mydir/", "mydir/mydir_2/"], "mv: cannot move 'mydir/' to a subdirectory of itself, 'mydir/mydir_2/mydir'\n")]
1473+
#[case(vec!["mydir", "mydir_2"], vec!["mydir/", "mydir_2/", "mydir_2/"], "mv: cannot move 'mydir_2/' to a subdirectory of itself, 'mydir_2/mydir_2'")]
1474+
#[case(vec!["mydir"], vec!["mydir/", "mydir"], "mv: cannot move 'mydir/' to a subdirectory of itself, 'mydir/mydir'")]
1475+
#[case(vec!["mydir"], vec!["-T", "mydir", "mydir"], "mv: 'mydir' and 'mydir' are the same file")]
1476+
#[case(vec!["mydir"], vec!["mydir/", "mydir/../"], "mv: 'mydir/' and 'mydir/../mydir' are the same file")]
1477+
fn test_mv_directory_self(
1478+
#[case] dirs: Vec<&str>,
1479+
#[case] args: Vec<&str>,
1480+
#[case] expected_error: &str,
1481+
) {
14611482
let scene = TestScenario::new(util_name!());
14621483
let at = &scene.fixtures;
1463-
let dir1 = "mydir";
1464-
let dir2 = "mydir/mydir_2";
1465-
at.mkdir(dir1);
1466-
at.mkdir(dir2);
1467-
scene.ucmd().arg(dir1).arg(dir2).fails().stderr_contains(
1468-
"mv: cannot move 'mydir' to a subdirectory of itself, 'mydir/mydir_2/mydir'",
1469-
);
1470-
1471-
// check that it also errors out with /
1484+
for dir in dirs {
1485+
at.mkdir_all(dir);
1486+
}
14721487
scene
14731488
.ucmd()
1474-
.arg(format!("{dir1}/"))
1475-
.arg(dir2)
1489+
.args(&args)
14761490
.fails()
1477-
.stderr_contains(
1478-
"mv: cannot move 'mydir/' to a subdirectory of itself, 'mydir/mydir_2/mydir/'",
1479-
);
1491+
.stderr_contains(expected_error);
14801492
}
14811493

14821494
#[test]
@@ -1755,23 +1767,3 @@ fn test_mv_error_msg_with_multiple_sources_that_does_not_exist() {
17551767
.stderr_contains("mv: cannot stat 'a': No such file or directory")
17561768
.stderr_contains("mv: cannot stat 'b/': No such file or directory");
17571769
}
1758-
1759-
#[test]
1760-
fn test_mv_error_cant_move_itself() {
1761-
let scene = TestScenario::new(util_name!());
1762-
let at = &scene.fixtures;
1763-
at.mkdir("b");
1764-
scene
1765-
.ucmd()
1766-
.arg("b")
1767-
.arg("b/")
1768-
.fails()
1769-
.stderr_contains("mv: cannot move 'b' to a subdirectory of itself, 'b/b'");
1770-
scene
1771-
.ucmd()
1772-
.arg("./b")
1773-
.arg("b")
1774-
.arg("b/")
1775-
.fails()
1776-
.stderr_contains("mv: cannot move 'b' to a subdirectory of itself, 'b/b'");
1777-
}

0 commit comments

Comments
 (0)