From 8d96904b86b4e60266876c07e1b36855d82df774 Mon Sep 17 00:00:00 2001 From: John Costa Date: Mon, 21 Sep 2026 21:59:23 -0700 Subject: [PATCH 1/3] uucore/fs: follow a symlinked parent directory in replace_link Fixes #14795 --- src/uucore/src/lib/features/fs.rs | 4 +++- tests/by-util/test_ln.rs | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/uucore/src/lib/features/fs.rs b/src/uucore/src/lib/features/fs.rs index 62c2ee822c0..6f57dcdbf77 100644 --- a/src/uucore/src/lib/features/fs.rs +++ b/src/uucore/src/lib/features/fs.rs @@ -1127,10 +1127,12 @@ pub fn replace_link(target: &Path, dest: &Path, symbolic: bool) -> IOResult<()> let basename = dest .file_name() .ok_or_else(|| Error::new(ErrorKind::InvalidInput, "invalid link path"))?; + // No NOFOLLOW: the parent may be a symlink to a directory, which the + // create attempt above already followed. let dir = openat( CWD, parent, - OFlags::DIRECTORY | OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW, + OFlags::DIRECTORY | OFlags::RDONLY | OFlags::CLOEXEC, Mode::empty(), )?; let mut urandom = fs::File::open("/dev/urandom")?; diff --git a/tests/by-util/test_ln.rs b/tests/by-util/test_ln.rs index ddd5e70463b..2cfcc286e48 100644 --- a/tests/by-util/test_ln.rs +++ b/tests/by-util/test_ln.rs @@ -193,6 +193,22 @@ fn test_force_replace_same_inode_leaves_no_temp_file() { ); } +/// The destination can sit inside a directory reached through a symlink, as +/// when the target argument is a symlink to a directory. The replace must +/// follow it the same way the first create attempt does. +#[test] +#[cfg(unix)] +fn test_force_replace_in_symlinked_directory() { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("real"); + at.symlink_dir("real", "dirlink"); + at.symlink_file("old", "real/link"); + + ucmd.args(&["-s", "-f", "new", "dirlink/link"]).succeeds(); + + assert_eq!(at.resolve_link("real/link"), "new"); +} + #[test] fn test_symlink_overwrite_force_overrides_interactive() { let (at, mut ucmd) = at_and_ucmd!(); From 380d3eb1555c14ebc2fd5f77068c0d930e9cbe03 Mon Sep 17 00:00:00 2001 From: John Costa Date: Thu, 24 Sep 2026 16:53:17 -0700 Subject: [PATCH 2/3] ln: skip the symlinked-parent test under the WASI sandbox --- tests/by-util/test_ln.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/by-util/test_ln.rs b/tests/by-util/test_ln.rs index 2cfcc286e48..6687128ee47 100644 --- a/tests/by-util/test_ln.rs +++ b/tests/by-util/test_ln.rs @@ -198,6 +198,10 @@ fn test_force_replace_same_inode_leaves_no_temp_file() { /// follow it the same way the first create attempt does. #[test] #[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: creating a link inside a symlinked directory is denied" +)] fn test_force_replace_in_symlinked_directory() { let (at, mut ucmd) = at_and_ucmd!(); at.mkdir("real"); From fb256eb78aac7b714368efffedb4ecbcb8368159 Mon Sep 17 00:00:00 2001 From: John Costa Date: Fri, 25 Sep 2026 12:25:34 -0700 Subject: [PATCH 3/3] ln: cover the hard link case of replacing in a symlinked directory --- tests/by-util/test_ln.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/by-util/test_ln.rs b/tests/by-util/test_ln.rs index 6687128ee47..f53028cdb4c 100644 --- a/tests/by-util/test_ln.rs +++ b/tests/by-util/test_ln.rs @@ -213,6 +213,28 @@ fn test_force_replace_in_symlinked_directory() { assert_eq!(at.resolve_link("real/link"), "new"); } +/// Same as above for a hard link, which goes through the same replace path. +#[test] +// Android's app-private filesystem refuses hard links. +#[cfg(all(unix, not(any(target_os = "redox", target_os = "android"))))] +#[cfg_attr( + wasi_runner, + ignore = "WASI sandbox: creating a link inside a symlinked directory is denied" +)] +fn test_force_replace_hard_link_in_symlinked_directory() { + use std::os::unix::fs::MetadataExt; + + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("new"); + at.mkdir("real"); + at.symlink_dir("real", "dirlink"); + at.touch("real/link"); + + ucmd.args(&["-f", "new", "dirlink/link"]).succeeds(); + + assert_eq!(at.metadata("real/link").ino(), at.metadata("new").ino()); +} + #[test] fn test_symlink_overwrite_force_overrides_interactive() { let (at, mut ucmd) = at_and_ucmd!();