From 517fa8da128ec5909576f97f60b85f50d7709d6b Mon Sep 17 00:00:00 2001 From: Anthony DePasquale Date: Sat, 8 Aug 2026 10:50:12 +0200 Subject: [PATCH 01/11] cp: support symlinks and timestamps on WASI --- .github/workflows/wasi.yml | 1 + src/uu/cp/Cargo.toml | 3 + src/uu/cp/src/cp.rs | 129 +++++++++++++++++++++++-------------- 3 files changed, 86 insertions(+), 47 deletions(-) diff --git a/.github/workflows/wasi.yml b/.github/workflows/wasi.yml index 5072ccc573b..7960f2e5f9c 100644 --- a/.github/workflows/wasi.yml +++ b/.github/workflows/wasi.yml @@ -69,6 +69,7 @@ jobs: UUTESTS_WASM_RUNNER=wasmtime \ cargo test --test tests -- \ test_base32:: test_base64:: test_basenc:: test_basename:: \ + test_cp::test_cp_arg_symlink test_cp::test_cp_preserve_timestamps \ test_comm:: test_cut:: test_dirname:: test_echo:: \ test_expand:: test_factor:: test_false:: test_fold:: \ test_head:: test_link:: test_ln:: test_nl:: test_numfmt:: \ diff --git a/src/uu/cp/Cargo.toml b/src/uu/cp/Cargo.toml index 0f2a818119e..6b778010b27 100644 --- a/src/uu/cp/Cargo.toml +++ b/src/uu/cp/Cargo.toml @@ -49,6 +49,9 @@ windows-sys = { workspace = true, features = [ "Win32_Storage_FileSystem", ] } +[target.'cfg(target_os = "wasi")'.dependencies] +rustix = { workspace = true, features = ["fs"] } + [[bin]] name = "cp" path = "src/main.rs" diff --git a/src/uu/cp/src/cp.rs b/src/uu/cp/src/cp.rs index b8062b8dafe..a0ec2318fa0 100644 --- a/src/uu/cp/src/cp.rs +++ b/src/uu/cp/src/cp.rs @@ -2,8 +2,8 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) copydir fiemap ftruncate linkgs lstat nlink nlinks pathbuf pwrite reflink strs xattrs symlinked deduplicated advcpmv nushell IRWXG IRWXO IRWXU IRWXUGO IRWXU IRWXG IRWXO IRWXUGO sflag -// spell-checker:ignore RDONLY futimens utimensat +// spell-checker:ignore (ToDO) copydir ficlone fiemap filestat ftruncate linkgs lstat nlink nlinks pathbuf pwrite reflink strs utimensat xattrs symlinked deduplicated advcpmv nushell IRWXG IRWXO IRWXU IRWXUGO IRWXU IRWXG IRWXO IRWXUGO sflag +// spell-checker:ignore RDONLY futimens use std::cmp::Ordering; use std::collections::{HashMap, HashSet}; @@ -21,6 +21,7 @@ use uucore::fsxattr::{copy_acls, copy_xattrs, copy_xattrs_skip_selinux}; use uucore::translate; use clap::{Arg, ArgAction, ArgMatches, Command, builder::ValueParser, value_parser}; +#[cfg(not(target_os = "wasi"))] use filetime::FileTime; use indicatif::{ProgressBar, ProgressStyle}; #[cfg(unix)] @@ -1368,9 +1369,9 @@ fn parse_path_args( /// Check if an error is ENOTSUP/EOPNOTSUPP (operation not supported). /// This is used to suppress xattr errors on filesystems that don't support them. fn is_enotsup_error(error: &CpError) -> bool { - #[cfg(unix)] + #[cfg(any(unix, target_os = "wasi"))] const EOPNOTSUPP: i32 = libc::EOPNOTSUPP; - #[cfg(not(unix))] + #[cfg(not(any(unix, target_os = "wasi")))] const EOPNOTSUPP: i32 = 95; match error { @@ -1780,6 +1781,66 @@ fn copy_extended_attrs(source: &Path, dest: &Path, skip_selinux: bool) -> CopyRe Ok(()) } +/// Copy the access and modification timestamps from `source_metadata` onto `dest`. +/// If `dest` is a symlink, the symlink's own timestamps are set rather than the +/// target's. +/// +/// On WASI this calls `rustix::fs::utimensat` directly because `filetime`'s +/// WASI backend panics in `from_last_{access,modification}_time`. `SystemTime` +/// values are converted to `Timespec` against `UNIX_EPOCH`, matching WASI's +/// `path_filestat_set_times` contract (unsigned nanosecond count — pre-epoch +/// source times can't be represented). +fn set_timestamps(source_metadata: &Metadata, dest: &Path) -> CopyResult<()> { + #[cfg(target_os = "wasi")] + { + use std::time::UNIX_EPOCH; + let to_timespec = |t: std::time::SystemTime| -> io::Result { + let d = t + .duration_since(UNIX_EPOCH) + .map_err(|e| io::Error::new(io::ErrorKind::Unsupported, e))?; + Ok(rustix::fs::Timespec { + tv_sec: d.as_secs() as i64, + tv_nsec: d.subsec_nanos() as i32, + }) + }; + let timestamps = rustix::fs::Timestamps { + last_access: to_timespec(source_metadata.accessed()?)?, + last_modification: to_timespec(source_metadata.modified()?)?, + }; + let flags = if dest.is_symlink() { + rustix::fs::AtFlags::SYMLINK_NOFOLLOW + } else { + rustix::fs::AtFlags::empty() + }; + rustix::fs::utimensat(rustix::fs::CWD, dest, ×tamps, flags) + .map_err(io::Error::from)?; + Ok(()) + } + + #[cfg(not(target_os = "wasi"))] + { + let atime = FileTime::from_last_access_time(source_metadata); + let mtime = FileTime::from_last_modification_time(source_metadata); + #[cfg(unix)] + let no_open = { + let ft = source_metadata.file_type(); + dest.is_symlink() + || ft.is_fifo() + || ft.is_socket() + || ft.is_char_device() + || ft.is_block_device() + }; + #[cfg(not(unix))] + let no_open = dest.is_symlink(); + if no_open { + filetime::set_symlink_file_times(dest, atime, mtime)?; + } else { + filetime::set_file_times(dest, atime, mtime)?; + } + Ok(()) + } +} + /// Copy the specified attributes from one path to another. /// If `skip_selinux_xattr` is true, the security.selinux xattr will not be copied /// (used when -Z is specified to set the default context instead). @@ -1888,32 +1949,8 @@ pub(crate) fn copy_attributes( Ok(()) })?; - handle_preserve(attributes.timestamps, || -> CopyResult<()> { - let atime = FileTime::from_last_access_time(&source_metadata); - let mtime = FileTime::from_last_modification_time(&source_metadata); - // `set_file_times` opens the destination (O_RDONLY) before calling - // futimens; opening a FIFO or device with no peer blocks forever, and a - // socket cannot be opened at all. For symlinks and these special files - // use the path-based, no-follow variant, which sets the times via - // utimensat without opening. - #[cfg(unix)] - let no_open = { - let ft = source_metadata.file_type(); - dest.is_symlink() - || ft.is_fifo() - || ft.is_socket() - || ft.is_char_device() - || ft.is_block_device() - }; - #[cfg(not(unix))] - let no_open = dest.is_symlink(); - if no_open { - filetime::set_symlink_file_times(dest, atime, mtime)?; - } else { - filetime::set_file_times(dest, atime, mtime)?; - } - - Ok(()) + handle_preserve(attributes.timestamps, || { + set_timestamps(&source_metadata, dest) })?; #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] @@ -1959,19 +1996,9 @@ pub(crate) fn copy_attributes( fn symlink_file( source: &Path, dest: &Path, - #[cfg(not(target_os = "wasi"))] symlinked_files: &mut HashSet, - #[cfg(target_os = "wasi")] _symlinked_files: &mut HashSet, + symlinked_files: &mut HashSet, ) -> CopyResult<()> { - #[cfg(target_os = "wasi")] - { - Err(CpError::IoErrContext( - io::Error::new(io::ErrorKind::Unsupported, "symlinks not supported"), - translate!("cp-error-cannot-create-symlink", - "dest" => get_filename(dest).unwrap_or("?").quote(), - "source" => get_filename(source).unwrap_or("?").quote()), - )) - } - #[cfg(not(any(windows, target_os = "wasi")))] + #[cfg(unix)] { std::os::unix::fs::symlink(source, dest).map_err(|e| { CpError::IoErrContext( @@ -1993,13 +2020,21 @@ fn symlink_file( ) })?; } - #[cfg(not(target_os = "wasi"))] + #[cfg(target_os = "wasi")] { - if let Ok(file_info) = FileInformation::from_path(dest, false) { - symlinked_files.insert(file_info); - } - Ok(()) + rustix::fs::symlink(source, dest).map_err(|e| { + CpError::IoErrContext( + io::Error::from(e), + translate!("cp-error-cannot-create-symlink", + "dest" => get_filename(dest).unwrap_or("?").quote(), + "source" => get_filename(source).unwrap_or("?").quote()), + ) + })?; } + if let Ok(file_info) = FileInformation::from_path(dest, false) { + symlinked_files.insert(file_info); + } + Ok(()) } fn context_for(src: &Path, dest: &Path) -> String { From 6aef79e0d33f825092a641527631dd7177082f61 Mon Sep 17 00:00:00 2001 From: Anthony DePasquale Date: Sat, 8 Aug 2026 12:31:33 +0200 Subject: [PATCH 02/11] cp: preserve pre-copy timestamps --- .github/workflows/wasi.yml | 3 +- src/uu/cp/src/copydir.rs | 2 + src/uu/cp/src/cp.rs | 84 +++++++++++++++++++++++++++----------- tests/by-util/test_cp.rs | 71 +++++++++++++++++++++++++++----- 4 files changed, 124 insertions(+), 36 deletions(-) diff --git a/.github/workflows/wasi.yml b/.github/workflows/wasi.yml index 7960f2e5f9c..556a2e92c13 100644 --- a/.github/workflows/wasi.yml +++ b/.github/workflows/wasi.yml @@ -69,7 +69,8 @@ jobs: UUTESTS_WASM_RUNNER=wasmtime \ cargo test --test tests -- \ test_base32:: test_base64:: test_basenc:: test_basename:: \ - test_cp::test_cp_arg_symlink test_cp::test_cp_preserve_timestamps \ + test_cp::test_cp_arg_symlink test_cp::test_cp_preserve_symlink_timestamps \ + test_cp::test_cp_preserve_timestamps \ test_comm:: test_cut:: test_dirname:: test_echo:: \ test_expand:: test_factor:: test_false:: test_fold:: \ test_head:: test_link:: test_ln:: test_nl:: test_numfmt:: \ diff --git a/src/uu/cp/src/copydir.rs b/src/uu/cp/src/copydir.rs index cafb33fbaab..28723cdd675 100644 --- a/src/uu/cp/src/copydir.rs +++ b/src/uu/cp/src/copydir.rs @@ -319,6 +319,7 @@ fn copy_direntry( copied_files, created_parent_dirs, false, + None, ) { if preserve_hard_links { @@ -385,6 +386,7 @@ pub(crate) fn copy_directory( copied_files, created_parent_dirs, source_in_command_line, + None, ); } diff --git a/src/uu/cp/src/cp.rs b/src/uu/cp/src/cp.rs index a0ec2318fa0..b8207013315 100644 --- a/src/uu/cp/src/cp.rs +++ b/src/uu/cp/src/cp.rs @@ -1447,6 +1447,10 @@ pub fn copy(sources: &[PathBuf], target: &Path, options: &Options) -> CopyResult }; for source in sources { + let initial_source_metadata = match options.attributes.timestamps { + Preserve::Yes { .. } => fs::symlink_metadata(source).ok(), + Preserve::No { .. } => None, + }; let normalized_source = normalize_path(source); if options.backup == BackupMode::None && seen_sources.contains(&normalized_source) { let file_type = if source.symlink_metadata()?.file_type().is_dir() { @@ -1495,6 +1499,7 @@ pub fn copy(sources: &[PathBuf], target: &Path, options: &Options) -> CopyResult &copied_destinations, &mut copied_files, &mut created_parent_dirs, + initial_source_metadata.as_ref(), ) { show_error_if_needed(&error); if !matches!(error, CpError::Skipped(false)) { @@ -1570,9 +1575,10 @@ fn copy_source( copied_destinations: &HashSet, copied_files: &mut HashMap, created_parent_dirs: &mut HashSet, + initial_source_metadata: Option<&Metadata>, ) -> CopyResult<()> { let source_path = Path::new(&source); - if source_path.is_dir() && (options.dereference || !source_path.is_symlink()) { + if (options.dereference || !source_path.is_symlink()) && source_path.is_dir() { // Copy as directory copy_directory( progress_bar, @@ -1598,6 +1604,7 @@ fn copy_source( copied_files, created_parent_dirs, true, + initial_source_metadata, ); if options.parents { for (x, y) in aligned_ancestors(source, dest.as_path()) { @@ -1851,10 +1858,29 @@ pub(crate) fn copy_attributes( attributes: &Attributes, dest_is_freshly_created_dir: bool, skip_selinux_xattr: bool, +) -> CopyResult<()> { + let source_metadata = fs::symlink_metadata(source) + .map_err(|e| CpError::IoErrContext(e, context_for(source, dest)))?; + copy_attributes_from_metadata( + source, + dest, + &source_metadata, + attributes, + dest_is_freshly_created_dir, + skip_selinux_xattr, + ) +} + +#[allow(unused_variables)] +fn copy_attributes_from_metadata( + source: &Path, + dest: &Path, + source_metadata: &Metadata, + attributes: &Attributes, + dest_is_freshly_created_dir: bool, + skip_selinux_xattr: bool, ) -> CopyResult<()> { let context = &*format!("{} -> {}", source.quote(), dest.quote()); - let source_metadata = - fs::symlink_metadata(source).map_err(|e| CpError::IoErrContext(e, context.to_owned()))?; let mode_explicitly_disabled = matches!(attributes.mode, Preserve::No { explicit: true }); @@ -1950,7 +1976,7 @@ pub(crate) fn copy_attributes( })?; handle_preserve(attributes.timestamps, || { - set_timestamps(&source_metadata, dest) + set_timestamps(source_metadata, dest) })?; #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] @@ -2544,8 +2570,29 @@ fn copy_file( copied_files: &mut HashMap, created_parent_dirs: &mut HashSet, source_in_command_line: bool, + initial_source_metadata: Option<&Metadata>, ) -> CopyResult<()> { - let source_is_symlink = source.is_symlink(); + let source_path_metadata = if let Some(metadata) = initial_source_metadata { + metadata.clone() + } else { + fs::symlink_metadata(source).map_err(|err| { + if err.to_string().contains("No such file or directory") { + return translate!("cp-error-cannot-stat", "source" => source.quote()); + } + err.to_string() + })? + }; + let source_is_symlink = source_path_metadata.file_type().is_symlink(); + let source_metadata = if options.dereference(source_in_command_line) { + fs::metadata(source).map_err(|err| { + if err.to_string().contains("No such file or directory") { + return translate!("cp-error-cannot-stat", "source" => source.quote()); + } + err.to_string() + })? + } else { + source_path_metadata + }; let initial_dest_metadata = dest.symlink_metadata().ok(); let dest_is_symlink = initial_dest_metadata .as_ref() @@ -2675,21 +2722,6 @@ fn copy_file( let context = context_for(source, dest); let context = context.as_str(); - let source_metadata = { - let result = if options.dereference(source_in_command_line) { - fs::metadata(source) - } else { - fs::symlink_metadata(source) - }; - // this is just for gnu tests compatibility - result.map_err(|err| { - if err.to_string().contains("No such file or directory") { - return translate!("cp-error-cannot-stat", "source" => source.quote()); - } - err.to_string() - })? - }; - let dest_metadata = dest.symlink_metadata().ok(); let dest_permissions = calculate_dest_permissions( @@ -2735,9 +2767,10 @@ fn copy_file( .ok() .filter(|p| p.exists()) .unwrap_or_else(|| source.to_path_buf()); - copy_attributes( + copy_attributes_from_metadata( &src_for_attrs, dest, + &source_metadata, &options.attributes, false, options.set_selinux_context, @@ -2749,9 +2782,10 @@ fn copy_file( // copy function (see `copy_stream` under platform/linux.rs). Ok(()) } else { - copy_attributes( + copy_attributes_from_metadata( source, dest, + &source_metadata, &options.attributes, false, options.set_selinux_context, @@ -2883,7 +2917,7 @@ fn copy_helper( } if source_metadata.is_symlink() { - copy_link(source, dest, symlinked_files, options)?; + copy_link(source, dest, source_metadata, symlinked_files, options)?; } else { // Use O_NOFOLLOW on the source open iff cp is in no-dereference mode. // In that case source_metadata was obtained via lstat, so a path swap @@ -2962,6 +2996,7 @@ fn copy_node( fn copy_link( source: &Path, dest: &Path, + source_metadata: &Metadata, symlinked_files: &mut HashSet, options: &Options, ) -> CopyResult<()> { @@ -2973,9 +3008,10 @@ fn copy_link( delete_path(dest, options)?; } symlink_file(&link, dest, symlinked_files)?; - copy_attributes( + copy_attributes_from_metadata( source, dest, + source_metadata, &options.attributes, false, options.set_selinux_context, diff --git a/tests/by-util/test_cp.rs b/tests/by-util/test_cp.rs index 9ad4ef75c4d..821a26f7e21 100644 --- a/tests/by-util/test_cp.rs +++ b/tests/by-util/test_cp.rs @@ -924,6 +924,11 @@ fn test_cp_arg_symlink() { .succeeds(); assert!(at.is_symlink(TEST_HELLO_WORLD_DEST)); + assert_eq!( + std_fs::read_link(at.plus(TEST_HELLO_WORLD_DEST)).unwrap(), + Path::new(TEST_HELLO_WORLD_SOURCE) + ); + assert_eq!(at.read(TEST_HELLO_WORLD_DEST), "Hello, World!\n"); } #[test] @@ -2493,12 +2498,12 @@ fn test_cp_archive_recursive() { fn test_cp_preserve_timestamps() { let (at, mut ucmd) = at_and_ucmd!(); let ts = time::OffsetDateTime::now_utc(); - let previous = FileTime::from_unix_time(ts.unix_timestamp() - 3600, ts.nanosecond()); - // set the file creation/modification an hour ago + let previous_atime = FileTime::from_unix_time(ts.unix_timestamp() - 7200, ts.nanosecond()); + let previous_mtime = FileTime::from_unix_time(ts.unix_timestamp() - 3600, ts.nanosecond()); filetime::set_file_times( at.plus_as_string(TEST_HELLO_WORLD_SOURCE), - previous, - previous, + previous_atime, + previous_mtime, ) .unwrap(); ucmd.arg(TEST_HELLO_WORLD_SOURCE) @@ -2506,19 +2511,63 @@ fn test_cp_preserve_timestamps() { .arg(TEST_HOW_ARE_YOU_SOURCE) .succeeds(); + let metadata = std_fs::metadata(at.subdir.join(TEST_HOW_ARE_YOU_SOURCE)).unwrap(); + assert_eq!(FileTime::from_last_access_time(&metadata), previous_atime); + assert_eq!( + FileTime::from_last_modification_time(&metadata), + previous_mtime + ); assert_eq!(at.read(TEST_HOW_ARE_YOU_SOURCE), "Hello, World!\n"); - let metadata = std_fs::metadata(at.subdir.join(TEST_HELLO_WORLD_SOURCE)).unwrap(); - let creation = metadata.modified().unwrap(); - - let metadata2 = std_fs::metadata(at.subdir.join(TEST_HOW_ARE_YOU_SOURCE)).unwrap(); - let creation2 = metadata2.modified().unwrap(); - let scene2 = TestScenario::new("ls"); let result = scene2.cmd("ls").arg("-al").arg(at.subdir).run(); println!("ls dest {}", result.stdout_str()); - assert_eq!(creation, creation2); +} + +#[test] +#[cfg(any(target_os = "linux", target_os = "android"))] +fn test_cp_preserve_symlink_timestamps() { + let (at, mut ucmd) = at_and_ucmd!(); + let ts = time::OffsetDateTime::now_utc(); + let previous_atime = FileTime::from_unix_time(ts.unix_timestamp() - 7200, ts.nanosecond()); + let previous_mtime = FileTime::from_unix_time(ts.unix_timestamp() - 3600, ts.nanosecond()); + let target_atime = FileTime::from_unix_time(ts.unix_timestamp() - 14_400, ts.nanosecond()); + let target_mtime = FileTime::from_unix_time(ts.unix_timestamp() - 10_800, ts.nanosecond()); + + at.write("target", "contents"); + at.relative_symlink_file("target", "source-link"); + filetime::set_file_times(at.plus("target"), target_atime, target_mtime).unwrap(); + filetime::set_symlink_file_times(at.plus("source-link"), previous_atime, previous_mtime) + .unwrap(); + + ucmd.args(&["-P", "--preserve=timestamps", "source-link", "dest-link"]) + .succeeds(); + + let link_metadata = std_fs::symlink_metadata(at.plus("dest-link")).unwrap(); + assert!(link_metadata.file_type().is_symlink()); + assert_eq!( + FileTime::from_last_access_time(&link_metadata), + previous_atime + ); + assert_eq!( + FileTime::from_last_modification_time(&link_metadata), + previous_mtime + ); + assert_eq!( + std_fs::read_link(at.plus("dest-link")).unwrap(), + std_fs::read_link(at.plus("source-link")).unwrap() + ); + + let target_metadata = std_fs::metadata(at.plus("target")).unwrap(); + assert_eq!( + FileTime::from_last_access_time(&target_metadata), + target_atime + ); + assert_eq!( + FileTime::from_last_modification_time(&target_metadata), + target_mtime + ); } #[test] From 82daa2955167c3aa0c76df364de496e93439626e Mon Sep 17 00:00:00 2001 From: Anthony DePasquale Date: Sat, 8 Aug 2026 12:42:58 +0200 Subject: [PATCH 03/11] cp: remove redundant spelling exception --- src/uu/cp/src/cp.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/uu/cp/src/cp.rs b/src/uu/cp/src/cp.rs index b8207013315..10085fb4b15 100644 --- a/src/uu/cp/src/cp.rs +++ b/src/uu/cp/src/cp.rs @@ -2,7 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) copydir ficlone fiemap filestat ftruncate linkgs lstat nlink nlinks pathbuf pwrite reflink strs utimensat xattrs symlinked deduplicated advcpmv nushell IRWXG IRWXO IRWXU IRWXUGO IRWXU IRWXG IRWXO IRWXUGO sflag +// spell-checker:ignore (ToDO) copydir fiemap filestat ftruncate linkgs lstat nlink nlinks pathbuf pwrite reflink strs utimensat xattrs symlinked deduplicated advcpmv nushell IRWXG IRWXO IRWXU IRWXUGO IRWXU IRWXG IRWXO IRWXUGO sflag // spell-checker:ignore RDONLY futimens use std::cmp::Ordering; From cdd70ccf2664d05a8bbdbbd7492a18bbc864eca3 Mon Sep 17 00:00:00 2001 From: Anthony DePasquale Date: Sat, 8 Aug 2026 13:50:03 +0200 Subject: [PATCH 04/11] cp: preserve recursive pre-copy timestamps --- .github/workflows/wasi.yml | 1 + src/uu/cp/src/copydir.rs | 110 ++++++++++++++++++++++++++++++------- src/uu/cp/src/cp.rs | 3 +- tests/by-util/test_cp.rs | 95 ++++++++++++++++++++++++-------- 4 files changed, 164 insertions(+), 45 deletions(-) diff --git a/.github/workflows/wasi.yml b/.github/workflows/wasi.yml index 556a2e92c13..6625edbb243 100644 --- a/.github/workflows/wasi.yml +++ b/.github/workflows/wasi.yml @@ -71,6 +71,7 @@ jobs: test_base32:: test_base64:: test_basenc:: test_basename:: \ test_cp::test_cp_arg_symlink test_cp::test_cp_preserve_symlink_timestamps \ test_cp::test_cp_preserve_timestamps \ + test_cp::test_cp_preserve_recursive_directory_timestamps \ test_comm:: test_cut:: test_dirname:: test_echo:: \ test_expand:: test_factor:: test_false:: test_fold:: \ test_head:: test_link:: test_ln:: test_nl:: test_numfmt:: \ diff --git a/src/uu/cp/src/copydir.rs b/src/uu/cp/src/copydir.rs index 28723cdd675..f75a1504054 100644 --- a/src/uu/cp/src/copydir.rs +++ b/src/uu/cp/src/copydir.rs @@ -11,7 +11,7 @@ use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::convert::identity; use std::env; -use std::fs::{self, exists}; +use std::fs::{self, Metadata, exists}; use std::io; use std::path::{Path, PathBuf, StripPrefixError}; @@ -30,7 +30,7 @@ use walkdir::{DirEntry, WalkDir}; use crate::set_selinux_context; use crate::{ CopyMode, CopyResult, CpError, Options, aligned_ancestors, context_for, copy_attributes, - copy_file, + copy_attributes_from_metadata, copy_file, }; /// Represents a directory that needs permission fixup after copying its contents. @@ -41,6 +41,8 @@ struct DirNeedingPermissions { dest: PathBuf, /// Whether this directory was freshly created by the copy operation was_created: bool, + /// Metadata captured before this directory was traversed + source_metadata: Option, } /// Ensure a Windows path starts with a `\\?`. @@ -373,6 +375,7 @@ pub(crate) fn copy_directory( copied_files: &mut HashMap, created_parent_dirs: &mut HashSet, source_in_command_line: bool, + initial_source_metadata: Option<&Metadata>, ) -> CopyResult<()> { // if no-dereference is enabled and this is a symlink, copy it as a file if !options.dereference(source_in_command_line) && root.is_symlink() { @@ -386,7 +389,7 @@ pub(crate) fn copy_directory( copied_files, created_parent_dirs, source_in_command_line, - None, + initial_source_metadata, ); } @@ -450,6 +453,12 @@ pub(crate) fn copy_directory( let preserve_hard_links = options.preserve_hard_links(); + let initial_source_metadata = if options.dereference(source_in_command_line) { + fs::metadata(root).ok() + } else { + initial_source_metadata.cloned() + }; + // Collect some paths here that are invariant during the traversal // of the given directory, like the current working directory and // the target directory. @@ -466,6 +475,12 @@ pub(crate) fn copy_directory( // Keep track of all directories we've created that need permission fixes let mut dirs_needing_permissions: Vec = Vec::new(); + // WalkDir opens each directory before yielding it, which can update its + // access time. Capture children while their parent is being processed so + // that timestamp preservation uses metadata from before that open. + let preserve_timestamps = matches!(options.attributes.timestamps, crate::Preserve::Yes { .. }); + let mut pending_source_metadata: HashMap = HashMap::new(); + // Traverse the contents of the directory, copying each one. for direntry_result in WalkDir::new(root) .same_file_system(options.one_file_system) @@ -475,14 +490,44 @@ pub(crate) fn copy_directory( Ok(direntry) => { let direntry_type = direntry.file_type(); let direntry_path = direntry.path(); + let mut source_metadata = if direntry.depth() == 0 { + initial_source_metadata.clone() + } else if let Some(metadata) = pending_source_metadata.remove(direntry_path) { + Some(metadata) + } else if options.dereference { + fs::metadata(direntry_path).ok() + } else { + direntry_path.symlink_metadata().ok() + }; let (entry_is_symlink, entry_is_dir_no_follow) = - match direntry_path.symlink_metadata() { - Ok(metadata) => { + source_metadata.as_ref().map_or_else( + || (direntry_type.is_symlink(), direntry_type.is_dir()), + |metadata| { let file_type = metadata.file_type(); (file_type.is_symlink(), file_type.is_dir()) + }, + ); + if preserve_timestamps + && entry_is_dir_no_follow + && let Ok(children) = fs::read_dir(direntry_path) + { + for child in children.flatten() { + let child_path = child.path(); + let child_metadata = if options.dereference { + fs::metadata(&child_path) + } else { + child_path.symlink_metadata() + }; + if let Ok(metadata) = child_metadata + && metadata.file_type().is_dir() + { + pending_source_metadata.insert(child_path, metadata); } - Err(_) => (direntry_type.is_symlink(), direntry_type.is_dir()), - }; + } + } + if !preserve_timestamps { + source_metadata = None; + } let entry = Entry::new(&context, direntry_path, options.no_target_dir)?; let created = copy_direntry( @@ -514,13 +559,24 @@ pub(crate) fn copy_directory( if is_dir_for_permissions { // For --link mode, copy attributes immediately to avoid O(n) memory if options.copy_mode == CopyMode::Link { - copy_attributes( - &entry.source_absolute, - &entry.local_to_target, - &options.attributes, - false, - options.set_selinux_context, - )?; + if let Some(metadata) = source_metadata.as_ref() { + copy_attributes_from_metadata( + &entry.source_absolute, + &entry.local_to_target, + metadata, + &options.attributes, + false, + options.set_selinux_context, + )?; + } else { + copy_attributes( + &entry.source_absolute, + &entry.local_to_target, + &options.attributes, + false, + options.set_selinux_context, + )?; + } continue; } // Add this directory to our list for permission fixing later @@ -528,6 +584,7 @@ pub(crate) fn copy_directory( source: entry.source_absolute.clone(), dest: entry.local_to_target.clone(), was_created: created, + source_metadata, }); // If true, last_iter is not a parent of this iter. @@ -579,13 +636,24 @@ pub(crate) fn copy_directory( // Fix permissions for all directories we created // This ensures that even sibling directories get their permissions fixed for dir in dirs_needing_permissions { - copy_attributes( - &dir.source, - &dir.dest, - &options.attributes, - dir.was_created, - options.set_selinux_context, - )?; + if let Some(metadata) = dir.source_metadata.as_ref() { + copy_attributes_from_metadata( + &dir.source, + &dir.dest, + metadata, + &options.attributes, + dir.was_created, + options.set_selinux_context, + )?; + } else { + copy_attributes( + &dir.source, + &dir.dest, + &options.attributes, + dir.was_created, + options.set_selinux_context, + )?; + } #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] if options.set_selinux_context { diff --git a/src/uu/cp/src/cp.rs b/src/uu/cp/src/cp.rs index 10085fb4b15..044f7c40fcb 100644 --- a/src/uu/cp/src/cp.rs +++ b/src/uu/cp/src/cp.rs @@ -1590,6 +1590,7 @@ fn copy_source( copied_files, created_parent_dirs, true, + initial_source_metadata, ) } else { // Copy as file @@ -1872,7 +1873,7 @@ pub(crate) fn copy_attributes( } #[allow(unused_variables)] -fn copy_attributes_from_metadata( +pub(crate) fn copy_attributes_from_metadata( source: &Path, dest: &Path, source_metadata: &Metadata, diff --git a/tests/by-util/test_cp.rs b/tests/by-util/test_cp.rs index 821a26f7e21..4ea10131150 100644 --- a/tests/by-util/test_cp.rs +++ b/tests/by-util/test_cp.rs @@ -28,7 +28,6 @@ use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::{FileTypeExt, MetadataExt}; #[cfg(windows)] use std::os::windows::fs::symlink_file; -#[cfg(not(windows))] use std::path::Path; #[cfg(target_os = "linux")] use std::path::PathBuf; @@ -925,7 +924,7 @@ fn test_cp_arg_symlink() { assert!(at.is_symlink(TEST_HELLO_WORLD_DEST)); assert_eq!( - std_fs::read_link(at.plus(TEST_HELLO_WORLD_DEST)).unwrap(), + std::fs::read_link(at.plus(TEST_HELLO_WORLD_DEST)).unwrap(), Path::new(TEST_HELLO_WORLD_SOURCE) ); assert_eq!(at.read(TEST_HELLO_WORLD_DEST), "Hello, World!\n"); @@ -1691,6 +1690,9 @@ fn test_cp_parents_with_permissions_copy_file() { at.set_mode(file, file_mode); } + #[cfg(all(unix, not(target_os = "freebsd"), not(target_os = "openbsd")))] + let expected_metadata = (at.metadata("p1"), at.metadata("p1/p2"), at.metadata(file)); + ucmd.arg("-p") .arg("--parents") .arg(file) @@ -1699,9 +1701,7 @@ fn test_cp_parents_with_permissions_copy_file() { #[cfg(all(unix, not(target_os = "freebsd"), not(target_os = "openbsd")))] { - let p1_metadata = at.metadata("p1"); - let p2_metadata = at.metadata("p1/p2"); - let file_metadata = at.metadata(file); + let (p1_metadata, p2_metadata, file_metadata) = expected_metadata; assert_metadata_eq!(p1_metadata, at.metadata("dir/p1")); assert_metadata_eq!(p2_metadata, at.metadata("dir/p1/p2")); @@ -1733,6 +1733,9 @@ fn test_cp_parents_with_permissions_copy_dir() { at.set_mode(file, file_mode); } + #[cfg(all(unix, not(target_os = "freebsd"), not(target_os = "openbsd")))] + let expected_metadata = (at.metadata("p1"), at.metadata("p1/p2"), at.metadata(file)); + ucmd.arg("-p") .arg("--parents") .arg("-r") @@ -1742,9 +1745,7 @@ fn test_cp_parents_with_permissions_copy_dir() { #[cfg(all(unix, not(target_os = "freebsd"), not(target_os = "openbsd")))] { - let p1_metadata = at.metadata("p1"); - let p2_metadata = at.metadata("p1/p2"); - let file_metadata = at.metadata(file); + let (p1_metadata, p2_metadata, file_metadata) = expected_metadata; assert_metadata_eq!(p1_metadata, at.metadata("dir/p1")); assert_metadata_eq!(p2_metadata, at.metadata("dir/p1/p2")); @@ -1779,6 +1780,9 @@ fn test_cp_preserve_no_args() { #[cfg(unix)] at.set_mode(src_file, 0o0500); + #[cfg(all(unix, not(target_os = "freebsd"), not(target_os = "openbsd")))] + let expected_metadata = at.metadata(src_file); + // Copy ucmd.arg(src_file) .arg(dst_file) @@ -1789,9 +1793,8 @@ fn test_cp_preserve_no_args() { { // Assert that the mode, ownership, and timestamps are preserved // NOTICE: the ownership is not modified on the src file, because that requires root permissions - let metadata_src = at.metadata(src_file); let metadata_dst = at.metadata(dst_file); - assert_metadata_eq!(metadata_src, metadata_dst); + assert_metadata_eq!(expected_metadata, metadata_dst); } } @@ -1807,6 +1810,9 @@ fn test_cp_preserve_no_args_before_opts() { #[cfg(unix)] at.set_mode(src_file, 0o0500); + #[cfg(all(unix, not(target_os = "freebsd"), not(target_os = "openbsd")))] + let expected_metadata = at.metadata(src_file); + // Copy ucmd.arg("--preserve") .arg(src_file) @@ -1817,9 +1823,8 @@ fn test_cp_preserve_no_args_before_opts() { { // Assert that the mode, ownership, and timestamps are preserved // NOTICE: the ownership is not modified on the src file, because that requires root permissions - let metadata_src = at.metadata(src_file); let metadata_dst = at.metadata(dst_file); - assert_metadata_eq!(metadata_src, metadata_dst); + assert_metadata_eq!(expected_metadata, metadata_dst); } } @@ -1835,6 +1840,9 @@ fn test_cp_preserve_all() { #[cfg(unix)] at.set_mode(src_file, 0o0500); + #[cfg(all(unix, not(target_os = "freebsd"), not(target_os = "openbsd")))] + let expected_metadata = at.metadata(src_file); + // TODO: create a destination that does not allow copying of xattr and context // Copy ucmd.arg(src_file).arg(dst_file).arg(argument).succeeds(); @@ -1843,9 +1851,8 @@ fn test_cp_preserve_all() { { // Assert that the mode, ownership, and timestamps are preserved // NOTICE: the ownership is not modified on the src file, because that requires root permissions - let metadata_src = at.metadata(src_file); let metadata_dst = at.metadata(dst_file); - assert_metadata_eq!(metadata_src, metadata_dst); + assert_metadata_eq!(expected_metadata, metadata_dst); } } } @@ -2525,6 +2532,42 @@ fn test_cp_preserve_timestamps() { println!("ls dest {}", result.stdout_str()); } +#[test] +#[cfg(any(target_os = "linux", target_os = "android"))] +fn test_cp_preserve_recursive_directory_timestamps() { + let (at, mut ucmd) = at_and_ucmd!(); + let ts = time::OffsetDateTime::now_utc(); + let root_atime = FileTime::from_unix_time(ts.unix_timestamp() - 7200, ts.nanosecond()); + let root_mtime = FileTime::from_unix_time(ts.unix_timestamp() - 3600, ts.nanosecond()); + let nested_atime = FileTime::from_unix_time(ts.unix_timestamp() - 14_400, ts.nanosecond()); + let nested_mtime = FileTime::from_unix_time(ts.unix_timestamp() - 10_800, ts.nanosecond()); + + at.mkdir_all("source/nested"); + at.write("source/nested/file", "contents"); + filetime::set_file_times(at.plus("source/nested"), nested_atime, nested_mtime).unwrap(); + filetime::set_file_times(at.plus("source"), root_atime, root_mtime).unwrap(); + + ucmd.args(&["-R", "--preserve=timestamps", "source", "destination"]) + .succeeds(); + + let root_metadata = std_fs::metadata(at.plus("destination")).unwrap(); + assert_eq!(FileTime::from_last_access_time(&root_metadata), root_atime); + assert_eq!( + FileTime::from_last_modification_time(&root_metadata), + root_mtime + ); + + let nested_metadata = std_fs::metadata(at.plus("destination/nested")).unwrap(); + assert_eq!( + FileTime::from_last_access_time(&nested_metadata), + nested_atime + ); + assert_eq!( + FileTime::from_last_modification_time(&nested_metadata), + nested_mtime + ); +} + #[test] #[cfg(any(target_os = "linux", target_os = "android"))] fn test_cp_preserve_symlink_timestamps() { @@ -3413,6 +3456,10 @@ fn test_copy_through_dangling_symlink_no_dereference_permissions() { at.symlink_file("no-such-file", "dangle"); // to check if access time and modification time didn't change sleep(Duration::from_millis(100)); + + #[cfg(all(unix, not(target_os = "freebsd"), not(target_os = "openbsd")))] + let expected_metadata = at.symlink_metadata("dangle"); + // don't dereference the link // | copy permissions, too // | | from the link @@ -3427,9 +3474,8 @@ fn test_copy_through_dangling_symlink_no_dereference_permissions() { // `-p` means `--preserve=mode,ownership,timestamps` #[cfg(all(unix, not(target_os = "freebsd"), not(target_os = "openbsd")))] { - let metadata1 = at.symlink_metadata("dangle"); let metadata2 = at.symlink_metadata("d2"); - assert_metadata_eq!(metadata1, metadata2); + assert_metadata_eq!(expected_metadata, metadata2); } } @@ -4124,6 +4170,7 @@ fn test_copy_dir_preserve_permissions() { let (at, mut ucmd) = at_and_ucmd!(); at.mkdir("d1"); at.set_mode("d1", 0o0500); + let expected_metadata = at.metadata("d1"); // Copy the directory, preserving those permissions. // @@ -4137,9 +4184,8 @@ fn test_copy_dir_preserve_permissions() { assert!(at.dir_exists("d2")); // Assert that the permissions are preserved. - let metadata1 = at.metadata("d1"); let metadata2 = at.metadata("d2"); - assert_metadata_eq!(metadata1, metadata2); + assert_metadata_eq!(expected_metadata, metadata2); } /// cp should preserve attributes of subdirectories when copying recursively. @@ -4152,14 +4198,16 @@ fn test_copy_dir_preserve_subdir_permissions() { // Use different permissions for a better test at.set_mode("a1/a2", 0o0555); at.set_mode("a1", 0o0777); + let expected_root_metadata = at.metadata("a1"); + let expected_subdir_metadata = at.metadata("a1/a2"); ucmd.args(&["-p", "-r", "a1", "b1"]).succeeds().no_output(); // Make sure everything is preserved assert!(at.dir_exists("b1")); assert!(at.dir_exists("b1/a2")); - assert_metadata_eq!(at.metadata("a1"), at.metadata("b1")); - assert_metadata_eq!(at.metadata("a1/a2"), at.metadata("b1/a2")); + assert_metadata_eq!(expected_root_metadata, at.metadata("b1")); + assert_metadata_eq!(expected_subdir_metadata, at.metadata("b1/a2")); } /// cp should successfully copy a read-only source directory containing files. @@ -4172,6 +4220,7 @@ fn test_copy_dir_preserve_readonly_source_with_files() { at.mkdir("src"); at.write("src/file.txt", "hello"); at.set_mode("src", 0o0555); + let expected_metadata = at.metadata("src"); ucmd.args(&["-p", "-r", "src", "dest"]) .succeeds() @@ -4179,7 +4228,7 @@ fn test_copy_dir_preserve_readonly_source_with_files() { assert!(at.dir_exists("dest")); assert_eq!(at.read("dest/file.txt"), "hello"); - assert_metadata_eq!(at.metadata("src"), at.metadata("dest")); + assert_metadata_eq!(expected_metadata, at.metadata("dest")); } /// Test for preserving permissions when copying a directory, even in @@ -4194,6 +4243,7 @@ fn test_copy_dir_preserve_permissions_inaccessible_file() { at.touch("d1/f"); at.set_mode("d1/f", 0); at.set_mode("d1", 0o0500); + let expected_metadata = at.metadata("d1"); // Copy the directory, preserving those permissions. There should // be an error message that the file `d1/f` is inaccessible. @@ -4211,9 +4261,8 @@ fn test_copy_dir_preserve_permissions_inaccessible_file() { assert!(!at.file_exists("d2/f")); // Assert that the permissions are preserved. - let metadata1 = at.metadata("d1"); let metadata2 = at.metadata("d2"); - assert_metadata_eq!(metadata1, metadata2); + assert_metadata_eq!(expected_metadata, metadata2); } /// Test that copying file to itself with backup fails. From 0a16bfd6ce4598ad8a68744134be2dd2be6c69cd Mon Sep 17 00:00:00 2001 From: Anthony DePasquale Date: Sat, 8 Aug 2026 14:10:19 +0200 Subject: [PATCH 05/11] tests/cp: snapshot SELinux metadata before copy --- tests/by-util/test_cp.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/by-util/test_cp.rs b/tests/by-util/test_cp.rs index 4ea10131150..7888b8f96b1 100644 --- a/tests/by-util/test_cp.rs +++ b/tests/by-util/test_cp.rs @@ -7327,6 +7327,9 @@ fn test_cp_preserve_selinux() { let args = ["-Z", "--context=unconfined_u:object_r:user_tmp_t:s0"]; at.touch(TEST_HELLO_WORLD_SOURCE); for arg in args { + #[cfg(all(unix, not(target_os = "freebsd"), not(target_os = "openbsd")))] + let expected_metadata = at.metadata(TEST_HELLO_WORLD_SOURCE); + ts.ucmd() .arg(arg) .arg(TEST_HELLO_WORLD_SOURCE) @@ -7348,9 +7351,8 @@ fn test_cp_preserve_selinux() { { // Assert that the mode, ownership, and timestamps are preserved // NOTICE: the ownership is not modified on the src file, because that requires root permissions - let metadata_src = at.metadata(TEST_HELLO_WORLD_SOURCE); let metadata_dst = at.metadata(TEST_HELLO_WORLD_DEST); - assert_metadata_eq!(metadata_src, metadata_dst); + assert_metadata_eq!(expected_metadata, metadata_dst); } at.remove(&at.plus_as_string(TEST_HELLO_WORLD_DEST)); From 85ca23f2339227bf2525db13728094eaf32ce9e4 Mon Sep 17 00:00:00 2001 From: Anthony DePasquale Date: Sat, 8 Aug 2026 14:43:59 +0200 Subject: [PATCH 06/11] cp: set Android timestamps without reopening files --- src/uu/cp/src/cp.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/uu/cp/src/cp.rs b/src/uu/cp/src/cp.rs index 044f7c40fcb..66938cb593d 100644 --- a/src/uu/cp/src/cp.rs +++ b/src/uu/cp/src/cp.rs @@ -1832,7 +1832,8 @@ fn set_timestamps(source_metadata: &Metadata, dest: &Path) -> CopyResult<()> { #[cfg(unix)] let no_open = { let ft = source_metadata.file_type(); - dest.is_symlink() + cfg!(target_os = "android") + || dest.is_symlink() || ft.is_fifo() || ft.is_socket() || ft.is_char_device() From 74be6f87b6ea510432f7a396b401c0c71cd9b818 Mon Sep 17 00:00:00 2001 From: Anthony DePasquale Date: Sat, 8 Aug 2026 15:08:39 +0200 Subject: [PATCH 07/11] cp: defer dereferenced metadata past hardlink reuse --- src/uu/cp/src/cp.rs | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/src/uu/cp/src/cp.rs b/src/uu/cp/src/cp.rs index 66938cb593d..730bb956c19 100644 --- a/src/uu/cp/src/cp.rs +++ b/src/uu/cp/src/cp.rs @@ -1832,8 +1832,7 @@ fn set_timestamps(source_metadata: &Metadata, dest: &Path) -> CopyResult<()> { #[cfg(unix)] let no_open = { let ft = source_metadata.file_type(); - cfg!(target_os = "android") - || dest.is_symlink() + dest.is_symlink() || ft.is_fifo() || ft.is_socket() || ft.is_char_device() @@ -2585,16 +2584,6 @@ fn copy_file( })? }; let source_is_symlink = source_path_metadata.file_type().is_symlink(); - let source_metadata = if options.dereference(source_in_command_line) { - fs::metadata(source).map_err(|err| { - if err.to_string().contains("No such file or directory") { - return translate!("cp-error-cannot-stat", "source" => source.quote()); - } - err.to_string() - })? - } else { - source_path_metadata - }; let initial_dest_metadata = dest.symlink_metadata().ok(); let dest_is_symlink = initial_dest_metadata .as_ref() @@ -2724,6 +2713,20 @@ fn copy_file( let context = context_for(source, dest); let context = context.as_str(); + // Defer dereferencing the source until after the preserved-hardlink fast + // path. Some platforms reject metadata access through a symlink even when + // the target was already copied and can be hard-linked directly. + let source_metadata = if options.dereference(source_in_command_line) { + fs::metadata(source).map_err(|err| { + if err.to_string().contains("No such file or directory") { + return translate!("cp-error-cannot-stat", "source" => source.quote()); + } + err.to_string() + })? + } else { + source_path_metadata + }; + let dest_metadata = dest.symlink_metadata().ok(); let dest_permissions = calculate_dest_permissions( From 9c777652c9943b5a59019dea5c522401eaa51c0a Mon Sep 17 00:00:00 2001 From: Anthony DePasquale Date: Sat, 8 Aug 2026 15:37:02 +0200 Subject: [PATCH 08/11] cp: classify traversed symlinks without dereferencing --- src/uu/cp/src/copydir.rs | 15 +++++++++------ src/uu/cp/src/cp.rs | 5 ++--- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/uu/cp/src/copydir.rs b/src/uu/cp/src/copydir.rs index f75a1504054..527990d23e0 100644 --- a/src/uu/cp/src/copydir.rs +++ b/src/uu/cp/src/copydir.rs @@ -500,15 +500,18 @@ pub(crate) fn copy_directory( direntry_path.symlink_metadata().ok() }; let (entry_is_symlink, entry_is_dir_no_follow) = - source_metadata.as_ref().map_or_else( - || (direntry_type.is_symlink(), direntry_type.is_dir()), - |metadata| { + match direntry_path.symlink_metadata() { + Ok(metadata) => { let file_type = metadata.file_type(); (file_type.is_symlink(), file_type.is_dir()) - }, - ); + } + Err(_) => (direntry_type.is_symlink(), direntry_type.is_dir()), + }; + let entry_is_dir_for_metadata = source_metadata + .as_ref() + .map_or_else(|| direntry_type.is_dir(), Metadata::is_dir); if preserve_timestamps - && entry_is_dir_no_follow + && entry_is_dir_for_metadata && let Ok(children) = fs::read_dir(direntry_path) { for child in children.flatten() { diff --git a/src/uu/cp/src/cp.rs b/src/uu/cp/src/cp.rs index 730bb956c19..02bcc6ef520 100644 --- a/src/uu/cp/src/cp.rs +++ b/src/uu/cp/src/cp.rs @@ -2713,9 +2713,8 @@ fn copy_file( let context = context_for(source, dest); let context = context.as_str(); - // Defer dereferencing the source until after the preserved-hardlink fast - // path. Some platforms reject metadata access through a symlink even when - // the target was already copied and can be hard-linked directly. + // A preserved-hardlink match returns before content or attributes need + // source metadata. let source_metadata = if options.dereference(source_in_command_line) { fs::metadata(source).map_err(|err| { if err.to_string().contains("No such file or directory") { From 85ec192a94176ed1c58d8eb784feefa521373471 Mon Sep 17 00:00:00 2001 From: Anthony DePasquale Date: Sat, 8 Aug 2026 16:12:05 +0200 Subject: [PATCH 09/11] cp: reduce recursive timestamp preservation overhead --- src/uu/cp/src/copydir.rs | 95 ++++++++++++++++++++++++---------------- src/uu/cp/src/cp.rs | 94 +++++++++++++++++++++++++++++++++------ 2 files changed, 137 insertions(+), 52 deletions(-) diff --git a/src/uu/cp/src/copydir.rs b/src/uu/cp/src/copydir.rs index 527990d23e0..379bb0827d6 100644 --- a/src/uu/cp/src/copydir.rs +++ b/src/uu/cp/src/copydir.rs @@ -29,8 +29,8 @@ use walkdir::{DirEntry, WalkDir}; #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] use crate::set_selinux_context; use crate::{ - CopyMode, CopyResult, CpError, Options, aligned_ancestors, context_for, copy_attributes, - copy_attributes_from_metadata, copy_file, + CopyMode, CopyResult, CpError, Options, SourceTimestamps, aligned_ancestors, context_for, + copy_attributes, copy_attributes_with_timestamps, copy_file, }; /// Represents a directory that needs permission fixup after copying its contents. @@ -41,8 +41,8 @@ struct DirNeedingPermissions { dest: PathBuf, /// Whether this directory was freshly created by the copy operation was_created: bool, - /// Metadata captured before this directory was traversed - source_metadata: Option, + /// Timestamps captured before this directory was traversed + source_timestamps: Option>, } /// Ensure a Windows path starts with a `\\?`. @@ -453,11 +453,16 @@ pub(crate) fn copy_directory( let preserve_hard_links = options.preserve_hard_links(); - let initial_source_metadata = if options.dereference(source_in_command_line) { + let preserve_timestamps = matches!(options.attributes.timestamps, crate::Preserve::Yes { .. }); + let initial_source_timestamps = if !preserve_timestamps { + None + } else if options.dereference(source_in_command_line) { fs::metadata(root).ok() } else { initial_source_metadata.cloned() - }; + } + .as_ref() + .and_then(|metadata| SourceTimestamps::from_metadata(metadata).ok()); // Collect some paths here that are invariant during the traversal // of the given directory, like the current working directory and @@ -478,8 +483,7 @@ pub(crate) fn copy_directory( // WalkDir opens each directory before yielding it, which can update its // access time. Capture children while their parent is being processed so // that timestamp preservation uses metadata from before that open. - let preserve_timestamps = matches!(options.attributes.timestamps, crate::Preserve::Yes { .. }); - let mut pending_source_metadata: HashMap = HashMap::new(); + let mut pending_source_timestamps: HashMap = HashMap::new(); // Traverse the contents of the directory, copying each one. for direntry_result in WalkDir::new(root) @@ -490,47 +494,62 @@ pub(crate) fn copy_directory( Ok(direntry) => { let direntry_type = direntry.file_type(); let direntry_path = direntry.path(); - let mut source_metadata = if direntry.depth() == 0 { - initial_source_metadata.clone() - } else if let Some(metadata) = pending_source_metadata.remove(direntry_path) { - Some(metadata) - } else if options.dereference { - fs::metadata(direntry_path).ok() - } else { - direntry_path.symlink_metadata().ok() - }; + let source_path_metadata = direntry_path.symlink_metadata().ok(); let (entry_is_symlink, entry_is_dir_no_follow) = - match direntry_path.symlink_metadata() { - Ok(metadata) => { + source_path_metadata.as_ref().map_or_else( + || (direntry_type.is_symlink(), direntry_type.is_dir()), + |metadata| { let file_type = metadata.file_type(); (file_type.is_symlink(), file_type.is_dir()) - } - Err(_) => (direntry_type.is_symlink(), direntry_type.is_dir()), - }; - let entry_is_dir_for_metadata = source_metadata - .as_ref() - .map_or_else(|| direntry_type.is_dir(), Metadata::is_dir); + }, + ); + let source_timestamps = if !preserve_timestamps { + None + } else if direntry.depth() == 0 { + initial_source_timestamps + } else if let Some(timestamps) = pending_source_timestamps.remove(direntry_path) { + Some(timestamps) + } else if options.dereference { + fs::metadata(direntry_path) + .ok() + .as_ref() + .and_then(|metadata| SourceTimestamps::from_metadata(metadata).ok()) + } else { + source_path_metadata + .as_ref() + .and_then(|metadata| SourceTimestamps::from_metadata(metadata).ok()) + }; + let entry_is_dir_for_metadata = if options.dereference { + direntry_type.is_dir() + } else { + entry_is_dir_no_follow + }; if preserve_timestamps && entry_is_dir_for_metadata && let Ok(children) = fs::read_dir(direntry_path) { for child in children.flatten() { let child_path = child.path(); - let child_metadata = if options.dereference { + let Ok(child_type) = child.file_type() else { + continue; + }; + let child_metadata = if options.dereference + && (child_type.is_dir() || child_type.is_symlink()) + { fs::metadata(&child_path) + } else if !options.dereference && child_type.is_dir() { + child.metadata() } else { - child_path.symlink_metadata() + continue; }; if let Ok(metadata) = child_metadata && metadata.file_type().is_dir() + && let Ok(timestamps) = SourceTimestamps::from_metadata(&metadata) { - pending_source_metadata.insert(child_path, metadata); + pending_source_timestamps.insert(child_path, timestamps); } } } - if !preserve_timestamps { - source_metadata = None; - } let entry = Entry::new(&context, direntry_path, options.no_target_dir)?; let created = copy_direntry( @@ -562,11 +581,11 @@ pub(crate) fn copy_directory( if is_dir_for_permissions { // For --link mode, copy attributes immediately to avoid O(n) memory if options.copy_mode == CopyMode::Link { - if let Some(metadata) = source_metadata.as_ref() { - copy_attributes_from_metadata( + if let Some(timestamps) = source_timestamps { + copy_attributes_with_timestamps( &entry.source_absolute, &entry.local_to_target, - metadata, + timestamps, &options.attributes, false, options.set_selinux_context, @@ -587,7 +606,7 @@ pub(crate) fn copy_directory( source: entry.source_absolute.clone(), dest: entry.local_to_target.clone(), was_created: created, - source_metadata, + source_timestamps: source_timestamps.map(Box::new), }); // If true, last_iter is not a parent of this iter. @@ -639,11 +658,11 @@ pub(crate) fn copy_directory( // Fix permissions for all directories we created // This ensures that even sibling directories get their permissions fixed for dir in dirs_needing_permissions { - if let Some(metadata) = dir.source_metadata.as_ref() { - copy_attributes_from_metadata( + if let Some(timestamps) = dir.source_timestamps.as_deref().copied() { + copy_attributes_with_timestamps( &dir.source, &dir.dest, - metadata, + timestamps, &options.attributes, dir.was_created, options.set_selinux_context, diff --git a/src/uu/cp/src/cp.rs b/src/uu/cp/src/cp.rs index 02bcc6ef520..06f98bd889f 100644 --- a/src/uu/cp/src/cp.rs +++ b/src/uu/cp/src/cp.rs @@ -1798,7 +1798,36 @@ fn copy_extended_attrs(source: &Path, dest: &Path, skip_selinux: bool) -> CopyRe /// values are converted to `Timespec` against `UNIX_EPOCH`, matching WASI's /// `path_filestat_set_times` contract (unsigned nanosecond count — pre-epoch /// source times can't be represented). -fn set_timestamps(source_metadata: &Metadata, dest: &Path) -> CopyResult<()> { +#[derive(Clone, Copy)] +pub(crate) struct SourceTimestamps { + accessed: std::time::SystemTime, + modified: std::time::SystemTime, + #[cfg(not(target_os = "wasi"))] + no_open: bool, +} + +impl SourceTimestamps { + pub(crate) fn from_metadata(metadata: &Metadata) -> io::Result { + Ok(Self { + accessed: metadata.accessed()?, + modified: metadata.modified()?, + #[cfg(not(target_os = "wasi"))] + no_open: { + #[cfg(unix)] + { + let ft = metadata.file_type(); + ft.is_fifo() || ft.is_socket() || ft.is_char_device() || ft.is_block_device() + } + #[cfg(not(unix))] + { + false + } + }, + }) + } +} + +fn set_timestamps(source_timestamps: SourceTimestamps, dest: &Path) -> CopyResult<()> { #[cfg(target_os = "wasi")] { use std::time::UNIX_EPOCH; @@ -1812,8 +1841,8 @@ fn set_timestamps(source_metadata: &Metadata, dest: &Path) -> CopyResult<()> { }) }; let timestamps = rustix::fs::Timestamps { - last_access: to_timespec(source_metadata.accessed()?)?, - last_modification: to_timespec(source_metadata.modified()?)?, + last_access: to_timespec(source_timestamps.accessed)?, + last_modification: to_timespec(source_timestamps.modified)?, }; let flags = if dest.is_symlink() { rustix::fs::AtFlags::SYMLINK_NOFOLLOW @@ -1827,17 +1856,10 @@ fn set_timestamps(source_metadata: &Metadata, dest: &Path) -> CopyResult<()> { #[cfg(not(target_os = "wasi"))] { - let atime = FileTime::from_last_access_time(source_metadata); - let mtime = FileTime::from_last_modification_time(source_metadata); + let atime = FileTime::from(source_timestamps.accessed); + let mtime = FileTime::from(source_timestamps.modified); #[cfg(unix)] - let no_open = { - let ft = source_metadata.file_type(); - dest.is_symlink() - || ft.is_fifo() - || ft.is_socket() - || ft.is_char_device() - || ft.is_block_device() - }; + let no_open = dest.is_symlink() || source_timestamps.no_open; #[cfg(not(unix))] let no_open = dest.is_symlink(); if no_open { @@ -1880,6 +1902,48 @@ pub(crate) fn copy_attributes_from_metadata( attributes: &Attributes, dest_is_freshly_created_dir: bool, skip_selinux_xattr: bool, +) -> CopyResult<()> { + copy_attributes_from_metadata_and_timestamps( + source, + dest, + source_metadata, + None, + attributes, + dest_is_freshly_created_dir, + skip_selinux_xattr, + ) +} + +pub(crate) fn copy_attributes_with_timestamps( + source: &Path, + dest: &Path, + source_timestamps: SourceTimestamps, + attributes: &Attributes, + dest_is_freshly_created_dir: bool, + skip_selinux_xattr: bool, +) -> CopyResult<()> { + let source_metadata = fs::symlink_metadata(source) + .map_err(|e| CpError::IoErrContext(e, context_for(source, dest)))?; + copy_attributes_from_metadata_and_timestamps( + source, + dest, + &source_metadata, + Some(source_timestamps), + attributes, + dest_is_freshly_created_dir, + skip_selinux_xattr, + ) +} + +#[allow(unused_variables)] +fn copy_attributes_from_metadata_and_timestamps( + source: &Path, + dest: &Path, + source_metadata: &Metadata, + source_timestamps: Option, + attributes: &Attributes, + dest_is_freshly_created_dir: bool, + skip_selinux_xattr: bool, ) -> CopyResult<()> { let context = &*format!("{} -> {}", source.quote(), dest.quote()); @@ -1977,7 +2041,9 @@ pub(crate) fn copy_attributes_from_metadata( })?; handle_preserve(attributes.timestamps, || { - set_timestamps(source_metadata, dest) + let timestamps = source_timestamps + .map_or_else(|| SourceTimestamps::from_metadata(source_metadata), Ok)?; + set_timestamps(timestamps, dest) })?; #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] From 7a41a6e47eb743241a4da258a46f6980c44b38be Mon Sep 17 00:00:00 2001 From: Anthony DePasquale Date: Sat, 8 Aug 2026 16:17:35 +0200 Subject: [PATCH 10/11] cp: omit Unix timestamp state on Windows --- src/uu/cp/src/cp.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/uu/cp/src/cp.rs b/src/uu/cp/src/cp.rs index 06f98bd889f..e9f084dcaa2 100644 --- a/src/uu/cp/src/cp.rs +++ b/src/uu/cp/src/cp.rs @@ -1802,7 +1802,7 @@ fn copy_extended_attrs(source: &Path, dest: &Path, skip_selinux: bool) -> CopyRe pub(crate) struct SourceTimestamps { accessed: std::time::SystemTime, modified: std::time::SystemTime, - #[cfg(not(target_os = "wasi"))] + #[cfg(all(unix, not(target_os = "wasi")))] no_open: bool, } @@ -1811,7 +1811,7 @@ impl SourceTimestamps { Ok(Self { accessed: metadata.accessed()?, modified: metadata.modified()?, - #[cfg(not(target_os = "wasi"))] + #[cfg(all(unix, not(target_os = "wasi")))] no_open: { #[cfg(unix)] { From c80eae6c4550ff788db56dd69e7760ab1fdd628a Mon Sep 17 00:00:00 2001 From: Anthony DePasquale Date: Sat, 8 Aug 2026 17:56:57 +0200 Subject: [PATCH 11/11] tests/cp: cover dereferenced symlink timestamps --- .github/workflows/wasi.yml | 1 + tests/by-util/test_cp.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/.github/workflows/wasi.yml b/.github/workflows/wasi.yml index 6625edbb243..feb808c6d00 100644 --- a/.github/workflows/wasi.yml +++ b/.github/workflows/wasi.yml @@ -71,6 +71,7 @@ jobs: test_base32:: test_base64:: test_basenc:: test_basename:: \ test_cp::test_cp_arg_symlink test_cp::test_cp_preserve_symlink_timestamps \ test_cp::test_cp_preserve_timestamps \ + test_cp::test_cp_preserve_dereferenced_symlink_timestamps \ test_cp::test_cp_preserve_recursive_directory_timestamps \ test_comm:: test_cut:: test_dirname:: test_echo:: \ test_expand:: test_factor:: test_false:: test_fold:: \ diff --git a/tests/by-util/test_cp.rs b/tests/by-util/test_cp.rs index 7888b8f96b1..a6f4c8a4566 100644 --- a/tests/by-util/test_cp.rs +++ b/tests/by-util/test_cp.rs @@ -2613,6 +2613,37 @@ fn test_cp_preserve_symlink_timestamps() { ); } +#[test] +#[cfg(any(target_os = "linux", target_os = "android"))] +fn test_cp_preserve_dereferenced_symlink_timestamps() { + let (at, mut ucmd) = at_and_ucmd!(); + let ts = time::OffsetDateTime::now_utc(); + let link_atime = FileTime::from_unix_time(ts.unix_timestamp() - 7200, ts.nanosecond()); + let link_mtime = FileTime::from_unix_time(ts.unix_timestamp() - 3600, ts.nanosecond()); + let target_atime = FileTime::from_unix_time(ts.unix_timestamp() - 14_400, ts.nanosecond()); + let target_mtime = FileTime::from_unix_time(ts.unix_timestamp() - 10_800, ts.nanosecond()); + + at.write("target", "contents"); + at.relative_symlink_file("target", "source-link"); + filetime::set_file_times(at.plus("target"), target_atime, target_mtime).unwrap(); + filetime::set_symlink_file_times(at.plus("source-link"), link_atime, link_mtime).unwrap(); + + ucmd.args(&["-L", "--preserve=timestamps", "source-link", "destination"]) + .succeeds(); + + let destination_metadata = std_fs::symlink_metadata(at.plus("destination")).unwrap(); + assert!(!destination_metadata.file_type().is_symlink()); + assert_eq!(at.read("destination"), "contents"); + assert_eq!( + FileTime::from_last_access_time(&destination_metadata), + target_atime + ); + assert_eq!( + FileTime::from_last_modification_time(&destination_metadata), + target_mtime + ); +} + #[test] #[cfg(any(target_os = "linux", target_os = "android"))] fn test_cp_no_preserve_timestamps() {