Skip to content

Commit 525d1f8

Browse files
cp/mv: suppress xattr ENOTSUP errors for optional preservation (#10083)
* cp/mv: suppress xattr ENOTSUP errors for optional preservation * mv: copy xattrs for symlink fallback and format tests * cp: fix test expectation for GNU-compatible error format * fix test portability for Android and xattr detection --------- Co-authored-by: Sylvestre Ledru <sylvestre@debian.org>
1 parent b578382 commit 525d1f8

6 files changed

Lines changed: 155 additions & 20 deletions

File tree

.vscode/cspell.dictionaries/jargon.wordlist.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,5 +220,7 @@ TUNABLES
220220
tunables
221221
VMULL
222222
vmull
223+
ENOTSUP
224+
enotsup
223225
SETFL
224226
tmpfs

src/uu/cp/locales/en-US.ftl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ cp-error-failed-to-create-whole-tree = failed to create whole tree
9191
cp-error-failed-to-create-directory = Failed to create directory: { $error }
9292
cp-error-backup-format = cp: { $error }
9393
Try '{ $exec } --help' for more information.
94+
cp-error-setting-attributes = setting attributes for { $path }
9495
9596
# Debug enum strings
9697
cp-debug-enum-no = no

src/uu/cp/src/cp.rs

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1316,6 +1316,20 @@ fn parse_path_args(
13161316
Ok((paths, target))
13171317
}
13181318

1319+
/// Check if an error is ENOTSUP/EOPNOTSUPP (operation not supported).
1320+
/// This is used to suppress xattr errors on filesystems that don't support them.
1321+
fn is_enotsup_error(error: &CpError) -> bool {
1322+
#[cfg(unix)]
1323+
const EOPNOTSUPP: i32 = libc::EOPNOTSUPP;
1324+
#[cfg(not(unix))]
1325+
const EOPNOTSUPP: i32 = 95;
1326+
1327+
match error {
1328+
CpError::IoErr(e) | CpError::IoErrContext(e, _) => e.raw_os_error() == Some(EOPNOTSUPP),
1329+
_ => false,
1330+
}
1331+
}
1332+
13191333
/// When handling errors, we don't always want to show them to the user. This function handles that.
13201334
fn show_error_if_needed(error: &CpError) {
13211335
match error {
@@ -1328,6 +1342,11 @@ fn show_error_if_needed(error: &CpError) {
13281342
// touch a b && echo "n"|cp -i a b && echo $?
13291343
// should return an error from GNU 9.2
13301344
}
1345+
// Format IoErrContext using strip_errno to remove "(os error N)" suffix
1346+
// for GNU-compatible output
1347+
CpError::IoErrContext(io_err, context) => {
1348+
show_error!("{}: {}", context, uucore::error::strip_errno(io_err));
1349+
}
13311350
_ => {
13321351
show_error!("{error}");
13331352
}
@@ -1630,15 +1649,23 @@ impl OverwriteMode {
16301649
/// Handles errors for attributes preservation. If the attribute is not required, and
16311650
/// errored, tries to show error (see `show_error_if_needed` for additional behavior details).
16321651
/// If it's required, then the error is thrown.
1652+
///
1653+
/// Note: ENOTSUP/EOPNOTSUPP errors are silently ignored when not required, as per GNU cp
1654+
/// documentation: "Try to preserve SELinux security context and extended attributes (xattr),
1655+
/// but ignore any failure to do that and print no corresponding diagnostic."
16331656
fn handle_preserve<F: Fn() -> CopyResult<()>>(p: &Preserve, f: F) -> CopyResult<()> {
16341657
match p {
16351658
Preserve::No { .. } => {}
16361659
Preserve::Yes { required } => {
16371660
let result = f();
16381661
if *required {
16391662
result?;
1640-
} else if let Err(error) = result {
1641-
show_error_if_needed(&error);
1663+
} else if let Err(ref error) = result {
1664+
// Suppress ENOTSUP errors when preservation is optional.
1665+
// This matches GNU cp behavior for -a and --preserve=all.
1666+
if !is_enotsup_error(error) {
1667+
show_error_if_needed(error);
1668+
}
16421669
}
16431670
}
16441671
}
@@ -1675,8 +1702,13 @@ fn copy_extended_attrs(source: &Path, dest: &Path) -> CopyResult<()> {
16751702
fs::set_permissions(dest, revert_perms)?;
16761703
}
16771704

1678-
// If copying xattrs failed, propagate that error now.
1679-
copy_xattrs_result?;
1705+
// If copying xattrs failed, propagate that error now with context.
1706+
copy_xattrs_result.map_err(|e| {
1707+
CpError::IoErrContext(
1708+
e,
1709+
translate!("cp-error-setting-attributes", "path" => dest.quote()),
1710+
)
1711+
})?;
16801712

16811713
Ok(())
16821714
}

src/uu/mv/src/mv.rs

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -908,7 +908,12 @@ fn rename_fifo_fallback(_from: &Path, _to: &Path) -> io::Result<()> {
908908
#[cfg(unix)]
909909
fn rename_symlink_fallback(from: &Path, to: &Path) -> io::Result<()> {
910910
let path_symlink_points_to = fs::read_link(from)?;
911-
unix::fs::symlink(path_symlink_points_to, to).and_then(|_| fs::remove_file(from))
911+
unix::fs::symlink(path_symlink_points_to, to)?;
912+
#[cfg(not(any(target_os = "macos", target_os = "redox")))]
913+
{
914+
let _ = copy_xattrs_if_supported(from, to);
915+
}
916+
fs::remove_file(from)
912917
}
913918

914919
#[cfg(windows)]
@@ -1147,13 +1152,11 @@ fn copy_file_with_hardlinks_helper(
11471152
rename_symlink_fallback(from, to)?;
11481153
} else {
11491154
// Copy a regular file.
1155+
fs::copy(from, to)?;
1156+
// Copy xattrs, ignoring ENOTSUP errors (filesystem doesn't support xattrs)
11501157
#[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))]
11511158
{
1152-
fs::copy(from, to).and_then(|_| fsxattr::copy_xattrs(&from, &to))?;
1153-
}
1154-
#[cfg(any(target_os = "macos", target_os = "redox"))]
1155-
{
1156-
fs::copy(from, to)?;
1159+
let _ = copy_xattrs_if_supported(from, to);
11571160
}
11581161
}
11591162

@@ -1195,18 +1198,32 @@ fn rename_file_fallback(
11951198
}
11961199

11971200
// Regular file copy
1198-
#[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))]
11991201
fs::copy(from, to)
1200-
.and_then(|_| fsxattr::copy_xattrs(&from, &to))
1201-
.and_then(|_| fs::remove_file(from))
12021202
.map_err(|err| io::Error::new(err.kind(), translate!("mv-error-permission-denied")))?;
1203-
#[cfg(any(target_os = "macos", target_os = "redox", not(unix)))]
1204-
fs::copy(from, to)
1205-
.and_then(|_| fs::remove_file(from))
1203+
1204+
// Copy xattrs, ignoring ENOTSUP errors (filesystem doesn't support xattrs)
1205+
#[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))]
1206+
{
1207+
let _ = copy_xattrs_if_supported(from, to);
1208+
}
1209+
1210+
fs::remove_file(from)
12061211
.map_err(|err| io::Error::new(err.kind(), translate!("mv-error-permission-denied")))?;
12071212
Ok(())
12081213
}
12091214

1215+
/// Copy xattrs from source to destination, ignoring ENOTSUP/EOPNOTSUPP errors.
1216+
/// These errors indicate the filesystem doesn't support extended attributes,
1217+
/// which is acceptable when moving files across filesystems.
1218+
#[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))]
1219+
fn copy_xattrs_if_supported(from: &Path, to: &Path) -> io::Result<()> {
1220+
match fsxattr::copy_xattrs(from, to) {
1221+
Ok(()) => Ok(()),
1222+
Err(e) if e.raw_os_error() == Some(libc::EOPNOTSUPP) => Ok(()),
1223+
Err(e) => Err(e),
1224+
}
1225+
}
1226+
12101227
fn is_empty_dir(path: &Path) -> bool {
12111228
fs::read_dir(path).is_ok_and(|mut contents| contents.next().is_none())
12121229
}

tests/by-util/test_cp.rs

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2615,7 +2615,7 @@ fn test_cp_reflink_insufficient_permission() {
26152615
.arg("unreadable")
26162616
.arg(TEST_EXISTING_FILE)
26172617
.fails()
2618-
.stderr_only("cp: 'unreadable' -> 'existing_file.txt': Permission denied (os error 13)\n");
2618+
.stderr_only("cp: 'unreadable' -> 'existing_file.txt': Permission denied\n");
26192619
}
26202620

26212621
#[cfg(target_os = "linux")]
@@ -3131,9 +3131,8 @@ fn test_cp_archive_on_nonexistent_file() {
31313131
.arg(TEST_NONEXISTENT_FILE)
31323132
.arg(TEST_EXISTING_FILE)
31333133
.fails()
3134-
.stderr_only(
3135-
"cp: cannot stat 'nonexistent_file.txt': No such file or directory (os error 2)\n",
3136-
);
3134+
.stderr_contains("cannot stat 'nonexistent_file.txt'")
3135+
.stderr_contains("No such file or directory");
31373136
}
31383137

31393138
#[test]
@@ -7512,3 +7511,63 @@ fn test_cp_to_existing_file_permissions() {
75127511
let new_dst_mode = std::fs::metadata(&dst_path).unwrap().permissions().mode();
75137512
assert_eq!(dst_mode, new_dst_mode);
75147513
}
7514+
7515+
/// Test xattr ENOTSUP handling: -a/--preserve=all silent, --preserve=xattr errors
7516+
#[test]
7517+
#[cfg(target_os = "linux")]
7518+
fn test_cp_xattr_enotsup_handling() {
7519+
use std::process::Command;
7520+
let scene = TestScenario::new(util_name!());
7521+
let at = &scene.fixtures;
7522+
at.write("src", "x");
7523+
7524+
// Check if setfattr is available and source fs supports xattrs
7525+
if !Command::new("setfattr")
7526+
.args(["-n", "user.t", "-v", "v", &at.plus_as_string("src")])
7527+
.status()
7528+
.is_ok_and(|s| s.success())
7529+
{
7530+
return; // Skip: setfattr not available or source doesn't support xattrs
7531+
}
7532+
7533+
// Check if /dev/shm exists
7534+
if !std::path::Path::new("/dev/shm").exists() {
7535+
return; // Skip: /dev/shm not available
7536+
}
7537+
7538+
// Check if /dev/shm actually doesn't support xattrs by trying to set one
7539+
let shm_test_file = "/dev/shm/xattr_test_probe";
7540+
std::fs::write(shm_test_file, "test").ok();
7541+
let shm_supports_xattr = Command::new("setfattr")
7542+
.args(["-n", "user.t", "-v", "v", shm_test_file])
7543+
.status()
7544+
.is_ok_and(|s| s.success());
7545+
std::fs::remove_file(shm_test_file).ok();
7546+
7547+
if shm_supports_xattr {
7548+
return; // Skip: /dev/shm supports xattrs on this system
7549+
}
7550+
7551+
// -a: silent success
7552+
scene
7553+
.ucmd()
7554+
.args(&["-a", &at.plus_as_string("src"), "/dev/shm/t1"])
7555+
.succeeds()
7556+
.no_stderr();
7557+
// --preserve=all: silent success
7558+
scene
7559+
.ucmd()
7560+
.args(&["--preserve=all", &at.plus_as_string("src"), "/dev/shm/t2"])
7561+
.succeeds()
7562+
.no_stderr();
7563+
// --preserve=xattr: must fail with proper message
7564+
scene
7565+
.ucmd()
7566+
.args(&["--preserve=xattr", &at.plus_as_string("src"), "/dev/shm/t3"])
7567+
.fails()
7568+
.stderr_contains("setting attributes")
7569+
.stderr_contains("Operation not supported");
7570+
for f in ["/dev/shm/t1", "/dev/shm/t2", "/dev/shm/t3"] {
7571+
std::fs::remove_file(f).ok();
7572+
}
7573+
}

tests/by-util/test_mv.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2821,3 +2821,27 @@ fn test_mv_no_prompt_unwriteable_file_with_no_tty() {
28212821
assert!(!at.file_exists("source_notty"));
28222822
assert!(at.file_exists("target_notty"));
28232823
}
2824+
2825+
/// Test mv silently succeeds when dest filesystem doesn't support xattrs (ENOTSUP)
2826+
#[test]
2827+
#[cfg(target_os = "linux")]
2828+
fn test_mv_xattr_enotsup_silent() {
2829+
use std::process::Command;
2830+
let scene = TestScenario::new(util_name!());
2831+
let at = &scene.fixtures;
2832+
at.write("src", "x");
2833+
2834+
if Command::new("setfattr")
2835+
.args(["-n", "user.t", "-v", "v", &at.plus_as_string("src")])
2836+
.status()
2837+
.is_ok_and(|s| s.success())
2838+
{
2839+
scene
2840+
.ucmd()
2841+
.arg(at.plus_as_string("src"))
2842+
.arg("/dev/shm/mv_test")
2843+
.succeeds()
2844+
.no_stderr();
2845+
std::fs::remove_file("/dev/shm/mv_test").ok();
2846+
}
2847+
}

0 commit comments

Comments
 (0)