From 76a46c31579043686d425cd0fe7a704ed8cc7971 Mon Sep 17 00:00:00 2001 From: Jake Wallin Date: Fri, 16 Oct 2026 20:28:23 +0000 Subject: [PATCH] cp: replace distinct symlinks when creating hard links --- src/uu/cp/src/cp.rs | 19 ++++++++++++-- tests/by-util/test_cp.rs | 56 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/uu/cp/src/cp.rs b/src/uu/cp/src/cp.rs index 9db77d6549b..75eca098725 100644 --- a/src/uu/cp/src/cp.rs +++ b/src/uu/cp/src/cp.rs @@ -2578,6 +2578,10 @@ fn handle_copy_mode( } force = options.overwrite == OverwriteMode::Clobber(ClobberMode::Force); } + // Check after backup handling, which may have moved the destination away. + let replace_symlink = !options.dereference(source_in_command_line) + && source_metadata.file_type().is_symlink() + && dest.is_symlink(); let src = if options.dereference(source_in_command_line) && source.is_symlink() { canonicalize(source, MissingHandling::Missing, ResolveMode::Physical).unwrap() } else { @@ -2585,7 +2589,7 @@ fn handle_copy_mode( }; // Replace atomically rather than unlinking first: the gap would // let another user claim `dest` under a name the caller trusts. - if force { + if force || replace_symlink { replace_link(&src, dest, false) } else { fs::hard_link(&src, dest) @@ -2596,6 +2600,12 @@ fn handle_copy_mode( translate!("cp-error-cannot-create-hard-link", "dest" => get_filename(dest).unwrap_or("?").quote(), "source" => get_filename(source).unwrap_or("?").quote()) ) })?; + if replace_symlink && options.verbose { + println!( + "{}", + translate!("cp-verbose-removed", "path" => dest.quote()) + ); + } } CopyMode::Copy => { copy_helper( @@ -2853,7 +2863,12 @@ fn copy_file( OverwriteMode::Clobber(ClobberMode::RemoveDestination) )) { - if paths_refer_to_same_file(source, dest, true) && options.copy_mode == CopyMode::Link { + // Distinct symlinks can share a target without being the same link under -P. + let dereference_links = + options.dereference(source_in_command_line) || !source_is_symlink || !dest_is_symlink; + if options.copy_mode == CopyMode::Link + && paths_refer_to_same_file(source, dest, dereference_links) + { if source_is_symlink { if !dest_is_symlink { return Ok(()); diff --git a/tests/by-util/test_cp.rs b/tests/by-util/test_cp.rs index b30e774a13f..1a7dd49d219 100644 --- a/tests/by-util/test_cp.rs +++ b/tests/by-util/test_cp.rs @@ -9890,3 +9890,59 @@ fn test_progressbar_inexistent_source() { .fails_with_code(1) .stderr_contains("cp: cannot stat 'inexistent1': No such file or directory"); } + +#[cfg(all(unix, not(target_os = "android")))] // Android denies hard links in the test directory. +#[rstest] +fn test_cp_hardlink_replaces_distinct_symlink( + #[values("file", "hop")] target: &str, + #[values(false, true)] backup: bool, +) { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("file", "keep me"); + at.symlink_file("file", "source"); + at.symlink_file("file", "hop"); + at.symlink_file(target, "dest"); + let dest_inode = at.symlink_metadata("dest").ino(); + + let expected = if backup { + ucmd.arg("--backup=simple"); + "'source' -> 'dest'\n" + } else { + "removed 'dest'\n'source' -> 'dest'\n" + }; + + ucmd.args(&["--link", "--no-dereference", "--verbose", "source", "dest"]) + .succeeds() + .stdout_only(expected); + + assert_eq!(at.resolve_link("dest"), "file"); + assert_eq!( + at.symlink_metadata("source").ino(), + at.symlink_metadata("dest").ino() + ); + assert_eq!(at.read("file"), "keep me"); + if backup { + assert_eq!(at.resolve_link("dest~"), target); + assert_eq!(at.symlink_metadata("dest~").ino(), dest_inode); + } +} + +#[cfg(all(unix, not(target_os = "android")))] // Android denies hard links in the test directory. +#[rstest] +fn test_cp_hardlink_keeps_existing_symlink(#[values(false, true)] dangling: bool) { + let (at, mut ucmd) = at_and_ucmd!(); + if !dangling { + at.write("file", "keep me"); + } + at.symlink_file("file", "source"); + at.hard_link("source", "dest"); + let inode = at.symlink_metadata("dest").ino(); + + ucmd.args(&["--link", "--no-dereference", "--verbose", "source", "dest"]) + .succeeds() + .no_output(); + + assert_eq!(at.resolve_link("dest"), "file"); + assert_eq!(at.symlink_metadata("dest").ino(), inode); + assert_eq!(at.symlink_metadata("source").ino(), inode); +}