use std::os::unix::fs::MetadataExt;
use anyhow::{anyhow, Context};
use async_recursion::async_recursion;
use throttle::get_file_iops_tokens;
use tracing::instrument;
use crate::config::DryRunMode;
use crate::filecmp;
use crate::filter::{FilterResult, FilterSettings};
use crate::preserve;
use crate::progress;
use crate::rm;
use crate::rm::{Settings as RmSettings, Summary as RmSummary};
#[derive(Debug, thiserror::Error)]
#[error("{source:#}")]
pub struct Error {
#[source]
pub source: anyhow::Error,
pub summary: Summary,
}
impl Error {
#[must_use]
pub fn new(source: anyhow::Error, summary: Summary) -> Self {
Error { source, summary }
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
pub enum OverwriteFilter {
#[value(name = "newer")]
Newer,
}
impl std::fmt::Display for OverwriteFilter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
OverwriteFilter::Newer => write!(f, "newer"),
}
}
}
#[derive(Debug, Clone)]
pub struct Settings {
pub dereference: bool,
pub fail_early: bool,
pub overwrite: bool,
pub overwrite_compare: filecmp::MetadataCmpSettings,
pub overwrite_filter: Option<OverwriteFilter>,
pub ignore_existing: bool,
pub chunk_size: u64,
pub remote_copy_buffer_size: usize,
pub filter: Option<crate::filter::FilterSettings>,
pub dry_run: Option<crate::config::DryRunMode>,
}
fn report_dry_run_copy(src: &std::path::Path, dst: &std::path::Path, entry_type: &str) {
println!("would copy {} {:?} -> {:?}", entry_type, src, dst);
}
fn report_dry_run_skip(
path: &std::path::Path,
result: &FilterResult,
mode: DryRunMode,
entry_type: &str,
) {
match mode {
DryRunMode::Brief => { }
DryRunMode::All => {
println!("skip {} {:?}", entry_type, path);
}
DryRunMode::Explain => match result {
FilterResult::ExcludedByDefault => {
println!(
"skip {} {:?} (no include pattern matched)",
entry_type, path
);
}
FilterResult::ExcludedByPattern(pattern) => {
println!("skip {} {:?} (excluded by '{}')", entry_type, path, pattern);
}
FilterResult::Included => { }
},
}
}
fn should_skip_entry(
filter: &Option<FilterSettings>,
relative_path: &std::path::Path,
is_dir: bool,
) -> Option<FilterResult> {
if let Some(ref f) = filter {
let result = f.should_include(relative_path, is_dir);
match result {
FilterResult::Included => None,
_ => Some(result),
}
} else {
None
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EmptyDirAction {
Keep,
Remove,
DryRunSkip,
}
pub fn check_empty_dir_cleanup(
filter: Option<&FilterSettings>,
we_created_dir: bool,
anything_copied: bool,
relative_path: &std::path::Path,
is_root: bool,
is_dry_run: bool,
) -> EmptyDirAction {
if filter.is_none() || anything_copied {
return EmptyDirAction::Keep;
}
if !we_created_dir {
return EmptyDirAction::Keep;
}
if is_root {
return EmptyDirAction::Keep;
}
let f = filter.unwrap();
if f.directly_matches_include(relative_path, true) {
return EmptyDirAction::Keep;
}
if is_dry_run {
EmptyDirAction::DryRunSkip
} else {
EmptyDirAction::Remove
}
}
#[instrument]
pub fn is_file_type_same(md1: &std::fs::Metadata, md2: &std::fs::Metadata) -> bool {
let ft1 = md1.file_type();
let ft2 = md2.file_type();
ft1.is_dir() == ft2.is_dir()
&& ft1.is_file() == ft2.is_file()
&& ft1.is_symlink() == ft2.is_symlink()
}
#[instrument(skip(prog_track, src_metadata, settings, preserve))]
pub async fn copy_file(
prog_track: &'static progress::Progress,
src: &std::path::Path,
dst: &std::path::Path,
src_metadata: &std::fs::Metadata,
settings: &Settings,
preserve: &preserve::Settings,
is_fresh: bool,
) -> Result<Summary, Error> {
if !is_fresh && settings.ignore_existing && tokio::fs::symlink_metadata(dst).await.is_ok() {
if let Some(mode) = settings.dry_run {
match mode {
DryRunMode::Brief => {}
DryRunMode::All => println!("skip file {:?}", dst),
DryRunMode::Explain => println!("skip file {:?} (destination exists)", dst),
}
}
tracing::debug!("destination exists, skipping (--ignore-existing)");
prog_track.files_unchanged.inc();
return Ok(Summary {
files_unchanged: 1,
..Default::default()
});
}
if settings.dry_run.is_some() {
report_dry_run_copy(src, dst, "file");
return Ok(Summary {
files_copied: 1,
bytes_copied: src_metadata.len(),
..Default::default()
});
}
tracing::debug!("opening 'src' for reading and 'dst' for writing");
get_file_iops_tokens(settings.chunk_size, src_metadata.size()).await;
let mut rm_summary = RmSummary::default();
if !is_fresh && dst.exists() {
if settings.overwrite {
tracing::debug!("file exists, check if it's identical");
let dst_metadata = tokio::fs::symlink_metadata(dst)
.await
.with_context(|| format!("failed reading metadata from {:?}", &dst))
.map_err(|err| Error::new(err, Default::default()))?;
if is_file_type_same(src_metadata, &dst_metadata) {
if filecmp::metadata_equal(&settings.overwrite_compare, src_metadata, &dst_metadata)
{
tracing::debug!("file is identical, skipping");
prog_track.files_unchanged.inc();
return Ok(Summary {
files_unchanged: 1,
..Default::default()
});
}
if let Some(OverwriteFilter::Newer) = settings.overwrite_filter {
if filecmp::dest_is_newer(src_metadata, &dst_metadata) {
tracing::debug!("dest is newer than source, skipping");
prog_track.files_unchanged.inc();
return Ok(Summary {
files_unchanged: 1,
..Default::default()
});
}
}
}
tracing::info!("file is different, removing existing file");
rm_summary = rm::rm(
prog_track,
dst,
&RmSettings {
fail_early: settings.fail_early,
filter: None,
dry_run: None,
},
)
.await
.map_err(|err| {
let rm_summary = err.summary;
let copy_summary = Summary {
rm_summary,
..Default::default()
};
Error::new(err.source, copy_summary)
})?;
} else {
return Err(Error::new(
anyhow!(
"destination {:?} already exists, did you intend to specify --overwrite?",
dst
),
Default::default(),
));
}
}
tracing::debug!("copying data");
let mut copy_summary = Summary {
rm_summary,
..Default::default()
};
tokio::fs::copy(src, dst)
.await
.with_context(|| format!("failed copying {:?} to {:?}", &src, &dst))
.map_err(|err| Error::new(err, copy_summary))?;
prog_track.files_copied.inc();
prog_track.bytes_copied.add(src_metadata.len());
tracing::debug!("setting permissions");
preserve::set_file_metadata(preserve, src_metadata, dst)
.await
.map_err(|err| Error::new(err, copy_summary))?;
copy_summary.bytes_copied += src_metadata.len();
copy_summary.files_copied += 1;
Ok(copy_summary)
}
#[derive(Copy, Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct Summary {
pub bytes_copied: u64,
pub files_copied: usize,
pub symlinks_created: usize,
pub directories_created: usize,
pub files_unchanged: usize,
pub symlinks_unchanged: usize,
pub directories_unchanged: usize,
pub files_skipped: usize,
pub symlinks_skipped: usize,
pub directories_skipped: usize,
pub rm_summary: RmSummary,
}
impl std::ops::Add for Summary {
type Output = Self;
fn add(self, other: Self) -> Self {
Self {
bytes_copied: self.bytes_copied + other.bytes_copied,
files_copied: self.files_copied + other.files_copied,
symlinks_created: self.symlinks_created + other.symlinks_created,
directories_created: self.directories_created + other.directories_created,
files_unchanged: self.files_unchanged + other.files_unchanged,
symlinks_unchanged: self.symlinks_unchanged + other.symlinks_unchanged,
directories_unchanged: self.directories_unchanged + other.directories_unchanged,
files_skipped: self.files_skipped + other.files_skipped,
symlinks_skipped: self.symlinks_skipped + other.symlinks_skipped,
directories_skipped: self.directories_skipped + other.directories_skipped,
rm_summary: self.rm_summary + other.rm_summary,
}
}
}
impl std::fmt::Display for Summary {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(
f,
"copy:\n\
-----\n\
bytes copied: {}\n\
files copied: {}\n\
symlinks created: {}\n\
directories created: {}\n\
files unchanged: {}\n\
symlinks unchanged: {}\n\
directories unchanged: {}\n\
files skipped: {}\n\
symlinks skipped: {}\n\
directories skipped: {}\n\
\n\
delete:\n\
-------\n\
{}",
bytesize::ByteSize(self.bytes_copied),
self.files_copied,
self.symlinks_created,
self.directories_created,
self.files_unchanged,
self.symlinks_unchanged,
self.directories_unchanged,
self.files_skipped,
self.symlinks_skipped,
self.directories_skipped,
&self.rm_summary,
)
}
}
#[instrument(skip(prog_track, settings, preserve))]
pub async fn copy(
prog_track: &'static progress::Progress,
src: &std::path::Path,
dst: &std::path::Path,
settings: &Settings,
preserve: &preserve::Settings,
is_fresh: bool,
) -> Result<Summary, Error> {
if let Some(ref filter) = settings.filter {
let src_name = src.file_name().map(std::path::Path::new);
if let Some(name) = src_name {
let src_metadata = tokio::fs::symlink_metadata(src)
.await
.with_context(|| format!("failed reading metadata from src: {:?}", &src))
.map_err(|err| Error::new(err, Default::default()))?;
let is_dir = src_metadata.is_dir();
let result = filter.should_include_root_item(name, is_dir);
match result {
crate::filter::FilterResult::Included => {}
result => {
if let Some(mode) = settings.dry_run {
let entry_type = if src_metadata.is_dir() {
"directory"
} else if src_metadata.is_symlink() {
"symlink"
} else {
"file"
};
report_dry_run_skip(src, &result, mode, entry_type);
}
let skipped_summary = if src_metadata.is_dir() {
prog_track.directories_skipped.inc();
Summary {
directories_skipped: 1,
..Default::default()
}
} else if src_metadata.is_symlink() {
prog_track.symlinks_skipped.inc();
Summary {
symlinks_skipped: 1,
..Default::default()
}
} else {
prog_track.files_skipped.inc();
Summary {
files_skipped: 1,
..Default::default()
}
};
return Ok(skipped_summary);
}
}
}
}
copy_internal(
prog_track, src, dst, src, settings, preserve, is_fresh, None,
)
.await
}
#[instrument(skip(prog_track, settings, preserve, open_file_guard))]
#[async_recursion]
#[allow(clippy::too_many_arguments)]
async fn copy_internal(
prog_track: &'static progress::Progress,
src: &std::path::Path,
dst: &std::path::Path,
source_root: &std::path::Path,
settings: &Settings,
preserve: &preserve::Settings,
mut is_fresh: bool,
open_file_guard: Option<throttle::OpenFileGuard>,
) -> Result<Summary, Error> {
let _ops_guard = prog_track.ops.guard();
tracing::debug!("reading source metadata");
let src_metadata = tokio::fs::symlink_metadata(src)
.await
.with_context(|| format!("failed reading metadata from src: {:?}", &src))
.map_err(|err| Error::new(err, Default::default()))?;
if settings.dereference && src_metadata.is_symlink() {
debug_assert!(
open_file_guard.is_none(),
"open file guard should not be pre-acquired for symlinks"
);
let link = tokio::fs::canonicalize(&src)
.await
.with_context(|| format!("failed reading src symlink {:?}", &src))
.map_err(|err| Error::new(err, Default::default()))?;
return copy(prog_track, &link, dst, settings, preserve, is_fresh).await;
}
if src_metadata.is_file() {
let _guard = match open_file_guard {
Some(g) => g,
None => throttle::open_file_permit().await,
};
return copy_file(
prog_track,
src,
dst,
&src_metadata,
settings,
preserve,
is_fresh,
)
.await;
}
debug_assert!(
open_file_guard.is_none(),
"open file guard should not be pre-acquired for directories or symlinks"
);
if src_metadata.is_symlink() {
if !is_fresh && settings.ignore_existing && tokio::fs::symlink_metadata(dst).await.is_ok() {
if let Some(mode) = settings.dry_run {
match mode {
DryRunMode::Brief => {}
DryRunMode::All => println!("skip symlink {:?}", dst),
DryRunMode::Explain => println!("skip symlink {:?} (destination exists)", dst),
}
}
tracing::debug!("destination exists, skipping symlink (--ignore-existing)");
prog_track.symlinks_unchanged.inc();
return Ok(Summary {
symlinks_unchanged: 1,
..Default::default()
});
}
if settings.dry_run.is_some() {
report_dry_run_copy(src, dst, "symlink");
return Ok(Summary {
symlinks_created: 1,
..Default::default()
});
}
let mut rm_summary = RmSummary::default();
let link = tokio::fs::read_link(src)
.await
.with_context(|| format!("failed reading symlink {:?}", &src))
.map_err(|err| Error::new(err, Default::default()))?;
if let Err(error) = tokio::fs::symlink(&link, dst).await {
if settings.ignore_existing && error.kind() == std::io::ErrorKind::AlreadyExists {
tracing::debug!("destination exists, skipping symlink (--ignore-existing)");
prog_track.symlinks_unchanged.inc();
return Ok(Summary {
symlinks_unchanged: 1,
..Default::default()
});
}
if settings.overwrite && error.kind() == std::io::ErrorKind::AlreadyExists {
let dst_metadata = tokio::fs::symlink_metadata(dst)
.await
.with_context(|| format!("failed reading metadata from dst: {:?}", &dst))
.map_err(|err| Error::new(err, Default::default()))?;
if is_file_type_same(&src_metadata, &dst_metadata) {
let dst_link = tokio::fs::read_link(dst)
.await
.with_context(|| format!("failed reading dst symlink: {:?}", &dst))
.map_err(|err| Error::new(err, Default::default()))?;
if link == dst_link {
tracing::debug!(
"'dst' is a symlink and points to the same location as 'src'"
);
if preserve.symlink.any() {
let dst_metadata = tokio::fs::symlink_metadata(dst)
.await
.with_context(|| {
format!("failed reading metadata from dst: {:?}", &dst)
})
.map_err(|err| Error::new(err, Default::default()))?;
if !filecmp::metadata_equal(
&settings.overwrite_compare,
&src_metadata,
&dst_metadata,
) {
tracing::debug!("'dst' metadata is different, updating");
preserve::set_symlink_metadata(preserve, &src_metadata, dst)
.await
.map_err(|err| Error::new(err, Default::default()))?;
prog_track.symlinks_removed.inc();
prog_track.symlinks_created.inc();
return Ok(Summary {
rm_summary: RmSummary {
symlinks_removed: 1,
..Default::default()
},
symlinks_created: 1,
..Default::default()
});
}
}
tracing::debug!("symlink already exists, skipping");
prog_track.symlinks_unchanged.inc();
return Ok(Summary {
symlinks_unchanged: 1,
..Default::default()
});
}
tracing::debug!("'dst' is a symlink but points to a different path, updating");
} else {
tracing::info!("'dst' is not a symlink, updating");
}
rm_summary = rm::rm(
prog_track,
dst,
&RmSettings {
fail_early: settings.fail_early,
filter: None,
dry_run: None,
},
)
.await
.map_err(|err| {
let rm_summary = err.summary;
let copy_summary = Summary {
rm_summary,
..Default::default()
};
Error::new(err.source, copy_summary)
})?;
tokio::fs::symlink(&link, dst)
.await
.with_context(|| format!("failed creating symlink {:?}", &dst))
.map_err(|err| {
let copy_summary = Summary {
rm_summary,
..Default::default()
};
Error::new(err, copy_summary)
})?;
} else {
return Err(Error::new(
anyhow!("failed creating symlink {:?}", &dst),
Default::default(),
));
}
}
preserve::set_symlink_metadata(preserve, &src_metadata, dst)
.await
.map_err(|err| {
let copy_summary = Summary {
rm_summary,
..Default::default()
};
Error::new(err, copy_summary)
})?;
prog_track.symlinks_created.inc();
return Ok(Summary {
rm_summary,
symlinks_created: 1,
..Default::default()
});
}
if !src_metadata.is_dir() {
return Err(Error::new(
anyhow!(
"copy: {:?} -> {:?} failed, unsupported src file type: {:?}",
src,
dst,
src_metadata.file_type()
),
Default::default(),
));
}
if settings.dry_run.is_some() {
if settings.ignore_existing
&& !is_fresh
&& tokio::fs::symlink_metadata(dst).await.is_ok()
&& !dst.is_dir()
{
if let Some(mode) = settings.dry_run {
match mode {
DryRunMode::Brief => {}
DryRunMode::All => println!("skip dir {:?}", dst),
DryRunMode::Explain => {
println!("skip dir {:?} (destination exists, not a directory)", dst);
}
}
}
return Ok(Summary {
directories_unchanged: 1,
..Default::default()
});
}
report_dry_run_copy(src, dst, "dir");
}
tracing::debug!("process contents of 'src' directory");
let mut entries = tokio::fs::read_dir(src)
.await
.with_context(|| format!("cannot open directory {src:?} for reading"))
.map_err(|err| Error::new(err, Default::default()))?;
let mut copy_summary = if settings.dry_run.is_some() {
Summary {
directories_created: 1, ..Default::default()
}
} else if let Err(error) = tokio::fs::create_dir(dst).await {
assert!(
!is_fresh,
"unexpected error creating directory: {dst:?}: {error}"
);
if (settings.overwrite || settings.ignore_existing)
&& error.kind() == std::io::ErrorKind::AlreadyExists
{
let dst_metadata = tokio::fs::symlink_metadata(dst)
.await
.with_context(|| format!("failed reading metadata from dst: {:?}", &dst))
.map_err(|err| Error::new(err, Default::default()))?;
if dst_metadata.is_dir() {
tracing::debug!("'dst' is a directory, leaving it as is");
prog_track.directories_unchanged.inc();
Summary {
directories_unchanged: 1,
..Default::default()
}
} else if settings.ignore_existing {
tracing::debug!("destination exists but is not a directory, skipping subtree (--ignore-existing)");
prog_track.directories_unchanged.inc();
return Ok(Summary {
directories_unchanged: 1,
..Default::default()
});
} else {
tracing::info!("'dst' is not a directory, removing and creating a new one");
let rm_summary = rm::rm(
prog_track,
dst,
&RmSettings {
fail_early: settings.fail_early,
filter: None,
dry_run: None,
},
)
.await
.map_err(|err| {
let rm_summary = err.summary;
let copy_summary = Summary {
rm_summary,
..Default::default()
};
Error::new(err.source, copy_summary)
})?;
tokio::fs::create_dir(dst)
.await
.with_context(|| format!("cannot create directory {dst:?}"))
.map_err(|err| {
let copy_summary = Summary {
rm_summary,
..Default::default()
};
Error::new(err, copy_summary)
})?;
is_fresh = true;
prog_track.directories_created.inc();
Summary {
rm_summary,
directories_created: 1,
..Default::default()
}
}
} else {
let error = Err::<(), std::io::Error>(error)
.with_context(|| format!("cannot create directory {:?}", dst))
.unwrap_err();
tracing::error!("{:#}", &error);
return Err(Error::new(error, Default::default()));
}
} else {
is_fresh = true;
prog_track.directories_created.inc();
Summary {
directories_created: 1,
..Default::default()
}
};
let we_created_this_dir = copy_summary.directories_created == 1;
let mut join_set = tokio::task::JoinSet::new();
let errors = crate::error_collector::ErrorCollector::default();
while let Some(entry) = entries
.next_entry()
.await
.with_context(|| format!("failed traversing src directory {:?}", &src))
.map_err(|err| Error::new(err, copy_summary))?
{
throttle::get_ops_token().await;
let entry_path = entry.path();
let entry_name = entry_path.file_name().unwrap();
let dst_path = dst.join(entry_name);
let entry_file_type = entry.file_type().await.ok();
let entry_is_dir = entry_file_type.map(|ft| ft.is_dir()).unwrap_or(false);
let entry_is_symlink = entry_file_type.map(|ft| ft.is_symlink()).unwrap_or(false);
let relative_path = entry_path.strip_prefix(source_root).unwrap_or(&entry_path);
if let Some(skip_result) = should_skip_entry(&settings.filter, relative_path, entry_is_dir)
{
if let Some(mode) = settings.dry_run {
let entry_type = if entry_is_dir {
"dir"
} else if entry_is_symlink {
"symlink"
} else {
"file"
};
report_dry_run_skip(&entry_path, &skip_result, mode, entry_type);
}
tracing::debug!("skipping {:?} due to filter", &entry_path);
if entry_is_dir {
copy_summary.directories_skipped += 1;
prog_track.directories_skipped.inc();
} else if entry_is_symlink {
copy_summary.symlinks_skipped += 1;
prog_track.symlinks_skipped.inc();
} else {
copy_summary.files_skipped += 1;
prog_track.files_skipped.inc();
}
continue;
}
let entry_is_regular_file = entry_file_type.as_ref().is_some_and(|ft| ft.is_file());
let open_file_guard = if entry_is_regular_file {
Some(throttle::open_file_permit().await)
} else {
None
};
let settings = settings.clone();
let preserve = *preserve;
let source_root = source_root.to_owned();
let do_copy = || async move {
copy_internal(
prog_track,
&entry_path,
&dst_path,
&source_root,
&settings,
&preserve,
is_fresh,
open_file_guard,
)
.await
};
join_set.spawn(do_copy());
}
drop(entries);
while let Some(res) = join_set.join_next().await {
match res {
Ok(result) => match result {
Ok(summary) => copy_summary = copy_summary + summary,
Err(error) => {
tracing::error!("copy: {:?} -> {:?} failed with: {:#}", src, dst, &error);
copy_summary = copy_summary + error.summary;
if settings.fail_early {
return Err(Error::new(error.source, copy_summary));
}
errors.push(error.source);
}
},
Err(error) => {
if settings.fail_early {
return Err(Error::new(error.into(), copy_summary));
}
errors.push(error.into());
}
}
}
let this_dir_count = usize::from(we_created_this_dir);
let child_dirs_created = copy_summary
.directories_created
.saturating_sub(this_dir_count);
let anything_copied = copy_summary.files_copied > 0
|| copy_summary.symlinks_created > 0
|| child_dirs_created > 0;
let relative_path = src.strip_prefix(source_root).unwrap_or(src);
let is_root = src == source_root;
match check_empty_dir_cleanup(
settings.filter.as_ref(),
we_created_this_dir,
anything_copied,
relative_path,
is_root,
settings.dry_run.is_some(),
) {
EmptyDirAction::Keep => { }
EmptyDirAction::DryRunSkip => {
tracing::debug!(
"dry-run: directory {:?} would not be created (nothing to copy inside)",
&dst
);
copy_summary.directories_created = 0;
return Ok(copy_summary);
}
EmptyDirAction::Remove => {
tracing::debug!(
"directory {:?} has nothing to copy inside, removing empty directory",
&dst
);
match tokio::fs::remove_dir(dst).await {
Ok(()) => {
copy_summary.directories_created = 0;
return Ok(copy_summary);
}
Err(err) => {
tracing::debug!(
"failed to remove empty directory {:?}: {:#}, keeping",
&dst,
&err
);
}
}
}
}
tracing::debug!("set 'dst' directory metadata");
let metadata_result = if settings.dry_run.is_some() {
Ok(()) } else {
preserve::set_dir_metadata(preserve, &src_metadata, dst).await
};
if errors.has_errors() {
if let Err(metadata_err) = metadata_result {
tracing::error!(
"copy: {:?} -> {:?} failed to set directory metadata: {:#}",
src,
dst,
&metadata_err
);
}
return Err(Error::new(errors.into_error().unwrap(), copy_summary));
}
metadata_result.map_err(|err| Error::new(err, copy_summary))?;
Ok(copy_summary)
}
#[cfg(test)]
mod copy_tests {
use crate::testutils;
use anyhow::Context;
use std::os::unix::fs::MetadataExt;
use std::os::unix::fs::PermissionsExt;
use tracing_test::traced_test;
use super::*;
static PROGRESS: std::sync::LazyLock<progress::Progress> =
std::sync::LazyLock::new(progress::Progress::new);
static NO_PRESERVE_SETTINGS: std::sync::LazyLock<preserve::Settings> =
std::sync::LazyLock::new(preserve::preserve_none);
static DO_PRESERVE_SETTINGS: std::sync::LazyLock<preserve::Settings> =
std::sync::LazyLock::new(preserve::preserve_all);
#[tokio::test]
#[traced_test]
async fn check_basic_copy() -> Result<(), anyhow::Error> {
let tmp_dir = testutils::setup_test_dir().await?;
let test_path = tmp_dir.as_path();
let summary = copy(
&PROGRESS,
&test_path.join("foo"),
&test_path.join("bar"),
&Settings {
dereference: false,
fail_early: false,
overwrite: false,
overwrite_compare: filecmp::MetadataCmpSettings {
size: true,
mtime: true,
..Default::default()
},
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(summary.files_copied, 5);
assert_eq!(summary.symlinks_created, 2);
assert_eq!(summary.directories_created, 3);
testutils::check_dirs_identical(
&test_path.join("foo"),
&test_path.join("bar"),
testutils::FileEqualityCheck::Basic,
)
.await?;
Ok(())
}
#[tokio::test]
#[traced_test]
async fn no_read_permission() -> Result<(), anyhow::Error> {
let tmp_dir = testutils::setup_test_dir().await?;
let test_path = tmp_dir.as_path();
let filepaths = vec![
test_path.join("foo").join("0.txt"),
test_path.join("foo").join("baz"),
];
for fpath in &filepaths {
tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o000)).await?;
}
match copy(
&PROGRESS,
&test_path.join("foo"),
&test_path.join("bar"),
&Settings {
dereference: false,
fail_early: false,
overwrite: false,
overwrite_compare: filecmp::MetadataCmpSettings {
size: true,
mtime: true,
..Default::default()
},
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
)
.await
{
Ok(_) => panic!("Expected the copy to error!"),
Err(error) => {
tracing::info!("{}", &error);
assert_eq!(error.summary.files_copied, 3);
assert_eq!(error.summary.symlinks_created, 0);
assert_eq!(error.summary.directories_created, 2);
}
}
for fpath in &filepaths {
tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o700)).await?;
if tokio::fs::symlink_metadata(fpath).await?.is_file() {
tokio::fs::remove_file(fpath).await?;
} else {
tokio::fs::remove_dir_all(fpath).await?;
}
}
testutils::check_dirs_identical(
&test_path.join("foo"),
&test_path.join("bar"),
testutils::FileEqualityCheck::Basic,
)
.await?;
Ok(())
}
#[tokio::test]
#[traced_test]
async fn check_default_mode() -> Result<(), anyhow::Error> {
let tmp_dir = testutils::setup_test_dir().await?;
tokio::fs::set_permissions(
tmp_dir.join("foo").join("0.txt"),
std::fs::Permissions::from_mode(0o700),
)
.await?;
let exec_sticky_file = tmp_dir.join("foo").join("bar").join("1.txt");
tokio::fs::set_permissions(&exec_sticky_file, std::fs::Permissions::from_mode(0o3770))
.await?;
let test_path = tmp_dir.as_path();
let summary = copy(
&PROGRESS,
&test_path.join("foo"),
&test_path.join("bar"),
&Settings {
dereference: false,
fail_early: false,
overwrite: false,
overwrite_compare: filecmp::MetadataCmpSettings {
size: true,
mtime: true,
..Default::default()
},
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(summary.files_copied, 5);
assert_eq!(summary.symlinks_created, 2);
assert_eq!(summary.directories_created, 3);
tokio::fs::set_permissions(
&exec_sticky_file,
std::fs::Permissions::from_mode(
std::fs::symlink_metadata(&exec_sticky_file)?
.permissions()
.mode()
& 0o0777,
),
)
.await?;
testutils::check_dirs_identical(
&test_path.join("foo"),
&test_path.join("bar"),
testutils::FileEqualityCheck::Basic,
)
.await?;
Ok(())
}
#[tokio::test]
#[traced_test]
async fn no_write_permission() -> Result<(), anyhow::Error> {
let tmp_dir = testutils::setup_test_dir().await?;
let test_path = tmp_dir.as_path();
let non_exec_dir = test_path.join("foo").join("bogey");
tokio::fs::create_dir(&non_exec_dir).await?;
tokio::fs::set_permissions(&non_exec_dir, std::fs::Permissions::from_mode(0o400)).await?;
tokio::fs::set_permissions(
&test_path.join("foo").join("baz"),
std::fs::Permissions::from_mode(0o500),
)
.await?;
tokio::fs::set_permissions(
&test_path.join("foo").join("baz").join("4.txt"),
std::fs::Permissions::from_mode(0o440),
)
.await?;
let summary = copy(
&PROGRESS,
&test_path.join("foo"),
&test_path.join("bar"),
&Settings {
dereference: false,
fail_early: false,
overwrite: false,
overwrite_compare: filecmp::MetadataCmpSettings {
size: true,
mtime: true,
..Default::default()
},
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(summary.files_copied, 5);
assert_eq!(summary.symlinks_created, 2);
assert_eq!(summary.directories_created, 4);
testutils::check_dirs_identical(
&test_path.join("foo"),
&test_path.join("bar"),
testutils::FileEqualityCheck::Basic,
)
.await?;
Ok(())
}
#[tokio::test]
#[traced_test]
async fn dereference() -> Result<(), anyhow::Error> {
let tmp_dir = testutils::setup_test_dir().await?;
let test_path = tmp_dir.as_path();
let src1 = &test_path.join("foo").join("bar").join("2.txt");
let src2 = &test_path.join("foo").join("bar").join("3.txt");
let test_mode = 0o440;
for f in [src1, src2] {
tokio::fs::set_permissions(f, std::fs::Permissions::from_mode(test_mode)).await?;
}
let summary = copy(
&PROGRESS,
&test_path.join("foo"),
&test_path.join("bar"),
&Settings {
dereference: true, fail_early: false,
overwrite: false,
overwrite_compare: filecmp::MetadataCmpSettings {
size: true,
mtime: true,
..Default::default()
},
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(summary.files_copied, 7);
assert_eq!(summary.symlinks_created, 0);
assert_eq!(summary.directories_created, 3);
let dst1 = &test_path.join("bar").join("baz").join("5.txt");
let dst2 = &test_path.join("bar").join("baz").join("6.txt");
for f in [dst1, dst2] {
let metadata = tokio::fs::symlink_metadata(f)
.await
.with_context(|| format!("failed reading metadata from {:?}", &f))?;
assert!(metadata.is_file());
assert_eq!(metadata.permissions().mode() & 0o777, test_mode);
}
Ok(())
}
async fn cp_compare(
cp_args: &[&str],
rcp_settings: &Settings,
preserve: bool,
) -> Result<(), anyhow::Error> {
let tmp_dir = testutils::setup_test_dir().await?;
let test_path = tmp_dir.as_path();
let cp_output = tokio::process::Command::new("cp")
.args(cp_args)
.arg(test_path.join("foo"))
.arg(test_path.join("bar"))
.output()
.await?;
assert!(cp_output.status.success());
let summary = copy(
&PROGRESS,
&test_path.join("foo"),
&test_path.join("baz"),
rcp_settings,
if preserve {
&DO_PRESERVE_SETTINGS
} else {
&NO_PRESERVE_SETTINGS
},
false,
)
.await?;
if rcp_settings.dereference {
assert_eq!(summary.files_copied, 7);
assert_eq!(summary.symlinks_created, 0);
} else {
assert_eq!(summary.files_copied, 5);
assert_eq!(summary.symlinks_created, 2);
}
assert_eq!(summary.directories_created, 3);
testutils::check_dirs_identical(
&test_path.join("bar"),
&test_path.join("baz"),
if preserve {
testutils::FileEqualityCheck::Timestamp
} else {
testutils::FileEqualityCheck::Basic
},
)
.await?;
Ok(())
}
#[tokio::test]
#[traced_test]
async fn test_cp_compat() -> Result<(), anyhow::Error> {
cp_compare(
&["-r"],
&Settings {
dereference: false,
fail_early: false,
overwrite: false,
overwrite_compare: filecmp::MetadataCmpSettings {
size: true,
mtime: true,
..Default::default()
},
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
false,
)
.await?;
Ok(())
}
#[tokio::test]
#[traced_test]
async fn test_cp_compat_preserve() -> Result<(), anyhow::Error> {
cp_compare(
&["-r", "-p"],
&Settings {
dereference: false,
fail_early: false,
overwrite: false,
overwrite_compare: filecmp::MetadataCmpSettings {
size: true,
mtime: true,
..Default::default()
},
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
true,
)
.await?;
Ok(())
}
#[tokio::test]
#[traced_test]
async fn test_cp_compat_dereference() -> Result<(), anyhow::Error> {
cp_compare(
&["-r", "-L"],
&Settings {
dereference: true,
fail_early: false,
overwrite: false,
overwrite_compare: filecmp::MetadataCmpSettings {
size: true,
mtime: true,
..Default::default()
},
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
false,
)
.await?;
Ok(())
}
#[tokio::test]
#[traced_test]
async fn test_cp_compat_preserve_and_dereference() -> Result<(), anyhow::Error> {
cp_compare(
&["-r", "-p", "-L"],
&Settings {
dereference: true,
fail_early: false,
overwrite: false,
overwrite_compare: filecmp::MetadataCmpSettings {
size: true,
mtime: true,
..Default::default()
},
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
true,
)
.await?;
Ok(())
}
async fn setup_test_dir_and_copy() -> Result<std::path::PathBuf, anyhow::Error> {
let tmp_dir = testutils::setup_test_dir().await?;
let test_path = tmp_dir.as_path();
let summary = copy(
&PROGRESS,
&test_path.join("foo"),
&test_path.join("bar"),
&Settings {
dereference: false,
fail_early: false,
overwrite: false,
overwrite_compare: filecmp::MetadataCmpSettings {
size: true,
mtime: true,
..Default::default()
},
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&DO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(summary.files_copied, 5);
assert_eq!(summary.symlinks_created, 2);
assert_eq!(summary.directories_created, 3);
Ok(tmp_dir)
}
#[tokio::test]
#[traced_test]
async fn test_cp_overwrite_basic() -> Result<(), anyhow::Error> {
let tmp_dir = setup_test_dir_and_copy().await?;
let output_path = &tmp_dir.join("bar");
{
let summary = rm::rm(
&PROGRESS,
&output_path.join("bar"),
&RmSettings {
fail_early: false,
filter: None,
dry_run: None,
},
)
.await?
+ rm::rm(
&PROGRESS,
&output_path.join("baz").join("5.txt"),
&RmSettings {
fail_early: false,
filter: None,
dry_run: None,
},
)
.await?;
assert_eq!(summary.files_removed, 3);
assert_eq!(summary.symlinks_removed, 1);
assert_eq!(summary.directories_removed, 1);
}
let summary = copy(
&PROGRESS,
&tmp_dir.join("foo"),
output_path,
&Settings {
dereference: false,
fail_early: false,
overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
size: true,
mtime: true,
..Default::default()
},
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&DO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(summary.files_copied, 3);
assert_eq!(summary.symlinks_created, 1);
assert_eq!(summary.directories_created, 1);
testutils::check_dirs_identical(
&tmp_dir.join("foo"),
output_path,
testutils::FileEqualityCheck::Timestamp,
)
.await?;
Ok(())
}
#[tokio::test]
#[traced_test]
async fn test_cp_overwrite_dir_file() -> Result<(), anyhow::Error> {
let tmp_dir = setup_test_dir_and_copy().await?;
let output_path = &tmp_dir.join("bar");
{
let summary = rm::rm(
&PROGRESS,
&output_path.join("bar").join("1.txt"),
&RmSettings {
fail_early: false,
filter: None,
dry_run: None,
},
)
.await?
+ rm::rm(
&PROGRESS,
&output_path.join("baz"),
&RmSettings {
fail_early: false,
filter: None,
dry_run: None,
},
)
.await?;
assert_eq!(summary.files_removed, 2);
assert_eq!(summary.symlinks_removed, 2);
assert_eq!(summary.directories_removed, 1);
}
{
tokio::fs::create_dir(&output_path.join("bar").join("1.txt")).await?;
tokio::fs::write(&output_path.join("baz"), "baz").await?;
}
let summary = copy(
&PROGRESS,
&tmp_dir.join("foo"),
output_path,
&Settings {
dereference: false,
fail_early: false,
overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
size: true,
mtime: true,
..Default::default()
},
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&DO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(summary.rm_summary.files_removed, 1);
assert_eq!(summary.rm_summary.symlinks_removed, 0);
assert_eq!(summary.rm_summary.directories_removed, 1);
assert_eq!(summary.files_copied, 2);
assert_eq!(summary.symlinks_created, 2);
assert_eq!(summary.directories_created, 1);
testutils::check_dirs_identical(
&tmp_dir.join("foo"),
output_path,
testutils::FileEqualityCheck::Timestamp,
)
.await?;
Ok(())
}
#[tokio::test]
#[traced_test]
async fn test_cp_overwrite_symlink_file() -> Result<(), anyhow::Error> {
let tmp_dir = setup_test_dir_and_copy().await?;
let output_path = &tmp_dir.join("bar");
{
let summary = rm::rm(
&PROGRESS,
&output_path.join("baz").join("4.txt"),
&RmSettings {
fail_early: false,
filter: None,
dry_run: None,
},
)
.await?
+ rm::rm(
&PROGRESS,
&output_path.join("baz").join("5.txt"),
&RmSettings {
fail_early: false,
filter: None,
dry_run: None,
},
)
.await?;
assert_eq!(summary.files_removed, 1);
assert_eq!(summary.symlinks_removed, 1);
assert_eq!(summary.directories_removed, 0);
}
{
tokio::fs::symlink("../0.txt", &output_path.join("baz").join("4.txt")).await?;
tokio::fs::write(&output_path.join("baz").join("5.txt"), "baz").await?;
}
let summary = copy(
&PROGRESS,
&tmp_dir.join("foo"),
output_path,
&Settings {
dereference: false,
fail_early: false,
overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
size: true,
mtime: true,
..Default::default()
},
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&DO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(summary.rm_summary.files_removed, 1);
assert_eq!(summary.rm_summary.symlinks_removed, 1);
assert_eq!(summary.rm_summary.directories_removed, 0);
assert_eq!(summary.files_copied, 1);
assert_eq!(summary.symlinks_created, 1);
assert_eq!(summary.directories_created, 0);
testutils::check_dirs_identical(
&tmp_dir.join("foo"),
output_path,
testutils::FileEqualityCheck::Timestamp,
)
.await?;
Ok(())
}
#[tokio::test]
#[traced_test]
async fn test_cp_overwrite_symlink_dir() -> Result<(), anyhow::Error> {
let tmp_dir = setup_test_dir_and_copy().await?;
let output_path = &tmp_dir.join("bar");
{
let summary = rm::rm(
&PROGRESS,
&output_path.join("bar"),
&RmSettings {
fail_early: false,
filter: None,
dry_run: None,
},
)
.await?
+ rm::rm(
&PROGRESS,
&output_path.join("baz").join("5.txt"),
&RmSettings {
fail_early: false,
filter: None,
dry_run: None,
},
)
.await?;
assert_eq!(summary.files_removed, 3);
assert_eq!(summary.symlinks_removed, 1);
assert_eq!(summary.directories_removed, 1);
}
{
tokio::fs::symlink("0.txt", &output_path.join("bar")).await?;
tokio::fs::create_dir(&output_path.join("baz").join("5.txt")).await?;
}
let summary = copy(
&PROGRESS,
&tmp_dir.join("foo"),
output_path,
&Settings {
dereference: false,
fail_early: false,
overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
size: true,
mtime: true,
..Default::default()
},
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&DO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(summary.rm_summary.files_removed, 0);
assert_eq!(summary.rm_summary.symlinks_removed, 1);
assert_eq!(summary.rm_summary.directories_removed, 1);
assert_eq!(summary.files_copied, 3);
assert_eq!(summary.symlinks_created, 1);
assert_eq!(summary.directories_created, 1);
assert_eq!(summary.files_unchanged, 2);
assert_eq!(summary.symlinks_unchanged, 1);
assert_eq!(summary.directories_unchanged, 2);
testutils::check_dirs_identical(
&tmp_dir.join("foo"),
output_path,
testutils::FileEqualityCheck::Timestamp,
)
.await?;
Ok(())
}
#[tokio::test]
#[traced_test]
async fn test_cp_overwrite_error() -> Result<(), anyhow::Error> {
let tmp_dir = testutils::setup_test_dir().await?;
let test_path = tmp_dir.as_path();
let summary = copy(
&PROGRESS,
&test_path.join("foo"),
&test_path.join("bar"),
&Settings {
dereference: false,
fail_early: false,
overwrite: false,
overwrite_compare: filecmp::MetadataCmpSettings {
size: true,
mtime: true,
..Default::default()
},
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&NO_PRESERVE_SETTINGS, false,
)
.await?;
assert_eq!(summary.files_copied, 5);
assert_eq!(summary.symlinks_created, 2);
assert_eq!(summary.directories_created, 3);
let source_path = &test_path.join("foo");
let output_path = &tmp_dir.join("bar");
tokio::fs::set_permissions(
&source_path.join("bar"),
std::fs::Permissions::from_mode(0o000),
)
.await?;
tokio::fs::set_permissions(
&source_path.join("baz").join("4.txt"),
std::fs::Permissions::from_mode(0o000),
)
.await?;
match copy(
&PROGRESS,
&tmp_dir.join("foo"),
output_path,
&Settings {
dereference: false,
fail_early: false,
overwrite: true, overwrite_compare: filecmp::MetadataCmpSettings {
size: true,
mtime: true,
..Default::default()
},
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&DO_PRESERVE_SETTINGS,
false,
)
.await
{
Ok(_) => panic!("Expected the copy to error!"),
Err(error) => {
tracing::info!("{}", &error);
assert_eq!(error.summary.files_copied, 1);
assert_eq!(error.summary.symlinks_created, 2);
assert_eq!(error.summary.directories_created, 0);
assert_eq!(error.summary.rm_summary.files_removed, 2);
assert_eq!(error.summary.rm_summary.symlinks_removed, 2);
assert_eq!(error.summary.rm_summary.directories_removed, 0);
}
}
Ok(())
}
#[tokio::test]
#[traced_test]
async fn overwrite_filter_newer_skips_when_dest_is_newer() -> Result<(), anyhow::Error> {
let tmp_dir = testutils::create_temp_dir().await?;
let test_path = tmp_dir.as_path();
let src_file = test_path.join("src.txt");
let dst_file = test_path.join("dst.txt");
tokio::fs::write(&dst_file, "newer content").await?;
let future_time = filetime::FileTime::from_unix_time(2_000_000_000, 0);
filetime::set_file_mtime(&dst_file, future_time)?;
tokio::fs::write(&src_file, "older content").await?;
let past_time = filetime::FileTime::from_unix_time(1_000_000_000, 0);
filetime::set_file_mtime(&src_file, past_time)?;
let summary = copy_file(
&PROGRESS,
&src_file,
&dst_file,
&tokio::fs::metadata(&src_file).await?,
&Settings {
dereference: false,
fail_early: false,
overwrite: true,
overwrite_compare: filecmp::MetadataCmpSettings {
size: true,
mtime: true,
..Default::default()
},
overwrite_filter: Some(OverwriteFilter::Newer),
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(summary.files_unchanged, 1);
assert_eq!(summary.files_copied, 0);
let content = tokio::fs::read_to_string(&dst_file).await?;
assert_eq!(content, "newer content");
Ok(())
}
#[tokio::test]
#[traced_test]
async fn overwrite_filter_newer_copies_when_dest_is_older() -> Result<(), anyhow::Error> {
let tmp_dir = testutils::create_temp_dir().await?;
let test_path = tmp_dir.as_path();
let src_file = test_path.join("src.txt");
let dst_file = test_path.join("dst.txt");
tokio::fs::write(&dst_file, "old content").await?;
let past_time = filetime::FileTime::from_unix_time(1_000_000_000, 0);
filetime::set_file_mtime(&dst_file, past_time)?;
tokio::fs::write(&src_file, "new content").await?;
let future_time = filetime::FileTime::from_unix_time(2_000_000_000, 0);
filetime::set_file_mtime(&src_file, future_time)?;
let summary = copy_file(
&PROGRESS,
&src_file,
&dst_file,
&tokio::fs::metadata(&src_file).await?,
&Settings {
dereference: false,
fail_early: false,
overwrite: true,
overwrite_compare: filecmp::MetadataCmpSettings {
size: true,
mtime: true,
..Default::default()
},
overwrite_filter: Some(OverwriteFilter::Newer),
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(summary.files_copied, 1);
assert_eq!(summary.files_unchanged, 0);
let content = tokio::fs::read_to_string(&dst_file).await?;
assert_eq!(content, "new content");
Ok(())
}
#[tokio::test]
#[traced_test]
async fn overwrite_filter_newer_copies_when_same_mtime() -> Result<(), anyhow::Error> {
let tmp_dir = testutils::create_temp_dir().await?;
let test_path = tmp_dir.as_path();
let src_file = test_path.join("src.txt");
let dst_file = test_path.join("dst.txt");
tokio::fs::write(&dst_file, "old").await?;
tokio::fs::write(&src_file, "new content").await?;
let same_time = filetime::FileTime::from_unix_time(1_500_000_000, 0);
filetime::set_file_mtime(&dst_file, same_time)?;
filetime::set_file_mtime(&src_file, same_time)?;
let summary = copy_file(
&PROGRESS,
&src_file,
&dst_file,
&tokio::fs::metadata(&src_file).await?,
&Settings {
dereference: false,
fail_early: false,
overwrite: true,
overwrite_compare: filecmp::MetadataCmpSettings {
size: true,
mtime: true,
..Default::default()
},
overwrite_filter: Some(OverwriteFilter::Newer),
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(summary.files_copied, 1);
assert_eq!(summary.files_unchanged, 0);
let content = tokio::fs::read_to_string(&dst_file).await?;
assert_eq!(content, "new content");
Ok(())
}
#[tokio::test]
#[traced_test]
async fn overwrite_without_filter_copies_when_dest_is_newer() -> Result<(), anyhow::Error> {
let tmp_dir = testutils::create_temp_dir().await?;
let test_path = tmp_dir.as_path();
let src_file = test_path.join("src.txt");
let dst_file = test_path.join("dst.txt");
tokio::fs::write(&dst_file, "newer content").await?;
let future_time = filetime::FileTime::from_unix_time(2_000_000_000, 0);
filetime::set_file_mtime(&dst_file, future_time)?;
tokio::fs::write(&src_file, "older content").await?;
let past_time = filetime::FileTime::from_unix_time(1_000_000_000, 0);
filetime::set_file_mtime(&src_file, past_time)?;
let summary = copy_file(
&PROGRESS,
&src_file,
&dst_file,
&tokio::fs::metadata(&src_file).await?,
&Settings {
dereference: false,
fail_early: false,
overwrite: true,
overwrite_compare: filecmp::MetadataCmpSettings {
size: true,
mtime: true,
..Default::default()
},
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(summary.files_copied, 1);
let content = tokio::fs::read_to_string(&dst_file).await?;
assert_eq!(content, "older content");
Ok(())
}
#[tokio::test]
#[traced_test]
async fn ignore_existing_skips_when_dest_exists() -> Result<(), anyhow::Error> {
let tmp_dir = testutils::create_temp_dir().await?;
let test_path = tmp_dir.as_path();
let src_file = test_path.join("src.txt");
let dst_file = test_path.join("dst.txt");
tokio::fs::write(&src_file, "source content").await?;
tokio::fs::write(&dst_file, "dest content").await?;
let summary = copy_file(
&PROGRESS,
&src_file,
&dst_file,
&tokio::fs::metadata(&src_file).await?,
&Settings {
dereference: false,
fail_early: false,
overwrite: false,
overwrite_compare: Default::default(),
overwrite_filter: None,
ignore_existing: true,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(summary.files_unchanged, 1);
assert_eq!(summary.files_copied, 0);
let content = tokio::fs::read_to_string(&dst_file).await?;
assert_eq!(content, "dest content");
Ok(())
}
#[tokio::test]
#[traced_test]
async fn ignore_existing_skips_when_dest_is_different_type() -> Result<(), anyhow::Error> {
let tmp_dir = testutils::create_temp_dir().await?;
let test_path = tmp_dir.as_path();
let src_file = test_path.join("src.txt");
let dst_dir = test_path.join("dst.txt");
tokio::fs::write(&src_file, "source content").await?;
tokio::fs::create_dir(&dst_dir).await?;
let summary = copy_file(
&PROGRESS,
&src_file,
&dst_dir,
&tokio::fs::metadata(&src_file).await?,
&Settings {
dereference: false,
fail_early: false,
overwrite: false,
overwrite_compare: Default::default(),
overwrite_filter: None,
ignore_existing: true,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(summary.files_unchanged, 1);
assert_eq!(summary.files_copied, 0);
assert!(dst_dir.is_dir());
Ok(())
}
#[tokio::test]
#[traced_test]
async fn ignore_existing_copies_when_dest_missing() -> Result<(), anyhow::Error> {
let tmp_dir = testutils::create_temp_dir().await?;
let test_path = tmp_dir.as_path();
let src_file = test_path.join("src.txt");
let dst_file = test_path.join("dst.txt");
tokio::fs::write(&src_file, "source content").await?;
let summary = copy_file(
&PROGRESS,
&src_file,
&dst_file,
&tokio::fs::metadata(&src_file).await?,
&Settings {
dereference: false,
fail_early: false,
overwrite: false,
overwrite_compare: Default::default(),
overwrite_filter: None,
ignore_existing: true,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(summary.files_copied, 1);
let content = tokio::fs::read_to_string(&dst_file).await?;
assert_eq!(content, "source content");
Ok(())
}
#[tokio::test]
#[traced_test]
async fn test_cp_dereference_symlink_chain() -> Result<(), anyhow::Error> {
let tmp_dir = testutils::create_temp_dir().await?;
let test_path = tmp_dir.as_path();
let baz_file = test_path.join("baz_file.txt");
tokio::fs::write(&baz_file, "final content").await?;
let bar_link = test_path.join("bar_link");
let foo_link = test_path.join("foo_link");
tokio::fs::symlink(&baz_file, &bar_link).await?;
tokio::fs::symlink(&bar_link, &foo_link).await?;
let src_dir = test_path.join("src_chain");
tokio::fs::create_dir(&src_dir).await?;
tokio::fs::symlink("../foo_link", &src_dir.join("foo")).await?;
tokio::fs::symlink("../bar_link", &src_dir.join("bar")).await?;
tokio::fs::symlink("../baz_file.txt", &src_dir.join("baz")).await?;
let summary = copy(
&PROGRESS,
&src_dir,
&test_path.join("dst_with_deref"),
&Settings {
dereference: true, fail_early: false,
overwrite: false,
overwrite_compare: filecmp::MetadataCmpSettings {
size: true,
mtime: true,
..Default::default()
},
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(summary.files_copied, 3); assert_eq!(summary.symlinks_created, 0); assert_eq!(summary.directories_created, 1);
let dst_dir = test_path.join("dst_with_deref");
let foo_content = tokio::fs::read_to_string(dst_dir.join("foo")).await?;
let bar_content = tokio::fs::read_to_string(dst_dir.join("bar")).await?;
let baz_content = tokio::fs::read_to_string(dst_dir.join("baz")).await?;
assert_eq!(foo_content, "final content");
assert_eq!(bar_content, "final content");
assert_eq!(baz_content, "final content");
assert!(dst_dir.join("foo").is_file());
assert!(dst_dir.join("bar").is_file());
assert!(dst_dir.join("baz").is_file());
assert!(!dst_dir.join("foo").is_symlink());
assert!(!dst_dir.join("bar").is_symlink());
assert!(!dst_dir.join("baz").is_symlink());
Ok(())
}
#[tokio::test]
#[traced_test]
async fn test_cp_dereference_symlink_to_directory() -> Result<(), anyhow::Error> {
let tmp_dir = testutils::create_temp_dir().await?;
let test_path = tmp_dir.as_path();
let target_dir = test_path.join("target_dir");
tokio::fs::create_dir(&target_dir).await?;
tokio::fs::set_permissions(&target_dir, std::fs::Permissions::from_mode(0o755)).await?;
tokio::fs::write(target_dir.join("file1.txt"), "content1").await?;
tokio::fs::write(target_dir.join("file2.txt"), "content2").await?;
tokio::fs::set_permissions(
&target_dir.join("file1.txt"),
std::fs::Permissions::from_mode(0o644),
)
.await?;
tokio::fs::set_permissions(
&target_dir.join("file2.txt"),
std::fs::Permissions::from_mode(0o600),
)
.await?;
let dir_symlink = test_path.join("dir_symlink");
tokio::fs::symlink(&target_dir, &dir_symlink).await?;
let summary = copy(
&PROGRESS,
&dir_symlink,
&test_path.join("copied_dir"),
&Settings {
dereference: true, fail_early: false,
overwrite: false,
overwrite_compare: filecmp::MetadataCmpSettings {
size: true,
mtime: true,
..Default::default()
},
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&DO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(summary.files_copied, 2); assert_eq!(summary.symlinks_created, 0); assert_eq!(summary.directories_created, 1); let copied_dir = test_path.join("copied_dir");
assert!(copied_dir.is_dir());
assert!(!copied_dir.is_symlink()); let file1_content = tokio::fs::read_to_string(copied_dir.join("file1.txt")).await?;
let file2_content = tokio::fs::read_to_string(copied_dir.join("file2.txt")).await?;
assert_eq!(file1_content, "content1");
assert_eq!(file2_content, "content2");
let copied_dir_metadata = tokio::fs::metadata(&copied_dir).await?;
let file1_metadata = tokio::fs::metadata(copied_dir.join("file1.txt")).await?;
let file2_metadata = tokio::fs::metadata(copied_dir.join("file2.txt")).await?;
assert_eq!(copied_dir_metadata.permissions().mode() & 0o777, 0o755);
assert_eq!(file1_metadata.permissions().mode() & 0o777, 0o644);
assert_eq!(file2_metadata.permissions().mode() & 0o777, 0o600);
Ok(())
}
#[tokio::test]
#[traced_test]
async fn test_cp_dereference_permissions_preserved() -> Result<(), anyhow::Error> {
let tmp_dir = testutils::create_temp_dir().await?;
let test_path = tmp_dir.as_path();
let file1 = test_path.join("file1.txt");
let file2 = test_path.join("file2.txt");
tokio::fs::write(&file1, "content1").await?;
tokio::fs::write(&file2, "content2").await?;
tokio::fs::set_permissions(&file1, std::fs::Permissions::from_mode(0o755)).await?;
tokio::fs::set_permissions(&file2, std::fs::Permissions::from_mode(0o640)).await?;
let symlink1 = test_path.join("symlink1");
let symlink2 = test_path.join("symlink2");
tokio::fs::symlink(&file1, &symlink1).await?;
tokio::fs::symlink(&file2, &symlink2).await?;
let summary1 = copy(
&PROGRESS,
&symlink1,
&test_path.join("copied_file1.txt"),
&Settings {
dereference: true, fail_early: false,
overwrite: false,
overwrite_compare: filecmp::MetadataCmpSettings::default(),
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&DO_PRESERVE_SETTINGS, false,
)
.await?;
let summary2 = copy(
&PROGRESS,
&symlink2,
&test_path.join("copied_file2.txt"),
&Settings {
dereference: true,
fail_early: false,
overwrite: false,
overwrite_compare: filecmp::MetadataCmpSettings::default(),
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&DO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(summary1.files_copied, 1);
assert_eq!(summary1.symlinks_created, 0);
assert_eq!(summary2.files_copied, 1);
assert_eq!(summary2.symlinks_created, 0);
let copied1 = test_path.join("copied_file1.txt");
let copied2 = test_path.join("copied_file2.txt");
assert!(copied1.is_file());
assert!(!copied1.is_symlink());
assert!(copied2.is_file());
assert!(!copied2.is_symlink());
let content1 = tokio::fs::read_to_string(&copied1).await?;
let content2 = tokio::fs::read_to_string(&copied2).await?;
assert_eq!(content1, "content1");
assert_eq!(content2, "content2");
let copied1_metadata = tokio::fs::metadata(&copied1).await?;
let copied2_metadata = tokio::fs::metadata(&copied2).await?;
assert_eq!(copied1_metadata.permissions().mode() & 0o777, 0o755);
assert_eq!(copied2_metadata.permissions().mode() & 0o777, 0o640);
Ok(())
}
#[tokio::test]
#[traced_test]
async fn test_cp_dereference_dir() -> Result<(), anyhow::Error> {
let tmp_dir = testutils::setup_test_dir().await?;
tokio::fs::symlink("bar", &tmp_dir.join("foo").join("bar-link")).await?;
tokio::fs::symlink("bar-link", &tmp_dir.join("foo").join("bar-link-link")).await?;
let summary = copy(
&PROGRESS,
&tmp_dir.join("foo"),
&tmp_dir.join("bar"),
&Settings {
dereference: true, fail_early: false,
overwrite: false,
overwrite_compare: filecmp::MetadataCmpSettings {
size: true,
mtime: true,
..Default::default()
},
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&DO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(summary.files_copied, 13); assert_eq!(summary.symlinks_created, 0); assert_eq!(summary.directories_created, 5);
tokio::process::Command::new("cp")
.args(["-r", "-L"])
.arg(tmp_dir.join("foo"))
.arg(tmp_dir.join("bar-cp"))
.output()
.await?;
testutils::check_dirs_identical(
&tmp_dir.join("bar"),
&tmp_dir.join("bar-cp"),
testutils::FileEqualityCheck::Basic,
)
.await?;
Ok(())
}
mod error_message_tests {
use super::*;
fn get_full_error_message(error: &Error) -> String {
format!("{:#}", error.source)
}
#[tokio::test]
#[traced_test]
async fn test_permission_error_includes_root_cause() -> Result<(), anyhow::Error> {
let tmp_dir = testutils::create_temp_dir().await?;
let unreadable = tmp_dir.join("unreadable.txt");
tokio::fs::write(&unreadable, "test").await?;
tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
let src_metadata = tokio::fs::symlink_metadata(&unreadable).await?;
let result = copy_file(
&PROGRESS,
&unreadable,
&tmp_dir.join("dest.txt"),
&src_metadata,
&Settings {
dereference: false,
fail_early: false,
overwrite: false,
overwrite_compare: Default::default(),
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
)
.await;
assert!(result.is_err(), "Should fail with permission error");
let err_msg = get_full_error_message(&result.unwrap_err());
assert!(
err_msg.to_lowercase().contains("permission")
|| err_msg.contains("EACCES")
|| err_msg.contains("denied"),
"Error message must include permission-related text. Got: {}",
err_msg
);
Ok(())
}
#[tokio::test]
#[traced_test]
async fn test_nonexistent_source_includes_root_cause() -> Result<(), anyhow::Error> {
let tmp_dir = testutils::create_temp_dir().await?;
let result = copy(
&PROGRESS,
&tmp_dir.join("does_not_exist.txt"),
&tmp_dir.join("dest.txt"),
&Settings {
dereference: false,
fail_early: false,
overwrite: false,
overwrite_compare: Default::default(),
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
)
.await;
assert!(result.is_err());
let err_msg = get_full_error_message(&result.unwrap_err());
assert!(
err_msg.to_lowercase().contains("no such file")
|| err_msg.to_lowercase().contains("not found")
|| err_msg.contains("ENOENT"),
"Error message must include file not found text. Got: {}",
err_msg
);
Ok(())
}
#[tokio::test]
#[traced_test]
async fn test_unreadable_directory_includes_root_cause() -> Result<(), anyhow::Error> {
let tmp_dir = testutils::create_temp_dir().await?;
let unreadable_dir = tmp_dir.join("unreadable_dir");
tokio::fs::create_dir(&unreadable_dir).await?;
tokio::fs::set_permissions(&unreadable_dir, std::fs::Permissions::from_mode(0o000))
.await?;
let result = copy(
&PROGRESS,
&unreadable_dir,
&tmp_dir.join("dest"),
&Settings {
dereference: false,
fail_early: true,
overwrite: false,
overwrite_compare: Default::default(),
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
)
.await;
assert!(result.is_err());
let err_msg = get_full_error_message(&result.unwrap_err());
assert!(
err_msg.to_lowercase().contains("permission")
|| err_msg.contains("EACCES")
|| err_msg.contains("denied"),
"Error message must include permission-related text. Got: {}",
err_msg
);
tokio::fs::set_permissions(&unreadable_dir, std::fs::Permissions::from_mode(0o700))
.await?;
Ok(())
}
#[tokio::test]
#[traced_test]
async fn test_destination_permission_error_includes_root_cause() -> Result<(), anyhow::Error>
{
let tmp_dir = testutils::setup_test_dir().await?;
let test_path = tmp_dir.as_path();
let readonly_parent = test_path.join("readonly_dest");
tokio::fs::create_dir(&readonly_parent).await?;
tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
.await?;
let result = copy(
&PROGRESS,
&test_path.join("foo"),
&readonly_parent.join("copy"),
&Settings {
dereference: false,
fail_early: true,
overwrite: false,
overwrite_compare: Default::default(),
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
)
.await;
tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
.await?;
assert!(result.is_err(), "copy into read-only parent should fail");
let err_msg = get_full_error_message(&result.unwrap_err());
assert!(
err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
"Error message must include permission denied text. Got: {}",
err_msg
);
Ok(())
}
}
mod empty_dir_cleanup_tests {
use super::*;
use std::path::Path;
#[test]
fn test_check_empty_dir_cleanup_no_filter() {
assert_eq!(
check_empty_dir_cleanup(None, true, false, Path::new("any"), false, false),
EmptyDirAction::Keep
);
}
#[test]
fn test_check_empty_dir_cleanup_something_copied() {
let mut filter = FilterSettings::new();
filter.add_include("*.txt").unwrap();
assert_eq!(
check_empty_dir_cleanup(Some(&filter), true, true, Path::new("any"), false, false),
EmptyDirAction::Keep
);
}
#[test]
fn test_check_empty_dir_cleanup_not_created() {
let mut filter = FilterSettings::new();
filter.add_include("*.txt").unwrap();
assert_eq!(
check_empty_dir_cleanup(
Some(&filter),
false,
false,
Path::new("any"),
false,
false
),
EmptyDirAction::Keep
);
}
#[test]
fn test_check_empty_dir_cleanup_directly_matched() {
let mut filter = FilterSettings::new();
filter.add_include("target/").unwrap();
assert_eq!(
check_empty_dir_cleanup(
Some(&filter),
true,
false,
Path::new("target"),
false,
false
),
EmptyDirAction::Keep
);
}
#[test]
fn test_check_empty_dir_cleanup_traversed_only() {
let mut filter = FilterSettings::new();
filter.add_include("*.txt").unwrap();
assert_eq!(
check_empty_dir_cleanup(Some(&filter), true, false, Path::new("src"), false, false),
EmptyDirAction::Remove
);
}
#[test]
fn test_check_empty_dir_cleanup_dry_run() {
let mut filter = FilterSettings::new();
filter.add_include("*.txt").unwrap();
assert_eq!(
check_empty_dir_cleanup(Some(&filter), true, false, Path::new("src"), false, true),
EmptyDirAction::DryRunSkip
);
}
#[test]
fn test_check_empty_dir_cleanup_root_always_kept() {
let mut filter = FilterSettings::new();
filter.add_include("*.txt").unwrap();
assert_eq!(
check_empty_dir_cleanup(Some(&filter), true, false, Path::new(""), true, false),
EmptyDirAction::Keep
);
}
#[test]
fn test_check_empty_dir_cleanup_root_kept_in_dry_run() {
let mut filter = FilterSettings::new();
filter.add_include("*.txt").unwrap();
assert_eq!(
check_empty_dir_cleanup(Some(&filter), true, false, Path::new(""), true, true),
EmptyDirAction::Keep
);
}
}
#[tokio::test]
#[traced_test]
async fn test_directory_metadata_applied_on_child_error() -> Result<(), anyhow::Error> {
let tmp_dir = testutils::create_temp_dir().await?;
let test_path = tmp_dir.as_path();
let src_dir = test_path.join("src");
tokio::fs::create_dir(&src_dir).await?;
tokio::fs::set_permissions(&src_dir, std::fs::Permissions::from_mode(0o750)).await?;
let readable_file = src_dir.join("readable.txt");
tokio::fs::write(&readable_file, "content").await?;
let unreadable_file = src_dir.join("unreadable.txt");
tokio::fs::write(&unreadable_file, "secret").await?;
tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o000))
.await?;
let dst_dir = test_path.join("dst");
let result = copy(
&PROGRESS,
&src_dir,
&dst_dir,
&Settings {
dereference: false,
fail_early: false,
overwrite: false,
overwrite_compare: Default::default(),
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&DO_PRESERVE_SETTINGS,
false,
)
.await;
tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o644))
.await?;
assert!(result.is_err(), "copy should fail due to unreadable file");
let error = result.unwrap_err();
assert_eq!(error.summary.files_copied, 1);
assert_eq!(error.summary.directories_created, 1);
let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
assert!(dst_metadata.is_dir());
let actual_mode = dst_metadata.permissions().mode() & 0o7777;
assert_eq!(
actual_mode, 0o750,
"directory should have preserved source permissions (0o750), got {:o}",
actual_mode
);
Ok(())
}
#[tokio::test]
#[traced_test]
async fn test_fail_early_does_not_apply_parent_directory_metadata_after_child_error(
) -> Result<(), anyhow::Error> {
let tmp_dir = testutils::create_temp_dir().await?;
let test_path = tmp_dir.as_path();
let src_dir = test_path.join("src");
tokio::fs::create_dir(&src_dir).await?;
tokio::fs::write(src_dir.join("readable.txt"), "content").await?;
let unreadable_file = src_dir.join("unreadable.txt");
tokio::fs::write(&unreadable_file, "secret").await?;
tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o000))
.await?;
let fixed_secs = 946684800;
let fixed_nsec = 123_456_789;
let fixed_time = nix::sys::time::TimeSpec::new(fixed_secs, fixed_nsec);
nix::sys::stat::utimensat(
nix::fcntl::AT_FDCWD,
&src_dir,
&fixed_time,
&fixed_time,
nix::sys::stat::UtimensatFlags::NoFollowSymlink,
)?;
let src_metadata = tokio::fs::metadata(&src_dir).await?;
let dst_dir = test_path.join("dst");
let result = copy(
&PROGRESS,
&src_dir,
&dst_dir,
&Settings {
dereference: false,
fail_early: true,
overwrite: false,
overwrite_compare: Default::default(),
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&DO_PRESERVE_SETTINGS,
false,
)
.await;
tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o644))
.await?;
assert!(result.is_err(), "copy should fail due to unreadable file");
let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
assert!(dst_metadata.is_dir());
assert_ne!(
(dst_metadata.mtime(), dst_metadata.mtime_nsec()),
(src_metadata.mtime(), src_metadata.mtime_nsec()),
"fail-early should return before applying preserved directory timestamps"
);
Ok(())
}
mod filter_tests {
use super::*;
use crate::filter::FilterSettings;
#[tokio::test]
#[traced_test]
async fn test_path_pattern_matches_nested_files() -> Result<(), anyhow::Error> {
let tmp_dir = testutils::setup_test_dir().await?;
let test_path = tmp_dir.as_path();
let mut filter = FilterSettings::new();
filter.add_include("bar/*.txt").unwrap();
let summary = copy(
&PROGRESS,
&test_path.join("foo"),
&test_path.join("dst"),
&Settings {
dereference: false,
fail_early: false,
overwrite: false,
overwrite_compare: Default::default(),
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: Some(filter),
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(
summary.files_copied, 3,
"should copy 3 files matching bar/*.txt"
);
assert!(
test_path.join("dst/bar/1.txt").exists(),
"bar/1.txt should be copied"
);
assert!(
test_path.join("dst/bar/2.txt").exists(),
"bar/2.txt should be copied"
);
assert!(
test_path.join("dst/bar/3.txt").exists(),
"bar/3.txt should be copied"
);
assert!(
!test_path.join("dst/0.txt").exists(),
"0.txt should not be copied"
);
Ok(())
}
#[tokio::test]
#[traced_test]
async fn test_anchored_pattern_matches_only_at_root() -> Result<(), anyhow::Error> {
let tmp_dir = testutils::setup_test_dir().await?;
let test_path = tmp_dir.as_path();
let mut filter = FilterSettings::new();
filter.add_include("/bar/**").unwrap();
let summary = copy(
&PROGRESS,
&test_path.join("foo"),
&test_path.join("dst"),
&Settings {
dereference: false,
fail_early: false,
overwrite: false,
overwrite_compare: Default::default(),
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: Some(filter),
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
)
.await?;
assert!(
test_path.join("dst/bar").exists(),
"bar directory should be copied"
);
assert!(
!test_path.join("dst/baz").exists(),
"baz directory should not be copied"
);
assert!(
!test_path.join("dst/0.txt").exists(),
"0.txt should not be copied"
);
assert_eq!(
summary.files_copied, 3,
"should copy 3 files in bar (1.txt, 2.txt, 3.txt)"
);
assert_eq!(
summary.directories_created, 2,
"should create 2 directories (root dst + bar)"
);
assert_eq!(summary.files_skipped, 1, "should skip 1 file (0.txt)");
assert_eq!(
summary.directories_skipped, 1,
"should skip 1 directory (baz)"
);
Ok(())
}
#[tokio::test]
#[traced_test]
async fn test_double_star_pattern_matches_nested() -> Result<(), anyhow::Error> {
let tmp_dir = testutils::setup_test_dir().await?;
let test_path = tmp_dir.as_path();
let mut filter = FilterSettings::new();
filter.add_include("**/*.txt").unwrap();
let summary = copy(
&PROGRESS,
&test_path.join("foo"),
&test_path.join("dst"),
&Settings {
dereference: false,
fail_early: false,
overwrite: false,
overwrite_compare: Default::default(),
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: Some(filter),
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(
summary.files_copied, 5,
"should copy all 5 .txt files with **/*.txt pattern"
);
Ok(())
}
#[tokio::test]
#[traced_test]
async fn test_filter_applies_to_single_file_source() -> Result<(), anyhow::Error> {
let tmp_dir = testutils::setup_test_dir().await?;
let test_path = tmp_dir.as_path();
let mut filter = FilterSettings::new();
filter.add_exclude("*.txt").unwrap();
let result = copy(
&PROGRESS,
&test_path.join("foo/0.txt"), &test_path.join("dst.txt"),
&Settings {
dereference: false,
fail_early: false,
overwrite: false,
overwrite_compare: Default::default(),
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: Some(filter),
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(
result.files_copied, 0,
"file matching exclude pattern should not be copied"
);
assert!(
!test_path.join("dst.txt").exists(),
"excluded file should not exist at destination"
);
Ok(())
}
#[tokio::test]
#[traced_test]
async fn test_filter_applies_to_root_directory() -> Result<(), anyhow::Error> {
let test_path = testutils::create_temp_dir().await?;
tokio::fs::create_dir_all(test_path.join("excluded_dir")).await?;
tokio::fs::write(test_path.join("excluded_dir/file.txt"), "content").await?;
let mut filter = FilterSettings::new();
filter.add_exclude("*_dir/").unwrap();
let result = copy(
&PROGRESS,
&test_path.join("excluded_dir"),
&test_path.join("dst"),
&Settings {
dereference: false,
fail_early: false,
overwrite: false,
overwrite_compare: Default::default(),
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: Some(filter),
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(
result.directories_created, 0,
"root directory matching exclude should not be created"
);
assert!(
!test_path.join("dst").exists(),
"excluded root directory should not exist at destination"
);
Ok(())
}
#[tokio::test]
#[traced_test]
async fn test_filter_applies_to_root_symlink() -> Result<(), anyhow::Error> {
let test_path = testutils::create_temp_dir().await?;
tokio::fs::write(test_path.join("target.txt"), "content").await?;
tokio::fs::symlink(
test_path.join("target.txt"),
test_path.join("excluded_link"),
)
.await?;
let mut filter = FilterSettings::new();
filter.add_exclude("*_link").unwrap();
let result = copy(
&PROGRESS,
&test_path.join("excluded_link"),
&test_path.join("dst"),
&Settings {
dereference: false,
fail_early: false,
overwrite: false,
overwrite_compare: Default::default(),
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: Some(filter),
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(
result.symlinks_created, 0,
"root symlink matching exclude should not be created"
);
assert!(
!test_path.join("dst").exists(),
"excluded root symlink should not exist at destination"
);
Ok(())
}
#[tokio::test]
#[traced_test]
async fn test_combined_include_exclude_patterns() -> Result<(), anyhow::Error> {
let tmp_dir = testutils::setup_test_dir().await?;
let test_path = tmp_dir.as_path();
let mut filter = FilterSettings::new();
filter.add_include("**/*.txt").unwrap();
filter.add_exclude("bar/2.txt").unwrap();
let summary = copy(
&PROGRESS,
&test_path.join("foo"),
&test_path.join("dst"),
&Settings {
dereference: false,
fail_early: false,
overwrite: false,
overwrite_compare: Default::default(),
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: Some(filter),
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(summary.files_copied, 4, "should copy 4 .txt files");
assert_eq!(
summary.files_skipped, 1,
"should skip 1 file (bar/2.txt excluded)"
);
assert!(
test_path.join("dst/bar/1.txt").exists(),
"bar/1.txt should be copied"
);
assert!(
!test_path.join("dst/bar/2.txt").exists(),
"bar/2.txt should be excluded"
);
assert!(
test_path.join("dst/bar/3.txt").exists(),
"bar/3.txt should be copied"
);
Ok(())
}
#[tokio::test]
#[traced_test]
async fn test_skipped_counts_comprehensive() -> Result<(), anyhow::Error> {
let tmp_dir = testutils::setup_test_dir().await?;
let test_path = tmp_dir.as_path();
let mut filter = FilterSettings::new();
filter.add_exclude("bar/").unwrap();
let summary = copy(
&PROGRESS,
&test_path.join("foo"),
&test_path.join("dst"),
&Settings {
dereference: false,
fail_early: false,
overwrite: false,
overwrite_compare: Default::default(),
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: Some(filter),
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(summary.files_copied, 2, "should copy 2 files");
assert_eq!(summary.symlinks_created, 2, "should copy 2 symlinks");
assert_eq!(
summary.directories_created, 2,
"should create 2 directories"
);
assert_eq!(
summary.directories_skipped, 1,
"should skip 1 directory (bar)"
);
assert_eq!(
summary.files_skipped, 0,
"no files skipped (bar contents not counted)"
);
Ok(())
}
#[tokio::test]
#[traced_test]
async fn test_empty_dir_not_created_when_only_traversed() -> Result<(), anyhow::Error> {
let test_path = testutils::create_temp_dir().await?;
let src_path = test_path.join("src");
tokio::fs::create_dir(&src_path).await?;
tokio::fs::write(src_path.join("foo"), "content").await?;
tokio::fs::write(src_path.join("bar"), "content").await?;
tokio::fs::create_dir(src_path.join("baz")).await?;
let mut filter = FilterSettings::new();
filter.add_include("foo").unwrap();
let summary = copy(
&PROGRESS,
&src_path,
&test_path.join("dst"),
&Settings {
dereference: false,
fail_early: false,
overwrite: false,
overwrite_compare: Default::default(),
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: Some(filter),
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
assert_eq!(
summary.directories_created, 1,
"should create only root directory (not empty 'baz')"
);
assert!(
test_path.join("dst").join("foo").exists(),
"foo should be copied"
);
assert!(
!test_path.join("dst").join("bar").exists(),
"bar should not be copied"
);
assert!(
!test_path.join("dst").join("baz").exists(),
"empty baz directory should NOT be created"
);
Ok(())
}
#[tokio::test]
#[traced_test]
async fn test_dir_with_nonmatching_content_not_created() -> Result<(), anyhow::Error> {
let test_path = testutils::create_temp_dir().await?;
let src_path = test_path.join("src");
tokio::fs::create_dir(&src_path).await?;
tokio::fs::write(src_path.join("foo"), "content").await?;
tokio::fs::create_dir(src_path.join("baz")).await?;
tokio::fs::write(src_path.join("baz").join("qux"), "content").await?;
tokio::fs::write(src_path.join("baz").join("quux"), "content").await?;
let mut filter = FilterSettings::new();
filter.add_include("foo").unwrap();
let summary = copy(
&PROGRESS,
&src_path,
&test_path.join("dst"),
&Settings {
dereference: false,
fail_early: false,
overwrite: false,
overwrite_compare: Default::default(),
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: Some(filter),
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
assert_eq!(
summary.files_skipped, 2,
"should skip 2 files (qux and quux)"
);
assert_eq!(
summary.directories_created, 1,
"should create only root directory (not 'baz' with non-matching content)"
);
assert!(
test_path.join("dst").join("foo").exists(),
"foo should be copied"
);
assert!(
!test_path.join("dst").join("baz").exists(),
"baz directory should NOT be created (no matching content inside)"
);
Ok(())
}
#[tokio::test]
#[traced_test]
async fn test_dry_run_empty_dir_not_reported_as_created() -> Result<(), anyhow::Error> {
let test_path = testutils::create_temp_dir().await?;
let src_path = test_path.join("src");
tokio::fs::create_dir(&src_path).await?;
tokio::fs::write(src_path.join("foo"), "content").await?;
tokio::fs::write(src_path.join("bar"), "content").await?;
tokio::fs::create_dir(src_path.join("baz")).await?;
let mut filter = FilterSettings::new();
filter.add_include("foo").unwrap();
let summary = copy(
&PROGRESS,
&src_path,
&test_path.join("dst"),
&Settings {
dereference: false,
fail_early: false,
overwrite: false,
overwrite_compare: Default::default(),
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: Some(filter),
dry_run: Some(crate::config::DryRunMode::Explain),
},
&NO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(
summary.files_copied, 1,
"should report only 'foo' would be copied"
);
assert_eq!(
summary.directories_created, 1,
"should report only root directory would be created (not empty 'baz')"
);
assert!(
!test_path.join("dst").exists(),
"dst should not exist in dry-run"
);
Ok(())
}
#[tokio::test]
#[traced_test]
async fn test_existing_dir_not_removed_with_overwrite() -> Result<(), anyhow::Error> {
let test_path = testutils::create_temp_dir().await?;
let src_path = test_path.join("src");
tokio::fs::create_dir(&src_path).await?;
tokio::fs::write(src_path.join("foo"), "content").await?;
tokio::fs::write(src_path.join("bar"), "content").await?;
tokio::fs::create_dir(src_path.join("baz")).await?;
let dst_path = test_path.join("dst");
tokio::fs::create_dir(&dst_path).await?;
tokio::fs::create_dir(dst_path.join("baz")).await?;
tokio::fs::write(dst_path.join("baz").join("marker.txt"), "existing").await?;
let mut filter = FilterSettings::new();
filter.add_include("foo").unwrap();
let summary = copy(
&PROGRESS,
&src_path,
&dst_path,
&Settings {
dereference: false,
fail_early: false,
overwrite: true, overwrite_compare: Default::default(),
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: Some(filter),
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
assert_eq!(
summary.directories_unchanged, 2,
"root dst and baz directories should be unchanged"
);
assert_eq!(
summary.directories_created, 0,
"should not create any directories"
);
assert!(dst_path.join("foo").exists(), "foo should be copied");
assert!(!dst_path.join("bar").exists(), "bar should not be copied");
assert!(
dst_path.join("baz").exists(),
"existing baz directory should still exist"
);
assert!(
dst_path.join("baz").join("marker.txt").exists(),
"existing content in baz should still exist"
);
Ok(())
}
}
mod dry_run_tests {
use super::*;
#[tokio::test]
#[traced_test]
async fn test_dry_run_directory_does_not_create_destination() -> Result<(), anyhow::Error> {
let tmp_dir = testutils::setup_test_dir().await?;
let test_path = tmp_dir.as_path();
let dst_path = test_path.join("nonexistent_dst");
assert!(
!dst_path.exists(),
"destination should not exist before dry-run"
);
let summary = copy(
&PROGRESS,
&test_path.join("foo"),
&dst_path,
&Settings {
dereference: false,
fail_early: false,
overwrite: false,
overwrite_compare: Default::default(),
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: Some(crate::config::DryRunMode::Brief),
},
&NO_PRESERVE_SETTINGS,
false,
)
.await?;
assert!(
!dst_path.exists(),
"dry-run should not create destination directory"
);
assert!(
summary.directories_created > 0,
"dry-run should report directories that would be created"
);
assert!(
summary.files_copied > 0,
"dry-run should report files that would be copied"
);
Ok(())
}
#[tokio::test]
#[traced_test]
async fn test_root_dir_preserved_when_nothing_matches() -> Result<(), anyhow::Error> {
let test_path = testutils::create_temp_dir().await?;
let src_path = test_path.join("src");
tokio::fs::create_dir(&src_path).await?;
tokio::fs::write(src_path.join("bar.log"), "content").await?;
tokio::fs::create_dir(src_path.join("baz")).await?;
let mut filter = FilterSettings::new();
filter.add_include("*.txt").unwrap();
let dst_path = test_path.join("dst");
let summary = copy(
&PROGRESS,
&src_path,
&dst_path,
&Settings {
dereference: false,
fail_early: false,
overwrite: false,
overwrite_compare: Default::default(),
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: Some(filter),
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(summary.files_copied, 0, "no files match *.txt");
assert_eq!(
summary.directories_created, 1,
"root directory should always be created"
);
assert!(dst_path.exists(), "root destination directory should exist");
assert!(
!dst_path.join("baz").exists(),
"empty baz should not be created"
);
Ok(())
}
#[tokio::test]
#[traced_test]
async fn test_root_dir_counted_in_dry_run_when_nothing_matches() -> Result<(), anyhow::Error>
{
let test_path = testutils::create_temp_dir().await?;
let src_path = test_path.join("src");
tokio::fs::create_dir(&src_path).await?;
tokio::fs::write(src_path.join("bar.log"), "content").await?;
let mut filter = FilterSettings::new();
filter.add_include("*.txt").unwrap();
let dst_path = test_path.join("dst");
let summary = copy(
&PROGRESS,
&src_path,
&dst_path,
&Settings {
dereference: false,
fail_early: false,
overwrite: false,
overwrite_compare: Default::default(),
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: Some(filter),
dry_run: Some(crate::config::DryRunMode::Explain),
},
&NO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(summary.files_copied, 0, "no files match *.txt");
assert_eq!(
summary.directories_created, 1,
"root directory should be counted in dry-run"
);
assert!(
!dst_path.exists(),
"nothing should be created in dry-run mode"
);
Ok(())
}
}
mod max_open_files_tests {
use super::*;
#[tokio::test]
#[traced_test]
async fn wide_copy_under_open_files_saturation() -> Result<(), anyhow::Error> {
let tmp_dir = testutils::create_temp_dir().await?;
let src = tmp_dir.join("src");
let dst = tmp_dir.join("dst");
tokio::fs::create_dir(&src).await?;
let file_count = 200;
for i in 0..file_count {
tokio::fs::write(src.join(format!("{}.txt", i)), format!("content-{}", i)).await?;
}
throttle::set_max_open_files(4);
let summary = copy(
&PROGRESS,
&src,
&dst,
&Settings {
dereference: false,
fail_early: true,
overwrite: false,
overwrite_compare: Default::default(),
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
)
.await?;
assert_eq!(summary.files_copied, file_count);
assert_eq!(summary.directories_created, 1);
for i in 0..file_count {
let content = tokio::fs::read_to_string(dst.join(format!("{}.txt", i))).await?;
assert_eq!(content, format!("content-{}", i));
}
Ok(())
}
#[tokio::test]
#[traced_test]
async fn deep_tree_no_deadlock_under_open_files_saturation() -> Result<(), anyhow::Error> {
let tmp_dir = testutils::create_temp_dir().await?;
let src = tmp_dir.join("src");
let dst = tmp_dir.join("dst");
let depth = 20;
let files_per_level = 5;
let limit = 4;
let mut dir = src.clone();
for level in 0..depth {
tokio::fs::create_dir_all(&dir).await?;
for f in 0..files_per_level {
tokio::fs::write(
dir.join(format!("f{}_{}.txt", level, f)),
format!("L{}F{}", level, f),
)
.await?;
}
dir = dir.join(format!("d{}", level));
}
throttle::set_max_open_files(limit);
let summary = tokio::time::timeout(
std::time::Duration::from_secs(30),
copy(
&PROGRESS,
&src,
&dst,
&Settings {
dereference: false,
fail_early: true,
overwrite: false,
overwrite_compare: Default::default(),
overwrite_filter: None,
ignore_existing: false,
chunk_size: 0,
remote_copy_buffer_size: 0,
filter: None,
dry_run: None,
},
&NO_PRESERVE_SETTINGS,
false,
),
)
.await
.context("copy timed out — possible deadlock")?
.context("copy failed")?;
assert_eq!(summary.files_copied, depth * files_per_level);
assert_eq!(summary.directories_created, depth);
let mut check_dir = dst.clone();
for level in 0..depth {
let content =
tokio::fs::read_to_string(check_dir.join(format!("f{}_0.txt", level))).await?;
assert_eq!(content, format!("L{}F0", level));
check_dir = check_dir.join(format!("d{}", level));
}
Ok(())
}
}
}