Skip to content

Commit 45f1e96

Browse files
committed
mv: preserve symlinks during cross-device moves instead of expanding them
closes: #10009
1 parent efbe49f commit 45f1e96

File tree

2 files changed

+80
-2
lines changed

2 files changed

+80
-2
lines changed

src/uu/mv/src/mv.rs

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1059,8 +1059,35 @@ fn copy_dir_contents_recursive(
10591059
pb.set_message(from_path.to_string_lossy().to_string());
10601060
}
10611061

1062-
if from_path.is_dir() {
1063-
// Recursively copy subdirectory
1062+
if from_path.is_symlink() {
1063+
// Handle symlinks first, before checking is_dir() which follows symlinks.
1064+
// This prevents symlinks to directories from being expanded into full copies.
1065+
#[cfg(unix)]
1066+
{
1067+
copy_file_with_hardlinks_helper(
1068+
&from_path,
1069+
&to_path,
1070+
hardlink_tracker,
1071+
hardlink_scanner,
1072+
)?;
1073+
}
1074+
#[cfg(not(unix))]
1075+
{
1076+
rename_symlink_fallback(&from_path, &to_path)?;
1077+
}
1078+
1079+
// Print verbose message for symlink
1080+
if verbose {
1081+
let message = translate!("mv-verbose-renamed", "from" => from_path.quote(), "to" => to_path.quote());
1082+
match display_manager {
1083+
Some(pb) => pb.suspend(|| {
1084+
println!("{message}");
1085+
}),
1086+
None => println!("{message}"),
1087+
}
1088+
}
1089+
} else if from_path.is_dir() {
1090+
// Recursively copy subdirectory (only real directories, not symlinks)
10641091
fs::create_dir_all(&to_path)?;
10651092

10661093
// Print verbose message for directory

tests/by-util/test_mv.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2851,3 +2851,54 @@ fn test_mv_xattr_enotsup_silent() {
28512851
std::fs::remove_file("/dev/shm/mv_test").ok();
28522852
}
28532853
}
2854+
2855+
/// Test that symlinks inside directories are preserved during cross-device moves
2856+
/// (not expanded into full copies of their targets)
2857+
#[test]
2858+
#[cfg(target_os = "linux")]
2859+
fn test_mv_cross_device_symlink_preserved() {
2860+
use std::fs;
2861+
use std::os::unix::fs::symlink;
2862+
use tempfile::TempDir;
2863+
2864+
let scene = TestScenario::new(util_name!());
2865+
let at = &scene.fixtures;
2866+
2867+
// Create a directory with a symlink to /etc inside
2868+
at.mkdir("src_dir");
2869+
at.write("src_dir/local.txt", "local content");
2870+
symlink("/etc", at.plus("src_dir/etc_link")).expect("Failed to create symlink");
2871+
2872+
// Verify initial state
2873+
assert!(at.is_symlink("src_dir/etc_link"));
2874+
2875+
// Force cross-filesystem move using /dev/shm (tmpfs)
2876+
let target_dir =
2877+
TempDir::new_in("/dev/shm/").expect("Unable to create temp directory in /dev/shm");
2878+
let target_path = target_dir.path().join("dst_dir");
2879+
2880+
scene
2881+
.ucmd()
2882+
.arg("src_dir")
2883+
.arg(target_path.to_str().unwrap())
2884+
.succeeds()
2885+
.no_stderr();
2886+
2887+
// Verify source was removed
2888+
assert!(!at.dir_exists("src_dir"));
2889+
2890+
// Verify the symlink was preserved (not expanded)
2891+
let moved_symlink = target_path.join("etc_link");
2892+
assert!(
2893+
moved_symlink.is_symlink(),
2894+
"etc_link should still be a symlink after cross-device move"
2895+
);
2896+
assert_eq!(
2897+
fs::read_link(&moved_symlink).expect("Failed to read symlink"),
2898+
std::path::Path::new("/etc"),
2899+
"symlink should still point to /etc"
2900+
);
2901+
2902+
// Verify the regular file was also moved
2903+
assert!(target_path.join("local.txt").exists());
2904+
}

0 commit comments

Comments
 (0)