Skip to content

Commit 376f213

Browse files
committed
mv: create the cross-device destination exclusively and chown it by fd
1 parent 7ec9923 commit 376f213

6 files changed

Lines changed: 124 additions & 17 deletions

File tree

src/uu/cp/src/platform/linux.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ use crate::{
2626
// Create the destination. It is followed when it is a pre-existing symlink,
2727
// matching GNU cp -d/-P which only forbid dereferencing on the source side.
2828
fn create_dest(dest: &Path) -> CopyResult<File> {
29-
create_dest_restrictive(dest, false).map_err(|e| {
29+
create_dest_restrictive(dest, false, false).map_err(|e| {
3030
CpError::IoErrContext(
3131
e,
3232
translate!("cp-error-cannot-create-regular-file", "path" => dest.quote()),
@@ -260,7 +260,7 @@ where
260260
// the dest does not momentarily sit with broader perms. The `0o622 &
261261
// !umask` form previously used here could still allow group/other write
262262
// under a permissive umask. See #10011.
263-
let mut dst_file = create_dest_restrictive(&dest, false).map_err(|e| {
263+
let mut dst_file = create_dest_restrictive(&dest, false, false).map_err(|e| {
264264
CpError::IoErrContext(
265265
e,
266266
translate!("cp-error-cannot-create-regular-file", "path" => dest.as_ref().quote()),

src/uu/cp/src/platform/macos.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ pub(crate) fn copy_on_write(
141141
} else {
142142
let mut src_file = open_source(source, nofollow)
143143
.map_err(|e| CpError::IoErrContext(e, context.to_owned()))?;
144-
let mut dst_file = create_dest_restrictive(dest, false).map_err(|e| {
144+
let mut dst_file = create_dest_restrictive(dest, false, false).map_err(|e| {
145145
CpError::IoErrContext(
146146
e,
147147
translate!("cp-error-cannot-create-regular-file", "path" => dest.quote()),

src/uu/cp/src/platform/other_unix.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ pub(crate) fn copy_on_write(
4646
if source_is_stream {
4747
let mut src_file = open_source(source, nofollow)
4848
.map_err(|e| CpError::IoErrContext(e, context.to_owned()))?;
49-
let mut dst_file = create_dest_restrictive(dest, false).map_err(|e| {
49+
let mut dst_file = create_dest_restrictive(dest, false, false).map_err(|e| {
5050
CpError::IoErrContext(
5151
e,
5252
translate!("cp-error-cannot-create-regular-file", "path" => dest.quote()),
@@ -72,7 +72,7 @@ pub(crate) fn copy_on_write(
7272
// dest is followed, matching GNU cp.
7373
let mut src_file =
7474
open_source(source, nofollow).map_err(|e| CpError::IoErrContext(e, context.to_owned()))?;
75-
let mut dst_file = create_dest_restrictive(dest, false).map_err(|e| {
75+
let mut dst_file = create_dest_restrictive(dest, false, false).map_err(|e| {
7676
CpError::IoErrContext(
7777
e,
7878
translate!("cp-error-cannot-create-regular-file", "path" => dest.quote()),

src/uu/mv/src/mv.rs

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1398,9 +1398,9 @@ fn rename_file_fallback(
13981398
}
13991399
}
14001400

1401-
// Open src/dst with O_NOFOLLOW and keep the fds alive across copy,
1402-
// chown, xattr, and chmod so a concurrent path-swap can't redirect any
1403-
// step to a different inode.
1401+
// Open the source with O_NOFOLLOW, create the destination exclusively,
1402+
// and keep both descriptors alive across copy, xattr, chown, and chmod so
1403+
// a concurrent path-swap cannot redirect any step to a different inode.
14041404
#[cfg(unix)]
14051405
{
14061406
use std::fs::Permissions;
@@ -1413,8 +1413,10 @@ fn rename_file_fallback(
14131413
.map_err(|err| io::Error::new(err.kind(), translate!("mv-error-permission-denied")))?
14141414
.mode()
14151415
& 0o7777;
1416-
let mut dst_file = create_dest_restrictive(to, /* nofollow */ true)
1417-
.map_err(|err| io::Error::new(err.kind(), translate!("mv-error-permission-denied")))?;
1416+
let mut dst_file = create_dest_restrictive(
1417+
to, /* nofollow */ true, /* exclusive */ true,
1418+
)
1419+
.map_err(|err| io::Error::new(err.kind(), translate!("mv-error-permission-denied")))?;
14181420
uucore::buf_copy::copy_fast(&mut &src_file, &mut dst_file)
14191421
.map_err(|err| io::Error::new(err.kind(), translate!("mv-error-permission-denied")))?;
14201422

@@ -1430,7 +1432,7 @@ fn rename_file_fallback(
14301432
// `mv`, and re-applying setuid/setgid would hand them a binary running
14311433
// as themselves that used to run as someone else. GNU strips the bits
14321434
// in that case and so do we.
1433-
let ownership_preserved = preserve_ownership(from, to).unwrap_or(false);
1435+
let ownership_preserved = preserve_ownership_fd(&src_file, &dst_file).unwrap_or(false);
14341436
let dest_mode = if ownership_preserved {
14351437
src_mode
14361438
} else {
@@ -1496,6 +1498,38 @@ fn preserve_ownership(from: &Path, to: &Path) -> io::Result<bool> {
14961498
Ok(true)
14971499
}
14981500

1501+
/// [`preserve_ownership`] on the already-open source and destination
1502+
/// descriptors, via `fchown`.
1503+
///
1504+
/// A cross-device copy holds both descriptors open across the content copy,
1505+
/// yet the chown used to re-resolve the destination by path, so a concurrent
1506+
/// path-swap could redirect it to an inode the copy never touched. `fchown`
1507+
/// acts on the inode behind `to`, which is the one the caller created and
1508+
/// wrote. Returns the same "did the destination keep the source's uid/gid"
1509+
/// answer as the path-based variant.
1510+
#[cfg(unix)]
1511+
fn preserve_ownership_fd(from: &fs::File, to: &fs::File) -> io::Result<bool> {
1512+
use rustix::fs::{Gid, Uid, fchown};
1513+
use std::os::unix::fs::MetadataExt;
1514+
1515+
let source_meta = from.metadata()?;
1516+
let uid = source_meta.uid();
1517+
let gid = source_meta.gid();
1518+
1519+
let dest_meta = to.metadata()?;
1520+
1521+
// Only chown if ownership actually differs
1522+
if uid != dest_meta.uid() || gid != dest_meta.gid() {
1523+
// Silently ignore errors: non-root users typically cannot chown to
1524+
// arbitrary uid, matching GNU mv behavior which also uses best-effort.
1525+
if fchown(to, Some(Uid::from_raw(uid)), Some(Gid::from_raw(gid))).is_err() {
1526+
return Ok(false);
1527+
}
1528+
}
1529+
1530+
Ok(true)
1531+
}
1532+
14991533
fn is_empty_dir(path: &Path) -> bool {
15001534
fs::read_dir(path).is_ok_and(|mut contents| contents.next().is_none())
15011535
}

src/uucore/src/lib/features/safe_copy.rs

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
//! shared directory cannot open the file before the caller narrows the
2020
//! final permissions via `set_permissions` (issue #10011). The same
2121
//! `nofollow` flag refuses to truncate through a symlink that may have
22-
//! been swapped in at the destination path.
22+
//! been swapped in at the destination path, and `exclusive` refuses to
23+
//! open *any* pre-existing name, including a hard link to a file the
24+
//! caller did not create.
2325
2426
use std::fs::File;
2527
use std::io;
@@ -38,6 +40,10 @@ const DEST_FLAGS: OFlags = OFlags::WRONLY
3840
.union(OFlags::CREATE)
3941
.union(OFlags::TRUNC)
4042
.union(OFlags::CLOEXEC);
43+
const DEST_EXCL_FLAGS: OFlags = OFlags::WRONLY
44+
.union(OFlags::CREATE)
45+
.union(OFlags::EXCL)
46+
.union(OFlags::CLOEXEC);
4147

4248
/// Open `path` for reading, optionally with `O_NOFOLLOW`.
4349
///
@@ -70,8 +76,25 @@ pub fn open_source<P: AsRef<Path>>(path: P, nofollow: bool) -> io::Result<File>
7076
/// who plants `path` as a symlink between the caller's check and this
7177
/// open can redirect the truncate (and the subsequent write) to any file
7278
/// the caller has permission to write.
73-
pub fn create_dest_restrictive<P: AsRef<Path>>(path: P, nofollow: bool) -> io::Result<File> {
74-
let mut flags = DEST_FLAGS;
79+
///
80+
/// With `exclusive = true`, the call carries `O_EXCL` and fails with
81+
/// `EEXIST` instead of opening an existing name at all. Pass `true`
82+
/// whenever the caller has just unlinked `path` and intends to create a
83+
/// fresh inode: `nofollow` alone still opens a hard link planted in that
84+
/// window, which would truncate (and later chown and chmod) a file the
85+
/// caller did not create. A symlink also fails under `O_EXCL`, so
86+
/// `nofollow` is subsumed when `exclusive` is set; the reverse is not
87+
/// true, since `O_NOFOLLOW` still opens a pre-existing regular file.
88+
pub fn create_dest_restrictive<P: AsRef<Path>>(
89+
path: P,
90+
nofollow: bool,
91+
exclusive: bool,
92+
) -> io::Result<File> {
93+
let mut flags = if exclusive {
94+
DEST_EXCL_FLAGS
95+
} else {
96+
DEST_FLAGS
97+
};
7598
if nofollow {
7699
flags |= OFlags::NOFOLLOW;
77100
}
@@ -138,7 +161,7 @@ mod tests {
138161
fn create_dest_uses_restrictive_initial_mode() {
139162
let dir = tempdir().unwrap();
140163
let path = dir.path().join("new");
141-
let f = create_dest_restrictive(&path, false).unwrap();
164+
let f = create_dest_restrictive(&path, false, false).unwrap();
142165
let mode = f.metadata().unwrap().mode() & 0o777;
143166
assert_eq!(mode, DEST_INITIAL_MODE);
144167
}
@@ -159,7 +182,7 @@ mod tests {
159182
}
160183
// Re-open via the helper — mode of the existing inode stays 0o644,
161184
// only the contents are truncated.
162-
create_dest_restrictive(&path, false).unwrap();
185+
create_dest_restrictive(&path, false, false).unwrap();
163186
let mode = std::fs::metadata(&path).unwrap().mode() & 0o777;
164187
assert_eq!(mode, 0o644);
165188
assert_eq!(std::fs::metadata(&path).unwrap().len(), 0);
@@ -177,11 +200,26 @@ mod tests {
177200
std::fs::write(&victim, b"do not truncate me").unwrap();
178201
symlink(&victim, &dst).unwrap();
179202

180-
let err = create_dest_restrictive(&dst, true).unwrap_err();
203+
let err = create_dest_restrictive(&dst, true, false).unwrap_err();
181204
assert_eq!(
182205
err.raw_os_error(),
183206
Some(rustix::io::Errno::LOOP.raw_os_error())
184207
);
185208
assert_eq!(std::fs::read(&victim).unwrap(), b"do not truncate me");
186209
}
210+
211+
#[test]
212+
fn create_dest_exclusive_refuses_existing_and_hard_linked() {
213+
// An attacker who plants a hard link in the window between the
214+
// caller's unlink and this create must not get the victim truncated
215+
// and later chowned/chmoded. O_EXCL refuses the existing name.
216+
let dir = tempdir().unwrap();
217+
let victim = dir.path().join("victim");
218+
std::fs::write(&victim, b"SECRET").unwrap();
219+
std::fs::hard_link(&victim, dir.path().join("planted")).unwrap();
220+
221+
let err = create_dest_restrictive(dir.path().join("planted"), true, true).unwrap_err();
222+
assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
223+
assert_eq!(std::fs::read(&victim).unwrap(), b"SECRET");
224+
}
187225
}

tests/by-util/test_mv.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2074,6 +2074,41 @@ mod inter_partition_copying {
20742074
);
20752075
}
20762076

2077+
// A cross-device move onto an existing regular file must replace that name
2078+
// with a fresh inode created O_EXCL, not truncate whatever the name
2079+
// pointed at. Here the destination name is a hard link to a victim; the
2080+
// victim's contents must survive the overwrite.
2081+
#[test]
2082+
pub(crate) fn test_mv_inter_partition_existing_dest_replaced() {
2083+
let scene = TestScenario::new(util_name!());
2084+
let at = &scene.fixtures;
2085+
2086+
at.write("src", "src contents");
2087+
2088+
let other_fs_tempdir =
2089+
TempDir::new_in("/dev/shm/").expect("Unable to create temp directory");
2090+
let victim = other_fs_tempdir.path().join("victim");
2091+
write(&victim, "victim contents").expect("Unable to write victim");
2092+
let dest = other_fs_tempdir.path().join("dest");
2093+
fs::hard_link(&victim, &dest).expect("Unable to hard link dest to victim");
2094+
2095+
scene
2096+
.ucmd()
2097+
.arg("src")
2098+
.arg(dest.to_str().unwrap())
2099+
.succeeds();
2100+
2101+
assert_eq!(
2102+
fs::read_to_string(&dest).expect("destination should be readable"),
2103+
"src contents"
2104+
);
2105+
assert_eq!(
2106+
fs::read_to_string(&victim).expect("victim should be readable"),
2107+
"victim contents",
2108+
"the hard link's target must not be truncated"
2109+
);
2110+
}
2111+
20772112
// Ensure that the copying code used in an inter-partition move unlinks the destination symlink.
20782113
#[test]
20792114
pub(crate) fn test_mv_unlinks_dest_symlink() {

0 commit comments

Comments
 (0)