use std::fs;
use std::io;
use std::path::Path;
use super::{
MAX_WALK_DEPTH, RenameFn, absolute, create_dir, exists, guard_destructive, is_within,
not_found_or_io, os_rename, remove_entry, resolve_lenient, special_file, staging_path,
too_deep,
};
use crate::error::{Error, Result};
pub fn copy(from: impl AsRef<Path>, to: impl AsRef<Path>) -> Result<u64> {
let (from, to) = (from.as_ref(), to.as_ref());
let meta = source_metadata(from)?;
check_destination(from, to, &meta)?;
copy_staged(from, to, &meta, &os_rename, MAX_WALK_DEPTH)
}
pub fn move_to(from: impl AsRef<Path>, to: impl AsRef<Path>) -> Result<()> {
move_with(from.as_ref(), to.as_ref(), &os_rename)
}
pub(super) fn move_with(from: &Path, to: &Path, rename: RenameFn<'_>) -> Result<()> {
guard_destructive(from)?;
let meta = source_metadata(from)?;
check_destination(from, to, &meta)?;
match rename(from, to) {
Ok(()) => Ok(()),
Err(e) if is_cross_device(&e) => {
if meta.file_type().is_symlink() {
return Err(Error::Refused {
path: from.to_path_buf(),
reason: "symbolic links cannot be moved across volumes",
});
}
let _bytes = copy_staged(from, to, &meta, &os_rename, MAX_WALK_DEPTH)?;
remove_entry(from, &meta).map_err(|err| match err {
Error::Io { path, source, .. } => Error::Io {
op: "remove moved source",
path,
source,
},
other => other,
})
}
Err(e) => Err(Error::io("move", from, e)),
}
}
pub(super) fn source_metadata(from: &Path) -> Result<fs::Metadata> {
fs::symlink_metadata(from).map_err(|e| not_found_or_io("inspect", from, e))
}
pub(super) fn check_destination(from: &Path, to: &Path, from_meta: &fs::Metadata) -> Result<()> {
if to.file_name().is_none() {
return Err(Error::Refused {
path: to.to_path_buf(),
reason: "the destination has no file name",
});
}
if exists(to) {
return Err(Error::AlreadyExists {
path: to.to_path_buf(),
});
}
if from_meta.is_dir() && is_inside(from, to)? {
return Err(Error::Refused {
path: to.to_path_buf(),
reason: "the destination is inside the source",
});
}
if let Some(parent) = to.parent().filter(|p| !p.as_os_str().is_empty()) {
create_dir(parent, None)?;
}
Ok(())
}
pub(super) fn is_inside(from: &Path, to: &Path) -> Result<bool> {
if is_within(&absolute(to)?, &absolute(from)?) {
return Ok(true);
}
Ok(is_within(&resolve_lenient(to)?, &resolve_lenient(from)?))
}
pub(super) fn copy_staged(
from: &Path,
to: &Path,
meta: &fs::Metadata,
rename: RenameFn<'_>,
max_depth: usize,
) -> Result<u64> {
if meta.file_type().is_symlink() {
return Err(Error::Refused {
path: from.to_path_buf(),
reason: "copying symbolic links is not supported",
});
}
let staging = staging_path(to);
let result = if meta.is_dir() {
copy_tree(from, &staging, max_depth)
} else if meta.is_file() {
fs::copy(from, &staging).map_err(|e| Error::io("copy", from, e))
} else {
Err(special_file(from))
};
let bytes = match result {
Ok(bytes) => bytes,
Err(err) => {
if let Ok(m) = fs::symlink_metadata(&staging) {
let _cleanup = remove_entry(&staging, &m);
}
return Err(err);
}
};
if let Err(e) = rename(&staging, to) {
if let Ok(m) = fs::symlink_metadata(&staging) {
let _cleanup = remove_entry(&staging, &m);
}
return Err(Error::io("finish copy", to, e));
}
Ok(bytes)
}
pub(super) fn copy_tree(from: &Path, to: &Path, max_depth: usize) -> Result<u64> {
let mut bytes = 0u64;
let mut stack = vec![(from.to_path_buf(), to.to_path_buf(), 0usize)];
while let Some((src, dst, depth)) = stack.pop() {
fs::create_dir(&dst).map_err(|e| Error::io("create directory", &dst, e))?;
let entries = fs::read_dir(&src).map_err(|e| Error::io("read directory", &src, e))?;
for entry in entries {
let entry = entry.map_err(|e| Error::io("read directory", &src, e))?;
let kind = entry
.file_type()
.map_err(|e| Error::io("inspect", entry.path(), e))?;
let target = dst.join(entry.file_name());
if kind.is_symlink() {
return Err(Error::Refused {
path: entry.path(),
reason: "the tree contains a symbolic link",
});
} else if kind.is_dir() {
if depth + 1 > max_depth {
return Err(too_deep(entry.path()));
}
stack.push((entry.path(), target, depth + 1));
} else if kind.is_file() {
let n = fs::copy(entry.path(), &target)
.map_err(|e| Error::io("copy", entry.path(), e))?;
bytes = bytes.saturating_add(n);
} else {
return Err(special_file(&entry.path()));
}
}
}
Ok(bytes)
}
pub(super) fn is_cross_device(e: &io::Error) -> bool {
#[cfg(unix)]
const CODE: i32 = 18;
#[cfg(windows)]
const CODE: i32 = 17;
e.raw_os_error() == Some(CODE)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ops::find::find_limited;
use crate::ops::test_support::*;
#[test]
fn test_move_across_volumes_falls_back_to_copy_then_remove() {
let dir = scratch("xdev");
fs::create_dir_all(dir.join("src/inner")).unwrap_or_default();
fs::write(dir.join("src/inner/a.txt"), b"abc").unwrap_or_default();
let moved = move_with(&dir.join("src"), &dir.join("dst"), &cross_device);
assert!(moved.is_ok(), "{moved:?}");
assert!(!dir.join("src").exists());
assert_eq!(
fs::read(dir.join("dst/inner/a.txt")).ok(),
Some(b"abc".to_vec())
);
let _ = fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn test_move_across_volumes_refuses_symlink_source() {
let dir = scratch("xdev-link");
let target = dir.join("target");
fs::write(&target, b"x").unwrap_or_default();
let made = std::os::unix::fs::symlink(&target, dir.join("link")).is_ok();
assert!(made);
let r = move_with(&dir.join("link"), &dir.join("moved"), &cross_device);
assert!(matches!(r, Err(Error::Refused { .. })), "{r:?}");
assert!(dir.join("link").exists());
let _ = fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn test_move_across_volumes_reports_failed_source_removal() {
use std::os::unix::fs::PermissionsExt;
let dir = scratch("xdev-keep");
let parent = dir.join("ro");
fs::create_dir_all(parent.join("src")).unwrap_or_default();
fs::write(parent.join("src/a"), b"x").unwrap_or_default();
fs::set_permissions(&parent, fs::Permissions::from_mode(0o500)).unwrap_or_default();
let r = move_with(&parent.join("src"), &dir.join("dst"), &cross_device);
fs::set_permissions(&parent, fs::Permissions::from_mode(0o700)).unwrap_or_default();
let as_root = fs::read_dir(&parent).is_ok() && r.is_ok();
if !as_root {
assert!(
matches!(
r,
Err(Error::Io {
op: "remove moved source",
..
})
),
"{r:?}"
);
assert!(dir.join("dst/a").exists(), "destination is complete");
assert!(
parent.join("src").exists(),
"source directory could not be removed"
);
}
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_move_other_rename_error_is_reported() {
let dir = scratch("move-err");
fs::write(dir.join("a"), b"x").unwrap_or_default();
let r = move_with(&dir.join("a"), &dir.join("b"), &denied);
assert!(matches!(r, Err(Error::Io { op: "move", .. })), "{r:?}");
assert!(dir.join("a").exists());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_copy_failed_final_rename_leaves_no_trace() {
let dir = scratch("copy-finish");
fs::create_dir_all(dir.join("src")).unwrap_or_default();
fs::write(dir.join("src/a"), b"x").unwrap_or_default();
let meta = fs::symlink_metadata(dir.join("src")).ok();
let r = meta.map(|m| copy_staged(&dir.join("src"), &dir.join("dst"), &m, &denied, 8));
assert!(
matches!(
r,
Some(Err(Error::Io {
op: "finish copy",
..
}))
),
"{r:?}"
);
let names: Vec<_> = fs::read_dir(&dir)
.map(|d| {
d.filter_map(std::result::Result::ok)
.map(|e| e.file_name())
.collect()
})
.unwrap_or_default();
assert_eq!(
names,
vec![std::ffi::OsString::from("src")],
"staging removed"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_walk_depth_limit_is_enforced_by_find_and_copy() {
let dir = scratch("depth");
fs::create_dir_all(dir.join("t/a/b/c")).unwrap_or_default();
assert!(find_limited(&dir.join("t"), "*", 3).is_ok());
assert!(matches!(
find_limited(&dir.join("t"), "*", 2),
Err(Error::Refused { .. })
));
let meta = fs::symlink_metadata(dir.join("t")).ok();
let r = meta.map(|m| copy_staged(&dir.join("t"), &dir.join("u"), &m, &os_rename, 2));
assert!(matches!(r, Some(Err(Error::Refused { .. }))), "{r:?}");
assert!(!dir.join("u").exists());
let _ = fs::remove_dir_all(&dir);
}
}