Skip to content

Commit be02dd9

Browse files
committed
mv,cp: fix xattr TOCTOU by using file descriptor-based operations
Closes: #10014
1 parent 6e18efb commit be02dd9

9 files changed

Lines changed: 477 additions & 78 deletions

File tree

src/uu/cp/src/cp.rs

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use std::os::unix::net::UnixListener;
1616
use std::path::{Path, PathBuf, StripPrefixError};
1717
use std::{fmt, io};
1818
#[cfg(all(unix, not(target_os = "android")))]
19-
use uucore::fsxattr::{copy_xattrs, copy_xattrs_skip_selinux};
19+
use uucore::fsxattr::{copy_xattrs_fd, copy_xattrs_skip_selinux};
2020
use uucore::translate;
2121

2222
use clap::{Arg, ArgAction, ArgMatches, Command, builder::ValueParser, value_parser};
@@ -1715,6 +1715,8 @@ pub(crate) fn set_selinux_context(path: &Path, context: Option<&String>) -> Copy
17151715
/// or if xattr copying fails.
17161716
#[cfg(all(unix, not(target_os = "android")))]
17171717
fn copy_extended_attrs(source: &Path, dest: &Path, skip_selinux: bool) -> CopyResult<()> {
1718+
use std::fs::File;
1719+
use uucore::fsxattr::copy_xattrs;
17181720
let metadata = fs::symlink_metadata(dest)?;
17191721

17201722
// Check if the destination file is currently read-only for the user.
@@ -1734,6 +1736,13 @@ fn copy_extended_attrs(source: &Path, dest: &Path, skip_selinux: bool) -> CopyRe
17341736
// When -Z is used, skip copying security.selinux xattr so that
17351737
// the default context can be set instead of preserving from source
17361738
copy_xattrs_skip_selinux(source, dest)
1739+
} else if metadata.is_file() {
1740+
// Use file descriptor-based operations for regular files to avoid TOCTOU races.
1741+
// Directories cannot be opened with write mode for xattr operations
1742+
// Symlinks (especially dangling ones) cannot be opened via File::open
1743+
let source_file = File::open(source)?;
1744+
let dest_file = OpenOptions::new().write(true).open(dest)?;
1745+
copy_xattrs_fd(&source_file, &dest_file)
17371746
} else {
17381747
copy_xattrs(source, dest)
17391748
};
@@ -2591,7 +2600,45 @@ fn copy_file(
25912600
fs::set_permissions(dest, dest_permissions).ok();
25922601
}
25932602

