diff --git a/.github/workflows/wasi.yml b/.github/workflows/wasi.yml index d0e6275bfb..d481b4c588 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" @@ -70,6 +70,17 @@ 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_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 cafb33fbaa..c8538baf86 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 `\\?`. @@ -319,8 +358,13 @@ fn copy_direntry( copied_files, created_parent_dirs, false, + #[cfg(target_os = "wasi")] + None, ) { + if matches!(err, CpError::Skipped(false)) { + return Ok(false); + } if preserve_hard_links { if !source_is_symlink { return Err(err); @@ -372,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() { @@ -385,6 +430,8 @@ pub(crate) fn copy_directory( copied_files, created_parent_dirs, source_in_command_line, + #[cfg(target_os = "wasi")] + None, ); } @@ -464,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) @@ -481,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( @@ -512,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; } @@ -526,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. @@ -555,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, )?; } } @@ -577,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 337ad4e004..c3e1b7f5f0 100644 --- a/src/uu/cp/src/cp.rs +++ b/src/uu/cp/src/cp.rs @@ -21,13 +21,18 @@ 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)] 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, 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}; @@ -40,6 +45,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, @@ -50,6 +56,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 @@ -1365,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) } } @@ -1429,23 +1447,54 @@ 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_snapshots = match (options.progress_bar, options.attributes.timestamps) { + (true, Preserve::Yes { .. }) => Some( + sources + .iter() + .map(|source| { + SourceTimesSnapshot::from_path(source, options.dereference(true)).ok() + }) + .collect::>(), + ), + _ => 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 { 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 +1507,19 @@ 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_snapshot = match options.attributes.timestamps { + 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, + }; + #[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()) @@ -1494,6 +1556,10 @@ 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_snapshot, + #[cfg(target_os = "wasi")] + initial_directory_times, ) { show_error_if_needed(&error); if !matches!(error, CpError::Skipped(false)) { @@ -1569,6 +1635,8 @@ fn copy_source( copied_destinations: &HashSet, 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()) { @@ -1583,6 +1651,8 @@ fn copy_source( copied_files, created_parent_dirs, true, + #[cfg(target_os = "wasi")] + initial_directory_times, ) } else { // Copy as file @@ -1597,6 +1667,8 @@ fn copy_source( copied_files, created_parent_dirs, true, + #[cfg(target_os = "wasi")] + initial_source_snapshot, ); if options.parents { for (x, y) in aligned_ancestors(source, dest.as_path()) { @@ -1710,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); } } @@ -1794,6 +1866,58 @@ 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, + #[cfg(target_os = "wasi")] + WasiTimestampContext { + captured_source: None, + follow_destination: !dest.is_symlink(), + }, + attributes, + dest_is_freshly_created_dir, + skip_selinux_xattr, + ) +} + +#[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, + dest: &Path, + source_metadata: &Metadata, + #[cfg(target_os = "wasi")] timestamp_context: WasiTimestampContext, + 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 +2012,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 +2041,15 @@ pub(crate) fn copy_attributes( Ok(()) })?; + #[cfg(target_os = "wasi")] + handle_preserve(attributes.timestamps, || { + 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")))] handle_preserve(attributes.context, || -> CopyResult<()> { // Get the source context and apply it to the destination @@ -2103,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)?; } @@ -2507,6 +2652,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, ) -> CopyResult<()> { let source_is_symlink = source.is_symlink(); let initial_dest_metadata = dest.symlink_metadata().ok(); @@ -2653,6 +2799,11 @@ fn copy_file( })? }; + #[cfg(target_os = "wasi")] + 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(); let dest_permissions = calculate_dest_permissions( @@ -2676,7 +2827,19 @@ fn copy_file( created_parent_dirs, )?; - if options.verbose && performed_action != PerformedAction::Skipped { + 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")] + let timestamp_context = WasiTimestampContext { + captured_source: captured_source_times, + follow_destination: !created_symlink_output, + }; + + if options.verbose { print_verbose_output(options.parents, progress_bar, source, dest)?; } @@ -2697,39 +2860,52 @@ 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( - &src_for_attrs, - dest, - &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( - 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, + &source_metadata, + 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 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")))] @@ -2860,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, @@ -2870,7 +3046,7 @@ fn copy_helper( context, #[cfg(unix)] is_stream(source_metadata), - #[cfg(unix)] + #[cfg(any(unix, target_os = "wasi"))] nofollow, )?; @@ -2942,13 +3118,7 @@ fn copy_link( delete_path(dest, options)?; } symlink_file(&link, dest, symlinked_files)?; - copy_attributes( - source, - dest, - &options.attributes, - false, - options.set_selinux_context, - ) + Ok(()) } /// Generate an error message if `target` is not the correct `target_type` @@ -2983,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(); @@ -2999,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 512b055f8c..e637127604 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::create_symlink; +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 d0cfe4088a..d1acc47666 100644 --- a/src/uu/cp/src/platform/wasi.rs +++ b/src/uu/cp/src/platform/wasi.rs @@ -2,10 +2,315 @@ // // 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; +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 { + times: SourceTimes, + device: u64, + inode: u64, + file_type: FileType, +} + +impl SourceTimesSnapshot { + pub(crate) fn from_path(path: &Path, dereference: bool) -> io::Result { + let stat = if dereference { + stat(path)? + } else { + lstat(path)? + }; + Ok(Self { + 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 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 { + 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.times.matches_stat(&stat, compare_accessed) + { + return None; + } + Some(self.times) + } +} + +/// 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, + modified: Timespec, +} + +impl SourceTimes { + pub(crate) fn from_metadata(metadata: &Metadata) -> io::Result { + Ok(Self { + accessed: to_timespec(metadata.accessed()?)?, + modified: to_timespec(metadata.modified()?)?, + }) + } + + fn from_stat(stat: &rustix::fs::Stat) -> Self { + Self { + 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, + }, + } + } + + 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) } + +/// 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, + follow_destination: bool, +) -> io::Result<()> { + let timestamps = Timestamps { + last_access: source_times.accessed, + last_modification: source_times.modified, + }; + let flags = if follow_destination { + AtFlags::empty() + } else { + AtFlags::SYMLINK_NOFOLLOW + }; + utimensat(CWD, dest, ×tamps, flags).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, + }) +} + +#[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 4ec60cc8de..d506c92994 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 d8cbc36b82..6e4fbc5427 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 048d6a0ca0..ce3293ffd2 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 d9942c7b19..16d09562b8 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,6 +2660,240 @@ fn test_cp_preserve_timestamps() { assert_eq!(creation, creation2); } +#[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); +} + +#[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_timestamps(&metadata, previous_atime, 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_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_timestamps(&target_metadata, target_atime, 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_timestamps(&destination_metadata, source_atime, 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", + "--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_timestamps(&destination_metadata, target_atime, target_mtime); +} + +#[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_timestamps(&target_metadata, source_atime, 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_timestamps(&metadata, first_atime, shared_mtime); + 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() {