From 87915e38871daaa2d4e169f33d75ea3a2c44cbb4 Mon Sep 17 00:00:00 2001 From: Jake Abendroth Date: Thu, 9 Jul 2026 22:19:55 -0700 Subject: [PATCH] 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 | 150 +++++++++++++++++++++++++++++++++++- 2 files changed, 268 insertions(+), 44 deletions(-) diff --git a/src/uu/mv/src/mv.rs b/src/uu/mv/src/mv.rs index fb395237099..f36fccd6376 100644 --- a/src/uu/mv/src/mv.rs +++ b/src/uu/mv/src/mv.rs @@ -793,13 +793,18 @@ fn rename( Ok(()) } +/// 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 } @@ -859,8 +864,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)] { @@ -878,15 +883,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))] @@ -894,7 +956,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(()) } @@ -927,6 +989,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. /// @@ -937,16 +1019,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()) @@ -965,12 +1041,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) { @@ -1239,21 +1310,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(()) @@ -1265,6 +1337,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| { @@ -1293,16 +1371,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 ecea9030dc3..2b5586b9ec5 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 +// spell-checker:ignore mydir hardlinked tmpfs notty unwriteable GHSA use filetime::FileTime; use rstest::rstest; @@ -2660,6 +2660,154 @@ fn test_mv_cross_device_refuses_planted_symlink_dest() { ); } +/// 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"); + // A mode outside the usual umask-derived values, so this fails if the + // fifo is recreated with a hardcoded mode instead of the source's. + 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; the +/// socket used to make the whole move fail because it was copied by content. +#[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 cross-device move of a source that cannot be opened for reading must +/// not destroy the destination; the destination used to be removed before +/// the source was opened. +#[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 { + // root can open the source regardless of its mode + 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",