2594-
let copy_attributes_result = if options.dereference(source_in_command_line) {
2603+
let copy_attributes_result = if source_is_stream && options.copy_contents {
2604+
// When copying contents from a stream (like a FIFO with --copy-contents),
2605+
// we can't re-access the source as it may block. Use the metadata we
2606+
// already have to preserve ownership and permissions.
2607+
#[cfg(unix)]
2608+
{
2609+
if let Preserve::Yes { .. } = options.attributes.ownership {
2610+
use std::os::unix::prelude::MetadataExt;
2611+
use uucore::perms::{Verbosity, VerbosityLevel, wrap_chown};
2612+
2613+
let dest_uid = source_metadata.uid();
2614+
let dest_gid = source_metadata.gid();
2615+
if let Ok(dest_meta) = dest.symlink_metadata() {
2616+
let try_chown = |uid| {
2617+
wrap_chown(
2618+
dest,
2619+
&dest_meta,
2620+
uid,
2621+
Some(dest_gid),
2622+
false,
2623+
Verbosity {
2624+
groups_only: false,
2625+
level: VerbosityLevel::Silent,
2626+
},
2627+
)
2628+
};
2629+
// gnu compatibility: cp doesn't report an error if it fails to set the
2630+
// ownership, and will fall back to changing only the gid if possible.
2631+
if try_chown(Some(dest_uid)).is_err() {
2632+
let _ = try_chown(None);
2633+
}
2634+
}
2635+
}
2636+
if let Preserve::Yes { .. } = options.attributes.mode {
2637+
fs::set_permissions(dest, source_metadata.permissions()).ok();
2638+
}
2639+
}
2640+
Ok(())
2641+
} else if options.dereference(source_in_command_line) {
25952642
// Try to canonicalize, but if it fails (e.g., due to inaccessible parent directories),
25962643
// fall back to the original source path
25972644
let src_for_attrs = canonicalize(source, MissingHandling::Normal, ResolveMode::Physical)
@@ -2605,12 +2652,6 @@ fn copy_file(
26052652
false,
26062653
options.set_selinux_context,
26072654
)
2608-
} else if source_is_stream && !source.exists() {
2609-
// Some stream files may not exist after we have copied it,
2610-
// like anonymous pipes. Thus, we can't really copy its
2611-
// attributes. However, this is already handled in the stream
2612-
// copy function (see `copy_stream` under platform/linux.rs).
2613-
Ok(())
26142655
} else {
26152656
copy_attributes(
26162657
source,

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

Lines changed: 68 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,18 @@
66

77
use libc::{SEEK_DATA, SEEK_HOLE};
88
use std::fs::{File, OpenOptions};
9-
use std::io::Read;
9+
use std::io::{self, Read};
1010
use std::os::unix::fs::FileExt;
1111
use std::os::unix::fs::MetadataExt;
1212
use std::os::unix::fs::{FileTypeExt, OpenOptionsExt};
1313
use std::os::unix::io::AsRawFd;
1414
use std::path::Path;
1515
use uucore::buf_copy;
16-
use uucore::mode::get_umask;
16+
use uucore::fs::copy_file_with_secure_permissions;
1717
use uucore::translate;
1818

1919
use crate::{
2020
CopyDebug, CopyResult, CpError, OffloadReflinkDebug, ReflinkMode, SparseDebug, SparseMode,
21-
is_stream,
2221
};
2322

2423
/// The fallback behavior for [`clone`] on failed system call.
@@ -54,21 +53,41 @@ enum CopyMethod {
5453
///
5554
/// `fallback` controls what to do if the system call fails.
5655
#[cfg(any(target_os = "linux", target_os = "android"))]
57-
fn clone<P>(source: P, dest: P, fallback: CloneFallback) -> std::io::Result<()>
56+
fn clone<P>(source: P, dest: P, fallback: CloneFallback) -> io::Result<()>
5857
where
5958
P: AsRef<Path>,
6059
{
6160
let src_file = File::open(&source)?;
62-
let dst_file = File::create(&dest)?;
61+
// Create destination with restrictive permissions initially (to prevent race conditions)
62+
// Mode 0o600 means read/write for owner only
63+
let dst_file = OpenOptions::new()
64+
.create(true)
65+
.write(true)
66+
.truncate(false)
67+
.mode(0o600)
68+
.open(&dest)?;
6369
let src_fd = src_file.as_raw_fd();
6470
let dst_fd = dst_file.as_raw_fd();
6571
let result = unsafe { libc::ioctl(dst_fd, libc::FICLONE, src_fd) };
6672
if result == 0 {
6773
return Ok(());
6874
}
75+
let err = io::Error::last_os_error();
6976
match fallback {
70-
CloneFallback::Error => Err(std::io::Error::last_os_error()),
71-
CloneFallback::FSCopy => std::fs::copy(source, dest).map(|_| ()),
77+
CloneFallback::Error => {
78+
// FICLONE fails with EINVAL if the destination is not empty.
79+
// Truncate and retry once before giving up.
80+
if err.raw_os_error() == Some(libc::EINVAL) {
81+
dst_file.set_len(0)?;
82+
let retry = unsafe { libc::ioctl(dst_fd, libc::FICLONE, src_fd) };
83+
if retry == 0 {
84+
return Ok(());
85+
}
86+
return Err(io::Error::last_os_error());
87+
}
88+
Err(err)
89+
}
90+
CloneFallback::FSCopy => copy_file_with_secure_permissions(source, dest).map(|_| ()),
7291
CloneFallback::SparseCopy => sparse_copy(source, dest),
7392
CloneFallback::SparseCopyWithoutHole => sparse_copy_without_hole(source, dest),
7493
}
@@ -78,7 +97,7 @@ where
7897
/// This function returns a tuple of (bool, u64, u64) signifying a tuple of (whether a file has
7998
/// data, its size, no of blocks it has allocated in disk)
8099
#[cfg(any(target_os = "linux", target_os = "android"))]
81-
fn check_for_data(source: &Path) -> Result<(bool, u64, u64), std::io::Error> {
100+
fn check_for_data(source: &Path) -> Result<(bool, u64, u64), io::Error> {
82101
let mut src_file = File::open(source)?;
83102
let metadata = src_file.metadata()?;
84103

@@ -98,14 +117,14 @@ fn check_for_data(source: &Path) -> Result<(bool, u64, u64), std::io::Error> {
98117
match result {
99118
-1 => Ok((false, size, blocks)), // No data found or end of file
100119
_ if result >= 0 => Ok((true, size, blocks)), // Data found
101-
_ => Err(std::io::Error::last_os_error()),
120+
_ => Err(io::Error::last_os_error()),
102121
}
103122
}
104123

105124
#[cfg(any(target_os = "linux", target_os = "android"))]
106125
/// Checks whether a file is sparse i.e. it contains holes, uses the crude heuristic blocks < size / 512
107126
/// Reference:`<https://doc.rust-lang.org/std/os/unix/fs/trait.MetadataExt.html#tymethod.blocks>`
108-
fn check_sparse_detection(source: &Path) -> Result<bool, std::io::Error> {
127+
fn check_sparse_detection(source: &Path) -> Result<bool, io::Error> {
109128
let src_file = File::open(source)?;
110129
let metadata = src_file.metadata()?;
111130
let size = metadata.size();
@@ -120,17 +139,24 @@ fn check_sparse_detection(source: &Path) -> Result<bool, std::io::Error> {
120139
/// Optimized [`sparse_copy`] doesn't create holes for large sequences of zeros in non `sparse_files`
121140
/// Used when `--sparse=auto`
122141
#[cfg(any(target_os = "linux", target_os = "android"))]
123-
fn sparse_copy_without_hole<P>(source: P, dest: P) -> std::io::Result<()>
142+
fn sparse_copy_without_hole<P>(source: P, dest: P) -> io::Result<()>
124143
where
125144
P: AsRef<Path>,
126145
{
127146
let src_file = File::open(source)?;
128-
let dst_file = File::create(dest)?;
147+
// Create destination with restrictive permissions initially (to prevent race conditions)
148+
// Mode 0o600 means read/write for owner only
149+
let dst_file = OpenOptions::new()
150+
.create(true)
151+
.write(true)
152+
.truncate(true)
153+
.mode(0o600)
154+
.open(dest)?;
129155
let dst_fd = dst_file.as_raw_fd();
130156

131157
let size = src_file.metadata()?.size();
132158
if unsafe { libc::ftruncate(dst_fd, size.try_into().unwrap()) } < 0 {
133-
return Err(std::io::Error::last_os_error());
159+
return Err(io::Error::last_os_error());
134160
}
135161
let src_fd = src_file.as_raw_fd();
136162
let mut current_offset: isize = 0;
@@ -152,7 +178,7 @@ where
152178
break;
153179
}
154180
if result <= -2 || hole <= -2 {
155-
return Err(std::io::Error::last_os_error());
181+
return Err(io::Error::last_os_error());
156182
}
157183
let len: isize = hole - current_offset;
158184
// Read and write data in chunks of `step` while reusing the same buffer
@@ -170,17 +196,24 @@ where
170196
/// Perform a sparse copy from one file to another.
171197
/// Creates a holes for large sequences of zeros in `non_sparse_files`, used for `--sparse=always`
172198
#[cfg(any(target_os = "linux", target_os = "android"))]
173-
fn sparse_copy<P>(source: P, dest: P) -> std::io::Result<()>
199+
fn sparse_copy<P>(source: P, dest: P) -> io::Result<()>
174200
where
175201
P: AsRef<Path>,
176202
{
177203
let mut src_file = File::open(source)?;
178-
let dst_file = File::create(dest)?;
204+
// Create destination with restrictive permissions initially (to prevent race conditions)
205+
// Mode 0o600 means read/write for owner only
206+
let dst_file = OpenOptions::new()
207+
.create(true)
208+
.write(true)
209+
.truncate(true)
210+
.mode(0o600)
211+
.open(dest)?;
179212
let dst_fd = dst_file.as_raw_fd();
180213

181214
let size: usize = src_file.metadata()?.size().try_into().unwrap();
182215
if unsafe { libc::ftruncate(dst_fd, size.try_into().unwrap()) } < 0 {
183-
return Err(std::io::Error::last_os_error());
216+
return Err(io::Error::last_os_error());
184217
}
185218

186219
let blksize = dst_file.metadata()?.blksize();
@@ -214,7 +247,7 @@ fn check_dest_is_fifo(dest: &Path) -> bool {
214247
}
215248

216249
/// Copy the contents of a stream from `source` to `dest`.
217-
fn copy_stream<P>(source: P, dest: P) -> std::io::Result<u64>
250+
fn copy_stream<P>(source: P, dest: P) -> io::Result<u64>
218251
where
219252
P: AsRef<Path>,
220253
{
@@ -237,21 +270,18 @@ where
237270
// TODO Update the code below to respect the case where
238271
// `--preserve=ownership` is not true.
239272
let mut src_file = File::open(&source)?;
240-
let mode = 0o622 & !get_umask();
273+
// Create with restrictive permissions initially to prevent race conditions
274+
// Mode 0o600 means read/write for owner only
275+
// Use truncate(true) to ensure we overwrite existing content
241276
let mut dst_file = OpenOptions::new()
242277
.create(true)
243278
.write(true)
244-
.mode(mode)
279+
.truncate(true)
280+
.mode(0o600)
245281
.open(&dest)?;
246282

247-
let dest_is_stream = is_stream(&dst_file.metadata()?);
248-
if !dest_is_stream {
249-
// `copy_stream` doesn't clear the dest file, if dest is not a stream, we should clear it manually.
250-
dst_file.set_len(0)?;
251-
}
252-
253283
let num_bytes_copied = buf_copy::copy_stream(&mut src_file, &mut dst_file)
254-
.map_err(|e| std::io::Error::other(format!("{e}")))?;
284+
.map_err(|e| io::Error::other(format!("{e}")))?;
255285

256286
Ok(num_bytes_copied)
257287
}
@@ -287,7 +317,9 @@ pub(crate) fn copy_on_write(
287317
}
288318

289319
match copy_method {
290-
CopyMethod::FSCopy => std::fs::copy(source, dest).map(|_| ()),
320+
CopyMethod::FSCopy => {
321+
copy_file_with_secure_permissions(source, dest).map(|_| ())
322+
}
291323
_ => sparse_copy(source, dest),
292324
}
293325
}
@@ -303,7 +335,7 @@ pub(crate) fn copy_on_write(
303335
if let Ok(debug) = result {
304336
copy_debug = debug;
305337
}
306-
std::fs::copy(source, dest).map(|_| ())
338+
copy_file_with_secure_permissions(source, dest).map(|_| ())
307339
}
308340
}
309341
(ReflinkMode::Never, SparseMode::Auto) => {
@@ -322,7 +354,7 @@ pub(crate) fn copy_on_write(
322354

323355
match copy_method {
324356
CopyMethod::SparseCopyWithoutHole => sparse_copy_without_hole(source, dest),
325-
_ => std::fs::copy(source, dest).map(|_| ()),
357+
_ => copy_file_with_secure_permissions(source, dest).map(|_| ()),
326358
}
327359
}
328360
}
@@ -401,7 +433,7 @@ pub(crate) fn copy_on_write(
401433
fn handle_reflink_auto_sparse_always(
402434
source: &Path,
403435
dest: &Path,
404-
) -> Result<(CopyDebug, CopyMethod), std::io::Error> {
436+
) -> Result<(CopyDebug, CopyMethod), io::Error> {
405437
let mut copy_debug = CopyDebug {
406438
offload: OffloadReflinkDebug::Unknown,
407439
reflink: OffloadReflinkDebug::Unsupported,
@@ -438,7 +470,7 @@ fn handle_reflink_auto_sparse_always(
438470

439471
/// Handles debug results when flags are "--reflink=auto" and "--sparse=auto" and specifies what
440472
/// type of copy should be used
441-
fn handle_reflink_never_sparse_never(source: &Path) -> Result<CopyDebug, std::io::Error> {
473+
fn handle_reflink_never_sparse_never(source: &Path) -> Result<CopyDebug, io::Error> {
442474
let mut copy_debug = CopyDebug {
443475
offload: OffloadReflinkDebug::Unknown,
444476
reflink: OffloadReflinkDebug::No,
@@ -459,7 +491,7 @@ fn handle_reflink_never_sparse_never(source: &Path) -> Result<CopyDebug, std::io
459491

460492
/// Handles debug results when flags are "--reflink=auto" and "--sparse=never", files will be copied
461493
/// through cloning them with fallback switching to [`std::fs::copy`]
462-
fn handle_reflink_auto_sparse_never(source: &Path) -> Result<CopyDebug, std::io::Error> {
494+
fn handle_reflink_auto_sparse_never(source: &Path) -> Result<CopyDebug, io::Error> {
463495
let mut copy_debug = CopyDebug {
464496
offload: OffloadReflinkDebug::Unknown,
465497
reflink: OffloadReflinkDebug::No,
@@ -484,7 +516,7 @@ fn handle_reflink_auto_sparse_never(source: &Path) -> Result<CopyDebug, std::io:
484516
fn handle_reflink_auto_sparse_auto(
485517
source: &Path,
486518
dest: &Path,
487-
) -> Result<(CopyDebug, CopyMethod), std::io::Error> {
519+
) -> Result<(CopyDebug, CopyMethod), io::Error> {
488520
let mut copy_debug = CopyDebug {
489521
offload: OffloadReflinkDebug::Unknown,
490522
reflink: OffloadReflinkDebug::Unsupported,
@@ -527,7 +559,7 @@ fn handle_reflink_auto_sparse_auto(
527559
fn handle_reflink_never_sparse_auto(
528560
source: &Path,
529561
dest: &Path,
530-
) -> Result<(CopyDebug, CopyMethod), std::io::Error> {
562+
) -> Result<(CopyDebug, CopyMethod), io::Error> {
531563
let mut copy_debug = CopyDebug {
532564
offload: OffloadReflinkDebug::Unknown,
533565
reflink: OffloadReflinkDebug::No,
@@ -563,7 +595,7 @@ fn handle_reflink_never_sparse_auto(
563595
fn handle_reflink_never_sparse_always(
564596
source: &Path,
565597
dest: &Path,
566-
) -> Result<(CopyDebug, CopyMethod), std::io::Error> {
598+
) -> Result<(CopyDebug, CopyMethod), io::Error> {
567599
let mut copy_debug = CopyDebug {
568600
offload: OffloadReflinkDebug::Unknown,
569601
reflink: OffloadReflinkDebug::No,

0 commit comments

Comments
 (0)