Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/uu/mv/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
147 changes: 130 additions & 17 deletions src/uu/mv/src/mv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -924,13 +926,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
}

Expand Down Expand Up @@ -983,8 +989,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)]
{
Expand All @@ -1002,23 +1008,132 @@ 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) = uucore::fs::create_temp_at(dir, |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))]
#[expect(
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(())
}

Expand Down Expand Up @@ -1365,15 +1480,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)?;
Expand Down
9 changes: 8 additions & 1 deletion src/uucore/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
1 change: 1 addition & 0 deletions src/uucore/locales/en-US.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/uucore/locales/fr-FR.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
137 changes: 94 additions & 43 deletions src/uucore/src/lib/features/fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {}
Expand All @@ -1132,39 +1123,10 @@ 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)?;
replace_existing_entry_at(&dir, basename, |dir, name| {
link_at(target, dir, name, symbolic)
})
}
#[cfg(any(windows, target_os = "redox", target_os = "wasi"))]
{
Expand All @@ -1180,6 +1142,95 @@ pub fn replace_link(target: &Path, dest: &Path, symbolic: bool) -> IOResult<()>
}
}

/// Create the entry `name` in `dir` with `create`, replacing an entry already
/// there.
///
/// Never unlinks the existing entry first, which would briefly free the name
/// for another user to claim. Try the create; if the name is taken, create the
/// entry under a random temporary name in `dir` and `renameat(2)` it over.
/// `create` must fail with `EEXIST` when its name is taken, must not leave an
/// entry behind when it fails otherwise, and must not create a directory.
///
/// # Errors
///
/// Returns an error if `create` or the rename fails, or if no unique temporary
/// name is available.
#[cfg(all(unix, not(target_os = "redox")))]
pub fn replace_entry_at<D: AsFd>(
dir: &D,
name: &OsStr,
mut create: impl FnMut(&D, &OsStr) -> IOResult<()>,
) -> IOResult<()> {
match create(dir, name) {
Err(e) if e.kind() == ErrorKind::AlreadyExists => {}
res => return res,
}
replace_existing_entry_at(dir, name, create)
}

/// [`replace_entry_at`] once creating `name` itself failed with `EEXIST`.
#[cfg(all(unix, not(target_os = "redox")))]
fn replace_existing_entry_at<D: AsFd>(
dir: &D,
name: &OsStr,
create: impl FnMut(&D, &OsStr) -> IOResult<()>,
) -> IOResult<()> {
use rustix::fs::{AtFlags, renameat, unlinkat};

let ((), tmp) = create_temp_at(dir, create)?;
let renamed = renameat(dir, &tmp, dir, name);
// Renaming onto an existing link to the same inode is a no-op, which
// leaves the temp behind.
let _ = unlinkat(dir, &tmp, AtFlags::empty());
renamed.map_err(Into::into)
}

/// Create an entry in `dir` under a random name that is not taken, and return
/// what `create` returned along with the name.
///
/// `create` must fail with `EEXIST` when its name is taken; another name is
/// tried then.
///
/// # Errors
///
/// Returns an error if `create` fails otherwise, or if no unique name is
/// available.
#[cfg(all(unix, not(target_os = "redox")))]
pub fn create_temp_at<D: AsFd, T>(
dir: &D,
mut create: impl FnMut(&D, &OsStr) -> IOResult<T>,
) -> IOResult<(T, OsString)> {
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";

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(dir, tmp) {
Ok(created) => return Ok((created, tmp.to_owned())),
Err(e) if e.kind() == ErrorKind::AlreadyExists => {}
Err(e) => return Err(e),
}
}
Err(Error::new(
ErrorKind::AlreadyExists,
translate!("error-no-unique-temp-name"),
))
}

/// `symlinkat`/`linkat` relative to an open directory.
#[cfg(all(unix, not(target_os = "redox")))]
fn link_at<Fd: AsFd>(target: &Path, dir: Fd, name: &OsStr, symbolic: bool) -> IOResult<()> {
Expand Down
Loading
Loading