diff --git a/Cargo.lock b/Cargo.lock index a5fb3f821dd..97f0371a72d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3803,6 +3803,7 @@ dependencies = [ "codspeed-divan-compat", "fluent", "indicatif", + "libc", "nix", "rustc-hash", "rustix", diff --git a/src/uu/mv/Cargo.toml b/src/uu/mv/Cargo.toml index f446311a32f..71a15e5bded 100644 --- a/src/uu/mv/Cargo.toml +++ b/src/uu/mv/Cargo.toml @@ -19,6 +19,7 @@ doctest = false clap = { workspace = true } fluent = { workspace = true } indicatif = { workspace = true } +libc = { workspace = true } nix = { workspace = true, features = ["fs"] } rustc-hash = { workspace = true } thiserror = { workspace = true } diff --git a/src/uu/mv/src/mv.rs b/src/uu/mv/src/mv.rs index 67a02e03a6f..59f784aef6a 100644 --- a/src/uu/mv/src/mv.rs +++ b/src/uu/mv/src/mv.rs @@ -5,11 +5,15 @@ // spell-checker:ignore (ToDO) sourcepath targetpath nushell canonicalized unwriteable // spell-checker:ignore renameat symlinkat unlinkat unguessability RDONLY CLOEXEC -// spell-checker:ignore renamer fsetxattr +// spell-checker:ignore renamer fsetxattr mknodat fchown mod error; #[cfg(unix)] mod hardlink; +#[cfg(target_os = "redox")] +mod platform; +#[cfg(target_os = "redox")] +use platform::{copy_special_file, create_symlink_replace, rename_special_fallback}; use clap::builder::ValueParser; use clap::error::ErrorKind; @@ -29,8 +33,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(all(unix, not(target_os = "redox")))] +use std::os::unix::fs::MetadataExt; #[cfg(unix)] use std::os::unix::fs::{FileTypeExt, PermissionsExt}; #[cfg(windows)] @@ -59,6 +69,8 @@ use uucore::fs::{ target_os = "netbsd" ))] 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; @@ -924,13 +936,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 } @@ -983,8 +1000,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)] { @@ -1002,15 +1019,132 @@ fn rename_with_fallback( }) } -/// Replace the destination with a new pipe with the same name as the source. -#[cfg(unix)] -fn rename_fifo_fallback(from: &Path, to: &Path) -> io::Result<()> { - if to.try_exists()? { - fs::remove_file(to)?; +/// 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())) +} + +/// 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")))] +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 file_type = metadata.file_type(); + let kind = if file_type.is_fifo() { + libc::S_IFIFO + } else 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; + // `mknodat` is available on Android where `mkfifoat` is not, and rustix + // does not expose it on Apple targets. + // SAFETY: `name_cstr` is a valid NUL-terminated string that outlives the + // call, and `dir_fd` is an open directory descriptor. + let result = unsafe { + 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()); } - // rustix::fs::mkfifoat is linux only - nix::unistd::mkfifo(to, nix::sys::stat::Mode::from_bits_truncate(0o666))?; - fs::remove_file(from) + + // 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(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) +} + +/// 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(all(unix, not(target_os = "redox")))] +fn rename_special_fallback(from: &Path, to: &Path, metadata: &fs::Metadata) -> io::Result<()> { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let (dir_fd, basename) = open_destination_parent(to)?; + let (src_parent_fd, src_basename) = open_source_parent(from)?; + 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(()) => { + if let Err(error) = + rustix::fs::renameat(&dir_fd, &tmp_name, &dir_fd, basename.as_os_str()) + { + let _ = dir_fd.unlink_at(&tmp_name, false); + return Err(error.into()); + } + 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(not(unix))] @@ -1018,7 +1152,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(()) } @@ -1036,10 +1170,7 @@ fn rename_symlink_fallback(from: &Path, to: &Path) -> io::Result<()> { #[cfg(not(target_os = "redox"))] uucore::fs::replace_link(&path_symlink_points_to, to, true)?; #[cfg(target_os = "redox")] - { - fs::remove_file(to)?; - unix::fs::symlink(&path_symlink_points_to, to)?; - } + create_symlink_replace(&path_symlink_points_to, to)?; } Err(e) => return Err(e), } @@ -1056,6 +1187,27 @@ fn rename_symlink_fallback(from: &Path, to: &Path) -> io::Result<()> { let _ = preserve_ownership(from, to); 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) +} + #[cfg(windows)] fn rename_symlink_fallback(from: &Path, to: &Path) -> io::Result<()> { let path_symlink_points_to = fs::read_link(from)?; @@ -1367,27 +1519,28 @@ 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(any( - target_os = "freebsd", - target_os = "hurd", - target_os = "linux", - target_os = "android", - target_os = "netbsd" - ))] - { - 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(any( + target_os = "freebsd", + target_os = "hurd", + target_os = "linux", + target_os = "android", + target_os = "netbsd" + ))] + { + 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(()) @@ -1399,6 +1552,16 @@ 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")))?; + + #[cfg(all(unix, not(target_os = "redox")))] + 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 if to.is_symlink() { fs::remove_file(to).map_err(|err| { @@ -1421,22 +1584,23 @@ 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(()); } } } - // 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")))? @@ -1465,6 +1629,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 @@ -1480,11 +1647,42 @@ fn rename_file_fallback( .map_err(|err| io::Error::new(err.kind(), translate!("mv-error-permission-denied")))?; } - fs::remove_file(from) + #[cfg(all(unix, not(target_os = "redox")))] + let remove_source_result = src_parent_fd.unlink_at(&src_basename, false); + #[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")))?; 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 owner = rustix::fs::Uid::from_raw(source_meta.uid()); + let group = rustix::fs::Gid::from_raw(source_meta.gid()); + if rustix::fs::fchown(to, Some(owner), Some(group)).is_err() { + 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. @@ -1604,3 +1802,40 @@ fn can_delete_file(_: &Path) -> bool { // succeed even with a copy+delete approach (e.g. permission errors). false } + +#[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()); + } + + #[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()); + } +} diff --git a/src/uu/mv/src/platform/mod.rs b/src/uu/mv/src/platform/mod.rs new file mode 100644 index 00000000000..ff805396699 --- /dev/null +++ b/src/uu/mv/src/platform/mod.rs @@ -0,0 +1,10 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +#[cfg(target_os = "redox")] +mod redox; + +#[cfg(target_os = "redox")] +pub(super) use redox::{copy_special_file, create_symlink_replace, rename_special_fallback}; diff --git a/src/uu/mv/src/platform/redox.rs b/src/uu/mv/src/platform/redox.rs new file mode 100644 index 00000000000..c2c8bede32d --- /dev/null +++ b/src/uu/mv/src/platform/redox.rs @@ -0,0 +1,75 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use std::ffi::OsStr; +use std::fs::{self, Metadata, Permissions}; +use std::io; +use std::os::unix; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt}; +use std::path::Path; + +use nix::sys::stat::{Mode, SFlag, mknod}; + +/// Recreate a special file using Redox's path-based filesystem operations. +pub(crate) fn copy_special_file(from: &Path, metadata: &Metadata, to: &Path) -> io::Result<()> { + let file_type = metadata.file_type(); + let mode = Mode::from_bits_truncate(metadata.mode() as _); + if file_type.is_fifo() { + 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 _)?; + } + let _ = crate::preserve_ownership(from, to); + let _ = fs::set_permissions(to, Permissions::from_mode(metadata.mode() & 0o7777)); + Ok(()) +} + +/// Replace the destination with a recreated special file and then remove the source. +pub(crate) fn rename_special_fallback( + from: &Path, + to: &Path, + metadata: &Metadata, +) -> io::Result<()> { + 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 = crate::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(error) = fs::rename(&tmp, to) { + let _ = fs::remove_file(&tmp); + return Err(error); + } + return fs::remove_file(from); + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error), + } + } + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "could not allocate a unique temp name in destination directory", + )) +} + +/// Replace an existing symlink using Redox's path-based filesystem operations. +pub(crate) fn create_symlink_replace(target: &Path, to: &Path) -> io::Result<()> { + fs::remove_file(to)?; + unix::fs::symlink(target, to) +} diff --git a/tests/by-util/test_mv.rs b/tests/by-util/test_mv.rs index e2c3f534685..7934d359132 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 realfile +// spell-checker:ignore mydir hardlinked tmpfs notty unwriteable myfolder SRCDATA DSTDATA REALDATA realfile GHSA // spell-checker:ignore dirattr dirvalue setfattr getfattr use rstest::rstest; @@ -2973,6 +2973,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 rustix::process::geteuid; + use std::fs::{Permissions, set_permissions}; + use std::os::unix::fs::PermissionsExt; + use tempfile::TempDir; + use uutests::util::TestScenario; + + if geteuid().is_root() { + 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 = "selinux", any(target_os = "linux", target_os = "android")))] fn test_mv_selinux_context() {