diff --git a/src/uu/ln/src/ln.rs b/src/uu/ln/src/ln.rs index f717294532d..f8ea4aff85c 100644 --- a/src/uu/ln/src/ln.rs +++ b/src/uu/ln/src/ln.rs @@ -20,9 +20,15 @@ use std::collections::HashSet; use std::ffi::OsString; use std::fs; +#[cfg(all(unix, not(target_os = "redox")))] +use std::ffi::OsStr; use std::path::{Path, PathBuf}; use uucore::backup_control::{self, BackupMode}; +#[cfg(all(unix, not(target_os = "redox")))] +use uucore::fs::link_at; use uucore::fs::{MissingHandling, ResolveMode, canonicalize}; +#[cfg(all(unix, not(target_os = "redox")))] +use uucore::safe_traversal::{DirFd, SymlinkBehavior}; /// Public visibility allows other apps to integrate with our /// `ln` utility by calling `exec` directly with their `Settings`. @@ -294,12 +300,26 @@ fn link_files_in_dir(files: &[PathBuf], target_dir: &Path, settings: &Settings) if !target_dir.is_dir() { return Err(LnError::TargetIsNotADirectory(target_dir.to_owned())); } + let replace_target_dir = settings.no_dereference && target_dir.is_symlink(); + // Links are made through the open directory, so they land in it even if + // `target_dir` is renamed meanwhile. + #[cfg(all(unix, not(target_os = "redox")))] + let dir = if replace_target_dir { + None + } else { + Some(DirFd::open_anchor(target_dir).map_err(|e| { + LnError::IoContext( + UIoError::from(e), + translate!("ln-failed-to-access", "file" => target_dir.quote()), + ) + })?) + }; // remember the linked destinations for further usage let mut linked_destinations: HashSet = HashSet::with_capacity(files.len()); let mut all_successful = true; for srcpath in files { - let targetpath = if settings.no_dereference && target_dir.is_symlink() { + let dest = if replace_target_dir { let remove_target = || { // Not sure why but on Windows, the symlink can be // considered as a dir @@ -326,26 +346,32 @@ fn link_files_in_dir(files: &[PathBuf], target_dir: &Path, settings: &Settings) remove_target(); } } - target_dir.to_path_buf() + Dest::by_path(target_dir.into()) } else { - match srcpath.file_name() { - Some(basename) => target_dir.join(basename), - // This can be None only for "." or "..". Trying - // to create a link with such name will fail with - // EEXIST, which agrees with the behavior of GNU - // coreutils. - None => target_dir.join(srcpath), + // This can be None only for "." or "..". Trying + // to create a link with such name will fail with + // EEXIST, which agrees with the behavior of GNU + // coreutils. + let name = srcpath.file_name().unwrap_or(srcpath.as_os_str()); + Dest { + path: target_dir.join(name).into(), + #[cfg(all(unix, not(target_os = "redox")))] + at: dir.as_ref().map(|dir| InDir { + dir, + dir_path: target_dir, + name: name.into(), + }), } }; - if linked_destinations.contains(&targetpath) { + if linked_destinations.contains(dest.path.as_ref()) { // If the target file was already created in this ln call, do not overwrite show_error!( "{}", - translate!("ln-error-will-not-overwrite", "target" => targetpath.quote(), "source" => srcpath.quote()) + translate!("ln-error-will-not-overwrite", "target" => dest.path.quote(), "source" => srcpath.quote()) ); all_successful = false; - } else if let Err(e) = link(srcpath, &targetpath, settings) { + } else if let Err(e) = link_to(srcpath, &dest, settings) { // A declined prompt has nothing to report. if !matches!(e, LnError::SomeLinksFailed) { show_error!("{e}"); @@ -353,7 +379,7 @@ fn link_files_in_dir(files: &[PathBuf], target_dir: &Path, settings: &Settings) all_successful = false; } - linked_destinations.insert(targetpath); + linked_destinations.insert(dest.path.into_owned()); } if all_successful { Ok(()) @@ -393,9 +419,13 @@ fn is_same_entry(src: &Path, dst: &Path) -> bool { /// Create symlink to src at dst with the given settings /// /// This is made public to allow other apps to use `ln` as a library. -#[allow(clippy::cognitive_complexity)] pub fn link(src: &Path, dst: &Path, settings: &Settings) -> LnResult<()> { - let mut backup_path = None; + link_to(src, &Dest::by_path(dst.into()), settings) +} + +fn link_to(src: &Path, dest: &Dest, settings: &Settings) -> LnResult<()> { + let dst = dest.path.as_ref(); + let mut backup = None; // Remove an existing destination only after a link attempt fails with EEXIST. let mut overwrite_on_conflict = false; let source: Cow<'_, Path> = if settings.relative { @@ -404,8 +434,8 @@ pub fn link(src: &Path, dst: &Path, settings: &Settings) -> LnResult<()> { src.into() }; - if dst.is_symlink() || dst.exists() { - backup_path = backup_control::get_backup_path(settings.backup, dst, &settings.suffix); + if dest.exists() { + backup = dest.backup(settings); if settings.backup != BackupMode::None && !settings.symbolic { // `ln --backup f f`: the backup would move `f` away before the // link is made, so detect the same file first, for any backup mode @@ -424,7 +454,7 @@ pub fn link(src: &Path, dst: &Path, settings: &Settings) -> LnResult<()> { } OverwriteMode::Force => { // A backup moves the destination away before the link is made. - if backup_path.is_none() + if backup.is_none() && !dst.is_symlink() && paths_refer_to_same_file(src, dst, true) && is_same_entry(src, dst) @@ -435,8 +465,8 @@ pub fn link(src: &Path, dst: &Path, settings: &Settings) -> LnResult<()> { overwrite_on_conflict = true; } } - if let Some(ref p) = backup_path { - fs::rename(dst, p).map_err(|e| { + if let Some(backup) = &backup { + dest.rename_to(backup).map_err(|e| { LnError::IoContext( UIoError::from(e), translate!("ln-cannot-backup", "file" => dst.quote()), @@ -462,13 +492,7 @@ pub fn link(src: &Path, dst: &Path, settings: &Settings) -> LnResult<()> { // Link first, matching GNU. Replacing uses a temp name + `renameat`, so // `dst` is never briefly free for another user to claim. let link_target = hard_link_src.as_deref().unwrap_or(&source); - let raw = if overwrite_on_conflict { - replace_link(link_target, dst, settings.symbolic) - } else if settings.symbolic { - symlink(&source, dst) - } else { - fs::hard_link(link_target, dst) - }; + let raw = dest.create_link(link_target, settings.symbolic, overwrite_on_conflict); let res = raw.map_err(|e| { if settings.symbolic { @@ -494,8 +518,8 @@ pub fn link(src: &Path, dst: &Path, settings: &Settings) -> LnResult<()> { }); if let Err(e) = res { - if let Some(ref p) = backup_path { - fs::rename(p, dst).map_err(|e| { + if let Some(backup) = &backup { + backup.rename_to(dest).map_err(|e| { LnError::IoContext( UIoError::from(e), translate!("ln-cannot-backup", "file" => dst.quote()), @@ -508,11 +532,11 @@ pub fn link(src: &Path, dst: &Path, settings: &Settings) -> LnResult<()> { if settings.verbose { let mut out = stdout(); write!(out, "{} -> {}", dst.quote(), source.quote())?; - match backup_path { - Some(path) => writeln!( + match backup { + Some(backup) => writeln!( out, " ({})", - translate!("ln-backup", "backup" => path.quote()) + translate!("ln-backup", "backup" => backup.path.quote()) )?, None => writeln!(out)?, } @@ -520,6 +544,95 @@ pub fn link(src: &Path, dst: &Path, settings: &Settings) -> LnResult<()> { Ok(()) } +/// Where [`link_to`] puts a link or a backup. +struct Dest<'a> { + path: Cow<'a, Path>, + #[cfg(all(unix, not(target_os = "redox")))] + at: Option>, +} + +/// The entry `name` in the directory `link_files_in_dir` opened, at `dir_path`. +#[cfg(all(unix, not(target_os = "redox")))] +struct InDir<'a> { + dir: &'a DirFd, + dir_path: &'a Path, + name: Cow<'a, OsStr>, +} + +impl<'a> Dest<'a> { + fn by_path(path: Cow<'a, Path>) -> Self { + Self { + path, + #[cfg(all(unix, not(target_os = "redox")))] + at: None, + } + } + + fn exists(&self) -> bool { + #[cfg(all(unix, not(target_os = "redox")))] + if let Some(at) = &self.at { + return at.dir.stat_at(&at.name, SymlinkBehavior::NoFollow).is_ok(); + } + self.path.is_symlink() || self.path.exists() + } + + /// Where the backup of this entry goes, in the same directory. + fn backup(&self, settings: &Settings) -> Option { + #[cfg(all(unix, not(target_os = "redox")))] + if let Some(at) = &self.at { + let exists = |path: &Path| { + at.dir + .stat_at(path.as_os_str(), SymlinkBehavior::NoFollow) + .is_ok() + }; + let name = backup_control::get_backup_path_with_exists( + settings.backup, + Path::new(&at.name), + &settings.suffix, + exists, + )?; + return Some(Self { + path: at.dir_path.join(&name).into(), + at: Some(InDir { + name: name.into_os_string().into(), + ..*at + }), + }); + } + backup_control::get_backup_path(settings.backup, &self.path, &settings.suffix) + .map(|path| Self::by_path(path.into())) + } + + /// Rename this entry to `to`, its backup or the entry it backs up. + fn rename_to(&self, to: &Self) -> io::Result<()> { + #[cfg(all(unix, not(target_os = "redox")))] + if let (Some(from), Some(to)) = (&self.at, &to.at) { + return rustix::fs::renameat(from.dir, &*from.name, to.dir, &*to.name) + .map_err(Into::into); + } + fs::rename(&self.path, &to.path) + } + + fn create_link(&self, target: &Path, symbolic: bool, replace: bool) -> io::Result<()> { + #[cfg(all(unix, not(target_os = "redox")))] + if let Some(at) = &self.at { + let create = |dir: &DirFd, name: &OsStr| link_at(target, dir, name, symbolic); + return if replace { + at.dir.replace_entry_at(&at.name, create) + } else { + create(at.dir, &at.name) + }; + } + if replace { + replace_link(target, &self.path, symbolic) + } else if symbolic { + symlink(target, &self.path) + } else { + fs::hard_link(target, &self.path) + } + } +} + #[cfg(windows)] pub fn symlink, P2: AsRef>(src: P1, dst: P2) -> io::Result<()> { use std::os::windows::fs::{symlink_dir, symlink_file}; diff --git a/src/uucore/Cargo.toml b/src/uucore/Cargo.toml index b1867dae76e..28803ebe4ca 100644 --- a/src/uucore/Cargo.toml +++ b/src/uucore/Cargo.toml @@ -159,7 +159,14 @@ encoding = ["data-encoding", "data-encoding-macro", "z85", "base64-simd"] entries = ["libc", "rustix/fs", "rustix/process"] extendedbigdecimal = ["bigdecimal", "num-traits"] fast-inc = [] -fs = ["dunce", "libc", "rustix/fs", "rustix/std", "windows-sys"] +fs = [ + "dunce", + "libc", + "rustix/fs", + "rustix/std", + "safe-traversal", + "windows-sys", +] fsext = ["libc", "windows-sys", "bstr", "wide"] fsxattr = ["xattr", "itertools", "libc"] hardware = [] diff --git a/src/uucore/locales/en-US.ftl b/src/uucore/locales/en-US.ftl index 6afc1ab7647..50d10db9479 100644 --- a/src/uucore/locales/en-US.ftl +++ b/src/uucore/locales/en-US.ftl @@ -35,6 +35,7 @@ error-invalid-argument = Invalid argument error-is-a-directory-text = Is a directory error-is-a-directory = { $file }: { error-is-a-directory-text } error-too-many-symlink-levels = Too many levels of symbolic links +error-no-unique-temp-name = no unique temporary name available in the destination directory # Common actions action-copying = copying diff --git a/src/uucore/locales/fr-FR.ftl b/src/uucore/locales/fr-FR.ftl index 80303701383..f781a57e160 100644 --- a/src/uucore/locales/fr-FR.ftl +++ b/src/uucore/locales/fr-FR.ftl @@ -34,6 +34,7 @@ error-invalid-argument = Argument invalide error-is-a-directory-text = Est un répertoire error-is-a-directory = { $file }: { error-is-a-directory-text } error-too-many-symlink-levels = Trop de niveaux de liens symboliques +error-no-unique-temp-name = aucun nom temporaire unique disponible dans le répertoire de destination # Actions communes action-copying = copie diff --git a/src/uucore/src/lib/features/backup_control.rs b/src/uucore/src/lib/features/backup_control.rs index 8ab36dbd14e..b4e43fba52e 100644 --- a/src/uucore/src/lib/features/backup_control.rs +++ b/src/uucore/src/lib/features/backup_control.rs @@ -406,12 +406,30 @@ pub fn get_backup_path>( backup_mode: BackupMode, backup_path: &Path, suffix: S, +) -> Option { + // `symlink_metadata`, not `exists()`: a dangling symlink still takes the + // name, and a live one whose target cannot be stat'd is not missing. + get_backup_path_with_exists(backup_mode, backup_path, suffix, |path| { + fs::symlink_metadata(path).is_ok() + }) +} + +/// [`get_backup_path`], with `exists` telling whether a candidate name is taken. +pub fn get_backup_path_with_exists>( + backup_mode: BackupMode, + backup_path: &Path, + suffix: S, + mut exists: impl FnMut(&Path) -> bool, ) -> Option { match backup_mode { BackupMode::None => None, BackupMode::Simple => Some(simple_backup_path(backup_path, suffix.as_ref())), - BackupMode::Numbered => Some(numbered_backup_path(backup_path)), - BackupMode::Existing => Some(existing_backup_path(backup_path, suffix.as_ref())), + BackupMode::Numbered => Some(numbered_backup_path(backup_path, &mut exists)), + BackupMode::Existing => Some(existing_backup_path( + backup_path, + suffix.as_ref(), + &mut exists, + )), } } @@ -421,25 +439,25 @@ fn simple_backup_path>(path: &Path, suffix: S) -> PathBuf { path.with_file_name(file_name) } -fn numbered_backup_path(path: &Path) -> PathBuf { +fn numbered_backup_path(path: &Path, exists: &mut impl FnMut(&Path) -> bool) -> PathBuf { let mut i: u64 = 1; loop { let new_path = simple_backup_path(path, OsString::from(format!(".~{i}~"))); - // Use `symlink_metadata` rather than `exists()` so that a dangling - // symlink still counts as an existing backup (avoiding a silent - // overwrite), and so we do not report a live symlink as missing when - // the target cannot be stat'd. - if fs::symlink_metadata(&new_path).is_err() { + if !exists(&new_path) { return new_path; } i += 1; } } -fn existing_backup_path>(path: &Path, suffix: S) -> PathBuf { +fn existing_backup_path>( + path: &Path, + suffix: S, + exists: &mut impl FnMut(&Path) -> bool, +) -> PathBuf { let test_path = simple_backup_path(path, OsString::from(".~1~")); - if fs::symlink_metadata(&test_path).is_ok() { - return numbered_backup_path(path); + if exists(&test_path) { + return numbered_backup_path(path, exists); } simple_backup_path(path, suffix.as_ref()) } @@ -681,14 +699,21 @@ mod tests { #[test] fn test_numbered_backup_path() { - assert_eq!(numbered_backup_path(Path::new("")), PathBuf::from(".~1~")); - assert_eq!(numbered_backup_path(Path::new("/")), PathBuf::from("/.~1~")); + let mut exists = |path: &Path| fs::symlink_metadata(path).is_ok(); + assert_eq!( + numbered_backup_path(Path::new(""), &mut exists), + PathBuf::from(".~1~") + ); + assert_eq!( + numbered_backup_path(Path::new("/"), &mut exists), + PathBuf::from("/.~1~") + ); assert_eq!( - numbered_backup_path(Path::new("/hello/world")), + numbered_backup_path(Path::new("/hello/world"), &mut exists), PathBuf::from("/hello/world.~1~") ); assert_eq!( - numbered_backup_path(Path::new("/hello/world/")), + numbered_backup_path(Path::new("/hello/world/"), &mut exists), PathBuf::from("/hello/world.~1~") ); } diff --git a/src/uucore/src/lib/features/fs.rs b/src/uucore/src/lib/features/fs.rs index e34e398d899..fb30d72c143 100644 --- a/src/uucore/src/lib/features/fs.rs +++ b/src/uucore/src/lib/features/fs.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore backport Ioctl absolutized linkat symlinkat renameat unlinkat openat urandom NOFOLLOW CLOEXEC RDONLY unguessability +// spell-checker:ignore backport Ioctl absolutized linkat symlinkat renameat NOFOLLOW //! Set of functions to manage regular files, special files, and links. @@ -1107,16 +1107,7 @@ pub fn get_filename(file: &Path) -> Option<&str> { pub fn replace_link(target: &Path, dest: &Path, symbolic: bool) -> IOResult<()> { #[cfg(all(unix, not(target_os = "redox")))] { - use rustix::fs::{AtFlags, CWD, Mode, OFlags, openat, renameat, unlinkat}; - use std::ffi::OsStr; - use std::io::Read; - use std::os::unix::ffi::OsStrExt; - - // GNU's template is `CuXXXXXX`: a 2-char prefix plus 6 random chars - // from a 62-char alphabet. The ~3% modulo bias per slot is irrelevant - // for an 8-char unguessability budget. - const ALPHABET: &[u8; 62] = - b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + use rustix::fs::CWD; match link_at(target, CWD, dest.as_os_str(), symbolic) { Err(e) if e.kind() == ErrorKind::AlreadyExists => {} @@ -1132,39 +1123,8 @@ pub fn replace_link(target: &Path, dest: &Path, symbolic: bool) -> IOResult<()> .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, - Mode::empty(), - )?; - let mut urandom = fs::File::open("/dev/urandom")?; - - for _ in 0..32 { - let mut name = *b"Cu------"; - let mut raw = [0u8; 6]; - urandom.read_exact(&mut raw)?; - for (slot, byte) in name[2..].iter_mut().zip(raw) { - *slot = ALPHABET[byte as usize % ALPHABET.len()]; - } - let tmp = OsStr::from_bytes(&name); - - match link_at(target, &dir, tmp, symbolic) { - Ok(()) => { - let renamed = renameat(&dir, tmp, &dir, basename); - // Renaming onto an existing link to the same inode is a - // no-op, which leaves the temp behind. - let _ = unlinkat(&dir, tmp, AtFlags::empty()); - return renamed.map_err(Into::into); - } - Err(e) if e.kind() == ErrorKind::AlreadyExists => {} - Err(e) => return Err(e), - } - } - Err(Error::new( - ErrorKind::AlreadyExists, - "no unique temporary name available in the destination directory", - )) + let dir = crate::safe_traversal::DirFd::open_anchor(parent)?; + dir.replace_existing_entry_at(basename, |dir, name| link_at(target, dir, name, symbolic)) } #[cfg(any(windows, target_os = "redox", target_os = "wasi"))] { @@ -1180,9 +1140,10 @@ pub fn replace_link(target: &Path, dest: &Path, symbolic: bool) -> IOResult<()> } } -/// `symlinkat`/`linkat` relative to an open directory. +/// Link `name` in `dir` to `target`: `symlinkat(2)` if `symbolic`, else `linkat(2)`, +/// which resolves a relative `target` from the current directory. #[cfg(all(unix, not(target_os = "redox")))] -fn link_at(target: &Path, dir: Fd, name: &OsStr, symbolic: bool) -> IOResult<()> { +pub fn link_at(target: &Path, dir: Fd, name: &OsStr, symbolic: bool) -> IOResult<()> { use rustix::fs::{AtFlags, CWD, linkat, symlinkat}; if symbolic { diff --git a/src/uucore/src/lib/features/safe_traversal.rs b/src/uucore/src/lib/features/safe_traversal.rs index 6b61d1cf35b..f4b56994b3b 100644 --- a/src/uucore/src/lib/features/safe_traversal.rs +++ b/src/uucore/src/lib/features/safe_traversal.rs @@ -6,7 +6,7 @@ // spell-checker:ignore CLOEXEC RDONLY TOCTOU closedir dirp fdopendir fstatat openat REMOVEDIR unlinkat smallfile // spell-checker:ignore RAII dirfd fchownat fchown FchmodatFlags fchmodat fchmod mkdirat CREAT WRONLY ELOOP ENOTDIR EXCL EEXIST // spell-checker:ignore atimensec mtimensec ctimensec opath chmods fakeroot fakechroot EOVERFLOW chowned chmoded -// spell-checker:ignore LARGEFILE getdents atim mtim ctim statat +// spell-checker:ignore LARGEFILE getdents atim mtim ctim statat renameat unguessability // Safe directory traversal using openat() and related syscalls // This module provides TOCTOU-safe filesystem operations for recursive traversal @@ -625,6 +625,73 @@ impl DirFd { Ok(fs::File::from(fd)) } + /// Create `name` with `create`, renaming a temporary entry over it if it is taken, so + /// the name is never free. `create` must fail with `EEXIST` there and not make a directory. + pub fn replace_entry_at( + &self, + name: &OsStr, + mut create: impl FnMut(&Self, &OsStr) -> io::Result<()>, + ) -> io::Result<()> { + match create(self, name) { + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {} + res => return res, + } + self.replace_existing_entry_at(name, create) + } + + /// [`Self::replace_entry_at`] once creating `name` itself failed with `EEXIST`. + pub(crate) fn replace_existing_entry_at( + &self, + name: &OsStr, + create: impl FnMut(&Self, &OsStr) -> io::Result<()>, + ) -> io::Result<()> { + use rustix::fs::{AtFlags, renameat}; + + let ((), tmp) = self.create_temp_at(create)?; + let renamed = renameat(self, &tmp, self, name); + // Renaming onto an existing link to the same inode is a no-op, which + // leaves the temp behind. + let _ = rustix::fs::unlinkat(self, &tmp, AtFlags::empty()); + renamed.map_err(Into::into) + } + + /// Create an entry under an unused random name with `create`, which must fail with + /// `EEXIST` on a taken name. Returns what `create` returned and the name. + pub fn create_temp_at( + &self, + mut create: impl FnMut(&Self, &OsStr) -> io::Result, + ) -> io::Result<(T, OsString)> { + use std::io::Read; + + // GNU's template is `CuXXXXXX`: a 2-char prefix plus 6 random chars + // from a 62-char alphabet. The ~3% modulo bias per slot is irrelevant + // for an 8-char unguessability budget. + const ALPHABET: &[u8; 62] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + + let mut urandom = fs::File::open("/dev/urandom")?; + + for _ in 0..32 { + let mut tmp = *b"Cu------"; + let mut raw = [0u8; 6]; + urandom.read_exact(&mut raw)?; + for (slot, byte) in tmp[2..].iter_mut().zip(raw) { + *slot = ALPHABET[byte as usize % ALPHABET.len()]; + } + let tmp = OsStr::from_bytes(&tmp); + + match create(self, tmp) { + Ok(created) => return Ok((created, tmp.to_owned())), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(e), + } + } + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + translate!("error-no-unique-temp-name"), + )) + } + /// Create a [`DirFd`] from an existing file descriptor (takes ownership) pub fn from_raw_fd(fd: RawFd) -> io::Result { if fd < 0 { diff --git a/tests/by-util/test_cp.rs b/tests/by-util/test_cp.rs index b30e774a13f..88dcef9bd42 100644 --- a/tests/by-util/test_cp.rs +++ b/tests/by-util/test_cp.rs @@ -1183,6 +1183,38 @@ fn test_cp_arg_symlink() { assert_eq!(at.read(TEST_HELLO_WORLD_DEST), "Hello, World!\n"); } +/// Replacing a file with a symlink needs write and search permission on its +/// directory, like creating one, not read permission. +#[test] +#[cfg(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "freebsd", + target_os = "netbsd" +))] +#[cfg_attr(wasi_runner, ignore = "WASI sandbox: host paths not visible")] +fn test_cp_symlink_force_in_write_search_only_dir() { + if rustix::process::geteuid().is_root() { + return; + } + let (at, mut ucmd) = at_and_ucmd!(); + at.write("src", "new"); + at.mkdir("dir"); + at.write("dir/dest", "old"); + at.set_mode("dir", 0o300); + + let src = at.plus_as_string("src"); + let result = ucmd.args(&["-sf", &src, "dir/dest"]).run(); + + at.set_mode("dir", 0o755); + result.success(); + assert_eq!( + std::fs::read_link(at.plus("dir/dest")).unwrap(), + Path::new(&src) + ); +} + // Recursively copying a tree that contains a symlink must not run chmod through // the destination symlink cp just created. chmod() follows symlinks, so doing so // would change the mode of the link target, which can live outside the copied diff --git a/tests/by-util/test_ln.rs b/tests/by-util/test_ln.rs index fb8652410ae..9f88b9909d1 100644 --- a/tests/by-util/test_ln.rs +++ b/tests/by-util/test_ln.rs @@ -141,6 +141,35 @@ fn test_symlink_overwrite_force() { assert_eq!(at.resolve_link(link), file_b); } +/// Replacing a link needs write and search permission on its directory, like +/// creating one, not read permission. +#[test] +#[cfg(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "freebsd", + target_os = "netbsd" +))] +fn test_symlink_overwrite_force_in_write_search_only_dir() { + use std::fs::{Permissions, set_permissions}; + use std::os::unix::fs::PermissionsExt; + + if rustix::process::geteuid().is_root() { + return; + } + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("dir"); + at.symlink_file("old", "dir/link"); + set_permissions(at.plus("dir"), Permissions::from_mode(0o300)).unwrap(); + + let result = ucmd.args(&["-sf", "new", "dir/link"]).run(); + + set_permissions(at.plus("dir"), Permissions::from_mode(0o755)).unwrap(); + result.success(); + assert_eq!(at.resolve_link("dir/link"), "new"); +} + /// A forced replace must be atomic, so a concurrent creator always loses. /// Fails reliably if unlink-then-create ever comes back. #[test] @@ -615,6 +644,96 @@ fn test_symlink_target_dir_non_utf8_source_name() { assert_eq!(created_link_target.as_os_str().as_bytes(), source_bytes); } +#[test] +#[cfg(unix)] +#[cfg_attr( + wasi_runner, + ignore = "WASI: ln makes links by path, not through the open directory" +)] +fn test_target_dir_renamed_during_prompt() { + use std::process::Stdio; + + for (flags, backup) in [ + (vec!["-s", "-i"], None), + #[cfg(not(target_os = "android"))] // no hard links on Android + (vec!["-i"], None), + (vec!["-s", "-r", "-i"], None), + (vec!["-s", "-i", "--backup=simple"], Some("~")), + (vec!["-s", "-i", "--backup=numbered"], Some(".~2~")), + (vec!["-s", "-i", "--backup=existing"], Some(".~2~")), + ] { + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.mkdir("dir"); + at.mkdir("other"); + at.write("a", "new a"); + at.write("b", "new b"); + at.write("c", "new c"); + at.write("dir/a", "old a"); + at.write("dir/b", "old b"); + at.write("dir/b.~1~", "first backup"); + at.write("other/b", "untouched"); + + let mut child = scene + .ucmd() + .args(&flags) + .args(&["-t", "dir", "a", "b", "c"]) + .set_stdin(Stdio::piped()) + .set_stderr(Stdio::piped()) + .run_no_wait(); + let prompt = "ln: replace 'dir/a'? "; + assert_eq!(child.stderr_exact_bytes(prompt.len()), prompt.as_bytes()); + + // Change the pathname while ln is waiting, then process another source. + at.rename("dir", "moved"); + at.relative_symlink_dir("other", "dir"); + child.write_in("y\n"); + let prompt = "ln: replace 'dir/b'? "; + assert_eq!(child.stderr_exact_bytes(prompt.len()), prompt.as_bytes()); + child.write_in("y\n"); + child.wait().unwrap().success(); + + for name in ["a", "b", "c"] { + if flags.contains(&"-s") { + let expected = if flags.contains(&"-r") { + format!("../{name}") + } else { + name.to_owned() + }; + assert_eq!(at.resolve_link(&format!("moved/{name}")), expected); + } else { + assert_eq!(at.read(&format!("moved/{name}")), format!("new {name}")); + } + } + if let Some(suffix) = backup { + assert_eq!(at.read(&format!("moved/b{suffix}")), "old b"); + assert_eq!(at.read("moved/b.~1~"), "first backup"); + } + assert_eq!(at.read("other/b"), "untouched"); + assert!(!at.file_exists("other/a")); + assert_eq!(std::fs::read_dir(at.plus("other")).unwrap().count(), 1); + } +} + +#[test] +#[cfg(all(unix, not(target_os = "android")))] +fn test_target_dir_backup_source_without_basename() { + for source in [".", "..", "nested/.."] { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("destination"); + at.mkdir("nested"); + at.write("destination/kept", "unchanged"); + ucmd.args(&["-s", "--backup=existing", source, "destination"]) + .timeout(Duration::from_secs(10)) + .fails(); + assert_eq!(at.read("destination/kept"), "unchanged"); + assert_eq!( + std::fs::read_dir(at.plus("destination")).unwrap().count(), + 1 + ); + } +} + #[test] fn test_symlink_target_dir_from_dir() { let (at, mut ucmd) = at_and_ucmd!();