diff --git a/src/uu/cp/src/cp.rs b/src/uu/cp/src/cp.rs
index 41872be659..747dd6078e 100644
--- a/src/uu/cp/src/cp.rs
+++ b/src/uu/cp/src/cp.rs
@@ -64,6 +64,12 @@ pub enum CpError {
#[error("{0}")]
Error(String),
+ /// The SELinux security context of the destination could not be
+ /// preserved; unlike other attribute failures, this one empties the
+ /// destination file.
+ #[error("{0}")]
+ SelinuxContext(String),
+
/// Represents the state when a non-fatal error has occurred
/// and not all files were copied.
#[error("{}", translate!("cp-error-not-all-files-copied"))]
@@ -1931,12 +1937,16 @@ pub(crate) fn copy_attributes(
handle_preserve(attributes.context, || -> CopyResult<()> {
// Get the source context and apply it to the destination
let context = selinux::SecurityContext::of_path(source, false, false).map_err(|_| {
- CpError::Error(translate!("cp-error-selinux-get-context", "path" => source.quote()))
+ CpError::SelinuxContext(
+ translate!("cp-error-selinux-get-context", "path" => source.quote()),
+ )
})?;
if let Some(context) = context {
- context.set_for_path(dest, false, false).map_err(|e|CpError::Error(
- translate!("cp-error-selinux-set-context", "path" => dest.quote(), "error" => e),
- ))?;
+ context.set_for_path(dest, false, false).map_err(|e| {
+ CpError::SelinuxContext(
+ translate!("cp-error-selinux-set-context", "path" => dest.quote(), "error" => e),
+ )
+ })?;
}
Ok(())
})?;
@@ -2755,9 +2765,24 @@ fn copy_file(
)
};
- // GNU cp truncates the destination when a required attribute cannot be preserved
- copy_attributes_result.inspect_err(|_| {
- fs::File::create(dest).map(|f| f.set_len(0)).ok();
+ // Empty the destination when the SELinux security context cannot be
+ // preserved, but keep the copied data when preserving other attributes
+ // (e.g. xattrs) fails.
+ copy_attributes_result.inspect_err(|err| {
+ if matches!(err, CpError::SelinuxContext(_)) && fs::File::create(dest).is_err() {
+ // The permissions applied above may lack the write bit (e.g. a
+ // read-only source), making the truncating open fail. Restore
+ // owner write long enough to truncate, then put the intended
+ // permissions back.
+ #[cfg(unix)]
+ if let Ok(metadata) = fs::symlink_metadata(dest) {
+ let mode = metadata.permissions().mode();
+ if fs::set_permissions(dest, Permissions::from_mode(mode | 0o200)).is_ok() {
+ fs::File::create(dest).ok();
+ fs::set_permissions(dest, Permissions::from_mode(mode)).ok();
+ }
+ }
+ }
})?;
#[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))]
diff --git a/src/uu/cp/src/platform/linux.rs b/src/uu/cp/src/platform/linux.rs
index 1faee8d4ed..98afa6d95a 100644
--- a/src/uu/cp/src/platform/linux.rs
+++ b/src/uu/cp/src/platform/linux.rs
@@ -5,6 +5,7 @@
// spell-checker:ignore reflink ftruncate pwrite fiemap lseek nofollow
use rustix::fs::{SeekFrom, ftruncate, ioctl_ficlone, seek};
+use std::fs::File;
use std::io::{self, Read};
use std::os::unix::fs::FileExt;
use std::os::unix::fs::FileTypeExt;
@@ -44,18 +45,22 @@ where
}
/// The fallback behavior for [`clone`] on failed system call.
+///
+/// Every fallback reuses the descriptors already opened for the clone
+/// attempt; re-opening the dest by path can fail with EACCES when the
+/// umask stripped the write bits from its freshly created mode.
#[derive(Clone, Copy)]
enum CloneFallback {
/// Raise an error.
Error,
- /// Use [`std::fs::copy`].
+ /// Copy the bytes with [`buf_copy::copy_fast`].
FSCopy,
- /// Use [`sparse_copy`]
+ /// Use [`sparse_copy_fd`]
SparseCopy,
- /// Use [`sparse_copy_without_hole`]
+ /// Use [`sparse_copy_without_hole_fd`]
SparseCopyWithoutHole,
}
@@ -85,24 +90,36 @@ fn clone
(
where
P: AsRef,
{
- let src_file =
+ // Only needed to decide whether a failed --reflink=always clone should
+ // clean up the dest, so skip the lstat for the other fallbacks.
+ let dest_existed =
+ matches!(fallback, CloneFallback::Error) && dest.as_ref().symlink_metadata().is_ok();
+ let mut src_file =
open_source(&source, nofollow).map_err(|e| CpError::IoErrContext(e, context.to_owned()))?;
- let dst_file = create_dest_restrictive(&dest, false).map_err(|e| {
+ let mut dst_file = create_dest_restrictive(&dest, false).map_err(|e| {
CpError::IoErrContext(
e,
translate!("cp-error-cannot-create-regular-file", "path" => dest.as_ref().quote()),
)
})?;
- if ioctl_ficlone(dst_file, src_file).is_err() {
+ if let Err(err) = ioctl_ficlone(&dst_file, &src_file) {
+ // Reuse the already-open descriptors: the dest was just created with
+ // a restrictive mode that the umask may have stripped of write bits,
+ // so re-opening it by path can fail with EACCES (LP: #2164777).
return match fallback {
- CloneFallback::Error => Err(CpError::IoErrContext(
- io::Error::last_os_error(),
- context.to_owned(),
- )),
- CloneFallback::FSCopy => fs_copy(source, dest, nofollow, context),
- CloneFallback::SparseCopy => sparse_copy(source, dest, nofollow, context),
+ CloneFallback::Error => {
+ // GNU cp removes a dest it created itself, but keeps a
+ // pre-existing (now truncated) one.
+ if !dest_existed {
+ let _ = std::fs::remove_file(&dest);
+ }
+ Err(CpError::IoErrContext(err.into(), context.to_owned()))
+ }
+ CloneFallback::FSCopy => buf_copy::copy_fast(&mut src_file, &mut dst_file)
+ .map_err(|e| CpError::IoErrContext(e, context.to_owned())),
+ CloneFallback::SparseCopy => sparse_copy_fd(&mut src_file, &dst_file, context),
CloneFallback::SparseCopyWithoutHole => {
- sparse_copy_without_hole(source, dest, nofollow, context)
+ sparse_copy_without_hole_fd(&src_file, &dst_file, context)
}
};
}
@@ -154,26 +171,33 @@ where
translate!("cp-error-cannot-create-regular-file", "path" => dest.as_ref().quote()),
)
})?;
+ sparse_copy_without_hole_fd(&src_file, &dst_file, context)
+}
+fn sparse_copy_without_hole_fd(src_file: &File, dst_file: &File, context: &str) -> CopyResult<()> {
let ctx_err = |e: io::Error| CpError::IoErrContext(e, context.to_owned());
let size = src_file.metadata().map_err(&ctx_err)?.size();
- ftruncate(&dst_file, size).map_err(|e| CpError::IoErrContext(e.into(), context.to_owned()))?;
+ ftruncate(dst_file, size).map_err(|e| CpError::IoErrContext(e.into(), context.to_owned()))?;
let mut current_offset = 0;
// Maximize the data read at once to 16 MiB to avoid memory hogging with large files
// 16 MiB chunks should saturate an SSD
- let step = std::cmp::min(size, 16 * 1024 * 1024) as usize;
+ // At least 1 byte, so that a source that was empty at fstat time but
+ // gained data before the SEEK_DATA loop cannot make `step_by` panic.
+ let step = size.clamp(1, 16 * 1024 * 1024) as usize;
let mut buf: Vec = vec![0x0; step];
- while let Ok(data) = seek(&src_file, SeekFrom::Data(current_offset)) {
+ while let Ok(data) = seek(src_file, SeekFrom::Data(current_offset)) {
current_offset = data;
- let Ok(hole) = seek(&src_file, SeekFrom::Hole(current_offset)) else {
+ let Ok(hole) = seek(src_file, SeekFrom::Hole(current_offset)) else {
break;
};
let len = hole - current_offset;
// Read and write data in chunks of `step` while reusing the same buffer
for i in (0..len).step_by(step) {
- // Ensure we don't read past the end of the file or the start of the next hole
- let read_len = std::cmp::min((len - i) as usize, step);
+ // Ensure we don't read past the end of the file or the start of
+ // the next hole. Take the min in u64: casting `len - i` first
+ // would truncate extents of 4 GiB and more on 32-bit targets.
+ let read_len = std::cmp::min(len - i, step as u64) as usize;
let buf = &mut buf[..read_len];
src_file
.read_exact_at(buf, current_offset + i)
@@ -200,34 +224,41 @@ where
translate!("cp-error-cannot-create-regular-file", "path" => dest.as_ref().quote()),
)
})?;
+ sparse_copy_fd(&mut src_file, &dst_file, context)
+}
+fn sparse_copy_fd(src_file: &mut File, dst_file: &File, context: &str) -> CopyResult<()> {
let ctx_err = |e: io::Error| CpError::IoErrContext(e, context.to_owned());
- let size: usize = src_file
- .metadata()
- .map_err(&ctx_err)?
- .size()
- .try_into()
- .unwrap();
- ftruncate(&dst_file, size.try_into().unwrap())
- .map_err(|e| CpError::IoErrContext(e.into(), context.to_owned()))?;
+ // Keep the size as u64: on 32-bit targets a usize conversion would
+ // panic for sources of 4 GiB and more.
+ let size = src_file.metadata().map_err(&ctx_err)?.size();
+ ftruncate(dst_file, size).map_err(|e| CpError::IoErrContext(e.into(), context.to_owned()))?;
let blksize = dst_file.metadata().map_err(&ctx_err)?.blksize();
- let mut buf: Vec = vec![0; blksize.try_into().unwrap()];
- let mut current_offset: usize = 0;
+ let mut buf: Vec = vec![0; blksize as usize];
+ let mut current_offset: u64 = 0;
// TODO Perhaps we can employ the "fiemap ioctl" API to get the
// file extent mappings:
// https://www.kernel.org/doc/html/latest/filesystems/fiemap.html
while current_offset < size {
let this_read = src_file.read(&mut buf).map_err(&ctx_err)?;
+ if this_read == 0 {
+ // EOF before the size seen at fstat time (source truncated
+ // concurrently): shrink the dest to the bytes actually copied
+ // instead of leaving a zero-filled tail up to the stale size.
+ ftruncate(dst_file, current_offset)
+ .map_err(|e| CpError::IoErrContext(e.into(), context.to_owned()))?;
+ break;
+ }
let buf = &buf[..this_read];
if buf.iter().any(|&x| x != 0) {
dst_file
- .write_all_at(buf, current_offset.try_into().unwrap())
+ .write_all_at(buf, current_offset)
.map_err(&ctx_err)?;
}
- current_offset += this_read;
+ current_offset += this_read as u64;
}
Ok(())
}
diff --git a/tests/by-util/test_cp.rs b/tests/by-util/test_cp.rs
index e8be391639..bfa6bdc12f 100644
--- a/tests/by-util/test_cp.rs
+++ b/tests/by-util/test_cp.rs
@@ -981,6 +981,85 @@ fn test_cp_remove_destination_symlink_applies_mode() {
assert_eq!(at.metadata("dst").permissions().mode() & 0o777, 0o644);
}
+// A umask that strips the owner write bit (0o333 here) makes the freshly
+// created destination read-only. The copy must keep using the fd from the
+// creating open instead of re-opening the path for writing, which fails
+// with EACCES and leaves an empty file behind (LP: #2164777).
+#[test]
+#[cfg(unix)]
+fn test_cp_umask_stripping_owner_write_bit() {
+ let (at, mut ucmd) = at_and_ucmd!();
+
+ at.write("input.txt", "copied through the first fd\n");
+ at.set_mode("input.txt", 0o664);
+
+ ucmd.umask(0o333)
+ .arg("input.txt")
+ .arg("output.txt")
+ .succeeds()
+ .no_output();
+
+ assert_eq!(at.read("output.txt"), "copied through the first fd\n");
+ assert_eq!(
+ at.metadata("output.txt").permissions().mode() & 0o777,
+ 0o444
+ );
+}
+
+// Same umask scenario without a clone attempt: on reflink-capable
+// filesystems the test above succeeds via FICLONE and never reaches the
+// fallback, so pin the plain and sparse copy paths explicitly.
+#[test]
+#[cfg(any(target_os = "linux", target_os = "android"))]
+fn test_cp_umask_stripping_owner_write_bit_reflink_never() {
+ for sparse in ["--sparse=auto", "--sparse=always"] {
+ let (at, mut ucmd) = at_and_ucmd!();
+
+ at.write("input.txt", "written while still writable\n");
+ at.set_mode("input.txt", 0o664);
+
+ ucmd.umask(0o333)
+ .args(&["--reflink=never", sparse, "input.txt", "output.txt"])
+ .succeeds()
+ .no_output();
+
+ assert_eq!(at.read("output.txt"), "written while still writable\n");
+ assert_eq!(
+ at.metadata("output.txt").permissions().mode() & 0o777,
+ 0o444
+ );
+ }
+}
+
+// When --reflink=always fails, GNU cp removes a destination it created
+// itself but keeps a pre-existing (truncated) one. Only observable on
+// filesystems without clone support; when the clone succeeds there is
+// nothing to clean up.
+#[test]
+#[cfg(any(target_os = "linux", target_os = "android"))]
+fn test_cp_reflink_always_failure_dest_cleanup() {
+ let scene = TestScenario::new(util_name!());
+ let at = &scene.fixtures;
+
+ at.write("payload.txt", "reflink me\n");
+ let fresh = scene
+ .ucmd()
+ .args(&["--reflink=always", "payload.txt", "fresh.txt"])
+ .run();
+ if fresh.succeeded() {
+ return; // clone worked, the failure path is unreachable here
+ }
+ assert!(!at.file_exists("fresh.txt"));
+
+ at.write("kept.txt", "previous contents\n");
+ scene
+ .ucmd()
+ .args(&["--reflink=always", "payload.txt", "kept.txt"])
+ .fails();
+ assert!(at.file_exists("kept.txt"));
+ assert_eq!(at.read("kept.txt"), "");
+}
+
#[test]
fn test_cp_arg_no_clobber() {
let (at, mut ucmd) = at_and_ucmd!();
@@ -8391,6 +8470,76 @@ fn test_cp_xattr_enotsup_handling() {
}
}
+/// A failed --preserve=xattr must not empty the already-copied destination;
+/// only a failure to preserve the SELinux context empties it.
+#[test]
+#[cfg(target_os = "linux")]
+fn test_cp_xattr_failure_keeps_dest_contents() {
+ use std::process::Command;
+ let scene = TestScenario::new(util_name!());
+
+ // tmpfs accepts large user-xattr values while ext4 and friends cap them
+ // near the block size, so copying such a source out of tmpfs makes
+ // --preserve=xattr fail only after the file data has been written.
+ // The fixtures dir may itself be on tmpfs, so put the destination in
+ // target/tmp, which lives on the build filesystem.
+ let big_value = "y".repeat(9_100);
+ let pid = std::process::id();
+ let source = format!("/dev/shm/cp_keep_dest_{pid}");
+ let dest_dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(format!("cp_keep_dest_{pid}"));
+ if std_fs::write(&source, "kept content").is_err() || std_fs::create_dir(&dest_dir).is_err() {
+ return; // skip: no usable /dev/shm or target/tmp
+ }
+ let source_accepts = Command::new("setfattr")
+ .args(["-n", "user.huge", "-v", &big_value, &source])
+ .status()
+ .is_ok_and(|s| s.success());
+ let probe = dest_dir.join("probe");
+ std_fs::write(&probe, "x").unwrap();
+ let dest_rejects = !Command::new("setfattr")
+ .args(["-n", "user.huge", "-v", &big_value])
+ .arg(&probe)
+ .status()
+ .is_ok_and(|s| s.success());
+ if !source_accepts || !dest_rejects {
+ std_fs::remove_file(&source).ok();
+ std_fs::remove_dir_all(&dest_dir).ok();
+ return; // skip: this filesystem combination cannot produce the failure
+ }
+
+ let out = dest_dir.join("out");
+ scene
+ .ucmd()
+ .arg("--preserve=xattr")
+ .arg(&source)
+ .arg(&out)
+ .fails()
+ .stderr_contains("setting attributes");
+ assert_eq!(std_fs::read_to_string(&out).unwrap(), "kept content");
+
+ // A read-only source propagates its mode to the destination; the failure
+ // handling must neither empty the file nor leave the mode altered.
+ set_permissions(&source, std_fs::Permissions::from_mode(0o444)).unwrap();
+ let out_ro = dest_dir.join("out_ro");
+ scene
+ .ucmd()
+ .arg("--preserve=xattr")
+ .arg(&source)
+ .arg(&out_ro)
+ .fails()
+ .stderr_contains("setting attributes");
+ assert_eq!(std_fs::read_to_string(&out_ro).unwrap(), "kept content");
+ assert_eq!(
+ std_fs::metadata(&out_ro).unwrap().mode() & 0o777,
+ 0o444,
+ "destination mode should stay read-only"
+ );
+
+ std_fs::remove_file(&source).ok();
+ set_permissions(&out_ro, std_fs::Permissions::from_mode(0o644)).ok();
+ std_fs::remove_dir_all(&dest_dir).ok();
+}
+
#[test]
#[cfg(not(target_os = "windows"))]
fn test_cp_preserve_directory_permissions_by_default() {