diff --git a/.github/workflows/wasi.yml b/.github/workflows/wasi.yml index 5072ccc573b..feb808c6d00 100644 --- a/.github/workflows/wasi.yml +++ b/.github/workflows/wasi.yml @@ -69,6 +69,10 @@ jobs: UUTESTS_WASM_RUNNER=wasmtime \ cargo test --test tests -- \ test_base32:: test_base64:: test_basenc:: test_basename:: \ + test_cp::test_cp_arg_symlink test_cp::test_cp_preserve_symlink_timestamps \ + test_cp::test_cp_preserve_timestamps \ + test_cp::test_cp_preserve_dereferenced_symlink_timestamps \ + test_cp::test_cp_preserve_recursive_directory_timestamps \ test_comm:: test_cut:: test_dirname:: test_echo:: \ test_expand:: test_factor:: test_false:: test_fold:: \ test_head:: test_link:: test_ln:: test_nl:: test_numfmt:: \ diff --git a/src/uu/cp/Cargo.toml b/src/uu/cp/Cargo.toml index 0f2a818119e..6b778010b27 100644 --- a/src/uu/cp/Cargo.toml +++ b/src/uu/cp/Cargo.toml @@ -49,6 +49,9 @@ windows-sys = { workspace = true, features = [ "Win32_Storage_FileSystem", ] } +[target.'cfg(target_os = "wasi")'.dependencies] +rustix = { workspace = true, features = ["fs"] } + [[bin]] name = "cp" path = "src/main.rs" diff --git a/src/uu/cp/src/copydir.rs b/src/uu/cp/src/copydir.rs index cafb33fbaab..379bb0827d6 100644 --- a/src/uu/cp/src/copydir.rs +++ b/src/uu/cp/src/copydir.rs @@ -11,7 +11,7 @@ use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::convert::identity; use std::env; -use std::fs::{self, exists}; +use std::fs::{self, Metadata, exists}; use std::io; use std::path::{Path, PathBuf, StripPrefixError}; @@ -29,8 +29,8 @@ use walkdir::{DirEntry, WalkDir}; #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] use crate::set_selinux_context; use crate::{ - CopyMode, CopyResult, CpError, Options, aligned_ancestors, context_for, copy_attributes, - copy_file, + CopyMode, CopyResult, CpError, Options, SourceTimestamps, aligned_ancestors, context_for, + copy_attributes, copy_attributes_with_timestamps, copy_file, }; /// Represents a directory that needs permission fixup after copying its contents. @@ -41,6 +41,8 @@ struct DirNeedingPermissions { dest: PathBuf, /// Whether this directory was freshly created by the copy operation was_created: bool, + /// Timestamps captured before this directory was traversed + source_timestamps: Option>, } /// Ensure a Windows path starts with a `\\?`. @@ -319,6 +321,7 @@ fn copy_direntry( copied_files, created_parent_dirs, false, + None, ) { if preserve_hard_links { @@ -372,6 +375,7 @@ pub(crate) fn copy_directory( copied_files: &mut HashMap, created_parent_dirs: &mut HashSet, source_in_command_line: bool, + initial_source_metadata: Option<&Metadata>, ) -> CopyResult<()> { // if no-dereference is enabled and this is a symlink, copy it as a file if !options.dereference(source_in_command_line) && root.is_symlink() { @@ -385,6 +389,7 @@ pub(crate) fn copy_directory( copied_files, created_parent_dirs, source_in_command_line, + initial_source_metadata, ); } @@ -448,6 +453,17 @@ pub(crate) fn copy_directory( let preserve_hard_links = options.preserve_hard_links(); + let preserve_timestamps = matches!(options.attributes.timestamps, crate::Preserve::Yes { .. }); + let initial_source_timestamps = if !preserve_timestamps { + None + } else if options.dereference(source_in_command_line) { + fs::metadata(root).ok() + } else { + initial_source_metadata.cloned() + } + .as_ref() + .and_then(|metadata| SourceTimestamps::from_metadata(metadata).ok()); + // Collect some paths here that are invariant during the traversal // of the given directory, like the current working directory and // the target directory. @@ -464,6 +480,11 @@ pub(crate) fn copy_directory( // Keep track of all directories we've created that need permission fixes let mut dirs_needing_permissions: Vec = Vec::new(); + // WalkDir opens each directory before yielding it, which can update its + // access time. Capture children while their parent is being processed so + // that timestamp preservation uses metadata from before that open. + let mut pending_source_timestamps: HashMap = HashMap::new(); + // Traverse the contents of the directory, copying each one. for direntry_result in WalkDir::new(root) .same_file_system(options.one_file_system) @@ -473,14 +494,62 @@ pub(crate) fn copy_directory( Ok(direntry) => { let direntry_type = direntry.file_type(); let direntry_path = direntry.path(); + let source_path_metadata = direntry_path.symlink_metadata().ok(); let (entry_is_symlink, entry_is_dir_no_follow) = - match direntry_path.symlink_metadata() { - Ok(metadata) => { + source_path_metadata.as_ref().map_or_else( + || (direntry_type.is_symlink(), direntry_type.is_dir()), + |metadata| { let file_type = metadata.file_type(); (file_type.is_symlink(), file_type.is_dir()) + }, + ); + let source_timestamps = if !preserve_timestamps { + None + } else if direntry.depth() == 0 { + initial_source_timestamps + } else if let Some(timestamps) = pending_source_timestamps.remove(direntry_path) { + Some(timestamps) + } else if options.dereference { + fs::metadata(direntry_path) + .ok() + .as_ref() + .and_then(|metadata| SourceTimestamps::from_metadata(metadata).ok()) + } else { + source_path_metadata + .as_ref() + .and_then(|metadata| SourceTimestamps::from_metadata(metadata).ok()) + }; + let entry_is_dir_for_metadata = if options.dereference { + direntry_type.is_dir() + } else { + entry_is_dir_no_follow + }; + if preserve_timestamps + && entry_is_dir_for_metadata + && let Ok(children) = fs::read_dir(direntry_path) + { + for child in children.flatten() { + let child_path = child.path(); + let Ok(child_type) = child.file_type() else { + continue; + }; + let child_metadata = if options.dereference + && (child_type.is_dir() || child_type.is_symlink()) + { + fs::metadata(&child_path) + } else if !options.dereference && child_type.is_dir() { + child.metadata() + } else { + continue; + }; + if let Ok(metadata) = child_metadata + && metadata.file_type().is_dir() + && let Ok(timestamps) = SourceTimestamps::from_metadata(&metadata) + { + pending_source_timestamps.insert(child_path, timestamps); } - Err(_) => (direntry_type.is_symlink(), direntry_type.is_dir()), - }; + } + } let entry = Entry::new(&context, direntry_path, options.no_target_dir)?; let created = copy_direntry( @@ -512,13 +581,24 @@ pub(crate) fn copy_directory( if is_dir_for_permissions { // For --link mode, copy attributes immediately to avoid O(n) memory if options.copy_mode == CopyMode::Link { - copy_attributes( - &entry.source_absolute, - &entry.local_to_target, - &options.attributes, - false, - options.set_selinux_context, - )?; + if let Some(timestamps) = source_timestamps { + copy_attributes_with_timestamps( + &entry.source_absolute, + &entry.local_to_target, + timestamps, + &options.attributes, + false, + options.set_selinux_context, + )?; + } else { + copy_attributes( + &entry.source_absolute, + &entry.local_to_target, + &options.attributes, + false, + options.set_selinux_context, + )?; + } continue; } // Add this directory to our list for permission fixing later @@ -526,6 +606,7 @@ pub(crate) fn copy_directory( source: entry.source_absolute.clone(), dest: entry.local_to_target.clone(), was_created: created, + source_timestamps: source_timestamps.map(Box::new), }); // If true, last_iter is not a parent of this iter. @@ -577,13 +658,24 @@ pub(crate) fn copy_directory( // Fix permissions for all directories we created // This ensures that even sibling directories get their permissions fixed for dir in dirs_needing_permissions { - copy_attributes( - &dir.source, - &dir.dest, - &options.attributes, - dir.was_created, - options.set_selinux_context, - )?; + if let Some(timestamps) = dir.source_timestamps.as_deref().copied() { + copy_attributes_with_timestamps( + &dir.source, + &dir.dest, + timestamps, + &options.attributes, + dir.was_created, + options.set_selinux_context, + )?; + } else { + copy_attributes( + &dir.source, + &dir.dest, + &options.attributes, + dir.was_created, + options.set_selinux_context, + )?; + } #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] if options.set_selinux_context { diff --git a/src/uu/cp/src/cp.rs b/src/uu/cp/src/cp.rs index b8062b8dafe..e9f084dcaa2 100644 --- a/src/uu/cp/src/cp.rs +++ b/src/uu/cp/src/cp.rs @@ -2,8 +2,8 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) copydir fiemap ftruncate linkgs lstat nlink nlinks pathbuf pwrite reflink strs xattrs symlinked deduplicated advcpmv nushell IRWXG IRWXO IRWXU IRWXUGO IRWXU IRWXG IRWXO IRWXUGO sflag -// spell-checker:ignore RDONLY futimens utimensat +// spell-checker:ignore (ToDO) copydir fiemap filestat ftruncate linkgs lstat nlink nlinks pathbuf pwrite reflink strs utimensat xattrs symlinked deduplicated advcpmv nushell IRWXG IRWXO IRWXU IRWXUGO IRWXU IRWXG IRWXO IRWXUGO sflag +// spell-checker:ignore RDONLY futimens use std::cmp::Ordering; use std::collections::{HashMap, HashSet}; @@ -21,6 +21,7 @@ use uucore::fsxattr::{copy_acls, copy_xattrs, copy_xattrs_skip_selinux}; use uucore::translate; use clap::{Arg, ArgAction, ArgMatches, Command, builder::ValueParser, value_parser}; +#[cfg(not(target_os = "wasi"))] use filetime::FileTime; use indicatif::{ProgressBar, ProgressStyle}; #[cfg(unix)] @@ -1368,9 +1369,9 @@ fn parse_path_args( /// Check if an error is ENOTSUP/EOPNOTSUPP (operation not supported). /// This is used to suppress xattr errors on filesystems that don't support them. fn is_enotsup_error(error: &CpError) -> bool { - #[cfg(unix)] + #[cfg(any(unix, target_os = "wasi"))] const EOPNOTSUPP: i32 = libc::EOPNOTSUPP; - #[cfg(not(unix))] + #[cfg(not(any(unix, target_os = "wasi")))] const EOPNOTSUPP: i32 = 95; match error { @@ -1446,6 +1447,10 @@ pub fn copy(sources: &[PathBuf], target: &Path, options: &Options) -> CopyResult }; for source in sources { + let initial_source_metadata = match options.attributes.timestamps { + Preserve::Yes { .. } => fs::symlink_metadata(source).ok(), + Preserve::No { .. } => None, + }; let normalized_source = normalize_path(source); if options.backup == BackupMode::None && seen_sources.contains(&normalized_source) { let file_type = if source.symlink_metadata()?.file_type().is_dir() { @@ -1494,6 +1499,7 @@ pub fn copy(sources: &[PathBuf], target: &Path, options: &Options) -> CopyResult &copied_destinations, &mut copied_files, &mut created_parent_dirs, + initial_source_metadata.as_ref(), ) { show_error_if_needed(&error); if !matches!(error, CpError::Skipped(false)) { @@ -1569,9 +1575,10 @@ fn copy_source( copied_destinations: &HashSet, copied_files: &mut HashMap, created_parent_dirs: &mut HashSet, + initial_source_metadata: Option<&Metadata>, ) -> CopyResult<()> { let source_path = Path::new(&source); - if source_path.is_dir() && (options.dereference || !source_path.is_symlink()) { + if (options.dereference || !source_path.is_symlink()) && source_path.is_dir() { // Copy as directory copy_directory( progress_bar, @@ -1583,6 +1590,7 @@ fn copy_source( copied_files, created_parent_dirs, true, + initial_source_metadata, ) } else { // Copy as file @@ -1597,6 +1605,7 @@ fn copy_source( copied_files, created_parent_dirs, true, + initial_source_metadata, ); if options.parents { for (x, y) in aligned_ancestors(source, dest.as_path()) { @@ -1780,6 +1789,88 @@ fn copy_extended_attrs(source: &Path, dest: &Path, skip_selinux: bool) -> CopyRe Ok(()) } +/// Copy the access and modification timestamps from `source_metadata` onto `dest`. +/// If `dest` is a symlink, the symlink's own timestamps are set rather than the +/// target's. +/// +/// On WASI this calls `rustix::fs::utimensat` directly because `filetime`'s +/// WASI backend panics in `from_last_{access,modification}_time`. `SystemTime` +/// values are converted to `Timespec` against `UNIX_EPOCH`, matching WASI's +/// `path_filestat_set_times` contract (unsigned nanosecond count — pre-epoch +/// source times can't be represented). +#[derive(Clone, Copy)] +pub(crate) struct SourceTimestamps { + accessed: std::time::SystemTime, + modified: std::time::SystemTime, + #[cfg(all(unix, not(target_os = "wasi")))] + no_open: bool, +} + +impl SourceTimestamps { + pub(crate) fn from_metadata(metadata: &Metadata) -> io::Result { + Ok(Self { + accessed: metadata.accessed()?, + modified: metadata.modified()?, + #[cfg(all(unix, not(target_os = "wasi")))] + no_open: { + #[cfg(unix)] + { + let ft = metadata.file_type(); + ft.is_fifo() || ft.is_socket() || ft.is_char_device() || ft.is_block_device() + } + #[cfg(not(unix))] + { + false + } + }, + }) + } +} + +fn set_timestamps(source_timestamps: SourceTimestamps, dest: &Path) -> CopyResult<()> { + #[cfg(target_os = "wasi")] + { + use std::time::UNIX_EPOCH; + let to_timespec = |t: std::time::SystemTime| -> io::Result { + let d = t + .duration_since(UNIX_EPOCH) + .map_err(|e| io::Error::new(io::ErrorKind::Unsupported, e))?; + Ok(rustix::fs::Timespec { + tv_sec: d.as_secs() as i64, + tv_nsec: d.subsec_nanos() as i32, + }) + }; + let timestamps = rustix::fs::Timestamps { + last_access: to_timespec(source_timestamps.accessed)?, + last_modification: to_timespec(source_timestamps.modified)?, + }; + let flags = if dest.is_symlink() { + rustix::fs::AtFlags::SYMLINK_NOFOLLOW + } else { + rustix::fs::AtFlags::empty() + }; + rustix::fs::utimensat(rustix::fs::CWD, dest, ×tamps, flags) + .map_err(io::Error::from)?; + Ok(()) + } + + #[cfg(not(target_os = "wasi"))] + { + let atime = FileTime::from(source_timestamps.accessed); + let mtime = FileTime::from(source_timestamps.modified); + #[cfg(unix)] + let no_open = dest.is_symlink() || source_timestamps.no_open; + #[cfg(not(unix))] + let no_open = dest.is_symlink(); + if no_open { + filetime::set_symlink_file_times(dest, atime, mtime)?; + } else { + filetime::set_file_times(dest, atime, mtime)?; + } + Ok(()) + } +} + /// Copy the specified attributes from one path to another. /// If `skip_selinux_xattr` is true, the security.selinux xattr will not be copied /// (used when -Z is specified to set the default context instead). @@ -1790,10 +1881,71 @@ pub(crate) fn copy_attributes( attributes: &Attributes, dest_is_freshly_created_dir: bool, skip_selinux_xattr: bool, +) -> CopyResult<()> { + let source_metadata = fs::symlink_metadata(source) + .map_err(|e| CpError::IoErrContext(e, context_for(source, dest)))?; + copy_attributes_from_metadata( + source, + dest, + &source_metadata, + attributes, + dest_is_freshly_created_dir, + skip_selinux_xattr, + ) +} + +#[allow(unused_variables)] +pub(crate) 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<()> { + copy_attributes_from_metadata_and_timestamps( + source, + dest, + source_metadata, + None, + attributes, + dest_is_freshly_created_dir, + skip_selinux_xattr, + ) +} + +pub(crate) fn copy_attributes_with_timestamps( + source: &Path, + dest: &Path, + source_timestamps: SourceTimestamps, + attributes: &Attributes, + dest_is_freshly_created_dir: bool, + skip_selinux_xattr: bool, +) -> CopyResult<()> { + let source_metadata = fs::symlink_metadata(source) + .map_err(|e| CpError::IoErrContext(e, context_for(source, dest)))?; + copy_attributes_from_metadata_and_timestamps( + source, + dest, + &source_metadata, + Some(source_timestamps), + attributes, + dest_is_freshly_created_dir, + skip_selinux_xattr, + ) +} + +#[allow(unused_variables)] +fn copy_attributes_from_metadata_and_timestamps( + source: &Path, + dest: &Path, + source_metadata: &Metadata, + source_timestamps: Option, + attributes: &Attributes, + dest_is_freshly_created_dir: bool, + skip_selinux_xattr: bool, ) -> CopyResult<()> { let context = &*format!("{} -> {}", source.quote(), dest.quote()); - let source_metadata = - fs::symlink_metadata(source).map_err(|e| CpError::IoErrContext(e, context.to_owned()))?; let mode_explicitly_disabled = matches!(attributes.mode, Preserve::No { explicit: true }); @@ -1888,32 +2040,10 @@ pub(crate) fn copy_attributes( Ok(()) })?; - handle_preserve(attributes.timestamps, || -> CopyResult<()> { - let atime = FileTime::from_last_access_time(&source_metadata); - let mtime = FileTime::from_last_modification_time(&source_metadata); - // `set_file_times` opens the destination (O_RDONLY) before calling - // futimens; opening a FIFO or device with no peer blocks forever, and a - // socket cannot be opened at all. For symlinks and these special files - // use the path-based, no-follow variant, which sets the times via - // utimensat without opening. - #[cfg(unix)] - let no_open = { - let ft = source_metadata.file_type(); - dest.is_symlink() - || ft.is_fifo() - || ft.is_socket() - || ft.is_char_device() - || ft.is_block_device() - }; - #[cfg(not(unix))] - let no_open = dest.is_symlink(); - if no_open { - filetime::set_symlink_file_times(dest, atime, mtime)?; - } else { - filetime::set_file_times(dest, atime, mtime)?; - } - - Ok(()) + handle_preserve(attributes.timestamps, || { + let timestamps = source_timestamps + .map_or_else(|| SourceTimestamps::from_metadata(source_metadata), Ok)?; + set_timestamps(timestamps, dest) })?; #[cfg(all(feature = "selinux", any(target_os = "linux", target_os = "android")))] @@ -1959,19 +2089,9 @@ pub(crate) fn copy_attributes( fn symlink_file( source: &Path, dest: &Path, - #[cfg(not(target_os = "wasi"))] symlinked_files: &mut HashSet, - #[cfg(target_os = "wasi")] _symlinked_files: &mut HashSet, + symlinked_files: &mut HashSet, ) -> CopyResult<()> { - #[cfg(target_os = "wasi")] - { - Err(CpError::IoErrContext( - io::Error::new(io::ErrorKind::Unsupported, "symlinks not supported"), - translate!("cp-error-cannot-create-symlink", - "dest" => get_filename(dest).unwrap_or("?").quote(), - "source" => get_filename(source).unwrap_or("?").quote()), - )) - } - #[cfg(not(any(windows, target_os = "wasi")))] + #[cfg(unix)] { std::os::unix::fs::symlink(source, dest).map_err(|e| { CpError::IoErrContext( @@ -1993,13 +2113,21 @@ fn symlink_file( ) })?; } - #[cfg(not(target_os = "wasi"))] + #[cfg(target_os = "wasi")] { - if let Ok(file_info) = FileInformation::from_path(dest, false) { - symlinked_files.insert(file_info); - } - Ok(()) + rustix::fs::symlink(source, dest).map_err(|e| { + CpError::IoErrContext( + io::Error::from(e), + translate!("cp-error-cannot-create-symlink", + "dest" => get_filename(dest).unwrap_or("?").quote(), + "source" => get_filename(source).unwrap_or("?").quote()), + ) + })?; } + if let Ok(file_info) = FileInformation::from_path(dest, false) { + symlinked_files.insert(file_info); + } + Ok(()) } fn context_for(src: &Path, dest: &Path) -> String { @@ -2509,8 +2637,19 @@ fn copy_file( copied_files: &mut HashMap, created_parent_dirs: &mut HashSet, source_in_command_line: bool, + initial_source_metadata: Option<&Metadata>, ) -> CopyResult<()> { - let source_is_symlink = source.is_symlink(); + let source_path_metadata = if let Some(metadata) = initial_source_metadata { + metadata.clone() + } else { + fs::symlink_metadata(source).map_err(|err| { + if err.to_string().contains("No such file or directory") { + return translate!("cp-error-cannot-stat", "source" => source.quote()); + } + err.to_string() + })? + }; + let source_is_symlink = source_path_metadata.file_type().is_symlink(); let initial_dest_metadata = dest.symlink_metadata().ok(); let dest_is_symlink = initial_dest_metadata .as_ref() @@ -2640,19 +2779,17 @@ fn copy_file( let context = context_for(source, dest); let context = context.as_str(); - let source_metadata = { - let result = if options.dereference(source_in_command_line) { - fs::metadata(source) - } else { - fs::symlink_metadata(source) - }; - // this is just for gnu tests compatibility - result.map_err(|err| { + // A preserved-hardlink match returns before content or attributes need + // source metadata. + let source_metadata = if options.dereference(source_in_command_line) { + fs::metadata(source).map_err(|err| { if err.to_string().contains("No such file or directory") { return translate!("cp-error-cannot-stat", "source" => source.quote()); } err.to_string() })? + } else { + source_path_metadata }; let dest_metadata = dest.symlink_metadata().ok(); @@ -2700,9 +2837,10 @@ fn copy_file( .ok() .filter(|p| p.exists()) .unwrap_or_else(|| source.to_path_buf()); - copy_attributes( + copy_attributes_from_metadata( &src_for_attrs, dest, + &source_metadata, &options.attributes, false, options.set_selinux_context, @@ -2714,9 +2852,10 @@ fn copy_file( // copy function (see `copy_stream` under platform/linux.rs). Ok(()) } else { - copy_attributes( + copy_attributes_from_metadata( source, dest, + &source_metadata, &options.attributes, false, options.set_selinux_context, @@ -2848,7 +2987,7 @@ fn copy_helper( } if source_metadata.is_symlink() { - copy_link(source, dest, symlinked_files, options)?; + copy_link(source, dest, source_metadata, symlinked_files, options)?; } else { // Use O_NOFOLLOW on the source open iff cp is in no-dereference mode. // In that case source_metadata was obtained via lstat, so a path swap @@ -2927,6 +3066,7 @@ fn copy_node( fn copy_link( source: &Path, dest: &Path, + source_metadata: &Metadata, symlinked_files: &mut HashSet, options: &Options, ) -> CopyResult<()> { @@ -2938,9 +3078,10 @@ fn copy_link( delete_path(dest, options)?; } symlink_file(&link, dest, symlinked_files)?; - copy_attributes( + copy_attributes_from_metadata( source, dest, + source_metadata, &options.attributes, false, options.set_selinux_context, diff --git a/tests/by-util/test_cp.rs b/tests/by-util/test_cp.rs index 9ad4ef75c4d..a6f4c8a4566 100644 --- a/tests/by-util/test_cp.rs +++ b/tests/by-util/test_cp.rs @@ -28,7 +28,6 @@ use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::{FileTypeExt, MetadataExt}; #[cfg(windows)] use std::os::windows::fs::symlink_file; -#[cfg(not(windows))] use std::path::Path; #[cfg(target_os = "linux")] use std::path::PathBuf; @@ -924,6 +923,11 @@ fn test_cp_arg_symlink() { .succeeds(); assert!(at.is_symlink(TEST_HELLO_WORLD_DEST)); + assert_eq!( + std::fs::read_link(at.plus(TEST_HELLO_WORLD_DEST)).unwrap(), + Path::new(TEST_HELLO_WORLD_SOURCE) + ); + assert_eq!(at.read(TEST_HELLO_WORLD_DEST), "Hello, World!\n"); } #[test] @@ -1686,6 +1690,9 @@ fn test_cp_parents_with_permissions_copy_file() { at.set_mode(file, file_mode); } + #[cfg(all(unix, not(target_os = "freebsd"), not(target_os = "openbsd")))] + let expected_metadata = (at.metadata("p1"), at.metadata("p1/p2"), at.metadata(file)); + ucmd.arg("-p") .arg("--parents") .arg(file) @@ -1694,9 +1701,7 @@ fn test_cp_parents_with_permissions_copy_file() { #[cfg(all(unix, not(target_os = "freebsd"), not(target_os = "openbsd")))] { - let p1_metadata = at.metadata("p1"); - let p2_metadata = at.metadata("p1/p2"); - let file_metadata = at.metadata(file); + let (p1_metadata, p2_metadata, file_metadata) = expected_metadata; assert_metadata_eq!(p1_metadata, at.metadata("dir/p1")); assert_metadata_eq!(p2_metadata, at.metadata("dir/p1/p2")); @@ -1728,6 +1733,9 @@ fn test_cp_parents_with_permissions_copy_dir() { at.set_mode(file, file_mode); } + #[cfg(all(unix, not(target_os = "freebsd"), not(target_os = "openbsd")))] + let expected_metadata = (at.metadata("p1"), at.metadata("p1/p2"), at.metadata(file)); + ucmd.arg("-p") .arg("--parents") .arg("-r") @@ -1737,9 +1745,7 @@ fn test_cp_parents_with_permissions_copy_dir() { #[cfg(all(unix, not(target_os = "freebsd"), not(target_os = "openbsd")))] { - let p1_metadata = at.metadata("p1"); - let p2_metadata = at.metadata("p1/p2"); - let file_metadata = at.metadata(file); + let (p1_metadata, p2_metadata, file_metadata) = expected_metadata; assert_metadata_eq!(p1_metadata, at.metadata("dir/p1")); assert_metadata_eq!(p2_metadata, at.metadata("dir/p1/p2")); @@ -1774,6 +1780,9 @@ fn test_cp_preserve_no_args() { #[cfg(unix)] at.set_mode(src_file, 0o0500); + #[cfg(all(unix, not(target_os = "freebsd"), not(target_os = "openbsd")))] + let expected_metadata = at.metadata(src_file); + // Copy ucmd.arg(src_file) .arg(dst_file) @@ -1784,9 +1793,8 @@ fn test_cp_preserve_no_args() { { // Assert that the mode, ownership, and timestamps are preserved // NOTICE: the ownership is not modified on the src file, because that requires root permissions - let metadata_src = at.metadata(src_file); let metadata_dst = at.metadata(dst_file); - assert_metadata_eq!(metadata_src, metadata_dst); + assert_metadata_eq!(expected_metadata, metadata_dst); } } @@ -1802,6 +1810,9 @@ fn test_cp_preserve_no_args_before_opts() { #[cfg(unix)] at.set_mode(src_file, 0o0500); + #[cfg(all(unix, not(target_os = "freebsd"), not(target_os = "openbsd")))] + let expected_metadata = at.metadata(src_file); + // Copy ucmd.arg("--preserve") .arg(src_file) @@ -1812,9 +1823,8 @@ fn test_cp_preserve_no_args_before_opts() { { // Assert that the mode, ownership, and timestamps are preserved // NOTICE: the ownership is not modified on the src file, because that requires root permissions - let metadata_src = at.metadata(src_file); let metadata_dst = at.metadata(dst_file); - assert_metadata_eq!(metadata_src, metadata_dst); + assert_metadata_eq!(expected_metadata, metadata_dst); } } @@ -1830,6 +1840,9 @@ fn test_cp_preserve_all() { #[cfg(unix)] at.set_mode(src_file, 0o0500); + #[cfg(all(unix, not(target_os = "freebsd"), not(target_os = "openbsd")))] + let expected_metadata = at.metadata(src_file); + // TODO: create a destination that does not allow copying of xattr and context // Copy ucmd.arg(src_file).arg(dst_file).arg(argument).succeeds(); @@ -1838,9 +1851,8 @@ fn test_cp_preserve_all() { { // Assert that the mode, ownership, and timestamps are preserved // NOTICE: the ownership is not modified on the src file, because that requires root permissions - let metadata_src = at.metadata(src_file); let metadata_dst = at.metadata(dst_file); - assert_metadata_eq!(metadata_src, metadata_dst); + assert_metadata_eq!(expected_metadata, metadata_dst); } } } @@ -2493,12 +2505,12 @@ fn test_cp_archive_recursive() { fn test_cp_preserve_timestamps() { let (at, mut ucmd) = at_and_ucmd!(); let ts = time::OffsetDateTime::now_utc(); - let previous = FileTime::from_unix_time(ts.unix_timestamp() - 3600, ts.nanosecond()); - // set the file creation/modification an hour ago + let previous_atime = FileTime::from_unix_time(ts.unix_timestamp() - 7200, ts.nanosecond()); + let previous_mtime = FileTime::from_unix_time(ts.unix_timestamp() - 3600, ts.nanosecond()); filetime::set_file_times( at.plus_as_string(TEST_HELLO_WORLD_SOURCE), - previous, - previous, + previous_atime, + previous_mtime, ) .unwrap(); ucmd.arg(TEST_HELLO_WORLD_SOURCE) @@ -2506,19 +2518,130 @@ fn test_cp_preserve_timestamps() { .arg(TEST_HOW_ARE_YOU_SOURCE) .succeeds(); + let metadata = std_fs::metadata(at.subdir.join(TEST_HOW_ARE_YOU_SOURCE)).unwrap(); + assert_eq!(FileTime::from_last_access_time(&metadata), previous_atime); + assert_eq!( + FileTime::from_last_modification_time(&metadata), + previous_mtime + ); assert_eq!(at.read(TEST_HOW_ARE_YOU_SOURCE), "Hello, World!\n"); - let metadata = std_fs::metadata(at.subdir.join(TEST_HELLO_WORLD_SOURCE)).unwrap(); - let creation = metadata.modified().unwrap(); - - let metadata2 = std_fs::metadata(at.subdir.join(TEST_HOW_ARE_YOU_SOURCE)).unwrap(); - let creation2 = metadata2.modified().unwrap(); - let scene2 = TestScenario::new("ls"); let result = scene2.cmd("ls").arg("-al").arg(at.subdir).run(); println!("ls dest {}", result.stdout_str()); - assert_eq!(creation, creation2); +} + +#[test] +#[cfg(any(target_os = "linux", target_os = "android"))] +fn test_cp_preserve_recursive_directory_timestamps() { + let (at, mut ucmd) = at_and_ucmd!(); + let ts = time::OffsetDateTime::now_utc(); + let root_atime = FileTime::from_unix_time(ts.unix_timestamp() - 7200, ts.nanosecond()); + let root_mtime = FileTime::from_unix_time(ts.unix_timestamp() - 3600, ts.nanosecond()); + let nested_atime = FileTime::from_unix_time(ts.unix_timestamp() - 14_400, ts.nanosecond()); + let nested_mtime = FileTime::from_unix_time(ts.unix_timestamp() - 10_800, ts.nanosecond()); + + at.mkdir_all("source/nested"); + at.write("source/nested/file", "contents"); + filetime::set_file_times(at.plus("source/nested"), nested_atime, nested_mtime).unwrap(); + filetime::set_file_times(at.plus("source"), root_atime, root_mtime).unwrap(); + + ucmd.args(&["-R", "--preserve=timestamps", "source", "destination"]) + .succeeds(); + + let root_metadata = std_fs::metadata(at.plus("destination")).unwrap(); + assert_eq!(FileTime::from_last_access_time(&root_metadata), root_atime); + assert_eq!( + FileTime::from_last_modification_time(&root_metadata), + root_mtime + ); + + let nested_metadata = std_fs::metadata(at.plus("destination/nested")).unwrap(); + assert_eq!( + FileTime::from_last_access_time(&nested_metadata), + nested_atime + ); + assert_eq!( + FileTime::from_last_modification_time(&nested_metadata), + nested_mtime + ); +} + +#[test] +#[cfg(any(target_os = "linux", target_os = "android"))] +fn test_cp_preserve_symlink_timestamps() { + let (at, mut ucmd) = at_and_ucmd!(); + let ts = time::OffsetDateTime::now_utc(); + let previous_atime = FileTime::from_unix_time(ts.unix_timestamp() - 7200, ts.nanosecond()); + let previous_mtime = FileTime::from_unix_time(ts.unix_timestamp() - 3600, ts.nanosecond()); + let target_atime = FileTime::from_unix_time(ts.unix_timestamp() - 14_400, ts.nanosecond()); + let target_mtime = FileTime::from_unix_time(ts.unix_timestamp() - 10_800, ts.nanosecond()); + + at.write("target", "contents"); + at.relative_symlink_file("target", "source-link"); + filetime::set_file_times(at.plus("target"), target_atime, target_mtime).unwrap(); + filetime::set_symlink_file_times(at.plus("source-link"), previous_atime, previous_mtime) + .unwrap(); + + ucmd.args(&["-P", "--preserve=timestamps", "source-link", "dest-link"]) + .succeeds(); + + let link_metadata = std_fs::symlink_metadata(at.plus("dest-link")).unwrap(); + assert!(link_metadata.file_type().is_symlink()); + assert_eq!( + FileTime::from_last_access_time(&link_metadata), + previous_atime + ); + assert_eq!( + FileTime::from_last_modification_time(&link_metadata), + previous_mtime + ); + assert_eq!( + std_fs::read_link(at.plus("dest-link")).unwrap(), + std_fs::read_link(at.plus("source-link")).unwrap() + ); + + let target_metadata = std_fs::metadata(at.plus("target")).unwrap(); + assert_eq!( + FileTime::from_last_access_time(&target_metadata), + target_atime + ); + assert_eq!( + FileTime::from_last_modification_time(&target_metadata), + target_mtime + ); +} + +#[test] +#[cfg(any(target_os = "linux", target_os = "android"))] +fn test_cp_preserve_dereferenced_symlink_timestamps() { + let (at, mut ucmd) = at_and_ucmd!(); + let ts = time::OffsetDateTime::now_utc(); + let link_atime = FileTime::from_unix_time(ts.unix_timestamp() - 7200, ts.nanosecond()); + let link_mtime = FileTime::from_unix_time(ts.unix_timestamp() - 3600, ts.nanosecond()); + let target_atime = FileTime::from_unix_time(ts.unix_timestamp() - 14_400, ts.nanosecond()); + let target_mtime = FileTime::from_unix_time(ts.unix_timestamp() - 10_800, ts.nanosecond()); + + at.write("target", "contents"); + at.relative_symlink_file("target", "source-link"); + filetime::set_file_times(at.plus("target"), target_atime, target_mtime).unwrap(); + filetime::set_symlink_file_times(at.plus("source-link"), link_atime, link_mtime).unwrap(); + + ucmd.args(&["-L", "--preserve=timestamps", "source-link", "destination"]) + .succeeds(); + + let destination_metadata = std_fs::symlink_metadata(at.plus("destination")).unwrap(); + assert!(!destination_metadata.file_type().is_symlink()); + assert_eq!(at.read("destination"), "contents"); + assert_eq!( + FileTime::from_last_access_time(&destination_metadata), + target_atime + ); + assert_eq!( + FileTime::from_last_modification_time(&destination_metadata), + target_mtime + ); } #[test] @@ -3364,6 +3487,10 @@ fn test_copy_through_dangling_symlink_no_dereference_permissions() { at.symlink_file("no-such-file", "dangle"); // to check if access time and modification time didn't change sleep(Duration::from_millis(100)); + + #[cfg(all(unix, not(target_os = "freebsd"), not(target_os = "openbsd")))] + let expected_metadata = at.symlink_metadata("dangle"); + // don't dereference the link // | copy permissions, too // | | from the link @@ -3378,9 +3505,8 @@ fn test_copy_through_dangling_symlink_no_dereference_permissions() { // `-p` means `--preserve=mode,ownership,timestamps` #[cfg(all(unix, not(target_os = "freebsd"), not(target_os = "openbsd")))] { - let metadata1 = at.symlink_metadata("dangle"); let metadata2 = at.symlink_metadata("d2"); - assert_metadata_eq!(metadata1, metadata2); + assert_metadata_eq!(expected_metadata, metadata2); } } @@ -4075,6 +4201,7 @@ fn test_copy_dir_preserve_permissions() { let (at, mut ucmd) = at_and_ucmd!(); at.mkdir("d1"); at.set_mode("d1", 0o0500); + let expected_metadata = at.metadata("d1"); // Copy the directory, preserving those permissions. // @@ -4088,9 +4215,8 @@ fn test_copy_dir_preserve_permissions() { assert!(at.dir_exists("d2")); // Assert that the permissions are preserved. - let metadata1 = at.metadata("d1"); let metadata2 = at.metadata("d2"); - assert_metadata_eq!(metadata1, metadata2); + assert_metadata_eq!(expected_metadata, metadata2); } /// cp should preserve attributes of subdirectories when copying recursively. @@ -4103,14 +4229,16 @@ fn test_copy_dir_preserve_subdir_permissions() { // Use different permissions for a better test at.set_mode("a1/a2", 0o0555); at.set_mode("a1", 0o0777); + let expected_root_metadata = at.metadata("a1"); + let expected_subdir_metadata = at.metadata("a1/a2"); ucmd.args(&["-p", "-r", "a1", "b1"]).succeeds().no_output(); // Make sure everything is preserved assert!(at.dir_exists("b1")); assert!(at.dir_exists("b1/a2")); - assert_metadata_eq!(at.metadata("a1"), at.metadata("b1")); - assert_metadata_eq!(at.metadata("a1/a2"), at.metadata("b1/a2")); + assert_metadata_eq!(expected_root_metadata, at.metadata("b1")); + assert_metadata_eq!(expected_subdir_metadata, at.metadata("b1/a2")); } /// cp should successfully copy a read-only source directory containing files. @@ -4123,6 +4251,7 @@ fn test_copy_dir_preserve_readonly_source_with_files() { at.mkdir("src"); at.write("src/file.txt", "hello"); at.set_mode("src", 0o0555); + let expected_metadata = at.metadata("src"); ucmd.args(&["-p", "-r", "src", "dest"]) .succeeds() @@ -4130,7 +4259,7 @@ fn test_copy_dir_preserve_readonly_source_with_files() { assert!(at.dir_exists("dest")); assert_eq!(at.read("dest/file.txt"), "hello"); - assert_metadata_eq!(at.metadata("src"), at.metadata("dest")); + assert_metadata_eq!(expected_metadata, at.metadata("dest")); } /// Test for preserving permissions when copying a directory, even in @@ -4145,6 +4274,7 @@ fn test_copy_dir_preserve_permissions_inaccessible_file() { at.touch("d1/f"); at.set_mode("d1/f", 0); at.set_mode("d1", 0o0500); + let expected_metadata = at.metadata("d1"); // Copy the directory, preserving those permissions. There should // be an error message that the file `d1/f` is inaccessible. @@ -4162,9 +4292,8 @@ fn test_copy_dir_preserve_permissions_inaccessible_file() { assert!(!at.file_exists("d2/f")); // Assert that the permissions are preserved. - let metadata1 = at.metadata("d1"); let metadata2 = at.metadata("d2"); - assert_metadata_eq!(metadata1, metadata2); + assert_metadata_eq!(expected_metadata, metadata2); } /// Test that copying file to itself with backup fails. @@ -7229,6 +7358,9 @@ fn test_cp_preserve_selinux() { let args = ["-Z", "--context=unconfined_u:object_r:user_tmp_t:s0"]; at.touch(TEST_HELLO_WORLD_SOURCE); for arg in args { + #[cfg(all(unix, not(target_os = "freebsd"), not(target_os = "openbsd")))] + let expected_metadata = at.metadata(TEST_HELLO_WORLD_SOURCE); + ts.ucmd() .arg(arg) .arg(TEST_HELLO_WORLD_SOURCE) @@ -7250,9 +7382,8 @@ fn test_cp_preserve_selinux() { { // Assert that the mode, ownership, and timestamps are preserved // NOTICE: the ownership is not modified on the src file, because that requires root permissions - let metadata_src = at.metadata(TEST_HELLO_WORLD_SOURCE); let metadata_dst = at.metadata(TEST_HELLO_WORLD_DEST); - assert_metadata_eq!(metadata_src, metadata_dst); + assert_metadata_eq!(expected_metadata, metadata_dst); } at.remove(&at.plus_as_string(TEST_HELLO_WORLD_DEST));