From 9544b4fdb4ace93937dd843fd3031eda45bd293d Mon Sep 17 00:00:00 2001 From: Anthony DePasquale Date: Thu, 13 Aug 2026 13:21:20 +0200 Subject: [PATCH 1/5] cp: preserve file and symlink timestamps on WASI --- .github/workflows/wasi.yml | 4 + src/uu/cp/src/copydir.rs | 4 + src/uu/cp/src/cp.rs | 125 +++++++++++++++++++++++++++-- src/uu/cp/src/platform/mod.rs | 2 +- src/uu/cp/src/platform/wasi.rs | 22 ++++++ tests/by-util/test_cp.rs | 140 +++++++++++++++++++++++++++++++++ 6 files changed, 288 insertions(+), 9 deletions(-) diff --git a/.github/workflows/wasi.yml b/.github/workflows/wasi.yml index d0e6275bfb3..14ab140bad1 100644 --- a/.github/workflows/wasi.yml +++ b/.github/workflows/wasi.yml @@ -70,6 +70,10 @@ jobs: cargo test --test tests -- \ test_base32:: test_base64:: test_basenc:: test_basename:: \ test_cp::test_cp_arg_symlink \ + test_cp::test_cp_wasi_preserve_dereferenced_symlink_timestamps \ + test_cp::test_cp_wasi_preserve_file_timestamps \ + test_cp::test_cp_wasi_preserve_symlink_timestamps \ + test_cp::test_cp_wasi_preserve_timestamps_with_symbolic_link \ 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..75ef93330ac 100644 --- a/src/uu/cp/src/copydir.rs +++ b/src/uu/cp/src/copydir.rs @@ -319,6 +319,8 @@ fn copy_direntry( copied_files, created_parent_dirs, false, + #[cfg(target_os = "wasi")] + None, ) { if preserve_hard_links { @@ -385,6 +387,8 @@ pub(crate) fn copy_directory( copied_files, created_parent_dirs, source_in_command_line, + #[cfg(target_os = "wasi")] + None, ); } diff --git a/src/uu/cp/src/cp.rs b/src/uu/cp/src/cp.rs index 337ad4e0043..46bedf6c947 100644 --- a/src/uu/cp/src/cp.rs +++ b/src/uu/cp/src/cp.rs @@ -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)] @@ -28,6 +29,8 @@ use nix::sys::stat::{Mode, SFlag, dev_t, mknod as nix_mknod, mode_t}; use thiserror::Error; use platform::copy_on_write; +#[cfg(target_os = "wasi")] +use platform::set_timestamps; use uucore::backup_control::backup_would_destroy_source; use uucore::display::Quotable; use uucore::error::{UError, UResult, UUsageError, set_exit_code, strip_errno}; @@ -1429,6 +1432,14 @@ pub fn copy(sources: &[PathBuf], target: &Path, options: &Options) -> CopyResult // we can't use copied_files as it is because the key is the source file's information. let mut copied_destinations: HashSet = HashSet::with_capacity(sources.len()); let mut created_parent_dirs: HashSet = HashSet::new(); + #[cfg(target_os = "wasi")] + let initial_source_metadata: Vec<_> = match options.attributes.timestamps { + Preserve::Yes { .. } => sources + .iter() + .map(|source| fs::symlink_metadata(source).ok()) + .collect(), + Preserve::No { .. } => Vec::new(), + }; let progress_bar = if options.progress_bar { let pb = ProgressBar::new(disk_usage(sources, options.recursive)?) @@ -1445,7 +1456,9 @@ pub fn copy(sources: &[PathBuf], target: &Path, options: &Options) -> CopyResult None }; - for source in sources { + for (source_index, source) in sources.iter().enumerate() { + #[cfg(not(target_os = "wasi"))] + let _ = source_index; 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() { @@ -1458,6 +1471,10 @@ pub fn copy(sources: &[PathBuf], target: &Path, options: &Options) -> CopyResult } else { let dest = construct_dest_path(source, target, target_type, options) .unwrap_or_else(|_| target.to_path_buf()); + #[cfg(target_os = "wasi")] + let initial_source_metadata = initial_source_metadata + .get(source_index) + .and_then(Option::as_ref); if FileInformation::from_path(&dest, true).is_ok() && !fs::symlink_metadata(&dest).is_ok_and(|m| m.file_type().is_symlink()) @@ -1494,6 +1511,8 @@ pub fn copy(sources: &[PathBuf], target: &Path, options: &Options) -> CopyResult &copied_destinations, &mut copied_files, &mut created_parent_dirs, + #[cfg(target_os = "wasi")] + initial_source_metadata, ) { show_error_if_needed(&error); if !matches!(error, CpError::Skipped(false)) { @@ -1569,6 +1588,7 @@ fn copy_source( copied_destinations: &HashSet, copied_files: &mut HashMap, created_parent_dirs: &mut HashSet, + #[cfg(target_os = "wasi")] initial_source_metadata: Option<&Metadata>, ) -> CopyResult<()> { let source_path = Path::new(&source); if source_path.is_dir() && (options.dereference || !source_path.is_symlink()) { @@ -1597,6 +1617,8 @@ fn copy_source( copied_files, created_parent_dirs, true, + #[cfg(target_os = "wasi")] + initial_source_metadata, ); if options.parents { for (x, y) in aligned_ancestors(source, dest.as_path()) { @@ -1794,6 +1816,26 @@ pub(crate) fn copy_attributes( let context = &*format!("{} -> {}", source.quote(), dest.quote()); let source_metadata = fs::symlink_metadata(source).map_err(|e| CpError::IoErrContext(e, context.to_owned()))?; + 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 mode_explicitly_disabled = matches!(attributes.mode, Preserve::No { explicit: true }); @@ -1888,9 +1930,10 @@ pub(crate) fn copy_attributes( Ok(()) })?; + #[cfg(not(target_os = "wasi"))] handle_preserve(attributes.timestamps, || -> CopyResult<()> { - let atime = FileTime::from_last_access_time(&source_metadata); - let mtime = FileTime::from_last_modification_time(&source_metadata); + 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 @@ -1916,6 +1959,11 @@ pub(crate) fn copy_attributes( Ok(()) })?; + #[cfg(target_os = "wasi")] + handle_preserve(attributes.timestamps, || { + set_timestamps(source_metadata, dest).map_err(CpError::from) + })?; + #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] handle_preserve(attributes.context, || -> CopyResult<()> { // Get the source context and apply it to the destination @@ -1956,6 +2004,38 @@ pub(crate) fn copy_attributes( Ok(()) } +fn copy_attributes_after_copy( + source: &Path, + dest: &Path, + #[cfg(target_os = "wasi")] source_metadata: &Metadata, + attributes: &Attributes, + dest_is_freshly_created_dir: bool, + skip_selinux_xattr: bool, +) -> CopyResult<()> { + #[cfg(target_os = "wasi")] + { + copy_attributes_from_metadata( + source, + dest, + source_metadata, + attributes, + dest_is_freshly_created_dir, + skip_selinux_xattr, + ) + } + + #[cfg(not(target_os = "wasi"))] + { + copy_attributes( + source, + dest, + attributes, + dest_is_freshly_created_dir, + skip_selinux_xattr, + ) + } +} + fn symlink_file( source: &Path, dest: &Path, @@ -2507,7 +2587,14 @@ fn copy_file( copied_files: &mut HashMap, created_parent_dirs: &mut HashSet, source_in_command_line: bool, + #[cfg(target_os = "wasi")] initial_source_metadata: Option<&Metadata>, ) -> CopyResult<()> { + #[cfg(target_os = "wasi")] + let source_is_symlink = initial_source_metadata.map_or_else( + || source.is_symlink(), + |metadata| metadata.file_type().is_symlink(), + ); + #[cfg(not(target_os = "wasi"))] let source_is_symlink = source.is_symlink(); let initial_dest_metadata = dest.symlink_metadata().ok(); let dest_is_symlink = initial_dest_metadata @@ -2642,7 +2729,15 @@ fn copy_file( let result = if options.dereference(source_in_command_line) { fs::metadata(source) } else { - fs::symlink_metadata(source) + #[cfg(target_os = "wasi")] + { + initial_source_metadata + .map_or_else(|| fs::symlink_metadata(source), |m| Ok(m.clone())) + } + #[cfg(not(target_os = "wasi"))] + { + fs::symlink_metadata(source) + } }; // this is just for gnu tests compatibility result.map_err(|err| { @@ -2704,9 +2799,11 @@ fn copy_file( .ok() .filter(|p| p.exists()) .unwrap_or_else(|| source.to_path_buf()); - copy_attributes( + copy_attributes_after_copy( &src_for_attrs, dest, + #[cfg(target_os = "wasi")] + &source_metadata, &options.attributes, false, options.set_selinux_context, @@ -2718,9 +2815,11 @@ fn copy_file( // copy function (see `copy_stream` under platform/linux.rs). Ok(()) } else { - copy_attributes( + copy_attributes_after_copy( source, dest, + #[cfg(target_os = "wasi")] + &source_metadata, &options.attributes, false, options.set_selinux_context, @@ -2852,7 +2951,14 @@ fn copy_helper( } if source_metadata.is_symlink() { - copy_link(source, dest, symlinked_files, options)?; + copy_link( + source, + dest, + #[cfg(target_os = "wasi")] + 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 @@ -2931,6 +3037,7 @@ fn copy_node( fn copy_link( source: &Path, dest: &Path, + #[cfg(target_os = "wasi")] source_metadata: &Metadata, symlinked_files: &mut HashSet, options: &Options, ) -> CopyResult<()> { @@ -2942,9 +3049,11 @@ fn copy_link( delete_path(dest, options)?; } symlink_file(&link, dest, symlinked_files)?; - copy_attributes( + copy_attributes_after_copy( source, dest, + #[cfg(target_os = "wasi")] + source_metadata, &options.attributes, false, options.set_selinux_context, diff --git a/src/uu/cp/src/platform/mod.rs b/src/uu/cp/src/platform/mod.rs index 512b055f8c6..05351362d80 100644 --- a/src/uu/cp/src/platform/mod.rs +++ b/src/uu/cp/src/platform/mod.rs @@ -37,4 +37,4 @@ pub(crate) use self::other::copy_on_write; #[cfg(target_os = "wasi")] mod wasi; #[cfg(target_os = "wasi")] -pub(crate) use self::wasi::create_symlink; +pub(crate) use self::wasi::{create_symlink, set_timestamps}; diff --git a/src/uu/cp/src/platform/wasi.rs b/src/uu/cp/src/platform/wasi.rs index d0cfe4088a9..6722fed7ecb 100644 --- a/src/uu/cp/src/platform/wasi.rs +++ b/src/uu/cp/src/platform/wasi.rs @@ -3,9 +3,31 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +use std::fs::Metadata; use std::io; use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use rustix::fs::{AtFlags, CWD, Timespec, Timestamps, utimensat}; pub(crate) fn create_symlink(source: &Path, dest: &Path) -> io::Result<()> { rustix::fs::symlink(source, dest).map_err(io::Error::from) } + +pub(crate) fn set_timestamps(source_metadata: &Metadata, dest: &Path) -> io::Result<()> { + let timestamps = Timestamps { + last_access: to_timespec(source_metadata.accessed()?)?, + last_modification: to_timespec(source_metadata.modified()?)?, + }; + utimensat(CWD, dest, ×tamps, AtFlags::SYMLINK_NOFOLLOW).map_err(io::Error::from) +} + +fn to_timespec(time: SystemTime) -> io::Result { + let duration = time + .duration_since(UNIX_EPOCH) + .map_err(|error| io::Error::new(io::ErrorKind::Unsupported, error))?; + Ok(Timespec { + tv_sec: duration.as_secs() as i64, + tv_nsec: duration.subsec_nanos() as i32, + }) +} diff --git a/tests/by-util/test_cp.rs b/tests/by-util/test_cp.rs index d9942c7b19f..03cc6232f31 100644 --- a/tests/by-util/test_cp.rs +++ b/tests/by-util/test_cp.rs @@ -2576,6 +2576,146 @@ fn test_cp_preserve_timestamps() { assert_eq!(creation, creation2); } +#[test] +#[cfg(wasi_runner)] +fn test_cp_wasi_preserve_file_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()); + filetime::set_file_times( + at.plus_as_string(TEST_HELLO_WORLD_SOURCE), + previous_atime, + previous_mtime, + ) + .unwrap(); + + ucmd.arg(TEST_HELLO_WORLD_SOURCE) + .arg("--preserve=timestamps") + .arg(TEST_HOW_ARE_YOU_SOURCE) + .succeeds(); + + let metadata = std_fs::metadata(at.plus(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 + ); +} + +#[test] +#[cfg(wasi_runner)] +fn test_cp_wasi_preserve_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(&[ + "-P", + "--progress", + "--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), link_atime); + assert_eq!( + FileTime::from_last_modification_time(&link_metadata), + link_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] +#[cfg(wasi_runner)] +fn test_cp_wasi_preserve_timestamps_with_symbolic_link() { + let (at, mut ucmd) = at_and_ucmd!(); + let ts = time::OffsetDateTime::now_utc(); + let source_atime = FileTime::from_unix_time(ts.unix_timestamp() - 7200, ts.nanosecond()); + let source_mtime = FileTime::from_unix_time(ts.unix_timestamp() - 3600, ts.nanosecond()); + + at.write("source", "contents"); + filetime::set_file_times(at.plus("source"), source_atime, source_mtime).unwrap(); + + ucmd.args(&[ + "--symbolic-link", + "--preserve=timestamps", + "source", + "destination", + ]) + .succeeds(); + + let destination_metadata = std_fs::symlink_metadata(at.plus("destination")).unwrap(); + assert!(destination_metadata.file_type().is_symlink()); + assert_eq!( + FileTime::from_last_access_time(&destination_metadata), + source_atime + ); + assert_eq!( + FileTime::from_last_modification_time(&destination_metadata), + source_mtime + ); + assert_eq!( + std_fs::read_link(at.plus("destination")).unwrap(), + Path::new("source") + ); +} + +#[test] +#[cfg(wasi_runner)] +fn test_cp_wasi_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() { From f0371a0b93d97689b748b7e1b21327c4182637b8 Mon Sep 17 00:00:00 2001 From: Anthony DePasquale Date: Thu, 13 Aug 2026 15:34:47 +0200 Subject: [PATCH 2/5] cp: preserve WASI timestamp work for reference --- .github/workflows/wasi.yml | 2 + src/uu/cp/src/cp.rs | 114 +++++++++++++++++++-------------- src/uu/cp/src/platform/mod.rs | 4 +- src/uu/cp/src/platform/wasi.rs | 97 ++++++++++++++++++++++++++-- tests/by-util/test_cp.rs | 63 ++++++++++++++++++ 5 files changed, 225 insertions(+), 55 deletions(-) diff --git a/.github/workflows/wasi.yml b/.github/workflows/wasi.yml index 14ab140bad1..7e2ff7cbd5a 100644 --- a/.github/workflows/wasi.yml +++ b/.github/workflows/wasi.yml @@ -72,8 +72,10 @@ jobs: test_cp::test_cp_arg_symlink \ test_cp::test_cp_wasi_preserve_dereferenced_symlink_timestamps \ test_cp::test_cp_wasi_preserve_file_timestamps \ + test_cp::test_cp_wasi_preserve_timestamps_through_destination_symlink \ test_cp::test_cp_wasi_preserve_symlink_timestamps \ test_cp::test_cp_wasi_preserve_timestamps_with_symbolic_link \ + test_cp::test_cp_wasi_refreshes_timestamps_for_later_source \ 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/cp.rs b/src/uu/cp/src/cp.rs index 46bedf6c947..b629428f9da 100644 --- a/src/uu/cp/src/cp.rs +++ b/src/uu/cp/src/cp.rs @@ -30,7 +30,7 @@ use thiserror::Error; use platform::copy_on_write; #[cfg(target_os = "wasi")] -use platform::set_timestamps; +use platform::{SourceTimestampSnapshot, SourceTimestamps, TimestampOptions, set_timestamps}; use uucore::backup_control::backup_would_destroy_source; use uucore::display::Quotable; use uucore::error::{UError, UResult, UUsageError, set_exit_code, strip_errno}; @@ -43,6 +43,7 @@ use uucore::{backup_control, update_control}; // These are exposed for projects (e.g. nushell) that want to create an `Options` value, which // requires these enum. pub use uucore::{backup_control::BackupMode, update_control::UpdateMode}; + use uucore::{ format_usage, parser::shortcut_value_parser::ShortcutValueParser, prompt_yes, show_error, show_warning, @@ -1433,12 +1434,14 @@ pub fn copy(sources: &[PathBuf], target: &Path, options: &Options) -> CopyResult let mut copied_destinations: HashSet = HashSet::with_capacity(sources.len()); let mut created_parent_dirs: HashSet = HashSet::new(); #[cfg(target_os = "wasi")] - let initial_source_metadata: Vec<_> = match options.attributes.timestamps { - Preserve::Yes { .. } => sources - .iter() - .map(|source| fs::symlink_metadata(source).ok()) - .collect(), - Preserve::No { .. } => Vec::new(), + let initial_source_timestamps = match (options.progress_bar, options.attributes.timestamps) { + (true, Preserve::Yes { .. }) => Some( + sources + .iter() + .map(|source| SourceTimestampSnapshot::from_path(source).ok()) + .collect::>(), + ), + _ => None, }; let progress_bar = if options.progress_bar { @@ -1472,9 +1475,19 @@ pub fn copy(sources: &[PathBuf], target: &Path, options: &Options) -> CopyResult let dest = construct_dest_path(source, target, target_type, options) .unwrap_or_else(|_| target.to_path_buf()); #[cfg(target_os = "wasi")] - let initial_source_metadata = initial_source_metadata - .get(source_index) - .and_then(Option::as_ref); + let current_source_snapshot; + #[cfg(target_os = "wasi")] + let initial_source_snapshot = match options.attributes.timestamps { + Preserve::Yes { .. } => { + if let Some(snapshots) = initial_source_timestamps.as_ref() { + snapshots.get(source_index).and_then(Option::as_ref) + } else { + current_source_snapshot = SourceTimestampSnapshot::from_path(source).ok(); + current_source_snapshot.as_ref() + } + } + Preserve::No { .. } => None, + }; if FileInformation::from_path(&dest, true).is_ok() && !fs::symlink_metadata(&dest).is_ok_and(|m| m.file_type().is_symlink()) @@ -1512,7 +1525,7 @@ pub fn copy(sources: &[PathBuf], target: &Path, options: &Options) -> CopyResult &mut copied_files, &mut created_parent_dirs, #[cfg(target_os = "wasi")] - initial_source_metadata, + initial_source_snapshot, ) { show_error_if_needed(&error); if !matches!(error, CpError::Skipped(false)) { @@ -1588,7 +1601,7 @@ fn copy_source( copied_destinations: &HashSet, copied_files: &mut HashMap, created_parent_dirs: &mut HashSet, - #[cfg(target_os = "wasi")] initial_source_metadata: Option<&Metadata>, + #[cfg(target_os = "wasi")] initial_source_snapshot: Option<&SourceTimestampSnapshot>, ) -> CopyResult<()> { let source_path = Path::new(&source); if source_path.is_dir() && (options.dereference || !source_path.is_symlink()) { @@ -1618,7 +1631,7 @@ fn copy_source( created_parent_dirs, true, #[cfg(target_os = "wasi")] - initial_source_metadata, + initial_source_snapshot, ); if options.parents { for (x, y) in aligned_ancestors(source, dest.as_path()) { @@ -1820,6 +1833,11 @@ pub(crate) fn copy_attributes( source, dest, &source_metadata, + #[cfg(target_os = "wasi")] + TimestampOptions { + source: None, + no_follow: dest.is_symlink(), + }, attributes, dest_is_freshly_created_dir, skip_selinux_xattr, @@ -1831,6 +1849,7 @@ fn copy_attributes_from_metadata( source: &Path, dest: &Path, source_metadata: &Metadata, + #[cfg(target_os = "wasi")] timestamp_options: TimestampOptions, attributes: &Attributes, dest_is_freshly_created_dir: bool, skip_selinux_xattr: bool, @@ -1961,7 +1980,10 @@ fn copy_attributes_from_metadata( #[cfg(target_os = "wasi")] handle_preserve(attributes.timestamps, || { - set_timestamps(source_metadata, dest).map_err(CpError::from) + let timestamps = timestamp_options + .source + .map_or_else(|| SourceTimestamps::from_metadata(source_metadata), Ok)?; + set_timestamps(timestamps, dest, timestamp_options.no_follow).map_err(CpError::from) })?; #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] @@ -2008,6 +2030,7 @@ fn copy_attributes_after_copy( source: &Path, dest: &Path, #[cfg(target_os = "wasi")] source_metadata: &Metadata, + #[cfg(target_os = "wasi")] timestamp_options: TimestampOptions, attributes: &Attributes, dest_is_freshly_created_dir: bool, skip_selinux_xattr: bool, @@ -2018,6 +2041,7 @@ fn copy_attributes_after_copy( source, dest, source_metadata, + timestamp_options, attributes, dest_is_freshly_created_dir, skip_selinux_xattr, @@ -2587,14 +2611,8 @@ fn copy_file( copied_files: &mut HashMap, created_parent_dirs: &mut HashSet, source_in_command_line: bool, - #[cfg(target_os = "wasi")] initial_source_metadata: Option<&Metadata>, + #[cfg(target_os = "wasi")] initial_source_snapshot: Option<&SourceTimestampSnapshot>, ) -> CopyResult<()> { - #[cfg(target_os = "wasi")] - let source_is_symlink = initial_source_metadata.map_or_else( - || source.is_symlink(), - |metadata| metadata.file_type().is_symlink(), - ); - #[cfg(not(target_os = "wasi"))] let source_is_symlink = source.is_symlink(); let initial_dest_metadata = dest.symlink_metadata().ok(); let dest_is_symlink = initial_dest_metadata @@ -2729,15 +2747,7 @@ fn copy_file( let result = if options.dereference(source_in_command_line) { fs::metadata(source) } else { - #[cfg(target_os = "wasi")] - { - initial_source_metadata - .map_or_else(|| fs::symlink_metadata(source), |m| Ok(m.clone())) - } - #[cfg(not(target_os = "wasi"))] - { - fs::symlink_metadata(source) - } + fs::symlink_metadata(source) }; // this is just for gnu tests compatibility result.map_err(|err| { @@ -2748,6 +2758,11 @@ fn copy_file( })? }; + #[cfg(target_os = "wasi")] + let initial_source_timestamps = initial_source_snapshot.and_then(|snapshot| { + snapshot.current_timestamps(source, options.dereference(source_in_command_line)) + }); + let dest_metadata = dest.symlink_metadata().ok(); let dest_permissions = calculate_dest_permissions( @@ -2771,6 +2786,11 @@ fn copy_file( created_parent_dirs, )?; + let created_symlink_output = + source_metadata.file_type().is_symlink() || options.copy_mode == CopyMode::SymLink; + #[cfg(target_os = "wasi")] + let no_follow_timestamps = created_symlink_output; + if options.verbose && performed_action != PerformedAction::Skipped { print_verbose_output(options.parents, progress_bar, source, dest)?; } @@ -2804,6 +2824,11 @@ fn copy_file( dest, #[cfg(target_os = "wasi")] &source_metadata, + #[cfg(target_os = "wasi")] + TimestampOptions { + source: initial_source_timestamps, + no_follow: no_follow_timestamps, + }, &options.attributes, false, options.set_selinux_context, @@ -2820,6 +2845,11 @@ fn copy_file( dest, #[cfg(target_os = "wasi")] &source_metadata, + #[cfg(target_os = "wasi")] + TimestampOptions { + source: initial_source_timestamps, + no_follow: no_follow_timestamps, + }, &options.attributes, false, options.set_selinux_context, @@ -2828,7 +2858,9 @@ 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(); + if !created_symlink_output { + fs::File::create(dest).map(|f| f.set_len(0)).ok(); + } })?; #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] @@ -2951,14 +2983,7 @@ fn copy_helper( } if source_metadata.is_symlink() { - copy_link( - source, - dest, - #[cfg(target_os = "wasi")] - source_metadata, - symlinked_files, - options, - )?; + copy_link(source, dest, 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 @@ -3037,7 +3062,6 @@ fn copy_node( fn copy_link( source: &Path, dest: &Path, - #[cfg(target_os = "wasi")] source_metadata: &Metadata, symlinked_files: &mut HashSet, options: &Options, ) -> CopyResult<()> { @@ -3049,15 +3073,7 @@ fn copy_link( delete_path(dest, options)?; } symlink_file(&link, dest, symlinked_files)?; - copy_attributes_after_copy( - source, - dest, - #[cfg(target_os = "wasi")] - source_metadata, - &options.attributes, - false, - options.set_selinux_context, - ) + Ok(()) } /// Generate an error message if `target` is not the correct `target_type` diff --git a/src/uu/cp/src/platform/mod.rs b/src/uu/cp/src/platform/mod.rs index 05351362d80..09e77574354 100644 --- a/src/uu/cp/src/platform/mod.rs +++ b/src/uu/cp/src/platform/mod.rs @@ -37,4 +37,6 @@ pub(crate) use self::other::copy_on_write; #[cfg(target_os = "wasi")] mod wasi; #[cfg(target_os = "wasi")] -pub(crate) use self::wasi::{create_symlink, set_timestamps}; +pub(crate) use self::wasi::{ + SourceTimestampSnapshot, SourceTimestamps, TimestampOptions, create_symlink, set_timestamps, +}; diff --git a/src/uu/cp/src/platform/wasi.rs b/src/uu/cp/src/platform/wasi.rs index 6722fed7ecb..ab12ee25e1f 100644 --- a/src/uu/cp/src/platform/wasi.rs +++ b/src/uu/cp/src/platform/wasi.rs @@ -8,18 +8,105 @@ use std::io; use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; -use rustix::fs::{AtFlags, CWD, Timespec, Timestamps, utimensat}; +use rustix::fs::{AtFlags, CWD, FileType, Timespec, Timestamps, lstat, stat, utimensat}; + +pub(crate) struct SourceTimestampSnapshot { + timestamps: SourceTimestamps, + device: u64, + inode: u64, + file_type: FileType, +} + +impl SourceTimestampSnapshot { + pub(crate) fn from_path(path: &Path) -> io::Result { + let stat = lstat(path)?; + Ok(Self { + timestamps: SourceTimestamps::from_stat(&stat), + device: stat.st_dev, + inode: stat.st_ino, + file_type: FileType::from_raw_mode(stat.st_mode), + }) + } + + pub(crate) fn current_timestamps( + &self, + path: &Path, + dereference: bool, + ) -> Option { + let stat = if dereference { + stat(path).ok()? + } else { + lstat(path).ok()? + }; + let file_type = FileType::from_raw_mode(stat.st_mode); + if stat.st_dev != self.device + || stat.st_ino != self.inode + || file_type != self.file_type + || !self + .timestamps + .matches_stat(&stat, !self.file_type.is_symlink()) + { + return None; + } + Some(self.timestamps) + } +} + +#[derive(Clone, Copy)] +pub(crate) struct SourceTimestamps { + accessed: SystemTime, + modified: SystemTime, +} + +#[derive(Clone, Copy)] +pub(crate) struct TimestampOptions { + pub(crate) source: Option, + pub(crate) no_follow: bool, +} + +impl SourceTimestamps { + pub(crate) fn from_metadata(metadata: &Metadata) -> io::Result { + Ok(Self { + accessed: metadata.accessed()?, + modified: metadata.modified()?, + }) + } + + fn from_stat(stat: &rustix::fs::Stat) -> Self { + Self { + accessed: UNIX_EPOCH + + std::time::Duration::new(stat.st_atim.tv_sec as u64, stat.st_atim.tv_nsec as u32), + modified: UNIX_EPOCH + + std::time::Duration::new(stat.st_mtim.tv_sec as u64, stat.st_mtim.tv_nsec as u32), + } + } + + fn matches_stat(&self, stat: &rustix::fs::Stat, compare_accessed: bool) -> bool { + let timestamps = Self::from_stat(stat); + self.modified == timestamps.modified + && (!compare_accessed || self.accessed == timestamps.accessed) + } +} pub(crate) fn create_symlink(source: &Path, dest: &Path) -> io::Result<()> { rustix::fs::symlink(source, dest).map_err(io::Error::from) } -pub(crate) fn set_timestamps(source_metadata: &Metadata, dest: &Path) -> io::Result<()> { +pub(crate) fn set_timestamps( + source_timestamps: SourceTimestamps, + dest: &Path, + no_follow: bool, +) -> io::Result<()> { let timestamps = 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 no_follow { + AtFlags::SYMLINK_NOFOLLOW + } else { + AtFlags::empty() }; - utimensat(CWD, dest, ×tamps, AtFlags::SYMLINK_NOFOLLOW).map_err(io::Error::from) + utimensat(CWD, dest, ×tamps, flags).map_err(io::Error::from) } fn to_timespec(time: SystemTime) -> io::Result { diff --git a/tests/by-util/test_cp.rs b/tests/by-util/test_cp.rs index 03cc6232f31..157746e49ce 100644 --- a/tests/by-util/test_cp.rs +++ b/tests/by-util/test_cp.rs @@ -2716,6 +2716,69 @@ fn test_cp_wasi_preserve_dereferenced_symlink_timestamps() { ); } +#[test] +#[cfg(wasi_runner)] +fn test_cp_wasi_preserve_timestamps_through_destination_symlink() { + let (at, mut ucmd) = at_and_ucmd!(); + let ts = time::OffsetDateTime::now_utc(); + let source_atime = FileTime::from_unix_time(ts.unix_timestamp() - 7200, ts.nanosecond()); + let source_mtime = FileTime::from_unix_time(ts.unix_timestamp() - 3600, ts.nanosecond()); + + at.write("source", "new contents"); + at.write("target", "old contents"); + at.relative_symlink_file("target", "destination"); + filetime::set_file_times(at.plus("source"), source_atime, source_mtime).unwrap(); + + ucmd.args(&["--preserve=timestamps", "source", "destination"]) + .succeeds(); + + assert!(at.is_symlink("destination")); + let target_metadata = std_fs::metadata(at.plus("target")).unwrap(); + assert_eq!( + FileTime::from_last_access_time(&target_metadata), + source_atime + ); + assert_eq!( + FileTime::from_last_modification_time(&target_metadata), + source_mtime + ); + assert_eq!(at.read("target"), "new contents"); +} + +#[test] +#[cfg(wasi_runner)] +fn test_cp_wasi_refreshes_timestamps_for_later_source() { + let (at, mut ucmd) = at_and_ucmd!(); + let ts = time::OffsetDateTime::now_utc(); + let first_atime = FileTime::from_unix_time(ts.unix_timestamp() - 14_400, ts.nanosecond()); + let second_atime = FileTime::from_unix_time(ts.unix_timestamp() - 7200, ts.nanosecond()); + let shared_mtime = FileTime::from_unix_time(ts.unix_timestamp() - 3600, ts.nanosecond()); + + at.write("first", "first contents"); + at.write("second", "second contents"); + at.mkdir("destination"); + std_fs::hard_link(at.plus("second"), at.plus("destination/first")).unwrap(); + filetime::set_file_times(at.plus("first"), first_atime, shared_mtime).unwrap(); + filetime::set_file_times(at.plus("second"), second_atime, shared_mtime).unwrap(); + + ucmd.args(&[ + "--progress", + "--preserve=timestamps", + "first", + "second", + "destination", + ]) + .succeeds(); + + let metadata = std_fs::metadata(at.plus("destination/second")).unwrap(); + assert_eq!(FileTime::from_last_access_time(&metadata), first_atime); + assert_eq!( + FileTime::from_last_modification_time(&metadata), + shared_mtime + ); + assert_eq!(at.read("destination/second"), "first contents"); +} + #[test] #[cfg(any(target_os = "linux", target_os = "android"))] fn test_cp_no_preserve_timestamps() { From b0fb181f7a66318f308add25d706d76822f0799b Mon Sep 17 00:00:00 2001 From: Anthony DePasquale Date: Thu, 13 Aug 2026 16:15:38 +0200 Subject: [PATCH 3/5] cp: preserve file and symlink timestamps on WASI --- src/uu/cp/src/cp.rs | 152 +++++++++++++-------------------- src/uu/cp/src/platform/mod.rs | 4 +- src/uu/cp/src/platform/wasi.rs | 73 ++++++++-------- tests/by-util/test_cp.rs | 70 +++++---------- 4 files changed, 118 insertions(+), 181 deletions(-) diff --git a/src/uu/cp/src/cp.rs b/src/uu/cp/src/cp.rs index b629428f9da..871958200ff 100644 --- a/src/uu/cp/src/cp.rs +++ b/src/uu/cp/src/cp.rs @@ -30,7 +30,7 @@ use thiserror::Error; use platform::copy_on_write; #[cfg(target_os = "wasi")] -use platform::{SourceTimestampSnapshot, SourceTimestamps, TimestampOptions, set_timestamps}; +use platform::{SourceTimes, SourceTimesSnapshot, set_timestamps}; use uucore::backup_control::backup_would_destroy_source; use uucore::display::Quotable; use uucore::error::{UError, UResult, UUsageError, set_exit_code, strip_errno}; @@ -54,6 +54,13 @@ use crate::copydir::copy_directory; mod copydir; mod platform; +#[cfg(target_os = "wasi")] +#[derive(Clone, Copy)] +struct WasiTimestampContext { + captured_source: Option, + follow_destination: bool, +} + #[derive(Debug, Error)] pub enum CpError { /// Simple [`io::Error`] wrapper @@ -1434,11 +1441,13 @@ pub fn copy(sources: &[PathBuf], target: &Path, options: &Options) -> CopyResult let mut copied_destinations: HashSet = HashSet::with_capacity(sources.len()); let mut created_parent_dirs: HashSet = HashSet::new(); #[cfg(target_os = "wasi")] - let initial_source_timestamps = match (options.progress_bar, options.attributes.timestamps) { + let initial_source_snapshots = match (options.progress_bar, options.attributes.timestamps) { (true, Preserve::Yes { .. }) => Some( sources .iter() - .map(|source| SourceTimestampSnapshot::from_path(source).ok()) + .map(|source| { + SourceTimesSnapshot::from_path(source, options.dereference(true)).ok() + }) .collect::>(), ), _ => None, @@ -1475,17 +1484,11 @@ pub fn copy(sources: &[PathBuf], target: &Path, options: &Options) -> CopyResult let dest = construct_dest_path(source, target, target_type, options) .unwrap_or_else(|_| target.to_path_buf()); #[cfg(target_os = "wasi")] - let current_source_snapshot; - #[cfg(target_os = "wasi")] let initial_source_snapshot = match options.attributes.timestamps { - Preserve::Yes { .. } => { - if let Some(snapshots) = initial_source_timestamps.as_ref() { - snapshots.get(source_index).and_then(Option::as_ref) - } else { - current_source_snapshot = SourceTimestampSnapshot::from_path(source).ok(); - current_source_snapshot.as_ref() - } - } + Preserve::Yes { .. } => match initial_source_snapshots.as_ref() { + Some(snapshots) => snapshots.get(source_index).copied().flatten(), + None => SourceTimesSnapshot::from_path(source, options.dereference(true)).ok(), + }, Preserve::No { .. } => None, }; @@ -1601,7 +1604,7 @@ fn copy_source( copied_destinations: &HashSet, copied_files: &mut HashMap, created_parent_dirs: &mut HashSet, - #[cfg(target_os = "wasi")] initial_source_snapshot: Option<&SourceTimestampSnapshot>, + #[cfg(target_os = "wasi")] initial_source_snapshot: Option, ) -> CopyResult<()> { let source_path = Path::new(&source); if source_path.is_dir() && (options.dereference || !source_path.is_symlink()) { @@ -1834,9 +1837,9 @@ pub(crate) fn copy_attributes( dest, &source_metadata, #[cfg(target_os = "wasi")] - TimestampOptions { - source: None, - no_follow: dest.is_symlink(), + WasiTimestampContext { + captured_source: None, + follow_destination: !dest.is_symlink(), }, attributes, dest_is_freshly_created_dir, @@ -1849,7 +1852,7 @@ fn copy_attributes_from_metadata( source: &Path, dest: &Path, source_metadata: &Metadata, - #[cfg(target_os = "wasi")] timestamp_options: TimestampOptions, + #[cfg(target_os = "wasi")] timestamp_context: WasiTimestampContext, attributes: &Attributes, dest_is_freshly_created_dir: bool, skip_selinux_xattr: bool, @@ -1980,10 +1983,11 @@ fn copy_attributes_from_metadata( #[cfg(target_os = "wasi")] handle_preserve(attributes.timestamps, || { - let timestamps = timestamp_options - .source - .map_or_else(|| SourceTimestamps::from_metadata(source_metadata), Ok)?; - set_timestamps(timestamps, dest, timestamp_options.no_follow).map_err(CpError::from) + let source_times = timestamp_context + .captured_source + .map_or_else(|| SourceTimes::from_metadata(source_metadata), Ok)?; + set_timestamps(source_times, dest, timestamp_context.follow_destination) + .map_err(CpError::from) })?; #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] @@ -2026,40 +2030,6 @@ fn copy_attributes_from_metadata( Ok(()) } -fn copy_attributes_after_copy( - source: &Path, - dest: &Path, - #[cfg(target_os = "wasi")] source_metadata: &Metadata, - #[cfg(target_os = "wasi")] timestamp_options: TimestampOptions, - attributes: &Attributes, - dest_is_freshly_created_dir: bool, - skip_selinux_xattr: bool, -) -> CopyResult<()> { - #[cfg(target_os = "wasi")] - { - copy_attributes_from_metadata( - source, - dest, - source_metadata, - timestamp_options, - attributes, - dest_is_freshly_created_dir, - skip_selinux_xattr, - ) - } - - #[cfg(not(target_os = "wasi"))] - { - copy_attributes( - source, - dest, - attributes, - dest_is_freshly_created_dir, - skip_selinux_xattr, - ) - } -} - fn symlink_file( source: &Path, dest: &Path, @@ -2611,7 +2581,7 @@ fn copy_file( copied_files: &mut HashMap, created_parent_dirs: &mut HashSet, source_in_command_line: bool, - #[cfg(target_os = "wasi")] initial_source_snapshot: Option<&SourceTimestampSnapshot>, + #[cfg(target_os = "wasi")] initial_source_snapshot: Option, ) -> CopyResult<()> { let source_is_symlink = source.is_symlink(); let initial_dest_metadata = dest.symlink_metadata().ok(); @@ -2759,8 +2729,8 @@ fn copy_file( }; #[cfg(target_os = "wasi")] - let initial_source_timestamps = initial_source_snapshot.and_then(|snapshot| { - snapshot.current_timestamps(source, options.dereference(source_in_command_line)) + let captured_source_times = initial_source_snapshot.and_then(|snapshot| { + snapshot.times_if_unchanged(source, options.dereference(source_in_command_line)) }); let dest_metadata = dest.symlink_metadata().ok(); @@ -2789,7 +2759,10 @@ fn copy_file( let created_symlink_output = source_metadata.file_type().is_symlink() || options.copy_mode == CopyMode::SymLink; #[cfg(target_os = "wasi")] - let no_follow_timestamps = created_symlink_output; + let timestamp_context = WasiTimestampContext { + captured_source: captured_source_times, + follow_destination: !created_symlink_output, + }; if options.verbose && performed_action != PerformedAction::Skipped { print_verbose_output(options.parents, progress_bar, source, dest)?; @@ -2812,48 +2785,45 @@ fn copy_file( fs::set_permissions(dest, dest_permissions).ok(); } - let copy_attributes_result = if options.dereference(source_in_command_line) { - // Try to canonicalize, but if it fails (e.g., due to inaccessible parent directories), - // fall back to the original source path - let src_for_attrs = canonicalize(source, MissingHandling::Normal, ResolveMode::Physical) - .ok() - .filter(|p| p.exists()) - .unwrap_or_else(|| source.to_path_buf()); - copy_attributes_after_copy( - &src_for_attrs, - dest, - #[cfg(target_os = "wasi")] - &source_metadata, - #[cfg(target_os = "wasi")] - TimestampOptions { - source: initial_source_timestamps, - no_follow: no_follow_timestamps, - }, - &options.attributes, - false, - options.set_selinux_context, - ) - } else if source_is_stream && !source.exists() { + let copy_attributes_result = if source_is_stream && !source.exists() { // Some stream files may not exist after we have copied it, // like anonymous pipes. Thus, we can't really copy its // attributes. However, this is already handled in the stream // copy function (see `copy_stream` under platform/linux.rs). Ok(()) } else { - copy_attributes_after_copy( - source, + // Try to canonicalize, but if it fails (e.g., due to inaccessible parent directories), + // fall back to the original source path + let source_for_attributes = if options.dereference(source_in_command_line) { + canonicalize(source, MissingHandling::Normal, ResolveMode::Physical) + .ok() + .filter(|path| path.exists()) + .unwrap_or_else(|| source.to_path_buf()) + } else { + source.to_path_buf() + }; + + #[cfg(target_os = "wasi")] + let result = copy_attributes_from_metadata( + &source_for_attributes, dest, - #[cfg(target_os = "wasi")] &source_metadata, - #[cfg(target_os = "wasi")] - TimestampOptions { - source: initial_source_timestamps, - no_follow: no_follow_timestamps, - }, + timestamp_context, &options.attributes, false, options.set_selinux_context, - ) + ); + + #[cfg(not(target_os = "wasi"))] + let result = copy_attributes( + &source_for_attributes, + dest, + &options.attributes, + false, + options.set_selinux_context, + ); + + result }; // GNU cp truncates the destination when a required attribute cannot be preserved diff --git a/src/uu/cp/src/platform/mod.rs b/src/uu/cp/src/platform/mod.rs index 09e77574354..812842e9591 100644 --- a/src/uu/cp/src/platform/mod.rs +++ b/src/uu/cp/src/platform/mod.rs @@ -37,6 +37,4 @@ pub(crate) use self::other::copy_on_write; #[cfg(target_os = "wasi")] mod wasi; #[cfg(target_os = "wasi")] -pub(crate) use self::wasi::{ - SourceTimestampSnapshot, SourceTimestamps, TimestampOptions, create_symlink, set_timestamps, -}; +pub(crate) use self::wasi::{SourceTimes, SourceTimesSnapshot, create_symlink, set_timestamps}; diff --git a/src/uu/cp/src/platform/wasi.rs b/src/uu/cp/src/platform/wasi.rs index ab12ee25e1f..73baa456b4f 100644 --- a/src/uu/cp/src/platform/wasi.rs +++ b/src/uu/cp/src/platform/wasi.rs @@ -10,29 +10,30 @@ use std::time::{SystemTime, UNIX_EPOCH}; use rustix::fs::{AtFlags, CWD, FileType, Timespec, Timestamps, lstat, stat, utimensat}; -pub(crate) struct SourceTimestampSnapshot { - timestamps: SourceTimestamps, +#[derive(Clone, Copy)] +pub(crate) struct SourceTimesSnapshot { + times: SourceTimes, device: u64, inode: u64, file_type: FileType, } -impl SourceTimestampSnapshot { - pub(crate) fn from_path(path: &Path) -> io::Result { - let stat = lstat(path)?; +impl SourceTimesSnapshot { + pub(crate) fn from_path(path: &Path, dereference: bool) -> io::Result { + let stat = if dereference { + stat(path)? + } else { + lstat(path)? + }; Ok(Self { - timestamps: SourceTimestamps::from_stat(&stat), + times: SourceTimes::from_stat(&stat), device: stat.st_dev, inode: stat.st_ino, file_type: FileType::from_raw_mode(stat.st_mode), }) } - pub(crate) fn current_timestamps( - &self, - path: &Path, - dereference: bool, - ) -> Option { + pub(crate) fn times_if_unchanged(&self, path: &Path, dereference: bool) -> Option { let stat = if dereference { stat(path).ok()? } else { @@ -42,42 +43,38 @@ impl SourceTimestampSnapshot { if stat.st_dev != self.device || stat.st_ino != self.inode || file_type != self.file_type - || !self - .timestamps - .matches_stat(&stat, !self.file_type.is_symlink()) + || !self.times.matches_stat(&stat, !self.file_type.is_symlink()) { return None; } - Some(self.timestamps) + Some(self.times) } } #[derive(Clone, Copy)] -pub(crate) struct SourceTimestamps { - accessed: SystemTime, - modified: SystemTime, +pub(crate) struct SourceTimes { + accessed: Timespec, + modified: Timespec, } -#[derive(Clone, Copy)] -pub(crate) struct TimestampOptions { - pub(crate) source: Option, - pub(crate) no_follow: bool, -} - -impl SourceTimestamps { +impl SourceTimes { pub(crate) fn from_metadata(metadata: &Metadata) -> io::Result { Ok(Self { - accessed: metadata.accessed()?, - modified: metadata.modified()?, + accessed: to_timespec(metadata.accessed()?)?, + modified: to_timespec(metadata.modified()?)?, }) } fn from_stat(stat: &rustix::fs::Stat) -> Self { Self { - accessed: UNIX_EPOCH - + std::time::Duration::new(stat.st_atim.tv_sec as u64, stat.st_atim.tv_nsec as u32), - modified: UNIX_EPOCH - + std::time::Duration::new(stat.st_mtim.tv_sec as u64, stat.st_mtim.tv_nsec as u32), + accessed: Timespec { + tv_sec: stat.st_atim.tv_sec, + tv_nsec: stat.st_atim.tv_nsec, + }, + modified: Timespec { + tv_sec: stat.st_mtim.tv_sec, + tv_nsec: stat.st_mtim.tv_nsec, + }, } } @@ -93,18 +90,18 @@ pub(crate) fn create_symlink(source: &Path, dest: &Path) -> io::Result<()> { } pub(crate) fn set_timestamps( - source_timestamps: SourceTimestamps, + source_times: SourceTimes, dest: &Path, - no_follow: bool, + follow_destination: bool, ) -> io::Result<()> { let timestamps = Timestamps { - last_access: to_timespec(source_timestamps.accessed)?, - last_modification: to_timespec(source_timestamps.modified)?, + last_access: source_times.accessed, + last_modification: source_times.modified, }; - let flags = if no_follow { - AtFlags::SYMLINK_NOFOLLOW - } else { + let flags = if follow_destination { AtFlags::empty() + } else { + AtFlags::SYMLINK_NOFOLLOW }; utimensat(CWD, dest, ×tamps, flags).map_err(io::Error::from) } diff --git a/tests/by-util/test_cp.rs b/tests/by-util/test_cp.rs index 157746e49ce..a4e9d5bb76f 100644 --- a/tests/by-util/test_cp.rs +++ b/tests/by-util/test_cp.rs @@ -2576,6 +2576,12 @@ fn test_cp_preserve_timestamps() { assert_eq!(creation, creation2); } +#[cfg(wasi_runner)] +fn assert_timestamps(metadata: &std_fs::Metadata, accessed: FileTime, modified: FileTime) { + assert_eq!(FileTime::from_last_access_time(metadata), accessed); + assert_eq!(FileTime::from_last_modification_time(metadata), modified); +} + #[test] #[cfg(wasi_runner)] fn test_cp_wasi_preserve_file_timestamps() { @@ -2596,11 +2602,7 @@ fn test_cp_wasi_preserve_file_timestamps() { .succeeds(); let metadata = std_fs::metadata(at.plus(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_timestamps(&metadata, previous_atime, previous_mtime); } #[test] @@ -2629,25 +2631,14 @@ fn test_cp_wasi_preserve_symlink_timestamps() { 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), link_atime); - assert_eq!( - FileTime::from_last_modification_time(&link_metadata), - link_mtime - ); + assert_timestamps(&link_metadata, link_atime, link_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 - ); + assert_timestamps(&target_metadata, target_atime, target_mtime); } #[test] @@ -2671,14 +2662,7 @@ fn test_cp_wasi_preserve_timestamps_with_symbolic_link() { let destination_metadata = std_fs::symlink_metadata(at.plus("destination")).unwrap(); assert!(destination_metadata.file_type().is_symlink()); - assert_eq!( - FileTime::from_last_access_time(&destination_metadata), - source_atime - ); - assert_eq!( - FileTime::from_last_modification_time(&destination_metadata), - source_mtime - ); + assert_timestamps(&destination_metadata, source_atime, source_mtime); assert_eq!( std_fs::read_link(at.plus("destination")).unwrap(), Path::new("source") @@ -2700,20 +2684,19 @@ fn test_cp_wasi_preserve_dereferenced_symlink_timestamps() { 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(); + ucmd.args(&[ + "-L", + "--progress", + "--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 - ); + assert_timestamps(&destination_metadata, target_atime, target_mtime); } #[test] @@ -2734,14 +2717,7 @@ fn test_cp_wasi_preserve_timestamps_through_destination_symlink() { assert!(at.is_symlink("destination")); let target_metadata = std_fs::metadata(at.plus("target")).unwrap(); - assert_eq!( - FileTime::from_last_access_time(&target_metadata), - source_atime - ); - assert_eq!( - FileTime::from_last_modification_time(&target_metadata), - source_mtime - ); + assert_timestamps(&target_metadata, source_atime, source_mtime); assert_eq!(at.read("target"), "new contents"); } @@ -2771,11 +2747,7 @@ fn test_cp_wasi_refreshes_timestamps_for_later_source() { .succeeds(); let metadata = std_fs::metadata(at.plus("destination/second")).unwrap(); - assert_eq!(FileTime::from_last_access_time(&metadata), first_atime); - assert_eq!( - FileTime::from_last_modification_time(&metadata), - shared_mtime - ); + assert_timestamps(&metadata, first_atime, shared_mtime); assert_eq!(at.read("destination/second"), "first contents"); } From 95e5d63effa5dd15675ae5b5402186a95021f03b Mon Sep 17 00:00:00 2001 From: Anthony DePasquale Date: Thu, 13 Aug 2026 17:05:37 +0200 Subject: [PATCH 4/5] cp: complete WASI metadata handling --- .github/workflows/wasi.yml | 9 +- src/uu/cp/src/copydir.rs | 78 ++++++++- src/uu/cp/src/cp.rs | 154 ++++++++++++++--- src/uu/cp/src/platform/mod.rs | 9 +- src/uu/cp/src/platform/wasi.rs | 204 ++++++++++++++++++++++- src/uucore/src/lib/features.rs | 2 +- src/uucore/src/lib/features/safe_copy.rs | 2 +- src/uucore/src/lib/lib.rs | 2 +- tests/by-util/test_cp.rs | 145 +++++++++++++++- 9 files changed, 563 insertions(+), 42 deletions(-) diff --git a/.github/workflows/wasi.yml b/.github/workflows/wasi.yml index 7e2ff7cbd5a..d481b4c588b 100644 --- a/.github/workflows/wasi.yml +++ b/.github/workflows/wasi.yml @@ -42,8 +42,8 @@ jobs: echo "$HOME/.wasmtime/bin" >> $GITHUB_PATH - name: Run unit tests env: - CARGO_TARGET_WASM32_WASIP1_RUNNER: wasmtime - CARGO_TARGET_WASM32_WASIP2_RUNNER: wasmtime + CARGO_TARGET_WASM32_WASIP1_RUNNER: wasmtime --dir=. + CARGO_TARGET_WASM32_WASIP2_RUNNER: wasmtime --dir=. run: | # Get all utilities and exclude ones that don't compile for ${{ matrix.job.target }} EXCLUDE="df|du|env|expr|more|tac|test" @@ -72,10 +72,15 @@ jobs: test_cp::test_cp_arg_symlink \ test_cp::test_cp_wasi_preserve_dereferenced_symlink_timestamps \ test_cp::test_cp_wasi_preserve_file_timestamps \ + test_cp::test_cp_wasi_preserve_overlapping_recursive_source_timestamps \ + test_cp::test_cp_wasi_preserve_recursive_directory_timestamps \ test_cp::test_cp_wasi_preserve_timestamps_through_destination_symlink \ test_cp::test_cp_wasi_preserve_symlink_timestamps \ test_cp::test_cp_wasi_preserve_timestamps_with_symbolic_link \ test_cp::test_cp_wasi_refreshes_timestamps_for_later_source \ + test_cp::test_cp_wasi_recursive_copy_ignores_unsupported_optional_mode \ + test_cp::test_cp_update_skip_preserves_destination_timestamps \ + test_cp::test_cp_recursive_update_continues_after_skipped_file \ 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 75ef93330ac..c8538baf863 100644 --- a/src/uu/cp/src/copydir.rs +++ b/src/uu/cp/src/copydir.rs @@ -32,6 +32,11 @@ use crate::{ CopyMode, CopyResult, CpError, Options, aligned_ancestors, context_for, copy_attributes, copy_file, }; +#[cfg(target_os = "wasi")] +use crate::{ + copy_attributes_with_source_times, + platform::{DirectoryTimesSnapshot, DirectoryTimesTracker}, +}; /// Represents a directory that needs permission fixup after copying its contents. struct DirNeedingPermissions { @@ -41,6 +46,40 @@ struct DirNeedingPermissions { dest: PathBuf, /// Whether this directory was freshly created by the copy operation was_created: bool, + /// Timestamps captured before recursive traversal opened the directory. + #[cfg(target_os = "wasi")] + source_times: Option, +} + +fn copy_directory_attributes( + source: &Path, + dest: &Path, + attributes: &crate::Attributes, + dest_is_freshly_created_dir: bool, + skip_selinux_xattr: bool, + #[cfg(target_os = "wasi")] source_times: Option, +) -> CopyResult<()> { + #[cfg(target_os = "wasi")] + if let Some(source_times) = + source_times.and_then(|snapshot| snapshot.times_if_unchanged(source)) + { + return copy_attributes_with_source_times( + source, + dest, + source_times, + attributes, + dest_is_freshly_created_dir, + skip_selinux_xattr, + ); + } + + copy_attributes( + source, + dest, + attributes, + dest_is_freshly_created_dir, + skip_selinux_xattr, + ) } /// Ensure a Windows path starts with a `\\?`. @@ -323,6 +362,9 @@ fn copy_direntry( None, ) { + if matches!(err, CpError::Skipped(false)) { + return Ok(false); + } if preserve_hard_links { if !source_is_symlink { return Err(err); @@ -374,6 +416,7 @@ pub(crate) fn copy_directory( copied_files: &mut HashMap, created_parent_dirs: &mut HashSet, source_in_command_line: bool, + #[cfg(target_os = "wasi")] initial_directory_times: Option, ) -> 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() { @@ -468,6 +511,17 @@ 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(); + #[cfg(target_os = "wasi")] + let mut directory_times = initial_directory_times.or_else(|| { + matches!(options.attributes.timestamps, crate::Preserve::Yes { .. }).then(|| { + DirectoryTimesTracker::new( + root, + options.dereference(source_in_command_line), + options.dereference, + ) + }) + }); + // Traverse the contents of the directory, copying each one. for direntry_result in WalkDir::new(root) .same_file_system(options.one_file_system) @@ -485,6 +539,16 @@ pub(crate) fn copy_directory( } Err(_) => (direntry_type.is_symlink(), direntry_type.is_dir()), }; + #[cfg(target_os = "wasi")] + let source_times = directory_times + .as_mut() + .and_then(|tracker| tracker.take(direntry_path, direntry.depth())); + #[cfg(target_os = "wasi")] + if (entry_is_dir_no_follow || (options.dereference && direntry_path.is_dir())) + && let Some(tracker) = directory_times.as_mut() + { + tracker.capture_children(direntry_path); + } let entry = Entry::new(&context, direntry_path, options.no_target_dir)?; let created = copy_direntry( @@ -516,12 +580,14 @@ 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( + copy_directory_attributes( &entry.source_absolute, &entry.local_to_target, &options.attributes, false, options.set_selinux_context, + #[cfg(target_os = "wasi")] + source_times, )?; continue; } @@ -530,6 +596,8 @@ pub(crate) fn copy_directory( source: entry.source_absolute.clone(), dest: entry.local_to_target.clone(), was_created: created, + #[cfg(target_os = "wasi")] + source_times, }); // If true, last_iter is not a parent of this iter. @@ -559,12 +627,14 @@ pub(crate) fn copy_directory( let src = direntry_path.join(p); let entry = Entry::new(&context, &src, options.no_target_dir)?; - copy_attributes( + copy_directory_attributes( &entry.source_absolute, &entry.local_to_target, &options.attributes, false, options.set_selinux_context, + #[cfg(target_os = "wasi")] + None, )?; } } @@ -581,12 +651,14 @@ 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( + copy_directory_attributes( &dir.source, &dir.dest, &options.attributes, dir.was_created, options.set_selinux_context, + #[cfg(target_os = "wasi")] + dir.source_times, )?; #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] diff --git a/src/uu/cp/src/cp.rs b/src/uu/cp/src/cp.rs index 871958200ff..c3e1b7f5f06 100644 --- a/src/uu/cp/src/cp.rs +++ b/src/uu/cp/src/cp.rs @@ -28,9 +28,11 @@ use indicatif::{ProgressBar, ProgressStyle}; use nix::sys::stat::{Mode, SFlag, dev_t, mknod as nix_mknod, mode_t}; use thiserror::Error; +#[cfg(target_os = "wasi")] +use platform::DirectoryTimesTracker; use platform::copy_on_write; #[cfg(target_os = "wasi")] -use platform::{SourceTimes, SourceTimesSnapshot, set_timestamps}; +use platform::{SourceTimes, SourceTimesSnapshot, is_optional_metadata_error, set_timestamps}; use uucore::backup_control::backup_would_destroy_source; use uucore::display::Quotable; use uucore::error::{UError, UResult, UUsageError, set_exit_code, strip_errno}; @@ -1376,17 +1378,22 @@ fn parse_path_args( Ok((paths, target)) } -/// 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)] - const EOPNOTSUPP: i32 = libc::EOPNOTSUPP; - #[cfg(not(unix))] - const EOPNOTSUPP: i32 = 95; +/// Check whether optional metadata preservation is unsupported by the platform. +fn is_unsupported_metadata_error(error: &CpError) -> bool { + let (CpError::IoErr(error) | CpError::IoErrContext(error, _)) = error else { + return false; + }; - match error { - CpError::IoErr(e) | CpError::IoErrContext(e, _) => e.raw_os_error() == Some(EOPNOTSUPP), - _ => false, + #[cfg(target_os = "wasi")] + return is_optional_metadata_error(error); + + #[cfg(not(target_os = "wasi"))] + { + #[cfg(unix)] + const EOPNOTSUPP: i32 = libc::EOPNOTSUPP; + #[cfg(not(unix))] + const EOPNOTSUPP: i32 = 95; + error.raw_os_error() == Some(EOPNOTSUPP) } } @@ -1452,16 +1459,33 @@ pub fn copy(sources: &[PathBuf], target: &Path, options: &Options) -> CopyResult ), _ => None, }; + #[cfg(target_os = "wasi")] + let mut initial_directory_times = match ( + options.progress_bar && options.recursive, + options.attributes.timestamps, + ) { + (true, Preserve::Yes { .. }) => Some(DirectoryTimesTracker::for_roots( + sources, + options.dereference(true), + options.dereference, + )), + _ => None, + }; let progress_bar = if options.progress_bar { - let pb = ProgressBar::new(disk_usage(sources, options.recursive)?) - .with_style( - ProgressStyle::with_template( - "{msg}: [{elapsed_precise}] {wide_bar} {bytes:>7}/{total_bytes:7}", - ) - .unwrap(), + let pb = ProgressBar::new(disk_usage( + sources, + options.recursive, + #[cfg(target_os = "wasi")] + initial_directory_times.as_deref_mut(), + )?) + .with_style( + ProgressStyle::with_template( + "{msg}: [{elapsed_precise}] {wide_bar} {bytes:>7}/{total_bytes:7}", ) - .with_message("cp"); + .unwrap(), + ) + .with_message("cp"); pb.tick(); Some(pb) } else { @@ -1491,6 +1515,11 @@ pub fn copy(sources: &[PathBuf], target: &Path, options: &Options) -> CopyResult }, Preserve::No { .. } => None, }; + #[cfg(target_os = "wasi")] + let initial_directory_times = initial_directory_times + .as_mut() + .and_then(|trackers| trackers.get_mut(source_index)) + .and_then(Option::take); if FileInformation::from_path(&dest, true).is_ok() && !fs::symlink_metadata(&dest).is_ok_and(|m| m.file_type().is_symlink()) @@ -1529,6 +1558,8 @@ pub fn copy(sources: &[PathBuf], target: &Path, options: &Options) -> CopyResult &mut created_parent_dirs, #[cfg(target_os = "wasi")] initial_source_snapshot, + #[cfg(target_os = "wasi")] + initial_directory_times, ) { show_error_if_needed(&error); if !matches!(error, CpError::Skipped(false)) { @@ -1605,6 +1636,7 @@ fn copy_source( copied_files: &mut HashMap, created_parent_dirs: &mut HashSet, #[cfg(target_os = "wasi")] initial_source_snapshot: Option, + #[cfg(target_os = "wasi")] initial_directory_times: Option, ) -> CopyResult<()> { let source_path = Path::new(&source); if source_path.is_dir() && (options.dereference || !source_path.is_symlink()) { @@ -1619,6 +1651,8 @@ fn copy_source( copied_files, created_parent_dirs, true, + #[cfg(target_os = "wasi")] + initial_directory_times, ) } else { // Copy as file @@ -1748,7 +1782,7 @@ fn handle_preserve CopyResult<()>>(p: Preserve, f: F) -> CopyResult<( } else if let Err(ref error) = result { // Suppress ENOTSUP errors when preservation is optional. // This matches GNU cp behavior for -a and --preserve=all. - if !is_enotsup_error(error) { + if !is_unsupported_metadata_error(error) { show_error_if_needed(error); } } @@ -1847,6 +1881,32 @@ pub(crate) fn copy_attributes( ) } +#[cfg(target_os = "wasi")] +pub(crate) fn copy_attributes_with_source_times( + source: &Path, + dest: &Path, + source_times: SourceTimes, + 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()))?; + copy_attributes_from_metadata( + source, + dest, + &source_metadata, + WasiTimestampContext { + captured_source: Some(source_times), + follow_destination: true, + }, + attributes, + dest_is_freshly_created_dir, + skip_selinux_xattr, + ) +} + #[allow(unused_variables)] fn copy_attributes_from_metadata( source: &Path, @@ -2177,6 +2237,17 @@ fn handle_existing_dest( return Err(CpError::Skipped(false)); } + if options.update == UpdateMode::IfOlder { + let source_metadata = if options.dereference(source_in_command_line) { + fs::metadata(source)? + } else { + fs::symlink_metadata(source)? + }; + if source_metadata.modified()? <= fs::symlink_metadata(dest)?.modified()? { + return Err(CpError::Skipped(false)); + } + } + if options.update != UpdateMode::IfOlder { options.overwrite.verify(dest, options.debug)?; } @@ -2756,6 +2827,10 @@ fn copy_file( created_parent_dirs, )?; + if performed_action == PerformedAction::Skipped { + return Err(CpError::Skipped(false)); + } + let created_symlink_output = source_metadata.file_type().is_symlink() || options.copy_mode == CopyMode::SymLink; #[cfg(target_os = "wasi")] @@ -2764,7 +2839,7 @@ fn copy_file( follow_destination: !created_symlink_output, }; - if options.verbose && performed_action != PerformedAction::Skipped { + if options.verbose { print_verbose_output(options.parents, progress_bar, source, dest)?; } @@ -2961,7 +3036,7 @@ fn copy_helper( // TOCTOU window described in issue #10017. In deref mode cp // intentionally follows symlinks, matching GNU cp's behavior of // applying O_NOFOLLOW here only with `-P`. - #[cfg(unix)] + #[cfg(any(unix, target_os = "wasi"))] let nofollow = !options.dereference(source_in_command_line); let copy_debug = copy_on_write( source, @@ -2971,7 +3046,7 @@ fn copy_helper( context, #[cfg(unix)] is_stream(source_metadata), - #[cfg(unix)] + #[cfg(any(unix, target_os = "wasi"))] nofollow, )?; @@ -3078,13 +3153,26 @@ pub fn localize_to_target(root: &Path, source: &Path, target: &Path) -> CopyResu /// This function is much like the `du` utility, by recursively getting the sizes of files in directories. /// Files are not deduplicated when appearing in multiple sources. If `recursive` is set to `false`, the /// directories in `paths` will be ignored. -fn disk_usage(paths: &[PathBuf], recursive: bool) -> io::Result { +fn disk_usage( + paths: &[PathBuf], + recursive: bool, + #[cfg(target_os = "wasi")] mut directory_times: Option<&mut [Option]>, +) -> io::Result { let mut total = 0; - for p in paths { + for (index, p) in paths.iter().enumerate() { + #[cfg(not(target_os = "wasi"))] + let _ = index; let md = fs::metadata(p)?; if md.file_type().is_dir() { if recursive { - total += disk_usage_directory(p)?; + total += disk_usage_directory( + p, + #[cfg(target_os = "wasi")] + directory_times + .as_deref_mut() + .and_then(|trackers| trackers.get_mut(index)) + .and_then(Option::as_mut), + )?; } } else { total += md.len(); @@ -3094,13 +3182,25 @@ fn disk_usage(paths: &[PathBuf], recursive: bool) -> io::Result { } /// A helper for `disk_usage` specialized for directories. -fn disk_usage_directory(p: &Path) -> io::Result { +fn disk_usage_directory( + p: &Path, + #[cfg(target_os = "wasi")] mut directory_times: Option<&mut DirectoryTimesTracker>, +) -> io::Result { let mut total = 0; + #[cfg(target_os = "wasi")] + if let Some(tracker) = directory_times.as_deref_mut() { + tracker.capture_children(p); + } + for entry in fs::read_dir(p)? { let entry = entry?; if entry.file_type()?.is_dir() { - total += disk_usage_directory(&entry.path())?; + total += disk_usage_directory( + &entry.path(), + #[cfg(target_os = "wasi")] + directory_times.as_deref_mut(), + )?; } else { total += entry.metadata()?.len(); } diff --git a/src/uu/cp/src/platform/mod.rs b/src/uu/cp/src/platform/mod.rs index 812842e9591..e6371276047 100644 --- a/src/uu/cp/src/platform/mod.rs +++ b/src/uu/cp/src/platform/mod.rs @@ -29,12 +29,15 @@ mod windows; #[cfg(target_os = "windows")] pub(crate) use self::windows::copy_on_write; -#[cfg(not(any(unix, target_os = "windows")))] +#[cfg(not(any(unix, target_os = "windows", target_os = "wasi")))] mod other; -#[cfg(not(any(unix, target_os = "windows")))] +#[cfg(not(any(unix, target_os = "windows", target_os = "wasi")))] pub(crate) use self::other::copy_on_write; #[cfg(target_os = "wasi")] mod wasi; #[cfg(target_os = "wasi")] -pub(crate) use self::wasi::{SourceTimes, SourceTimesSnapshot, create_symlink, set_timestamps}; +pub(crate) use self::wasi::{ + DirectoryTimesSnapshot, DirectoryTimesTracker, SourceTimes, SourceTimesSnapshot, copy_on_write, + create_symlink, is_optional_metadata_error, set_timestamps, +}; diff --git a/src/uu/cp/src/platform/wasi.rs b/src/uu/cp/src/platform/wasi.rs index 73baa456b4f..345755ed359 100644 --- a/src/uu/cp/src/platform/wasi.rs +++ b/src/uu/cp/src/platform/wasi.rs @@ -3,12 +3,23 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -use std::fs::Metadata; +use std::cell::RefCell; +use std::collections::HashMap; +use std::fs::{self, Metadata}; use std::io; -use std::path::Path; +use std::path::{Path, PathBuf}; +use std::rc::Rc; use std::time::{SystemTime, UNIX_EPOCH}; use rustix::fs::{AtFlags, CWD, FileType, Timespec, Timestamps, lstat, stat, utimensat}; +use uucore::buf_copy; +use uucore::display::Quotable; +use uucore::safe_copy::{create_dest_restrictive, open_source}; +use uucore::translate; + +use crate::{ + CopyDebug, CopyResult, CpError, OffloadReflinkDebug, ReflinkMode, SparseDebug, SparseMode, +}; #[derive(Clone, Copy)] pub(crate) struct SourceTimesSnapshot { @@ -34,6 +45,15 @@ impl SourceTimesSnapshot { } pub(crate) fn times_if_unchanged(&self, path: &Path, dereference: bool) -> Option { + self.times_if_matches(path, dereference, !self.file_type.is_symlink()) + } + + fn times_if_matches( + &self, + path: &Path, + dereference: bool, + compare_accessed: bool, + ) -> Option { let stat = if dereference { stat(path).ok()? } else { @@ -43,7 +63,7 @@ impl SourceTimesSnapshot { if stat.st_dev != self.device || stat.st_ino != self.inode || file_type != self.file_type - || !self.times.matches_stat(&stat, !self.file_type.is_symlink()) + || !self.times.matches_stat(&stat, compare_accessed) { return None; } @@ -51,6 +71,107 @@ impl SourceTimesSnapshot { } } +/// Source directory timestamps captured before recursive traversal opens each directory. +pub(crate) struct DirectoryTimesTracker { + root_dereference: bool, + child_dereference: bool, + snapshots: Rc>>, +} + +pub(crate) struct DirectoryTimesSnapshot { + source: SourceTimesSnapshot, + dereference: bool, +} + +impl DirectoryTimesSnapshot { + pub(crate) fn times_if_unchanged(&self, path: &Path) -> Option { + // Recursive traversal legitimately opens directories, so only identity, + // type, and modification time determine whether the snapshot is stale. + self.source.times_if_matches(path, self.dereference, false) + } +} + +impl DirectoryTimesTracker { + pub(crate) fn new(root: &Path, root_dereference: bool, child_dereference: bool) -> Self { + let tracker = Self { + root_dereference, + child_dereference, + snapshots: Rc::new(RefCell::new(HashMap::new())), + }; + tracker.capture(root, root_dereference); + tracker + } + + /// Create trackers that share the earliest snapshot of overlapping source trees. + pub(crate) fn for_roots( + roots: &[PathBuf], + root_dereference: bool, + child_dereference: bool, + ) -> Vec> { + let snapshots = Rc::new(RefCell::new(HashMap::new())); + roots + .iter() + .map(|root| { + let tracker = Self { + root_dereference, + child_dereference, + snapshots: Rc::clone(&snapshots), + }; + tracker.capture(root, root_dereference); + Some(tracker) + }) + .collect() + } + + /// Return the directory's pre-traversal snapshot for final validation. + pub(crate) fn take(&mut self, path: &Path, depth: usize) -> Option { + let dereference = if depth == 0 { + self.root_dereference + } else { + self.child_dereference + }; + let current = SourceTimesSnapshot::from_path(path, dereference).ok()?; + let snapshot = self + .snapshots + .borrow() + .get(&(current.device, current.inode)) + .copied() + .unwrap_or(current); + Some(DirectoryTimesSnapshot { + source: snapshot, + dereference, + }) + } + + /// Capture direct child directories before the walker opens them. + pub(crate) fn capture_children(&mut self, path: &Path) { + let Ok(children) = fs::read_dir(path) else { + return; + }; + for child in children.flatten() { + let child_path = child.path(); + if let Ok(snapshot) = + SourceTimesSnapshot::from_path(&child_path, self.child_dereference) + && snapshot.file_type.is_dir() + { + self.snapshots + .borrow_mut() + .entry((snapshot.device, snapshot.inode)) + .or_insert(snapshot); + } + } + } + + fn capture(&self, path: &Path, dereference: bool) { + if let Ok(snapshot) = SourceTimesSnapshot::from_path(path, dereference) { + self.snapshots + .borrow_mut() + .entry((snapshot.device, snapshot.inode)) + .or_insert(snapshot); + } + } +} + #[derive(Clone, Copy)] pub(crate) struct SourceTimes { accessed: Timespec, @@ -89,6 +210,51 @@ pub(crate) fn create_symlink(source: &Path, dest: &Path) -> io::Result<()> { rustix::fs::symlink(source, dest).map_err(io::Error::from) } +/// Copy a regular file while refusing source symlinks in no-dereference mode. +pub(crate) fn copy_on_write( + source: &Path, + dest: &Path, + reflink_mode: ReflinkMode, + sparse_mode: SparseMode, + context: &str, + nofollow: bool, +) -> CopyResult { + if reflink_mode != ReflinkMode::Never { + return Err(translate!("cp-error-reflink-not-supported") + .to_string() + .into()); + } + if sparse_mode != SparseMode::Auto { + return Err(translate!("cp-error-sparse-not-supported") + .to_string() + .into()); + } + + let mut source_file = + open_source(source, nofollow).map_err(|e| CpError::IoErrContext(e, context.to_owned()))?; + let mut dest_file = create_dest_restrictive(dest, false).map_err(|e| { + CpError::IoErrContext( + e, + translate!("cp-error-cannot-create-regular-file", "path" => dest.quote()), + ) + })?; + buf_copy::copy_fast(&mut source_file, &mut dest_file) + .map_err(|e| CpError::IoErrContext(e, context.to_owned()))?; + + Ok(CopyDebug { + offload: OffloadReflinkDebug::Unsupported, + reflink: OffloadReflinkDebug::Unsupported, + sparse_detection: SparseDebug::Unsupported, + }) +} + +pub(crate) fn is_optional_metadata_error(error: &io::Error) -> bool { + matches!( + error.raw_os_error(), + Some(code) if code == libc::EOPNOTSUPP || code == libc::ENOSYS + ) +} + pub(crate) fn set_timestamps( source_times: SourceTimes, dest: &Path, @@ -115,3 +281,35 @@ fn to_timespec(time: SystemTime) -> io::Result { tv_nsec: duration.subsec_nanos() as i32, }) } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::File; + + #[test] + fn nofollow_copy_rejects_symlink_source() { + let target = Path::new("cp-wasi-nofollow-target"); + let source = Path::new("cp-wasi-nofollow-source"); + let dest = Path::new("cp-wasi-nofollow-dest"); + for path in [source, target, dest] { + fs::remove_file(path).ok(); + } + File::create(target).unwrap(); + create_symlink(target, source).unwrap(); + + let result = copy_on_write( + source, + dest, + ReflinkMode::Never, + SparseMode::Auto, + "copy", + true, + ); + + assert!(result.is_err()); + assert!(!dest.exists()); + fs::remove_file(source).unwrap(); + fs::remove_file(target).unwrap(); + } +} diff --git a/src/uucore/src/lib/features.rs b/src/uucore/src/lib/features.rs index 4ec60cc8dee..d506c92994f 100644 --- a/src/uucore/src/lib/features.rs +++ b/src/uucore/src/lib/features.rs @@ -78,7 +78,7 @@ pub mod pipes; pub mod proc_info; #[cfg(all(any(unix, windows), feature = "process"))] pub mod process; -#[cfg(all(unix, feature = "safe-copy"))] +#[cfg(all(any(unix, target_os = "wasi"), feature = "safe-copy"))] pub mod safe_copy; #[cfg(all(unix, not(target_os = "redox")))] pub mod safe_traversal; diff --git a/src/uucore/src/lib/features/safe_copy.rs b/src/uucore/src/lib/features/safe_copy.rs index d8cbc36b824..6e4fbc54279 100644 --- a/src/uucore/src/lib/features/safe_copy.rs +++ b/src/uucore/src/lib/features/safe_copy.rs @@ -79,7 +79,7 @@ pub fn create_dest_restrictive>(path: P, nofollow: bool) -> io::R Ok(File::from(fd)) } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use super::*; use std::io::{Read, Write}; diff --git a/src/uucore/src/lib/lib.rs b/src/uucore/src/lib/lib.rs index 048d6a0ca01..ce3293ffd25 100644 --- a/src/uucore/src/lib/lib.rs +++ b/src/uucore/src/lib/lib.rs @@ -105,7 +105,7 @@ pub use crate::features::perms; pub use crate::features::pipes; #[cfg(all(any(unix, windows), feature = "process"))] pub use crate::features::process; -#[cfg(all(unix, feature = "safe-copy"))] +#[cfg(all(any(unix, target_os = "wasi"), feature = "safe-copy"))] pub use crate::features::safe_copy; #[cfg(all(unix, not(target_os = "redox")))] pub use crate::features::safe_traversal; diff --git a/tests/by-util/test_cp.rs b/tests/by-util/test_cp.rs index a4e9d5bb76f..16d09562b83 100644 --- a/tests/by-util/test_cp.rs +++ b/tests/by-util/test_cp.rs @@ -502,6 +502,78 @@ fn test_cp_arg_update_none() { assert_eq!(at.read(TEST_HOW_ARE_YOU_SOURCE), "How are you?\n"); } +#[rstest] +#[case("--update=none", false)] +#[case("--update=older", true)] +#[cfg(any(target_os = "linux", target_os = "android"))] +fn test_cp_update_skip_preserves_destination_timestamps( + #[case] update: &str, + #[case] with_backup: bool, +) { + let (at, mut ucmd) = at_and_ucmd!(); + let ts = time::OffsetDateTime::now_utc(); + let source_time = FileTime::from_unix_time(ts.unix_timestamp() - 7200, ts.nanosecond()); + let dest_atime = FileTime::from_unix_time(ts.unix_timestamp() - 3600, ts.nanosecond()); + let dest_mtime = FileTime::from_unix_time(ts.unix_timestamp() - 1800, ts.nanosecond()); + + at.write("source", "new contents"); + at.write("destination", "old contents"); + filetime::set_file_times(at.plus("source"), source_time, source_time).unwrap(); + filetime::set_file_times(at.plus("destination"), dest_atime, dest_mtime).unwrap(); + + let mut args = vec![update, "--preserve=timestamps", "source", "destination"]; + if with_backup { + args.insert(1, "--backup"); + } + ucmd.args(&args).succeeds().no_output(); + + let metadata = std_fs::metadata(at.plus("destination")).unwrap(); + assert_timestamps(&metadata, dest_atime, dest_mtime); + assert_eq!(at.read("destination"), "old contents"); + assert!(!at.plus("destination~").exists()); +} + +#[test] +#[cfg(any(target_os = "linux", target_os = "android"))] +fn test_cp_recursive_update_continues_after_skipped_file() { + let (at, mut ucmd) = at_and_ucmd!(); + let ts = time::OffsetDateTime::now_utc(); + let old_time = FileTime::from_unix_time(ts.unix_timestamp() - 7200, ts.nanosecond()); + let new_time = FileTime::from_unix_time(ts.unix_timestamp() - 3600, ts.nanosecond()); + + at.mkdir_all("source"); + at.mkdir_all("destination/source"); + at.write("source/first", "first contents"); + at.write("source/second", "second contents"); + let source_order = walkdir::WalkDir::new(at.plus("source")) + .min_depth(1) + .max_depth(1) + .into_iter() + .map(|entry| entry.unwrap().file_name().to_owned()) + .collect::>(); + assert_eq!(source_order.len(), 2); + let skipped_source = Path::new("source").join(&source_order[0]); + let skipped_dest = Path::new("destination/source").join(&source_order[0]); + let copied_source = Path::new("source").join(&source_order[1]); + let copied_dest = Path::new("destination/source").join(&source_order[1]); + std_fs::write(at.plus(&skipped_dest), "old contents").unwrap(); + filetime::set_file_times(at.plus(&skipped_source), old_time, old_time).unwrap(); + filetime::set_file_times(at.plus(&skipped_dest), new_time, new_time).unwrap(); + + ucmd.args(&["-R", "--update=older", "source", "destination"]) + .succeeds() + .no_output(); + + assert_eq!( + std_fs::read_to_string(at.plus(&skipped_dest)).unwrap(), + "old contents" + ); + assert_eq!( + std_fs::read_to_string(at.plus(&copied_dest)).unwrap(), + std_fs::read_to_string(at.plus(&copied_source)).unwrap() + ); +} + #[test] fn test_cp_arg_update_none_fail() { let (at, mut ucmd) = at_and_ucmd!(); @@ -1900,6 +1972,18 @@ fn test_cp_preserve_all() { } } +#[test] +#[cfg(wasi_runner)] +fn test_cp_wasi_recursive_copy_ignores_unsupported_optional_mode() { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("source"); + at.touch("source/file"); + + ucmd.args(&["-R", "source", "destination"]) + .succeeds() + .no_output(); +} + // GNU `cp -p` preserves mode, ownership, and timestamps but NOT xattrs. // xattr preservation requires explicit `--preserve=xattr` or `-a`. See #9704. #[test] @@ -2576,7 +2660,7 @@ fn test_cp_preserve_timestamps() { assert_eq!(creation, creation2); } -#[cfg(wasi_runner)] +#[cfg(any(wasi_runner, target_os = "linux", target_os = "android"))] fn assert_timestamps(metadata: &std_fs::Metadata, accessed: FileTime, modified: FileTime) { assert_eq!(FileTime::from_last_access_time(metadata), accessed); assert_eq!(FileTime::from_last_modification_time(metadata), modified); @@ -2751,6 +2835,65 @@ fn test_cp_wasi_refreshes_timestamps_for_later_source() { assert_eq!(at.read("destination/second"), "first contents"); } +#[test] +#[cfg(wasi_runner)] +fn test_cp_wasi_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", + "--progress", + "--preserve=timestamps", + "source", + "destination", + ]) + .succeeds() + .no_output(); + + let root_metadata = std_fs::metadata(at.plus("destination")).unwrap(); + assert_timestamps(&root_metadata, root_atime, root_mtime); + let nested_metadata = std_fs::metadata(at.plus("destination/nested")).unwrap(); + assert_timestamps(&nested_metadata, nested_atime, nested_mtime); +} + +#[test] +#[cfg(wasi_runner)] +fn test_cp_wasi_preserve_overlapping_recursive_source_timestamps() { + let (at, mut ucmd) = at_and_ucmd!(); + let ts = time::OffsetDateTime::now_utc(); + let nested_atime = FileTime::from_unix_time(ts.unix_timestamp() - 7200, ts.nanosecond()); + let nested_mtime = FileTime::from_unix_time(ts.unix_timestamp() - 3600, ts.nanosecond()); + + at.mkdir_all("tree/subdir/nested"); + at.write("tree/subdir/nested/file", "contents"); + at.mkdir("destination"); + filetime::set_file_times(at.plus("tree/subdir/nested"), nested_atime, nested_mtime).unwrap(); + + ucmd.args(&[ + "-R", + "--progress", + "--preserve=timestamps", + "tree", + "tree/subdir", + "destination", + ]) + .succeeds() + .no_output(); + + let metadata = std_fs::metadata(at.plus("destination/subdir/nested")).unwrap(); + assert_timestamps(&metadata, nested_atime, nested_mtime); +} + #[test] #[cfg(any(target_os = "linux", target_os = "android"))] fn test_cp_no_preserve_timestamps() { From 51b913a03051e24b4fd9a292ebf54f90b252f731 Mon Sep 17 00:00:00 2001 From: Anthony DePasquale Date: Thu, 13 Aug 2026 21:44:58 +0200 Subject: [PATCH 5/5] cp: suppress WASI field spelling warnings --- src/uu/cp/src/platform/wasi.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/uu/cp/src/platform/wasi.rs b/src/uu/cp/src/platform/wasi.rs index 345755ed359..d1acc476667 100644 --- a/src/uu/cp/src/platform/wasi.rs +++ b/src/uu/cp/src/platform/wasi.rs @@ -2,6 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +// spell-checker:ignore (vars) atim mtim use std::cell::RefCell; use std::collections::HashMap;