diff --git a/src/uu/mv/Cargo.toml b/src/uu/mv/Cargo.toml index f446311a32f..018a4279d1d 100644 --- a/src/uu/mv/Cargo.toml +++ b/src/uu/mv/Cargo.toml @@ -42,7 +42,7 @@ windows-sys = { workspace = true, features = [ ] } [target.'cfg(unix)'.dependencies] -rustix = { workspace = true, features = ["fs"] } +rustix = { workspace = true, features = ["fs", "process"] } [[bin]] name = "mv" diff --git a/src/uu/mv/src/mv.rs b/src/uu/mv/src/mv.rs index 336a84e4fdb..39ab181f433 100644 --- a/src/uu/mv/src/mv.rs +++ b/src/uu/mv/src/mv.rs @@ -59,6 +59,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; @@ -929,13 +931,17 @@ fn is_directory_not_empty_error(err: &io::Error) -> bool { err.kind() == io::ErrorKind::DirectoryNotEmpty } +/// Fifos, sockets and device nodes are recreated rather than copied. #[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 } @@ -988,8 +994,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)] { @@ -1007,15 +1013,124 @@ fn rename_with_fallback( }) } -/// Replace the destination with a new pipe with the same name as the source. +/// Replace the destination with a new special file like the source. #[cfg(unix)] -fn rename_fifo_fallback(from: &Path, to: &Path) -> io::Result<()> { - if to.try_exists()? { +fn rename_special_fallback(from: &Path, to: &Path, metadata: &fs::Metadata) -> io::Result<()> { + copy_special_file(to, metadata)?; + fs::remove_file(from) +} + +/// The mode to give `node`, a new special file like `metadata`, after giving +/// it the same owner with `chown` if it does not have it yet. +/// +/// Ownership is best effort for unprivileged callers. As for regular files, if +/// it did not take, the node belongs to whoever ran mv, so setuid and setgid +/// are dropped. The rest of the mode, which the umask reduced, is restored. +#[cfg(unix)] +fn special_file_mode( + metadata: &fs::Metadata, + node: &impl unix::fs::MetadataExt, + chown: impl FnOnce(u32, u32) -> io::Result<()>, +) -> u32 { + use std::os::unix::fs::MetadataExt; + + let (uid, gid) = (metadata.uid(), metadata.gid()); + let owned = (node.uid(), node.gid()) == (uid, gid) || chown(uid, gid).is_ok(); + metadata.mode() & if owned { 0o7777 } else { 0o1777 } +} + +/// Create the fifo, socket or device node that `metadata` describes at `to`, +/// with its ownership and permissions. +/// +/// An entry at `to` is replaced atomically, so it is kept if the node cannot +/// be created. +#[cfg(all(unix, not(target_os = "redox")))] +fn copy_special_file(to: &Path, metadata: &fs::Metadata) -> io::Result<()> { + let parent = to + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let name = to.file_name().ok_or(io::ErrorKind::InvalidInput)?; + // Follows symlinks in `parent`, as creating the node by path would. + let dir = DirFd::open_anchor(parent)?; + create_special_file_at(&dir, name, metadata) +} + +/// Create the special file that `metadata` describes as `name` in `dir`, with +/// its ownership and permissions, replacing an entry already there. +/// +/// The node gets its ownership and mode inside a new private directory in +/// `dir` and is then renamed into place. In `dir` itself, whoever else can +/// write there could link another file over the name between those calls. +#[cfg(all(unix, not(target_os = "redox")))] +fn create_special_file_at( + dir: &DirFd, + name: &std::ffi::OsStr, + metadata: &fs::Metadata, +) -> io::Result<()> { + use rustix::fs::renameat; + use rustix::process::geteuid; + use std::os::unix::fs::MetadataExt; + + let (staging, staging_name) = dir.create_temp_at(|dir, tmp| { + dir.mkdir_at(tmp, 0o700)?; + // Whatever the umask, the owner must be able to create entries inside. + dir.chmod_at(tmp, 0o700, SymlinkBehavior::NoFollow) + .and_then(|()| dir.open_subdir(tmp, SymlinkBehavior::NoFollow)) + .and_then(|staging| { + // Another directory may have been moved to `tmp` since. + let stat = staging.metadata()?; + if stat.uid() == geteuid().as_raw() && stat.mode() & 0o777 == 0o700 { + Ok(staging) + } else { + Err(io::ErrorKind::AlreadyExists.into()) + } + }) + .inspect_err(|_| { + // Removes only an empty directory: the one made above, or at + // worst an empty one moved to `tmp` since. + let _ = dir.unlink_at(tmp, true); + }) + })?; + + let created = staging + .mknod_at(name, metadata.mode(), metadata.rdev()) + .and_then(|()| { + let node = staging.metadata_at(name, SymlinkBehavior::NoFollow)?; + let mode = special_file_mode(metadata, &node, |uid, gid| { + staging.chown_at(name, Some(uid), Some(gid), SymlinkBehavior::NoFollow) + }); + staging.chmod_at(name, mode, SymlinkBehavior::NoFollow) + }) + .and_then(|()| Ok(renameat(&staging, name, dir, name)?)) + .inspect_err(|_| { + let _ = staging.unlink_at(name, false); + }); + let _ = dir.unlink_at(&staging_name, true); + created +} + +/// Without `safe_traversal`, the node is created by path after removing the +/// destination, as `uucore::fs::replace_link` does on Redox. +#[cfg(target_os = "redox")] +fn copy_special_file(to: &Path, metadata: &fs::Metadata) -> io::Result<()> { + use nix::sys::stat::{Mode, SFlag, mknod}; + use std::os::unix::fs::MetadataExt; + + if to.symlink_metadata().is_ok() { fs::remove_file(to)?; } - // rustix::fs::mkfifoat is linux only - nix::unistd::mkfifo(to, nix::sys::stat::Mode::from_bits_truncate(0o666))?; - fs::remove_file(from) + let mode = metadata.mode() as nix::libc::mode_t; + mknod( + to, + SFlag::from_bits_truncate(mode & nix::libc::S_IFMT), + Mode::from_bits_truncate(mode), + metadata.rdev() as nix::libc::dev_t, + )?; + let mode = special_file_mode(metadata, &to.symlink_metadata()?, |uid, gid| { + unix::fs::lchown(to, Some(uid), Some(gid)) + }); + fs::set_permissions(to, fs::Permissions::from_mode(mode)) } #[cfg(not(unix))] @@ -1023,7 +1138,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(()) } @@ -1370,15 +1485,13 @@ fn copy_file_with_hardlinks_helper( return Ok(()); } - if from.is_symlink() { + let metadata = from.symlink_metadata()?; + if metadata.is_symlink() { // 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 if is_special_file(metadata.file_type()) { + copy_special_file(to, &metadata)?; } else { // Copy a regular file. fs::copy(from, to)?; 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/fs.rs b/src/uucore/src/lib/features/fs.rs index c17fa99d21d..5ecba14068a 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"))] { diff --git a/src/uucore/src/lib/features/safe_traversal.rs b/src/uucore/src/lib/features/safe_traversal.rs index 2ebc460f1d6..27eb99460c8 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 mknodat // Safe directory traversal using openat() and related syscalls // This module provides TOCTOU-safe filesystem operations for recursive traversal @@ -599,6 +599,32 @@ impl DirFd { Ok(()) } + /// Create a fifo, socket, or device node relative to this directory + /// + /// `mode` holds the file type bits as well as the permissions, as for + /// `mknod(2)`, and the umask applies. Fails with `EEXIST` if the name + /// already exists. + pub fn mknod_at(&self, name: &OsStr, mode: u32, dev: u64) -> io::Result<()> { + let name_cstr = + CString::new(name.as_bytes()).map_err(|_| SafeTraversalError::PathContainsNull)?; + // Neither nix nor rustix provides `mknodat` on Apple targets. + // SAFETY: `name_cstr` is NUL-terminated and outlives the call, and + // `self.fd` is an open directory descriptor. + let res = unsafe { + libc::mknodat( + self.fd.as_raw_fd(), + name_cstr.as_ptr(), + mode as libc::mode_t, + dev as libc::dev_t, + ) + }; + if res == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } + } + /// Create a file for writing relative to this directory /// Fails with `EEXIST` if the name already exists pub fn open_file_at(&self, name: &OsStr) -> io::Result { @@ -625,6 +651,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 { @@ -1506,6 +1599,23 @@ mod tests { assert!(result.is_err()); } + #[test] + fn test_mknod_at_creates_fifo_and_refuses_existing() { + use std::os::unix::fs::FileTypeExt; + + let temp_dir = TempDir::new().unwrap(); + let dir_fd = DirFd::open(temp_dir.path(), SymlinkBehavior::Follow).unwrap(); + // S_IFIFO | 0o600 + let fifo = 0o010_600; + + dir_fd.mknod_at(OsStr::new("fifo"), fifo, 0).unwrap(); + + let metadata = fs::symlink_metadata(temp_dir.path().join("fifo")).unwrap(); + assert!(metadata.file_type().is_fifo()); + let err = dir_fd.mknod_at(OsStr::new("fifo"), fifo, 0).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::AlreadyExists); + } + #[test] fn test_open_file_at_creates_file() { use std::io::Write; diff --git a/tests/by-util/test_cp.rs b/tests/by-util/test_cp.rs index 96cf173c0ff..686a3aca5ec 100644 --- a/tests/by-util/test_cp.rs +++ b/tests/by-util/test_cp.rs @@ -1184,6 +1184,37 @@ 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" +))] +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 68a8121066b..efc6989d6c7 100644 --- a/tests/by-util/test_ln.rs +++ b/tests/by-util/test_ln.rs @@ -116,6 +116,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] diff --git a/tests/by-util/test_mv.rs b/tests/by-util/test_mv.rs index f88bf146b6b..36a973752b9 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 myfolder SRCDATA DSTDATA REALDATA realfile -// spell-checker:ignore dirattr dirvalue setfattr getfattr +// spell-checker:ignore dirattr dirvalue setfattr getfattr Nofile use rstest::rstest; use std::io::Write; @@ -2985,6 +2985,289 @@ fn test_mv_cross_device_dir_refuses_symlink_at_recreated_dest() { assert_eq!(at.read("victim/guard"), "PROTECTED_DATA"); } +/// A cross-device move of a socket used to remove the destination and then +/// fail to copy the socket. Like GNU, the socket is recreated instead (#13145). +#[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; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + UnixListener::bind(at.plus("sock")).expect("bind socket"); + let other_fs = TempDir::new_in("/dev/shm/").expect("create temp dir in /dev/shm"); + let dest = other_fs.path().join("dest"); + std::fs::write(&dest, "old content").unwrap(); + + scene.ucmd().arg("sock").arg(&dest).succeeds().no_output(); + + assert!(at.plus("sock").symlink_metadata().is_err()); + assert!(dest.symlink_metadata().unwrap().file_type().is_socket()); +} + +/// A fifo moved across devices replaces the destination and keeps its mode. +#[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; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.mkfifo("fifo"); + set_permissions(at.plus("fifo"), Permissions::from_mode(0o604)).unwrap(); + let other_fs = TempDir::new_in("/dev/shm/").expect("create temp dir in /dev/shm"); + let dest = other_fs.path().join("dest"); + std::fs::write(&dest, "old content").unwrap(); + + scene.ucmd().arg("fifo").arg(&dest).succeeds().no_output(); + + let metadata = dest.symlink_metadata().unwrap(); + assert!(metadata.file_type().is_fifo()); + assert_eq!(metadata.permissions().mode() & 0o7777, 0o604); +} + +/// A destination reached through a symlinked directory is followed, as it is +/// for any other move. +#[test] +#[cfg(target_os = "linux")] +fn test_mv_cross_device_special_file_into_symlinked_parent() { + use std::os::unix::fs::{FileTypeExt, symlink}; + use std::os::unix::net::UnixListener; + use tempfile::TempDir; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + UnixListener::bind(at.plus("sock")).expect("bind socket"); + at.mkfifo("fifo"); + let other_fs = TempDir::new_in("/dev/shm/").expect("create temp dir in /dev/shm"); + let real = other_fs.path().join("real"); + let link = other_fs.path().join("link"); + std::fs::create_dir(&real).unwrap(); + symlink(&real, &link).unwrap(); + std::fs::write(real.join("sock_dest"), "old content").unwrap(); + + for (source, dest) in [("sock", "sock_dest"), ("fifo", "fifo_dest")] { + scene + .ucmd() + .arg(source) + .arg(link.join(dest)) + .succeeds() + .no_output(); + } + + let sock = real.join("sock_dest").symlink_metadata().unwrap(); + assert!(sock.file_type().is_socket()); + let fifo = real.join("fifo_dest").symlink_metadata().unwrap(); + assert!(fifo.file_type().is_fifo()); + assert!(link.symlink_metadata().unwrap().is_symlink()); +} + +/// Like a same-device move, a cross-device move of a special file only needs +/// write and search permission on the parent directories, not read. +#[test] +#[cfg(target_os = "linux")] +fn test_mv_cross_device_special_file_write_search_only_parents() { + use std::fs::{Permissions, set_permissions}; + use std::os::unix::fs::{FileTypeExt, PermissionsExt}; + use tempfile::TempDir; + + if rustix::process::geteuid().is_root() { + return; + } + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.mkdir("src"); + at.mkfifo("src/fifo"); + let other_fs = TempDir::new_in("/dev/shm/").expect("create temp dir in /dev/shm"); + let dst = other_fs.path().join("dst"); + std::fs::create_dir(&dst).unwrap(); + std::fs::write(dst.join("fifo"), "old content").unwrap(); + set_permissions(at.plus("src"), Permissions::from_mode(0o300)).unwrap(); + set_permissions(&dst, Permissions::from_mode(0o300)).unwrap(); + + let result = scene.ucmd().arg("src/fifo").arg(dst.join("fifo")).run(); + + set_permissions(at.plus("src"), Permissions::from_mode(0o700)).unwrap(); + set_permissions(&dst, Permissions::from_mode(0o700)).unwrap(); + result.success().no_output(); + assert!(at.plus("src/fifo").symlink_metadata().is_err()); + let fifo = dst.join("fifo").symlink_metadata().unwrap(); + assert!(fifo.file_type().is_fifo()); +} + +/// The mode of a special file moved across devices is set before the file +/// appears at the destination name, so a file linked over that name in the +/// meantime keeps its own mode. +#[test] +#[cfg(target_os = "linux")] +fn test_mv_cross_device_special_file_mode_not_applied_to_swapped_entry() { + use std::fs::{Permissions, hard_link, remove_file, rename, set_permissions}; + use std::os::unix::fs::{FileTypeExt, PermissionsExt}; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::thread; + use tempfile::TempDir; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + let other_fs = TempDir::new_in("/dev/shm/").expect("create temp dir in /dev/shm"); + let victim = other_fs.path().join("victim"); + std::fs::write(&victim, "").unwrap(); + set_permissions(&victim, Permissions::from_mode(0o600)).unwrap(); + let dest = other_fs.path().join("dest"); + + // Whenever a fifo shows up at `dest`, link `victim` over it. The link is + // made ahead of time, so that only a rename follows the check. + let done = Arc::new(AtomicBool::new(false)); + let swapper = thread::spawn({ + let (victim, dest, done) = (victim.clone(), dest.clone(), Arc::clone(&done)); + let link = other_fs.path().join("link"); + move || { + let mut linked = false; + while !done.load(Ordering::Relaxed) { + linked = linked || hard_link(&victim, &link).is_ok(); + if linked + && dest + .symlink_metadata() + .is_ok_and(|m| m.file_type().is_fifo()) + { + linked = rename(&link, &dest).is_err(); + } + } + } + }); + + for i in 0..100 { + let fifo = format!("fifo{i}"); + at.mkfifo(&fifo); + set_permissions(at.plus(&fifo), Permissions::from_mode(0o646)).unwrap(); + scene.ucmd().arg(&fifo).arg(&dest).run(); + let _ = remove_file(&dest); + if victim.metadata().unwrap().permissions().mode() & 0o7777 != 0o600 { + break; + } + } + done.store(true, Ordering::Relaxed); + swapper.join().unwrap(); + + assert_eq!( + victim.metadata().unwrap().permissions().mode() & 0o7777, + 0o600, + "the fifo's mode was applied to a file linked over the destination" + ); +} + +/// Like GNU, a special file keeps its mode whatever the umask, including one +/// that takes the owner's write or search permission, which creating the +/// node in its private staging directory needs. +#[test] +#[cfg(target_os = "linux")] +fn test_mv_cross_device_special_file_under_owner_umask() { + use std::fs::{Permissions, set_permissions}; + use std::os::unix::fs::{FileTypeExt, PermissionsExt}; + use tempfile::TempDir; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + let other_fs = TempDir::new_in("/dev/shm/").expect("create temp dir in /dev/shm"); + + for umask in [0o100, 0o200, 0o400] { + let name = format!("fifo{umask:o}"); + at.mkfifo(&name); + set_permissions(at.plus(&name), Permissions::from_mode(0o646)).unwrap(); + let dest = other_fs.path().join(&name); + + scene + .ucmd() + .arg(&name) + .arg(&dest) + .umask(umask) + .succeeds() + .no_output(); + + let metadata = dest.symlink_metadata().unwrap(); + assert!(metadata.file_type().is_fifo()); + assert_eq!(metadata.permissions().mode() & 0o7777, 0o646); + } + assert_eq!(std::fs::read_dir(other_fs.path()).unwrap().count(), 3); +} + +/// The private directory a special file is staged in is removed again when +/// opening it fails, here because no descriptor is left for it. +#[test] +#[cfg(target_os = "linux")] +fn test_mv_cross_device_special_file_staging_removed_at_fd_limit() { + use rustix::process::Resource; + use std::os::unix::fs::FileTypeExt; + use tempfile::TempDir; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + let other_fs = TempDir::new_in("/dev/shm/").expect("create temp dir in /dev/shm"); + let dest = other_fs.path().join("dest"); + std::fs::write(&dest, "old content").unwrap(); + at.mkfifo("fifo"); + + // Raise the limit one descriptor at a time: each run fails at a later + // step, one of them right after creating the staging directory, until + // the move succeeds. + for limit in 3..64 { + scene + .ucmd() + .arg("fifo") + .arg(&dest) + .limit(Resource::Nofile, limit, limit) + .run(); + + let entries: Vec<_> = std::fs::read_dir(other_fs.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!(entries, ["dest"], "left behind at a limit of {limit}"); + if !at.plus("fifo").exists() { + break; + } + } + assert!(dest.symlink_metadata().unwrap().file_type().is_fifo()); +} + +/// Special files inside a directory moved across devices are recreated with +/// their mode, not copied by content. +#[test] +#[cfg(target_os = "linux")] +fn test_mv_cross_device_dir_with_special_files() { + use std::fs::{Permissions, set_permissions}; + use std::os::unix::fs::{FileTypeExt, PermissionsExt}; + use std::os::unix::net::UnixListener; + use tempfile::TempDir; + + let scene = TestScenario::new(util_name!()); + let at = &scene.fixtures; + at.mkdir("dir"); + UnixListener::bind(at.plus("dir/sock")).expect("bind socket"); + at.mkfifo("dir/fifo"); + set_permissions(at.plus("dir/fifo"), Permissions::from_mode(0o604)).unwrap(); + let other_fs = TempDir::new_in("/dev/shm/").expect("create temp dir in /dev/shm"); + + scene + .ucmd() + .arg("dir") + .arg(other_fs.path()) + .succeeds() + .no_output(); + + assert!(!at.dir_exists("dir")); + let sock = other_fs.path().join("dir/sock").symlink_metadata().unwrap(); + assert!(sock.file_type().is_socket()); + let fifo = other_fs.path().join("dir/fifo").symlink_metadata().unwrap(); + assert!(fifo.file_type().is_fifo()); + assert_eq!(fifo.permissions().mode() & 0o7777, 0o604); +} + #[test] #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] fn test_mv_selinux_context() {