Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/uu/mv/locales/en-US.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,4 @@ mv-prompt-overwrite-mode = replace {$target}, overriding mode {$mode_info}?

# Progress messages
mv-progress-moving = moving
mv-error-dest-appeared = destination { $path } was replaced while moving; refusing to follow it
1 change: 1 addition & 0 deletions src/uu/mv/locales/fr-FR.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,4 @@ mv-prompt-overwrite = écraser {$target} ?

# Messages de progression
mv-progress-moving = déplacement
mv-error-dest-appeared = la destination { $path } a été remplacée pendant le déplacement ; refus de la suivre
21 changes: 19 additions & 2 deletions src/uu/mv/src/mv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1075,6 +1075,23 @@ fn rename_dir_fallback(
}

/// Copy directory recursively, optionally preserving hardlinks
/// Create `path`, refusing to reuse anything already there.
///
/// `create_dir_all` would accept a symlink planted at `path` after the caller
/// removed the destination, redirecting the move out of the destination tree.
fn create_dir_fail_closed(path: &Path) -> io::Result<()> {
fs::create_dir(path).map_err(|e| {
if e.kind() == io::ErrorKind::AlreadyExists {
io::Error::new(
io::ErrorKind::AlreadyExists,
translate!("mv-error-dest-appeared", "path" => path.quote()),
)
} else {
e
}
})
}

fn copy_dir_contents(
from: &Path,
to: &Path,
Expand All @@ -1085,7 +1102,7 @@ fn copy_dir_contents(
display_manager: Option<&MultiProgress>,
) -> io::Result<()> {
// Create the destination directory
fs::create_dir_all(to)?;
create_dir_fail_closed(to)?;

#[cfg(unix)]
{
Expand Down Expand Up @@ -1167,7 +1184,7 @@ fn copy_dir_contents_recursive(
print_verbose(&from_path, &to_path);
} else if from_path.is_dir() {
// Recursively copy subdirectory (only real directories, not symlinks)
fs::create_dir_all(&to_path)?;
create_dir_fail_closed(&to_path)?;

// Preserve ownership (uid/gid) of the subdirectory
#[cfg(unix)]
Expand Down
41 changes: 41 additions & 0 deletions tests/by-util/test_mv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2660,6 +2660,47 @@ fn test_mv_cross_device_refuses_planted_symlink_dest() {
);
}

/// Directory counterpart of `test_mv_cross_device_refuses_planted_symlink_dest`.
///
/// The cross-device directory fallback removes the destination and recreates it.
/// Recreation must fail closed: `create_dir_all` would accept a symlink-to-directory
/// left at the path and move the whole source tree through it, outside the destination.
#[test]
#[cfg(target_os = "linux")]
fn test_mv_cross_device_dir_refuses_symlink_at_recreated_dest() {
use std::os::unix::fs::symlink;
use tempfile::TempDir;
use uutests::util::TestScenario;

let scene = TestScenario::new(util_name!());
let at = &scene.fixtures;

let src_dir =
TempDir::new_in("/dev/shm/").expect("Unable to create temp directory in /dev/shm");
let src = src_dir.path().join("srcdir");
std::fs::create_dir(&src).expect("create src");
std::fs::write(src.join("payload"), "PAYLOAD_FROM_SRC").expect("write payload");

at.mkdir("victim");
at.write("victim/guard", "PROTECTED_DATA");
at.symlink_dir("victim", "target");

// Whether this succeeds or fails, the invariant is that nothing from the
// source is written inside `victim`.
scene
.ucmd()
.arg("-T")
.arg(src.to_str().unwrap())
.arg("target")
.run();

assert!(
!at.file_exists("victim/payload"),
"cross-device dir move escaped the destination through a symlink"
);
assert_eq!(at.read("victim/guard"), "PROTECTED_DATA");
}

#[test]
#[cfg(all(
feature = "feat_selinux",
Expand Down
Loading