From eaf69db2a31ce5c0d620a226941777118aa763a9 Mon Sep 17 00:00:00 2001 From: Jake Abendroth Date: Thu, 9 Jul 2026 22:19:55 -0700 Subject: [PATCH 1/3] fix(mv): don't destroy the destination when a cross-device move fails The cross-device (EXDEV) fallback removed an existing destination before it knew the source could be recreated there, so a failed move destroyed the destination: - A socket or device node fell through to the regular-file copy path, which removed the destination and then failed to open the source, losing the destination entirely. - A fifo was recreated only after the destination was removed, so a failed mkfifo also lost the destination. The fifo was also recreated with a hardcoded 0666 mode instead of the source's. - A regular source that could not be opened for reading (e.g. mode 000) cost the destination as well, because the destination was removed before the source was opened. Special files (fifos, sockets, and device nodes) are now recreated with mkfifo/mknod like GNU mv does, preserving ownership and permissions. The node is created under a random temporary name and renamed over the destination, so a failure to create it can never destroy an existing destination. Sockets and device nodes inside directories moved across devices are recreated the same way instead of failing the whole move. For regular files, the source is now opened before the destination is touched. Reported via security advisory GHSA-xw28-j282-p74c. Fixes #13145 --- src/uu/mv/src/mv.rs | 162 ++++++++++++++++++++++++++++----------- tests/by-util/test_mv.rs | 144 +++++++++++++++++++++++++++++++++- 2 files changed, 262 insertions(+), 44 deletions(-) diff --git a/src/uu/mv/src/mv.rs b/src/uu/mv/src/mv.rs index 393cc74d19..21e97f3640 100644 --- a/src/uu/mv/src/mv.rs +++ b/src/uu/mv/src/mv.rs @@ -914,13 +914,18 @@ fn is_directory_not_empty_error(err: &io::Error) -> bool { err.kind() == io::ErrorKind::DirectoryNotEmpty } +/// Check if a file type must be recreated with [`copy_special_file`] rather +/// than copied by content: fifos, sockets, and device nodes. #[cfg(unix)] -fn is_fifo(filetype: fs::FileType) -> bool { +fn is_special_file(filetype: fs::FileType) -> bool { filetype.is_fifo() + || filetype.is_socket() + || filetype.is_block_device() + || filetype.is_char_device() } #[cfg(not(unix))] -fn is_fifo(_filetype: fs::FileType) -> bool { +fn is_special_file(_filetype: fs::FileType) -> bool { false } @@ -980,8 +985,8 @@ fn rename_with_fallback( { rename_dir_fallback(from, to, display_manager, verbose) } - } else if is_fifo(file_type) { - rename_fifo_fallback(from, to) + } else if is_special_file(file_type) { + rename_special_fallback(from, to, &metadata) } else { #[cfg(unix)] { @@ -999,15 +1004,72 @@ fn rename_with_fallback( }) } -/// Replace the destination with a new pipe with the same name as the source. +/// Recreate the source special file (fifo, socket, or device node) at `to`, +/// preserving its ownership and permissions. #[cfg(unix)] -fn rename_fifo_fallback(from: &Path, to: &Path) -> io::Result<()> { - if to.try_exists()? { - fs::remove_file(to)?; +fn copy_special_file(from: &Path, metadata: &fs::Metadata, to: &Path) -> io::Result<()> { + use nix::sys::stat::{Mode, SFlag, mknod}; + use std::fs::Permissions; + use std::os::unix::fs::MetadataExt; + + let file_type = metadata.file_type(); + let mode = Mode::from_bits_truncate(metadata.mode() as _); + if file_type.is_fifo() { + // rustix::fs::mkfifoat is linux only + nix::unistd::mkfifo(to, mode)?; + } else { + let kind = if file_type.is_socket() { + SFlag::S_IFSOCK + } else if file_type.is_block_device() { + SFlag::S_IFBLK + } else { + SFlag::S_IFCHR + }; + mknod(to, kind, mode, metadata.rdev() as _)?; } - // rustix::fs::mkfifoat is linux only - nix::unistd::mkfifo(to, nix::sys::stat::Mode::from_bits_truncate(0o666))?; - fs::remove_file(from) + // chown before chmod: chown(2) clears setuid/setgid for non-root, and the + // explicit chmod undoes the umask applied during creation. + let _ = preserve_ownership(from, to); + let _ = fs::set_permissions(to, Permissions::from_mode(metadata.mode() & 0o7777)); + Ok(()) +} + +/// Replace the destination with a new special file (fifo, socket, or device +/// node) with the same name as the source. The node is created under a +/// temporary name and then renamed over the destination, so that failing to +/// create it cannot destroy an existing destination. +#[cfg(unix)] +fn rename_special_fallback(from: &Path, to: &Path, metadata: &fs::Metadata) -> io::Result<()> { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + let parent = to + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + + let mut urandom = fs::File::open("/dev/urandom")?; + + for _ in 0..32 { + let tmp_bytes = random_temp_name(&mut urandom)?; + let tmp = parent.join(OsStr::from_bytes(&tmp_bytes)); + + match copy_special_file(from, metadata, &tmp) { + Ok(()) => { + if let Err(e) = fs::rename(&tmp, to) { + let _ = fs::remove_file(&tmp); + return Err(e); + } + return fs::remove_file(from); + } + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(e), + } + } + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "could not allocate a unique temp name in destination directory", + )) } #[cfg(not(unix))] @@ -1015,7 +1077,7 @@ fn rename_fifo_fallback(from: &Path, to: &Path) -> io::Result<()> { clippy::unnecessary_wraps, reason = "fn sig must match on all platforms" )] -fn rename_fifo_fallback(_from: &Path, _to: &Path) -> io::Result<()> { +fn rename_special_fallback(_from: &Path, _to: &Path, _metadata: &fs::Metadata) -> io::Result<()> { Ok(()) } @@ -1048,6 +1110,26 @@ fn rename_symlink_fallback(from: &Path, to: &Path) -> io::Result<()> { fs::remove_file(from) } +/// Generate a temp file name for atomically replacing a destination entry. +/// +/// GNU's template is `CuXXXXXX`: a 2-char prefix plus 6 random chars +/// drawn from a 62-char alphabet. Modulo bias on a 256→62 mapping is +/// ~3% per slot — irrelevant for an 8-char unguessability budget. The +/// name is drawn from `/dev/urandom` so it is unguessable to other +/// users in the destination directory. +#[cfg(unix)] +fn random_temp_name(urandom: &mut impl io::Read) -> io::Result<[u8; 8]> { + const ALPHABET: &[u8; 62] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + + let mut tmp_bytes = *b"Cu------"; + let mut raw = [0u8; 6]; + urandom.read_exact(&mut raw)?; + for (slot, byte) in tmp_bytes[2..].iter_mut().zip(raw) { + *slot = ALPHABET[(byte as usize) % ALPHABET.len()]; + } + Ok(tmp_bytes) +} + /// Create a symlink at `to`, atomically replacing any existing entry via /// a temp-name + `renameat(2)` so observers never see `to` missing. /// @@ -1058,16 +1140,10 @@ fn rename_symlink_fallback(from: &Path, to: &Path) -> io::Result<()> { /// directory. #[cfg(all(unix, not(target_os = "redox")))] fn create_symlink_replace(target: &Path, to: &Path) -> io::Result<()> { - use io::Read; use rustix::fs::{AtFlags, CWD, Mode, OFlags, openat, renameat, symlinkat, unlinkat}; use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt; - // GNU's template is `CuXXXXXX`: a 2-char prefix plus 6 random chars - // drawn from a 62-char alphabet. Modulo bias on a 256→62 mapping is - // ~3% per slot — irrelevant for an 8-char unguessability budget. - const ALPHABET: &[u8; 62] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - let parent = to .parent() .filter(|p| !p.as_os_str().is_empty()) @@ -1086,12 +1162,7 @@ fn create_symlink_replace(target: &Path, to: &Path) -> io::Result<()> { let mut urandom = fs::File::open("/dev/urandom")?; for _ in 0..32 { - let mut tmp_bytes = *b"Cu------"; - let mut raw = [0u8; 6]; - urandom.read_exact(&mut raw)?; - for (slot, byte) in tmp_bytes[2..].iter_mut().zip(raw) { - *slot = ALPHABET[(byte as usize) % ALPHABET.len()]; - } + let tmp_bytes = random_temp_name(&mut urandom)?; let tmp = OsStr::from_bytes(&tmp_bytes); match symlinkat(target, &dir_fd, tmp) { @@ -1394,21 +1465,22 @@ fn copy_file_with_hardlinks_helper( // Copy a symlink file (no-follow). // rename_symlink_fallback already preserves ownership and removes the source. rename_symlink_fallback(from, to)?; - } else if is_fifo(from.symlink_metadata()?.file_type()) { - // rustix::fs::mkfifoat is linux only - nix::unistd::mkfifo(to, nix::sys::stat::Mode::from_bits_truncate(0o666))?; - // Preserve ownership (uid/gid) from the source - let _ = preserve_ownership(from, to); } else { - // Copy a regular file. - fs::copy(from, to)?; - // Copy xattrs, ignoring ENOTSUP errors (filesystem doesn't support xattrs) - #[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))] - { - let _ = fsxattr::copy_xattrs_ignore_unsupported(from, to); + let metadata = from.symlink_metadata()?; + if is_special_file(metadata.file_type()) { + // Recreate a fifo, socket, or device node. + copy_special_file(from, &metadata, to)?; + } else { + // Copy a regular file. + fs::copy(from, to)?; + // Copy xattrs, ignoring ENOTSUP errors (filesystem doesn't support xattrs) + #[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))] + { + let _ = fsxattr::copy_xattrs_ignore_unsupported(from, to); + } + // Preserve ownership (uid/gid) from the source + let _ = preserve_ownership(from, to); } - // Preserve ownership (uid/gid) from the source - let _ = preserve_ownership(from, to); } Ok(()) @@ -1420,6 +1492,12 @@ fn rename_file_fallback( #[cfg(unix)] hardlink_tracker: Option<&mut HardlinkTracker>, #[cfg(unix)] hardlink_scanner: Option<&HardlinkGroupScanner>, ) -> io::Result<()> { + // Open the source before touching the destination: a source that cannot + // be opened for reading must not cost us an existing destination. + #[cfg(unix)] + let src_file = uucore::safe_copy::open_source(from, /* nofollow */ true) + .map_err(|err| io::Error::new(err.kind(), translate!("mv-error-permission-denied")))?; + // Remove existing target file if it exists if to.is_symlink() { fs::remove_file(to).map_err(|err| { @@ -1448,16 +1526,14 @@ fn rename_file_fallback( } } - // Open src/dst with O_NOFOLLOW and keep the fds alive across copy, - // chown, xattr, and chmod so a concurrent path-swap can't redirect any - // step to a different inode. + // Src/dst are open with O_NOFOLLOW and the fds are kept alive across + // copy, chown, xattr, and chmod so a concurrent path-swap can't redirect + // any step to a different inode. #[cfg(unix)] { use std::fs::Permissions; use std::os::unix::fs::{MetadataExt, PermissionsExt}; - use uucore::safe_copy::{create_dest_restrictive, open_source}; - let src_file = open_source(from, /* nofollow */ true) - .map_err(|err| io::Error::new(err.kind(), translate!("mv-error-permission-denied")))?; + use uucore::safe_copy::create_dest_restrictive; let src_mode = src_file .metadata() .map_err(|err| io::Error::new(err.kind(), translate!("mv-error-permission-denied")))? diff --git a/tests/by-util/test_mv.rs b/tests/by-util/test_mv.rs index f7b0f0bc2f..71e4b2a9c9 100644 --- a/tests/by-util/test_mv.rs +++ b/tests/by-util/test_mv.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 mydir hardlinked tmpfs notty unwriteable myfolder SRCDATA DSTDATA REALDATA +// spell-checker:ignore mydir hardlinked tmpfs notty unwriteable GHSA // spell-checker:ignore dirattr dirvalue setfattr getfattr use rstest::rstest; @@ -2839,6 +2839,148 @@ fn test_mv_cross_device_dir_refuses_symlink_at_recreated_dest() { assert_eq!(at.read("victim/guard"), "PROTECTED_DATA"); } +/// Regression for #13145 (GHSA-xw28-j282-p74c). A cross-device move of a +/// socket used to delete the destination and then fail to copy the socket, +/// destroying the destination. Like GNU, the socket must be recreated at the +/// destination instead. +#[test] +#[cfg(target_os = "linux")] +fn test_mv_cross_device_socket_replaces_dest() { + use std::os::unix::fs::FileTypeExt; + use std::os::unix::net::UnixListener; + use tempfile::TempDir; + use uutests::util::TestScenario; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + UnixListener::bind(at.plus("sock")).expect("bind socket"); + + let other_fs_tempdir = + TempDir::new_in("/dev/shm/").expect("Unable to create temp directory in /dev/shm"); + let dest = other_fs_tempdir.path().join("dest"); + std::fs::write(&dest, "precious content").expect("write dest"); + + scene + .ucmd() + .arg("sock") + .arg(dest.to_str().unwrap()) + .succeeds() + .no_output(); + + assert!( + !at.plus("sock").exists(), + "source socket should be gone after the move" + ); + assert!( + dest.symlink_metadata().unwrap().file_type().is_socket(), + "destination should have been replaced by the moved socket" + ); +} + +/// A cross-device move of a fifo onto an existing file must replace the +/// destination and preserve the fifo's permissions. +#[test] +#[cfg(target_os = "linux")] +fn test_mv_cross_device_fifo_replaces_dest_and_preserves_mode() { + use std::fs::{Permissions, set_permissions}; + use std::os::unix::fs::{FileTypeExt, PermissionsExt}; + use tempfile::TempDir; + use uutests::util::TestScenario; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.mkfifo("fifo"); + set_permissions(at.plus("fifo"), Permissions::from_mode(0o604)).expect("chmod fifo"); + + let other_fs_tempdir = + TempDir::new_in("/dev/shm/").expect("Unable to create temp directory in /dev/shm"); + let dest = other_fs_tempdir.path().join("dest"); + std::fs::write(&dest, "old content").expect("write dest"); + + scene + .ucmd() + .arg("fifo") + .arg(dest.to_str().unwrap()) + .succeeds() + .no_output(); + + let metadata = dest.symlink_metadata().unwrap(); + assert!(metadata.file_type().is_fifo()); + assert_eq!(metadata.permissions().mode() & 0o7777, 0o604); +} + +/// A directory containing a socket must survive a cross-device move. +#[test] +#[cfg(target_os = "linux")] +fn test_mv_dir_with_socket_across_partitions() { + use std::os::unix::fs::FileTypeExt; + use std::os::unix::net::UnixListener; + use tempfile::TempDir; + use uutests::util::TestScenario; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.mkdir("dir"); + UnixListener::bind(at.plus("dir/sock")).expect("bind socket"); + + let other_fs_tempdir = + TempDir::new_in("/dev/shm/").expect("Unable to create temp directory in /dev/shm"); + + scene + .ucmd() + .arg("dir") + .arg(other_fs_tempdir.path().to_str().unwrap()) + .succeeds() + .no_output(); + + assert!(!at.dir_exists("dir")); + let moved_sock = other_fs_tempdir.path().join("dir/sock"); + assert!( + moved_sock + .symlink_metadata() + .unwrap() + .file_type() + .is_socket() + ); +} + +/// A failed cross-device move must not destroy an existing destination. +#[test] +#[cfg(target_os = "linux")] +fn test_mv_cross_device_unreadable_source_preserves_dest() { + use std::fs::{Permissions, set_permissions}; + use std::os::unix::fs::PermissionsExt; + use tempfile::TempDir; + use uucore::process::geteuid; + use uutests::util::TestScenario; + + if geteuid() == 0 { + return; + } + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + + at.write("src", "source content"); + set_permissions(at.plus("src"), Permissions::from_mode(0o000)).expect("chmod src"); + + let other_fs_tempdir = + TempDir::new_in("/dev/shm/").expect("Unable to create temp directory in /dev/shm"); + let dest = other_fs_tempdir.path().join("dest"); + std::fs::write(&dest, "keep me").expect("write dest"); + + scene.ucmd().arg("src").arg(dest.to_str().unwrap()).fails(); + + assert_eq!( + std::fs::read_to_string(&dest).expect("dest must still exist"), + "keep me", + "a failed cross-device move must not destroy the destination" + ); +} + #[test] #[cfg(all( feature = "feat_selinux", From c34fcbe5669d5c5a4f60afc80b07cb9a3e3f7ef6 Mon Sep 17 00:00:00 2001 From: Jake Abendroth Date: Mon, 17 Aug 2026 01:19:43 -0700 Subject: [PATCH 2/3] fix(mv): anchor special-file fallback operations --- src/uu/mv/src/mv.rs | 211 ++++++++++++++++++++++++++++++++++++--- tests/by-util/test_mv.rs | 2 +- 2 files changed, 199 insertions(+), 14 deletions(-) diff --git a/src/uu/mv/src/mv.rs b/src/uu/mv/src/mv.rs index 21e97f3640..ed885fbeaa 100644 --- a/src/uu/mv/src/mv.rs +++ b/src/uu/mv/src/mv.rs @@ -23,10 +23,14 @@ use std::env; use std::ffi::OsString; use std::fs; use std::io::{self, IsTerminal}; +#[cfg(all(unix, not(target_os = "redox")))] +use std::os::fd::AsRawFd; #[cfg(unix)] use std::os::unix; +#[cfg(all(unix, not(target_os = "redox")))] +use std::os::unix::ffi::OsStrExt; #[cfg(unix)] -use std::os::unix::fs::{FileTypeExt, PermissionsExt}; +use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt}; #[cfg(windows)] use std::os::windows; use std::path::{Path, PathBuf, absolute}; @@ -47,6 +51,8 @@ use uucore::fs::{ }; #[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))] use uucore::fsxattr; +#[cfg(all(unix, not(target_os = "redox")))] +use uucore::safe_traversal::{DirFd, SymlinkBehavior}; #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] use uucore::selinux::set_selinux_security_context; use uucore::translate; @@ -1004,18 +1010,88 @@ fn rename_with_fallback( }) } +/// Open the destination's parent directory without following a symlink in +/// the parent path, returning the directory fd and destination basename. +#[cfg(all(unix, not(target_os = "redox")))] +fn open_destination_parent(to: &Path) -> io::Result<(DirFd, OsString)> { + let parent = to + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let basename = to + .file_name() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid destination path"))?; + let dir_fd = DirFd::open(parent, SymlinkBehavior::NoFollow)?; + Ok((dir_fd, basename.to_os_string())) +} + +/// Recreate the source special file (fifo, socket, or device node) relative +/// to an already-open destination directory. +#[cfg(all(unix, not(target_os = "redox")))] +fn copy_special_file_at( + metadata: &fs::Metadata, + dir_fd: &DirFd, + to: &std::ffi::OsStr, +) -> io::Result<()> { + use std::ffi::CString; + + let name_cstr = CString::new(to.as_bytes()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid file name"))?; + let mode = metadata.mode() & 0o7777; + let result = unsafe { + if metadata.file_type().is_fifo() { + libc::mkfifoat(dir_fd.as_raw_fd(), name_cstr.as_ptr(), mode as libc::mode_t) + } else { + let file_type = metadata.file_type(); + let kind = if file_type.is_socket() { + libc::S_IFSOCK + } else if file_type.is_block_device() { + libc::S_IFBLK + } else { + libc::S_IFCHR + } as libc::mode_t; + libc::mknodat( + dir_fd.as_raw_fd(), + name_cstr.as_ptr(), + kind | mode as libc::mode_t, + metadata.rdev() as libc::dev_t, + ) + } + }; + if result != 0 { + return Err(io::Error::last_os_error()); + } + + // Ownership is best effort for unprivileged callers, but permissions are + // part of the recreated node's contract and must not be silently lost. + let _ = dir_fd.chown_at( + to, + Some(metadata.uid()), + Some(metadata.gid()), + SymlinkBehavior::NoFollow, + ); + dir_fd.chmod_at(to, mode, SymlinkBehavior::NoFollow)?; + Ok(()) +} + /// Recreate the source special file (fifo, socket, or device node) at `to`, /// preserving its ownership and permissions. -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "redox")))] +fn copy_special_file(_from: &Path, metadata: &fs::Metadata, to: &Path) -> io::Result<()> { + let (dir_fd, basename) = open_destination_parent(to)?; + copy_special_file_at(metadata, &dir_fd, &basename) +} + +/// Redox does not expose the safe directory-fd traversal layer, so retain the +/// existing path-based special-file recreation for that target. +#[cfg(target_os = "redox")] fn copy_special_file(from: &Path, metadata: &fs::Metadata, to: &Path) -> io::Result<()> { use nix::sys::stat::{Mode, SFlag, mknod}; use std::fs::Permissions; - use std::os::unix::fs::MetadataExt; let file_type = metadata.file_type(); let mode = Mode::from_bits_truncate(metadata.mode() as _); if file_type.is_fifo() { - // rustix::fs::mkfifoat is linux only nix::unistd::mkfifo(to, mode)?; } else { let kind = if file_type.is_socket() { @@ -1027,8 +1103,6 @@ fn copy_special_file(from: &Path, metadata: &fs::Metadata, to: &Path) -> io::Res }; mknod(to, kind, mode, metadata.rdev() as _)?; } - // chown before chmod: chown(2) clears setuid/setgid for non-root, and the - // explicit chmod undoes the umask applied during creation. let _ = preserve_ownership(from, to); let _ = fs::set_permissions(to, Permissions::from_mode(metadata.mode() & 0o7777)); Ok(()) @@ -1038,7 +1112,54 @@ fn copy_special_file(from: &Path, metadata: &fs::Metadata, to: &Path) -> io::Res /// node) with the same name as the source. The node is created under a /// temporary name and then renamed over the destination, so that failing to /// create it cannot destroy an existing destination. -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "redox")))] +fn rename_special_fallback(from: &Path, to: &Path, metadata: &fs::Metadata) -> io::Result<()> { + use std::ffi::{CString, OsString}; + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + + let (dir_fd, basename) = open_destination_parent(to)?; + let (src_parent_fd, src_basename) = open_destination_parent(from)?; + let basename_cstr = CString::new(basename.as_bytes()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid destination name"))?; + let mut urandom = fs::File::open("/dev/urandom")?; + + for _ in 0..32 { + let tmp_name = OsString::from_vec(random_temp_name(&mut urandom)?.to_vec()); + + match copy_special_file_at(metadata, &dir_fd, &tmp_name) { + Ok(()) => { + let tmp_cstr = CString::new(tmp_name.as_bytes()).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidInput, "invalid temporary name") + })?; + let result = unsafe { + libc::renameat( + dir_fd.as_raw_fd(), + tmp_cstr.as_ptr(), + dir_fd.as_raw_fd(), + basename_cstr.as_ptr(), + ) + }; + if result != 0 { + let error = io::Error::last_os_error(); + let _ = dir_fd.unlink_at(&tmp_name, false); + return Err(error); + } + return src_parent_fd.unlink_at(&src_basename, false); + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} + Err(error) => { + let _ = dir_fd.unlink_at(&tmp_name, false); + return Err(error); + } + } + } + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "could not allocate a unique temp name in destination directory", + )) +} + +#[cfg(target_os = "redox")] fn rename_special_fallback(from: &Path, to: &Path, metadata: &fs::Metadata) -> io::Result<()> { use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt; @@ -1047,7 +1168,6 @@ fn rename_special_fallback(from: &Path, to: &Path, metadata: &fs::Metadata) -> i .parent() .filter(|p| !p.as_os_str().is_empty()) .unwrap_or_else(|| Path::new(".")); - let mut urandom = fs::File::open("/dev/urandom")?; for _ in 0..32 { @@ -1056,14 +1176,14 @@ fn rename_special_fallback(from: &Path, to: &Path, metadata: &fs::Metadata) -> i match copy_special_file(from, metadata, &tmp) { Ok(()) => { - if let Err(e) = fs::rename(&tmp, to) { + if let Err(error) = fs::rename(&tmp, to) { let _ = fs::remove_file(&tmp); - return Err(e); + return Err(error); } return fs::remove_file(from); } - Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {} - Err(e) => return Err(e), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error), } } Err(io::Error::new( @@ -1498,6 +1618,10 @@ fn rename_file_fallback( let src_file = uucore::safe_copy::open_source(from, /* nofollow */ true) .map_err(|err| io::Error::new(err.kind(), translate!("mv-error-permission-denied")))?; + #[cfg(all(unix, not(target_os = "redox")))] + let (src_parent_fd, src_basename) = open_destination_parent(from) + .map_err(|err| io::Error::new(err.kind(), translate!("mv-error-permission-denied")))?; + // Remove existing target file if it exists if to.is_symlink() { fs::remove_file(to).map_err(|err| { @@ -1520,6 +1644,9 @@ fn rename_file_fallback( { // Create a hardlink to the first moved file instead of copying fs::hard_link(&existing_target, to)?; + #[cfg(all(unix, not(target_os = "redox")))] + src_parent_fd.unlink_at(&src_basename, false)?; + #[cfg(any(not(unix), target_os = "redox"))] fs::remove_file(from)?; return Ok(()); } @@ -1556,6 +1683,9 @@ fn rename_file_fallback( // `mv`, and re-applying setuid/setgid would hand them a binary running // as themselves that used to run as someone else. GNU strips the bits // in that case and so do we. + #[cfg(not(target_os = "redox"))] + let ownership_preserved = preserve_ownership_fd(&src_file, &dst_file); + #[cfg(target_os = "redox")] let ownership_preserved = preserve_ownership(from, to).unwrap_or(false); let dest_mode = if ownership_preserved { src_mode @@ -1571,11 +1701,47 @@ fn rename_file_fallback( .map_err(|err| io::Error::new(err.kind(), translate!("mv-error-permission-denied")))?; } - fs::remove_file(from) + #[cfg(not(target_os = "redox"))] + let remove_source_result = src_parent_fd.unlink_at(&src_basename, false); + #[cfg(target_os = "redox")] + let remove_source_result = fs::remove_file(from); + remove_source_result .map_err(|err| io::Error::new(err.kind(), translate!("mv-error-permission-denied")))?; Ok(()) } +/// Preserve ownership between two already-open files without resolving either +/// pathname again. Ownership failures are best-effort, matching the existing +/// path-based helper's behavior for unprivileged callers. +#[cfg(all(unix, not(target_os = "redox")))] +fn preserve_ownership_fd(from: &fs::File, to: &fs::File) -> bool { + use std::os::unix::fs::MetadataExt; + + let Ok(source_meta) = from.metadata() else { + return false; + }; + let Ok(destination_meta) = to.metadata() else { + return false; + }; + + if source_meta.uid() != destination_meta.uid() || source_meta.gid() != destination_meta.gid() { + let result = unsafe { + libc::fchown( + to.as_raw_fd(), + source_meta.uid() as libc::uid_t, + source_meta.gid() as libc::gid_t, + ) + }; + if result != 0 { + return false; + } + } + + to.metadata().is_ok_and(|metadata| { + metadata.uid() == source_meta.uid() && metadata.gid() == source_meta.gid() + }) +} + /// Preserve ownership (uid/gid) from source to destination. /// Uses lchown so it works on symlinks without following them. /// Errors are silently ignored for non-root users who cannot chown. @@ -1678,6 +1844,25 @@ fn prompt_overwrite(to: &Path, cached_mode: Option) -> io::Result<()> { } /// Checks if a file can be deleted by attempting to open it with delete permissions. +#[cfg(all(test, unix, not(target_os = "redox")))] +mod tests { + use super::*; + use std::os::unix::fs::symlink; + use tempfile::TempDir; + + #[test] + fn special_file_destination_parent_does_not_follow_symlink() { + let temp_dir = TempDir::new().unwrap(); + let real_parent = temp_dir.path().join("real"); + fs::create_dir(&real_parent).unwrap(); + let symlink_parent = temp_dir.path().join("link"); + symlink(&real_parent, &symlink_parent).unwrap(); + + let destination = symlink_parent.join("destination"); + assert!(open_destination_parent(&destination).is_err()); + } +} + #[cfg(windows)] fn can_delete_file(path: &Path) -> bool { use std::{ diff --git a/tests/by-util/test_mv.rs b/tests/by-util/test_mv.rs index 71e4b2a9c9..9a42768bc8 100644 --- a/tests/by-util/test_mv.rs +++ b/tests/by-util/test_mv.rs @@ -4,7 +4,7 @@ // file that was distributed with this source code. // // spell-checker:ignore mydir hardlinked tmpfs notty unwriteable GHSA -// spell-checker:ignore dirattr dirvalue setfattr getfattr +// spell-checker:ignore dirattr dirvalue setfattr getfattr myfolder SRCDATA DSTDATA REALDATA use rstest::rstest; use std::io::Write; From 87ee53e29fd3d6218415200cb5d165a488629be0 Mon Sep 17 00:00:00 2001 From: Jake Abendroth Date: Mon, 17 Aug 2026 18:47:28 -0700 Subject: [PATCH 3/3] fix(mv): follow symlinked source parents safely --- src/uu/mv/src/mv.rs | 41 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/src/uu/mv/src/mv.rs b/src/uu/mv/src/mv.rs index ed885fbeaa..6f6a7115e7 100644 --- a/src/uu/mv/src/mv.rs +++ b/src/uu/mv/src/mv.rs @@ -1025,6 +1025,21 @@ fn open_destination_parent(to: &Path) -> io::Result<(DirFd, OsString)> { Ok((dir_fd, basename.to_os_string())) } +/// Open the source's parent directory following symlinks as normal path +/// resolution does, while returning a pinned directory fd for source removal. +#[cfg(all(unix, not(target_os = "redox")))] +fn open_source_parent(from: &Path) -> io::Result<(DirFd, OsString)> { + let parent = from + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let basename = from + .file_name() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid source path"))?; + let dir_fd = DirFd::open(parent, SymlinkBehavior::Follow)?; + Ok((dir_fd, basename.to_os_string())) +} + /// Recreate the source special file (fifo, socket, or device node) relative /// to an already-open destination directory. #[cfg(all(unix, not(target_os = "redox")))] @@ -1118,7 +1133,7 @@ fn rename_special_fallback(from: &Path, to: &Path, metadata: &fs::Metadata) -> i use std::os::unix::ffi::{OsStrExt, OsStringExt}; let (dir_fd, basename) = open_destination_parent(to)?; - let (src_parent_fd, src_basename) = open_destination_parent(from)?; + let (src_parent_fd, src_basename) = open_source_parent(from)?; let basename_cstr = CString::new(basename.as_bytes()) .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid destination name"))?; let mut urandom = fs::File::open("/dev/urandom")?; @@ -1619,7 +1634,7 @@ fn rename_file_fallback( .map_err(|err| io::Error::new(err.kind(), translate!("mv-error-permission-denied")))?; #[cfg(all(unix, not(target_os = "redox")))] - let (src_parent_fd, src_basename) = open_destination_parent(from) + let (src_parent_fd, src_basename) = open_source_parent(from) .map_err(|err| io::Error::new(err.kind(), translate!("mv-error-permission-denied")))?; // Remove existing target file if it exists @@ -1701,9 +1716,9 @@ fn rename_file_fallback( .map_err(|err| io::Error::new(err.kind(), translate!("mv-error-permission-denied")))?; } - #[cfg(not(target_os = "redox"))] + #[cfg(all(unix, not(target_os = "redox")))] let remove_source_result = src_parent_fd.unlink_at(&src_basename, false); - #[cfg(target_os = "redox")] + #[cfg(any(not(unix), target_os = "redox"))] let remove_source_result = fs::remove_file(from); remove_source_result .map_err(|err| io::Error::new(err.kind(), translate!("mv-error-permission-denied")))?; @@ -1861,6 +1876,24 @@ mod tests { let destination = symlink_parent.join("destination"); assert!(open_destination_parent(&destination).is_err()); } + + #[test] + fn file_fallback_follows_symlinked_source_parent() { + let temp_dir = TempDir::new().unwrap(); + let real_parent = temp_dir.path().join("real"); + fs::create_dir(&real_parent).unwrap(); + let symlink_parent = temp_dir.path().join("link"); + symlink(&real_parent, &symlink_parent).unwrap(); + + let source = symlink_parent.join("source"); + let destination = temp_dir.path().join("destination"); + fs::write(&source, b"source").unwrap(); + + rename_file_fallback(&source, &destination, None, None).unwrap(); + + assert_eq!(fs::read(&destination).unwrap(), b"source"); + assert!(!real_parent.join("source").exists()); + } } #[cfg(windows)]