From 235259e4c63a9c9156ec2fff0059c36f3cf9c723 Mon Sep 17 00:00:00 2001 From: Chaganti-Reddy Date: Fri, 10 Jul 2026 17:36:46 +0530 Subject: [PATCH 1/3] ln: don't refuse --backup when src/dst are different names for the same inode. Fixes #13166 --- src/uu/ln/src/ln.rs | 29 +++++++++++++++++------------ tests/by-util/test_ln.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/src/uu/ln/src/ln.rs b/src/uu/ln/src/ln.rs index 742ac5c4a2e..fb9d5c60bc1 100644 --- a/src/uu/ln/src/ln.rs +++ b/src/uu/ln/src/ln.rs @@ -402,6 +402,17 @@ fn relative_path<'a>(src: &'a Path, dst: &Path) -> Cow<'a, Path> { src.into() } +/// Decide whether `src` and `dst` are actually the same directory entry. +fn is_same_entry(src: &Path, dst: &Path) -> bool { + match ( + canonicalize(src, MissingHandling::Missing, ResolveMode::Physical), + canonicalize(dst, MissingHandling::Missing, ResolveMode::Physical), + ) { + (Ok(src), Ok(dst)) => src == dst, + _ => true, + } +} + #[allow(clippy::cognitive_complexity)] fn link(src: &Path, dst: &Path, settings: &Settings) -> LnResult<()> { let mut backup_path = None; @@ -415,7 +426,7 @@ fn link(src: &Path, dst: &Path, settings: &Settings) -> LnResult<()> { backup_path = backup_control::get_backup_path(settings.backup, dst, &settings.suffix); if settings.backup == BackupMode::Existing && !settings.symbolic { // when ln --backup f f, it should detect that it is the same file - if paths_refer_to_same_file(src, dst, true) { + if paths_refer_to_same_file(src, dst, true) && is_same_entry(src, dst) { return Err(LnError::SameFile(src.to_owned(), dst.to_owned())); } } @@ -438,18 +449,12 @@ fn link(src: &Path, dst: &Path, settings: &Settings) -> LnResult<()> { // In case of error, don't do anything } OverwriteMode::Force => { - if !dst.is_symlink() && paths_refer_to_same_file(src, dst, true) { + if !dst.is_symlink() + && paths_refer_to_same_file(src, dst, true) + && is_same_entry(src, dst) + { // Even in force overwrite mode, verify we are not targeting the same entry and return a SameFile error if so - let same_entry = match ( - canonicalize(src, MissingHandling::Missing, ResolveMode::Physical), - canonicalize(dst, MissingHandling::Missing, ResolveMode::Physical), - ) { - (Ok(src), Ok(dst)) => src == dst, - _ => true, - }; - if same_entry { - return Err(LnError::SameFile(src.to_owned(), dst.to_owned())); - } + return Err(LnError::SameFile(src.to_owned(), dst.to_owned())); } let _ = fs::remove_file(dst); // In case of error, don't do anything diff --git a/tests/by-util/test_ln.rs b/tests/by-util/test_ln.rs index 7702b8b9de1..06e850780e2 100644 --- a/tests/by-util/test_ln.rs +++ b/tests/by-util/test_ln.rs @@ -5,6 +5,7 @@ #![allow(clippy::similar_names)] use std::path::PathBuf; +use std::time::Duration; use uutests::at_and_ucmd; use uutests::new_ucmd; use uutests::util::TestScenario; @@ -840,6 +841,37 @@ fn test_backup_same_file() { .stderr_contains("n: 'file1' and './file1' are the same file"); } +#[test] +fn test_backup_existing_hardlinked_under_different_name() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("a"); + at.hard_link("a", "b"); + + ucmd.args(&["--backup", "a", "b"]).succeeds().no_stderr(); + + assert!(at.file_exists("a")); + assert!(at.file_exists("b")); + assert!(at.file_exists("b~")); +} + +#[test] +#[cfg(unix)] +fn test_backup_existing_hardlinked_target_is_fifo() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("a"); + at.hard_link("a", "b"); + at.mkfifo("b~"); + + ucmd.args(&["--backup", "a", "b"]) + .timeout(Duration::from_secs(10)) + .succeeds() + .no_stderr(); + + assert!(at.file_exists("a")); + assert!(at.file_exists("b")); + assert!(!at.is_fifo("b~")); +} + #[test] fn test_backup_force() { let scene = TestScenario::new(util_name!()); From f8ed0237ea1a75fe2b14569c1a06040e98e26783 Mon Sep 17 00:00:00 2001 From: Chaganti-Reddy Date: Fri, 10 Jul 2026 17:53:08 +0530 Subject: [PATCH 2/3] ln: fix cspell word and unused-import-on-windows in the new backup tests --- tests/by-util/test_ln.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/by-util/test_ln.rs b/tests/by-util/test_ln.rs index 06e850780e2..d1b9387826d 100644 --- a/tests/by-util/test_ln.rs +++ b/tests/by-util/test_ln.rs @@ -5,6 +5,7 @@ #![allow(clippy::similar_names)] use std::path::PathBuf; +#[cfg(unix)] use std::time::Duration; use uutests::at_and_ucmd; use uutests::new_ucmd; @@ -842,7 +843,7 @@ fn test_backup_same_file() { } #[test] -fn test_backup_existing_hardlinked_under_different_name() { +fn test_backup_existing_hard_linked_under_different_name() { let (at, mut ucmd) = at_and_ucmd!(); at.touch("a"); at.hard_link("a", "b"); @@ -856,7 +857,7 @@ fn test_backup_existing_hardlinked_under_different_name() { #[test] #[cfg(unix)] -fn test_backup_existing_hardlinked_target_is_fifo() { +fn test_backup_existing_hard_linked_target_is_fifo() { let (at, mut ucmd) = at_and_ucmd!(); at.touch("a"); at.hard_link("a", "b"); From f9e8617bc7278b1e779d1d719799423144bc130e Mon Sep 17 00:00:00 2001 From: Chaganti-Reddy Date: Mon, 13 Jul 2026 10:12:30 +0530 Subject: [PATCH 3/3] ln: skip the new backup tests on android, hard_link isn't permitted there --- tests/by-util/test_ln.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/by-util/test_ln.rs b/tests/by-util/test_ln.rs index d1b9387826d..f9f2276eef6 100644 --- a/tests/by-util/test_ln.rs +++ b/tests/by-util/test_ln.rs @@ -5,7 +5,7 @@ #![allow(clippy::similar_names)] use std::path::PathBuf; -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "android")))] use std::time::Duration; use uutests::at_and_ucmd; use uutests::new_ucmd; @@ -843,6 +843,7 @@ fn test_backup_same_file() { } #[test] +#[cfg(not(target_os = "android"))] fn test_backup_existing_hard_linked_under_different_name() { let (at, mut ucmd) = at_and_ucmd!(); at.touch("a"); @@ -856,7 +857,7 @@ fn test_backup_existing_hard_linked_under_different_name() { } #[test] -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "android")))] fn test_backup_existing_hard_linked_target_is_fifo() { let (at, mut ucmd) = at_and_ucmd!(); at.touch("a");