1
Fork 0
mirror of https://github.com/RGBCube/uutils-coreutils synced 2025-07-27 19:17:43 +00:00

mv: improve the hardlink support (#8296)

* mv: implement hardlink support

Should fix GNU tests/mv/part-hardlink.sh tests/mv/hard-link-1.sh

Co-authored-by: Daniel Hofstetter <daniel.hofstetter@42dh.com>

* mv: fix the GNU test - tests/mv/part-fail

* make it pass on windows

---------

Co-authored-by: Daniel Hofstetter <daniel.hofstetter@42dh.com>
This commit is contained in:
Sylvestre Ledru 2025-07-03 16:28:09 +02:00 committed by GitHub
parent 854d9af125
commit e9b24b4bc2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 1238 additions and 62 deletions

View file

@ -3,7 +3,8 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
//
// spell-checker:ignore mydir
// spell-checker:ignore mydir hardlinked tmpfs
use filetime::FileTime;
use rstest::rstest;
use std::io::Write;
@ -1845,24 +1846,17 @@ mod inter_partition_copying {
let scene = TestScenario::new(util_name!());
let at = &scene.fixtures;
// create a file in the current partition.
at.write("src", "src contents");
// create a folder in another partition.
let other_fs_tempdir =
TempDir::new_in("/dev/shm/").expect("Unable to create temp directory");
// create a file inside that folder.
let other_fs_file_path = other_fs_tempdir.path().join("other_fs_file");
write(&other_fs_file_path, "other fs file contents")
.expect("Unable to write to other_fs_file");
// create a symlink to the file inside the same directory.
let symlink_path = other_fs_tempdir.path().join("symlink_to_file");
symlink(&other_fs_file_path, &symlink_path).expect("Unable to create symlink_to_file");
// disable write for the target folder so that when mv tries to remove the
// the destination symlink inside the target directory it would fail.
set_permissions(other_fs_tempdir.path(), PermissionsExt::from_mode(0o555))
.expect("Unable to set permissions for temp directory");
@ -1875,6 +1869,459 @@ mod inter_partition_copying {
.stderr_contains("inter-device move failed:")
.stderr_contains("Permission denied");
}
// Test that hardlinks are preserved when moving files across partitions
#[test]
#[cfg(unix)]
pub(crate) fn test_mv_preserves_hardlinks_across_partitions() {
use std::fs::metadata;
use std::os::unix::fs::MetadataExt;
use tempfile::TempDir;
use uutests::util::TestScenario;
let scene = TestScenario::new(util_name!());
let at = &scene.fixtures;
at.write("file1", "test content");
at.hard_link("file1", "file2");
let metadata1 = metadata(at.plus("file1")).expect("Failed to get metadata for file1");
let metadata2 = metadata(at.plus("file2")).expect("Failed to get metadata for file2");
assert_eq!(
metadata1.ino(),
metadata2.ino(),
"Files should have same inode before move"
);
assert_eq!(
metadata1.nlink(),
2,
"Files should have nlink=2 before move"
);
// Create a target directory in another partition (using /dev/shm which is typically tmpfs)
let other_fs_tempdir = TempDir::new_in("/dev/shm/")
.expect("Unable to create temp directory in /dev/shm - test requires tmpfs");
scene
.ucmd()
.arg("file1")
.arg("file2")
.arg(other_fs_tempdir.path().to_str().unwrap())
.succeeds();
assert!(!at.file_exists("file1"), "file1 should not exist in source");
assert!(!at.file_exists("file2"), "file2 should not exist in source");
let moved_file1 = other_fs_tempdir.path().join("file1");
let moved_file2 = other_fs_tempdir.path().join("file2");
assert!(moved_file1.exists(), "file1 should exist in destination");
assert!(moved_file2.exists(), "file2 should exist in destination");
let moved_metadata1 =
metadata(&moved_file1).expect("Failed to get metadata for moved file1");
let moved_metadata2 =
metadata(&moved_file2).expect("Failed to get metadata for moved file2");
assert_eq!(
moved_metadata1.ino(),
moved_metadata2.ino(),
"Files should have same inode after cross-partition move (hardlinks preserved)"
);
assert_eq!(
moved_metadata1.nlink(),
2,
"Files should have nlink=2 after cross-partition move"
);
// Verify content is preserved
assert_eq!(
std::fs::read_to_string(&moved_file1).expect("Failed to read moved file1"),
"test content"
);
assert_eq!(
std::fs::read_to_string(&moved_file2).expect("Failed to read moved file2"),
"test content"
);
}
// Test that hardlinks are preserved even with multiple sets of hardlinked files
#[test]
#[cfg(unix)]
#[allow(clippy::too_many_lines)]
#[allow(clippy::similar_names)]
pub(crate) fn test_mv_preserves_multiple_hardlink_groups_across_partitions() {
use std::fs::metadata;
use std::os::unix::fs::MetadataExt;
use tempfile::TempDir;
use uutests::util::TestScenario;
let scene = TestScenario::new(util_name!());
let at = &scene.fixtures;
at.write("group1_file1", "content group 1");
at.hard_link("group1_file1", "group1_file2");
at.write("group2_file1", "content group 2");
at.hard_link("group2_file1", "group2_file2");
at.write("single_file", "single file content");
let g1f1_meta = metadata(at.plus("group1_file1")).unwrap();
let g1f2_meta = metadata(at.plus("group1_file2")).unwrap();
let g2f1_meta = metadata(at.plus("group2_file1")).unwrap();
let g2f2_meta = metadata(at.plus("group2_file2")).unwrap();
let single_meta = metadata(at.plus("single_file")).unwrap();
assert_eq!(
g1f1_meta.ino(),
g1f2_meta.ino(),
"Group 1 files should have same inode"
);
assert_eq!(
g2f1_meta.ino(),
g2f2_meta.ino(),
"Group 2 files should have same inode"
);
assert_ne!(
g1f1_meta.ino(),
g2f1_meta.ino(),
"Different groups should have different inodes"
);
assert_eq!(single_meta.nlink(), 1, "Single file should have nlink=1");
let other_fs_tempdir =
TempDir::new_in("/dev/shm/").expect("Unable to create temp directory in /dev/shm");
scene
.ucmd()
.arg("group1_file1")
.arg("group1_file2")
.arg("group2_file1")
.arg("group2_file2")
.arg("single_file")
.arg(other_fs_tempdir.path().to_str().unwrap())
.succeeds();
// Verify hardlinks are preserved for both groups
let moved_g1f1 = other_fs_tempdir.path().join("group1_file1");
let moved_g1f2 = other_fs_tempdir.path().join("group1_file2");
let moved_g2f1 = other_fs_tempdir.path().join("group2_file1");
let moved_g2f2 = other_fs_tempdir.path().join("group2_file2");
let moved_single = other_fs_tempdir.path().join("single_file");
let moved_g1f1_meta = metadata(&moved_g1f1).unwrap();
let moved_g1f2_meta = metadata(&moved_g1f2).unwrap();
let moved_g2f1_meta = metadata(&moved_g2f1).unwrap();
let moved_g2f2_meta = metadata(&moved_g2f2).unwrap();
let moved_single_meta = metadata(&moved_single).unwrap();
assert_eq!(
moved_g1f1_meta.ino(),
moved_g1f2_meta.ino(),
"Group 1 files should still be hardlinked after move"
);
assert_eq!(
moved_g1f1_meta.nlink(),
2,
"Group 1 files should have nlink=2"
);
assert_eq!(
moved_g2f1_meta.ino(),
moved_g2f2_meta.ino(),
"Group 2 files should still be hardlinked after move"
);
assert_eq!(
moved_g2f1_meta.nlink(),
2,
"Group 2 files should have nlink=2"
);
assert_ne!(
moved_g1f1_meta.ino(),
moved_g2f1_meta.ino(),
"Different groups should still have different inodes"
);
assert_eq!(
moved_single_meta.nlink(),
1,
"Single file should still have nlink=1"
);
assert_eq!(
std::fs::read_to_string(&moved_g1f1).unwrap(),
"content group 1"
);
assert_eq!(
std::fs::read_to_string(&moved_g1f2).unwrap(),
"content group 1"
);
assert_eq!(
std::fs::read_to_string(&moved_g2f1).unwrap(),
"content group 2"
);
assert_eq!(
std::fs::read_to_string(&moved_g2f2).unwrap(),
"content group 2"
);
assert_eq!(
std::fs::read_to_string(&moved_single).unwrap(),
"single file content"
);
}
// Test the exact GNU test scenario: hardlinks within directories being moved
#[test]
#[cfg(unix)]
pub(crate) fn test_mv_preserves_hardlinks_in_directories_across_partitions() {
use std::fs::metadata;
use std::os::unix::fs::MetadataExt;
use tempfile::TempDir;
use uutests::util::TestScenario;
let scene = TestScenario::new(util_name!());
let at = &scene.fixtures;
at.write("f", "file content");
at.hard_link("f", "g");
at.mkdir("a");
at.mkdir("b");
at.write("a/1", "directory file content");
at.hard_link("a/1", "b/1");
let f_meta = metadata(at.plus("f")).unwrap();
let g_meta = metadata(at.plus("g")).unwrap();
let a1_meta = metadata(at.plus("a/1")).unwrap();
let b1_meta = metadata(at.plus("b/1")).unwrap();
assert_eq!(
f_meta.ino(),
g_meta.ino(),
"f and g should have same inode before move"
);
assert_eq!(f_meta.nlink(), 2, "f should have nlink=2 before move");
assert_eq!(
a1_meta.ino(),
b1_meta.ino(),
"a/1 and b/1 should have same inode before move"
);
assert_eq!(a1_meta.nlink(), 2, "a/1 should have nlink=2 before move");
let other_fs_tempdir =
TempDir::new_in("/dev/shm/").expect("Unable to create temp directory in /dev/shm");
scene
.ucmd()
.arg("f")
.arg("g")
.arg(other_fs_tempdir.path().to_str().unwrap())
.succeeds();
scene
.ucmd()
.arg("a")
.arg("b")
.arg(other_fs_tempdir.path().to_str().unwrap())
.succeeds();
let moved_f = other_fs_tempdir.path().join("f");
let moved_g = other_fs_tempdir.path().join("g");
let moved_f_metadata = metadata(&moved_f).unwrap();
let moved_second_file_metadata = metadata(&moved_g).unwrap();
assert_eq!(
moved_f_metadata.ino(),
moved_second_file_metadata.ino(),
"f and g should have same inode after cross-partition move"
);
assert_eq!(
moved_f_metadata.nlink(),
2,
"f should have nlink=2 after move"
);
// Verify directory files' hardlinks are preserved (the main test)
let moved_dir_a_file = other_fs_tempdir.path().join("a/1");
let moved_dir_second_file = other_fs_tempdir.path().join("b/1");
let moved_dir_a_file_metadata = metadata(&moved_dir_a_file).unwrap();
let moved_dir_second_file_metadata = metadata(&moved_dir_second_file).unwrap();
assert_eq!(
moved_dir_a_file_metadata.ino(),
moved_dir_second_file_metadata.ino(),
"a/1 and b/1 should have same inode after cross-partition directory move (hardlinks preserved)"
);
assert_eq!(
moved_dir_a_file_metadata.nlink(),
2,
"a/1 should have nlink=2 after move"
);
assert_eq!(std::fs::read_to_string(&moved_f).unwrap(), "file content");
assert_eq!(std::fs::read_to_string(&moved_g).unwrap(), "file content");
assert_eq!(
std::fs::read_to_string(&moved_dir_a_file).unwrap(),
"directory file content"
);
assert_eq!(
std::fs::read_to_string(&moved_dir_second_file).unwrap(),
"directory file content"
);
}
// Test complex scenario with multiple hardlink groups across nested directories
#[test]
#[cfg(unix)]
#[allow(clippy::too_many_lines)]
#[allow(clippy::similar_names)]
pub(crate) fn test_mv_preserves_complex_hardlinks_across_nested_directories() {
use std::fs::metadata;
use std::os::unix::fs::MetadataExt;
use tempfile::TempDir;
use uutests::util::TestScenario;
let scene = TestScenario::new(util_name!());
let at = &scene.fixtures;
at.mkdir("dir1");
at.mkdir("dir1/subdir1");
at.mkdir("dir1/subdir2");
at.mkdir("dir2");
at.mkdir("dir2/subdir1");
at.write("dir1/subdir1/file_a", "content A");
at.hard_link("dir1/subdir1/file_a", "dir1/subdir2/file_a_link1");
at.hard_link("dir1/subdir1/file_a", "dir2/subdir1/file_a_link2");
at.write("dir1/file_b", "content B");
at.hard_link("dir1/file_b", "dir2/file_b_link");
at.write("dir1/subdir1/nested_file", "nested content");
at.hard_link("dir1/subdir1/nested_file", "dir1/subdir2/nested_file_link");
let orig_file_a_metadata = metadata(at.plus("dir1/subdir1/file_a")).unwrap();
let orig_file_a_link1_metadata = metadata(at.plus("dir1/subdir2/file_a_link1")).unwrap();
let orig_file_a_link2_metadata = metadata(at.plus("dir2/subdir1/file_a_link2")).unwrap();
assert_eq!(orig_file_a_metadata.ino(), orig_file_a_link1_metadata.ino());
assert_eq!(orig_file_a_metadata.ino(), orig_file_a_link2_metadata.ino());
assert_eq!(
orig_file_a_metadata.nlink(),
3,
"file_a group should have nlink=3"
);
let orig_file_b_metadata = metadata(at.plus("dir1/file_b")).unwrap();
let orig_file_b_link_metadata = metadata(at.plus("dir2/file_b_link")).unwrap();
assert_eq!(orig_file_b_metadata.ino(), orig_file_b_link_metadata.ino());
assert_eq!(
orig_file_b_metadata.nlink(),
2,
"file_b group should have nlink=2"
);
let nested_meta = metadata(at.plus("dir1/subdir1/nested_file")).unwrap();
let nested_link_meta = metadata(at.plus("dir1/subdir2/nested_file_link")).unwrap();
assert_eq!(nested_meta.ino(), nested_link_meta.ino());
assert_eq!(
nested_meta.nlink(),
2,
"nested file group should have nlink=2"
);
let other_fs_tempdir =
TempDir::new_in("/dev/shm/").expect("Unable to create temp directory in /dev/shm");
scene
.ucmd()
.arg("dir1")
.arg("dir2")
.arg(other_fs_tempdir.path().to_str().unwrap())
.succeeds();
let moved_file_a = other_fs_tempdir.path().join("dir1/subdir1/file_a");
let moved_file_a_link1 = other_fs_tempdir.path().join("dir1/subdir2/file_a_link1");
let moved_file_a_link2 = other_fs_tempdir.path().join("dir2/subdir1/file_a_link2");
let final_file_a_metadata = metadata(&moved_file_a).unwrap();
let final_file_a_link1_metadata = metadata(&moved_file_a_link1).unwrap();
let final_file_a_link2_metadata = metadata(&moved_file_a_link2).unwrap();
assert_eq!(
final_file_a_metadata.ino(),
final_file_a_link1_metadata.ino(),
"file_a hardlinks should be preserved"
);
assert_eq!(
final_file_a_metadata.ino(),
final_file_a_link2_metadata.ino(),
"file_a hardlinks should be preserved across directories"
);
assert_eq!(
final_file_a_metadata.nlink(),
3,
"file_a group should still have nlink=3"
);
let moved_file_b = other_fs_tempdir.path().join("dir1/file_b");
let moved_file_b_hardlink = other_fs_tempdir.path().join("dir2/file_b_link");
let final_file_b_metadata = metadata(&moved_file_b).unwrap();
let final_file_b_hardlink_metadata = metadata(&moved_file_b_hardlink).unwrap();
assert_eq!(
final_file_b_metadata.ino(),
final_file_b_hardlink_metadata.ino(),
"file_b hardlinks should be preserved"
);
assert_eq!(
final_file_b_metadata.nlink(),
2,
"file_b group should still have nlink=2"
);
let moved_nested = other_fs_tempdir.path().join("dir1/subdir1/nested_file");
let moved_nested_link = other_fs_tempdir
.path()
.join("dir1/subdir2/nested_file_link");
let moved_nested_meta = metadata(&moved_nested).unwrap();
let moved_nested_link_meta = metadata(&moved_nested_link).unwrap();
assert_eq!(
moved_nested_meta.ino(),
moved_nested_link_meta.ino(),
"nested file hardlinks should be preserved"
);
assert_eq!(
moved_nested_meta.nlink(),
2,
"nested file group should still have nlink=2"
);
assert_eq!(std::fs::read_to_string(&moved_file_a).unwrap(), "content A");
assert_eq!(
std::fs::read_to_string(&moved_file_a_link1).unwrap(),
"content A"
);
assert_eq!(
std::fs::read_to_string(&moved_file_a_link2).unwrap(),
"content A"
);
assert_eq!(std::fs::read_to_string(&moved_file_b).unwrap(), "content B");
assert_eq!(
std::fs::read_to_string(&moved_file_b_hardlink).unwrap(),
"content B"
);
assert_eq!(
std::fs::read_to_string(&moved_nested).unwrap(),
"nested content"
);
assert_eq!(
std::fs::read_to_string(&moved_nested_link).unwrap(),
"nested content"
);
}
}
#[test]
@ -1892,6 +2339,97 @@ fn test_mv_error_msg_with_multiple_sources_that_does_not_exist() {
.stderr_contains("mv: cannot stat 'b/': No such file or directory");
}
// Tests for hardlink preservation (now always enabled)
#[test]
#[cfg(all(unix, not(target_os = "android")))]
fn test_mv_hardlink_preservation() {
let (at, mut ucmd) = at_and_ucmd!();
at.write("file1", "test content");
at.hard_link("file1", "file2");
at.mkdir("target");
ucmd.arg("file1")
.arg("file2")
.arg("target")
.succeeds()
.no_stderr();
assert!(at.file_exists("target/file1"));
assert!(at.file_exists("target/file2"));
}
#[test]
#[cfg(all(unix, not(target_os = "android")))]
fn test_mv_hardlink_progress_indication() {
let (at, mut ucmd) = at_and_ucmd!();
at.write("file1", "content1");
at.write("file2", "content2");
at.hard_link("file1", "file1_link");
at.mkdir("target");
// Test with progress bar and verbose mode
ucmd.arg("--progress")
.arg("--verbose")
.arg("file1")
.arg("file1_link")
.arg("file2")
.arg("target")
.succeeds();
// Verify all files were moved
assert!(at.file_exists("target/file1"));
assert!(at.file_exists("target/file1_link"));
assert!(at.file_exists("target/file2"));
}
#[test]
#[cfg(all(unix, not(target_os = "android")))]
fn test_mv_mixed_hardlinks_and_regular_files() {
use std::fs::metadata;
use std::os::unix::fs::MetadataExt;
let (at, mut ucmd) = at_and_ucmd!();
// Create a mix of hardlinked and regular files
at.write("hardlink1", "hardlink content");
at.hard_link("hardlink1", "hardlink2");
at.write("regular1", "regular content");
at.write("regular2", "regular content 2");
at.mkdir("target");
// Move all files (hardlinks automatically preserved)
ucmd.arg("hardlink1")
.arg("hardlink2")
.arg("regular1")
.arg("regular2")
.arg("target")
.succeeds();
// Verify all files moved
assert!(at.file_exists("target/hardlink1"));
assert!(at.file_exists("target/hardlink2"));
assert!(at.file_exists("target/regular1"));
assert!(at.file_exists("target/regular2"));
// Verify hardlinks are preserved (on same filesystem)
let h1_meta = metadata(at.plus("target/hardlink1")).unwrap();
let h2_meta = metadata(at.plus("target/hardlink2")).unwrap();
let r1_meta = metadata(at.plus("target/regular1")).unwrap();
let r2_meta = metadata(at.plus("target/regular2")).unwrap();
// Hardlinked files should have same inode if on same filesystem
if h1_meta.dev() == h2_meta.dev() {
assert_eq!(h1_meta.ino(), h2_meta.ino());
}
// Regular files should have different inodes
assert_ne!(r1_meta.ino(), r2_meta.ino());
}
#[cfg(not(windows))]
#[ignore = "requires access to a different filesystem"]
#[test]
@ -1906,3 +2444,47 @@ fn test_special_file_different_filesystem() {
assert!(Path::new("/dev/shm/tmp/f").exists());
std::fs::remove_dir_all("/dev/shm/tmp").unwrap();
}
/// Test cross-device move with permission denied error
/// This test mimics the scenario from the GNU part-fail test where
/// a cross-device move fails due to permission errors when removing the target file
#[test]
#[cfg(target_os = "linux")]
fn test_mv_cross_device_permission_denied() {
use std::fs::{set_permissions, write};
use std::os::unix::fs::PermissionsExt;
use tempfile::TempDir;
use uutests::util::TestScenario;
let scene = TestScenario::new(util_name!());
let at = &scene.fixtures;
at.write("k", "source content");
let other_fs_tempdir =
TempDir::new_in("/dev/shm/").expect("Unable to create temp directory in /dev/shm");
let target_file_path = other_fs_tempdir.path().join("k");
write(&target_file_path, "target content").expect("Unable to write target file");
// Remove write permissions from the directory to cause permission denied
set_permissions(other_fs_tempdir.path(), PermissionsExt::from_mode(0o555))
.expect("Unable to set directory permissions");
// Attempt to move file to the other filesystem
// This should fail with a permission denied error
let result = scene
.ucmd()
.arg("-f")
.arg("k")
.arg(target_file_path.to_str().unwrap())
.fails();
// Check that it contains permission denied and references the file
// The exact format may vary but should contain these key elements
let stderr = result.stderr_str();
assert!(stderr.contains("Permission denied") || stderr.contains("permission denied"));
set_permissions(other_fs_tempdir.path(), PermissionsExt::from_mode(0o755))
.expect("Unable to restore directory permissions");
}