Skip to content

Commit 9654e4a

Browse files
sylvestrecakebaker
authored andcommitted
mv: preserve symlinks during cross-device moves instead of expanding them
closes: #10009
1 parent 60b4d1b commit 9654e4a

2 files changed

Lines changed: 180 additions & 29 deletions

File tree

src/uu/mv/src/mv.rs

Lines changed: 38 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1039,6 +1039,20 @@ fn copy_dir_contents_recursive(
10391039
progress_bar: Option<&ProgressBar>,
10401040
display_manager: Option<&MultiProgress>,
10411041
) -> io::Result<()> {
1042+
// Helper closure to print verbose messages
1043+
let print_verbose = |from: &Path, to: &Path| {
1044+
if verbose {
1045+
let message =
1046+
translate!("mv-verbose-renamed", "from" => from.quote(), "to" => to.quote());
1047+
match display_manager {
1048+
Some(pb) => pb.suspend(|| {
1049+
println!("{message}");
1050+
}),
1051+
None => println!("{message}"),
1052+
}
1053+
}
1054+
};
1055+
10421056
let entries = fs::read_dir(from_dir)?;
10431057

10441058
for entry in entries {
@@ -1051,20 +1065,29 @@ fn copy_dir_contents_recursive(
10511065
pb.set_message(from_path.to_string_lossy().to_string());
10521066
}
10531067

1054-
if from_path.is_dir() {
1055-
// Recursively copy subdirectory
1068+
if from_path.is_symlink() {
1069+
// Handle symlinks first, before checking is_dir() which follows symlinks.
1070+
// This prevents symlinks to directories from being expanded into full copies.
1071+
#[cfg(unix)]
1072+
{
1073+
copy_file_with_hardlinks_helper(
1074+
&from_path,
1075+
&to_path,
1076+
hardlink_tracker,
1077+
hardlink_scanner,
1078+
)?;
1079+
}
1080+
#[cfg(not(unix))]
1081+
{
1082+
rename_symlink_fallback(&from_path, &to_path)?;
1083+
}
1084+
1085+
print_verbose(&from_path, &to_path);
1086+
} else if from_path.is_dir() {
1087+
// Recursively copy subdirectory (only real directories, not symlinks)
10561088
fs::create_dir_all(&to_path)?;
10571089

1058-
// Print verbose message for directory
1059-
if verbose {
1060-
let message = translate!("mv-verbose-renamed", "from" => from_path.quote(), "to" => to_path.quote());
1061-
match display_manager {
1062-
Some(pb) => pb.suspend(|| {
1063-
println!("{message}");
1064-
}),
1065-
None => println!("{message}"),
1066-
}
1067-
}
1090+
print_verbose(&from_path, &to_path);
10681091

10691092
copy_dir_contents_recursive(
10701093
&from_path,
@@ -1090,25 +1113,11 @@ fn copy_dir_contents_recursive(
10901113
}
10911114
#[cfg(not(unix))]
10921115
{
1093-
if from_path.is_symlink() {
1094-
// Copy a symlink file (no-follow).
1095-
rename_symlink_fallback(&from_path, &to_path)?;
1096-
} else {
1097-
// Copy a regular file.
1098-
fs::copy(&from_path, &to_path)?;
1099-
}
1116+
// Symlinks are already handled above, so this is always a regular file
1117+
fs::copy(&from_path, &to_path)?;
11001118
}
11011119

1102-
// Print verbose message for file
1103-
if verbose {
1104-
let message = translate!("mv-verbose-renamed", "from" => from_path.quote(), "to" => to_path.quote());
1105-
match display_manager {
1106-
Some(pb) => pb.suspend(|| {
1107-
println!("{message}");
1108-
}),
1109-
None => println!("{message}"),
1110-
}
1111-
}
1120+
print_verbose(&from_path, &to_path);
11121121
}
11131122

11141123
if let Some(pb) = progress_bar {

tests/by-util/test_mv.rs

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2864,3 +2864,145 @@ fn test_mv_xattr_enotsup_silent() {
28642864
std::fs::remove_file("/dev/shm/mv_test").ok();
28652865
}
28662866
}
2867+
2868+
/// Test that symlinks inside directories are preserved during cross-device moves
2869+
/// (not expanded into full copies of their targets)
2870+
#[test]
2871+
#[cfg(target_os = "linux")]
2872+
fn test_mv_cross_device_symlink_preserved() {
2873+
use std::fs;
2874+
use std::os::unix::fs::symlink;
2875+
use tempfile::TempDir;
2876+
2877+
let scene = TestScenario::new(util_name!());
2878+
let at = &scene.fixtures;
2879+
2880+
// Create a directory with a symlink to /etc inside
2881+
at.mkdir("src_dir");
2882+
at.write("src_dir/local.txt", "local content");
2883+
symlink("/etc", at.plus("src_dir/etc_link")).expect("Failed to create symlink");
2884+
2885+
assert!(at.is_symlink("src_dir/etc_link"));
2886+
2887+
// Force cross-filesystem move using /dev/shm (tmpfs)
2888+
let target_dir =
2889+
TempDir::new_in("/dev/shm/").expect("Unable to create temp directory in /dev/shm");
2890+
let target_path = target_dir.path().join("dst_dir");
2891+
2892+
scene
2893+
.ucmd()
2894+
.arg("src_dir")
2895+
.arg(target_path.to_str().unwrap())
2896+
.succeeds()
2897+
.no_stderr();
2898+
2899+
assert!(!at.dir_exists("src_dir"));
2900+
2901+
// Verify the symlink was preserved (not expanded)
2902+
let moved_symlink = target_path.join("etc_link");
2903+
assert!(
2904+
moved_symlink.is_symlink(),
2905+
"etc_link should still be a symlink after cross-device move"
2906+
);
2907+
assert_eq!(
2908+
fs::read_link(&moved_symlink).expect("Failed to read symlink"),
2909+
Path::new("/etc"),
2910+
"symlink should still point to /etc"
2911+
);
2912+
2913+
assert!(target_path.join("local.txt").exists());
2914+
}
2915+
2916+
/// Test that broken/dangling symlinks are preserved during cross-device moves
2917+
#[test]
2918+
#[cfg(target_os = "linux")]
2919+
fn test_mv_cross_device_broken_symlink_preserved() {
2920+
use std::fs;
2921+
use std::os::unix::fs::symlink;
2922+
use tempfile::TempDir;
2923+
2924+
let scene = TestScenario::new(util_name!());
2925+
let at = &scene.fixtures;
2926+
2927+
// Create a directory with a broken symlink inside
2928+
at.mkdir("src_dir");
2929+
symlink("/nonexistent/path", at.plus("src_dir/broken_link"))
2930+
.expect("Failed to create broken symlink");
2931+
2932+
assert!(at.is_symlink("src_dir/broken_link"));
2933+
assert!(!at.file_exists("src_dir/broken_link"));
2934+
2935+
// Force cross-filesystem move using /dev/shm (tmpfs)
2936+
let target_dir =
2937+
TempDir::new_in("/dev/shm/").expect("Unable to create temp directory in /dev/shm");
2938+
let target_path = target_dir.path().join("dst_dir");
2939+
2940+
scene
2941+
.ucmd()
2942+
.arg("src_dir")
2943+
.arg(target_path.to_str().unwrap())
2944+
.succeeds()
2945+
.no_stderr();
2946+
2947+
assert!(!at.dir_exists("src_dir"));
2948+
2949+
let moved_symlink = target_path.join("broken_link");
2950+
assert!(
2951+
moved_symlink.is_symlink(),
2952+
"broken_link should still be a symlink after cross-device move"
2953+
);
2954+
assert_eq!(
2955+
fs::read_link(&moved_symlink).expect("Failed to read broken symlink"),
2956+
Path::new("/nonexistent/path"),
2957+
"broken symlink should still point to its original (nonexistent) target"
2958+
);
2959+
}
2960+
2961+
/// Test that symlinks to regular files are preserved during cross-device moves
2962+
#[test]
2963+
#[cfg(target_os = "linux")]
2964+
fn test_mv_cross_device_file_symlink_preserved() {
2965+
use std::fs;
2966+
use std::os::unix::fs::symlink;
2967+
use tempfile::TempDir;
2968+
2969+
let scene = TestScenario::new(util_name!());
2970+
let at = &scene.fixtures;
2971+
2972+
// Create a directory with a file and a symlink to it
2973+
at.mkdir("src_dir");
2974+
at.write("src_dir/target.txt", "target content");
2975+
symlink(at.plus("src_dir/target.txt"), at.plus("src_dir/file_link"))
2976+
.expect("Failed to create file symlink");
2977+
2978+
assert!(at.is_symlink("src_dir/file_link"));
2979+
2980+
// Force cross-filesystem move using /dev/shm (tmpfs)
2981+
let target_dir =
2982+
TempDir::new_in("/dev/shm/").expect("Unable to create temp directory in /dev/shm");
2983+
let target_path = target_dir.path().join("dst_dir");
2984+
2985+
scene
2986+
.ucmd()
2987+
.arg("src_dir")
2988+
.arg(target_path.to_str().unwrap())
2989+
.succeeds()
2990+
.no_stderr();
2991+
2992+
assert!(!at.dir_exists("src_dir"));
2993+
2994+
// Verify the symlink was preserved (not expanded)
2995+
let moved_symlink = target_path.join("file_link");
2996+
assert!(
2997+
moved_symlink.is_symlink(),
2998+
"file_link should still be a symlink after cross-device move"
2999+
);
3000+
3001+
// Verify the target file was also moved
3002+
let moved_target = target_path.join("target.txt");
3003+
assert!(moved_target.exists());
3004+
assert_eq!(
3005+
fs::read_to_string(&moved_target).expect("Failed to read target file"),
3006+
"target content"
3007+
);
3008+
}

0 commit comments

Comments
 (0)