use crate::app::Item;
use crate::constants::COPY_CHUNK;
use crate::options::{Options, SortKey};
use crate::utils::format_size;
use std::env;
use std::ffi::OsString;
use std::fs::{self, File, create_dir, read_dir, remove_dir, remove_file, rename};
use std::io::{self, Error, ErrorKind, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
#[cfg(unix)]
fn format_attributes(metadata: &fs::Metadata, is_dir: bool, is_symlink: bool) -> String {
use std::os::unix::fs::PermissionsExt;
let mode = metadata.permissions().mode();
let kind = if is_symlink {
'l'
} else if is_dir {
'd'
} else {
'-'
};
let triplet = |shift: u32, special: u32, on: char| {
let bits = mode >> shift;
let execute = bits & 0o1 != 0;
[
if bits & 0o4 != 0 { 'r' } else { '-' },
if bits & 0o2 != 0 { 'w' } else { '-' },
match (mode & special != 0, execute) {
(true, true) => on,
(true, false) => on.to_ascii_uppercase(),
(false, true) => 'x',
(false, false) => '-',
},
]
};
let mut text = String::with_capacity(10);
text.push(kind);
text.extend(triplet(6, 0o4000, 's'));
text.extend(triplet(3, 0o2000, 's'));
text.extend(triplet(0, 0o1000, 't'));
text
}
#[cfg(windows)]
fn format_attributes(metadata: &fs::Metadata, _is_dir: bool, is_symlink: bool) -> String {
use std::os::windows::fs::MetadataExt;
const READONLY: u32 = 0x0000_0001;
const HIDDEN: u32 = 0x0000_0002;
const SYSTEM: u32 = 0x0000_0004;
const DIRECTORY: u32 = 0x0000_0010;
const ARCHIVE: u32 = 0x0000_0020;
let flags = metadata.file_attributes();
let flag = |bit: u32, on: char| if flags & bit != 0 { on } else { '-' };
let kind = if is_symlink { 'l' } else { flag(DIRECTORY, 'd') };
[kind, flag(ARCHIVE, 'a'), flag(READONLY, 'r'), flag(HIDDEN, 'h'), flag(SYSTEM, 's')].iter().collect()
}
#[cfg(not(any(unix, windows)))]
fn format_attributes(_metadata: &fs::Metadata, _is_dir: bool, _is_symlink: bool) -> String {
String::new()
}
#[cfg(windows)]
pub fn is_hidden(_name: &str, metadata: Option<&fs::Metadata>) -> bool {
use std::os::windows::fs::MetadataExt;
const HIDDEN: u32 = 0x0000_0002;
metadata.is_some_and(|metadata| metadata.file_attributes() & HIDDEN != 0)
}
#[cfg(not(windows))]
pub fn is_hidden(name: &str, _metadata: Option<&fs::Metadata>) -> bool {
name.starts_with('.')
}
pub fn load_directory_rows(path: &Path, options: &Options) -> Result<Vec<Item>, Error> {
let entries: Vec<_> = read_dir(path)?.filter_map(|entry| entry.ok()).collect();
let has_parent = path.parent().is_some();
let mut children = Vec::with_capacity(entries.len() + usize::from(has_parent));
if has_parent {
children.push(Item {
name_os: OsString::from(".."),
name_full: "..".to_string(),
name: "..".to_string(),
extension: String::new(),
is_dir: true,
size: String::new(),
size_bytes: 0,
modified_at: None,
attributes: String::new(),
});
}
for entry in &entries {
let entry_path = entry.path();
let is_symlink = entry.file_type().map(|file_type| file_type.is_symlink()).unwrap_or(false);
let metadata = if is_symlink { fs::metadata(&entry_path).or_else(|_| entry.metadata()).ok() } else { entry.metadata().ok() };
let is_dir = metadata.as_ref().map(|m| m.is_dir()).unwrap_or(false);
let name_os = entry.file_name();
let name_full = name_os.to_string_lossy().into_owned();
if !options.show_hidden && is_hidden(&name_full, metadata.as_ref()) {
continue;
}
let name = if is_dir { name_full.clone() } else { entry_path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default() };
let extension = if is_dir { String::new() } else { entry_path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default() };
let size_bytes = if is_dir { 0 } else { metadata.as_ref().map(|m| m.len()).unwrap_or(0) };
let size = if is_dir { "<DIR>".to_string() } else { format_size(size_bytes) };
let modified_at = metadata.as_ref().and_then(|m| m.modified().ok());
let attributes = metadata.as_ref().map(|metadata| format_attributes(metadata, is_dir, is_symlink)).unwrap_or_default();
children.push(Item {
name_os,
name_full,
name,
extension,
is_dir,
size,
size_bytes,
modified_at,
attributes,
});
}
let sort_start = usize::from(has_parent);
children[sort_start..].sort_by(|a, b| compare_rows(a, b, options));
Ok(children)
}
fn compare_rows(a: &Item, b: &Item, options: &Options) -> std::cmp::Ordering {
use std::cmp::Ordering;
if options.dirs_first {
match (a.is_dir, b.is_dir) {
(true, false) => return Ordering::Less,
(false, true) => return Ordering::Greater,
_ => {}
}
}
let text = |a: &str, b: &str| if options.case_sensitive { a.cmp(b) } else { a.to_lowercase().cmp(&b.to_lowercase()) };
let name = || text(&a.name_full, &b.name_full);
let order = match options.sort_key {
SortKey::Name => name(),
SortKey::Extension => text(&a.extension, &b.extension).then_with(name),
SortKey::Size => a.size_bytes.cmp(&b.size_bytes).then_with(name),
SortKey::Modified => a.modified_at.cmp(&b.modified_at).then_with(name),
};
if options.sort_descending { order.reverse() } else { order }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MountKind {
Home,
Disk,
#[cfg_attr(not(any(windows, target_os = "linux")), allow(dead_code))]
Removable,
Network,
#[cfg_attr(not(any(windows, target_os = "linux")), allow(dead_code))]
Optical,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Mount {
pub path: PathBuf,
pub label: String,
pub kind: MountKind,
}
fn home_mount() -> Option<Mount> {
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
Some(Mount {
path: PathBuf::from(home),
label: "~".to_string(),
kind: MountKind::Home,
})
}
#[cfg(target_os = "linux")]
pub fn list_mounts() -> Vec<Mount> {
let mut mounts = vec![Mount {
path: PathBuf::from("/"),
label: "/".to_string(),
kind: MountKind::Disk,
}];
mounts.extend(home_mount());
if let Ok(table) = fs::read_to_string("/proc/mounts") {
mounts.extend(parse_proc_mounts(&table));
}
mounts
}
#[cfg(target_os = "linux")]
fn parse_proc_mounts(table: &str) -> Vec<Mount> {
let mut found: Vec<Mount> = Vec::new();
for line in table.lines() {
let mut fields = line.split_whitespace();
let (Some(source), Some(target), Some(fstype)) = (fields.next(), fields.next(), fields.next()) else {
continue;
};
let target = target.replace("\\040", " ").replace("\\011", "\t");
const MACHINERY: [&str; 4] = ["/run", "/sys", "/proc", "/dev"];
const REMOVABLE_MEDIA: &str = "/run/media/";
let machinery = MACHINERY.iter().any(|prefix| target.starts_with(prefix)) && !target.starts_with(REMOVABLE_MEDIA);
if target == "/" || machinery {
continue;
}
let is_device = source.starts_with("/dev/");
let is_fuse = fstype.starts_with("fuse");
if !is_device && !is_fuse {
continue;
}
let path = PathBuf::from(&target);
if found.iter().any(|mount| mount.path == path) {
continue;
}
let label = path.file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_else(|| target.clone());
found.push(Mount {
path,
label,
kind: classify_mount(source, fstype, is_fuse),
});
}
found.sort_by(|a, b| a.path.cmp(&b.path));
found
}
#[cfg(target_os = "linux")]
fn classify_mount(source: &str, fstype: &str, is_fuse: bool) -> MountKind {
if matches!(fstype, "iso9660" | "udf") {
return MountKind::Optical;
}
if is_fuse {
return MountKind::Network;
}
if is_removable(source) {
return MountKind::Removable;
}
MountKind::Disk
}
#[cfg(target_os = "linux")]
fn is_removable(source: &str) -> bool {
let Some(device) = source.strip_prefix("/dev/") else {
return false;
};
for candidate in [device, base_device(device)] {
if let Ok(flag) = fs::read_to_string(format!("/sys/block/{candidate}/removable")) {
return flag.trim() == "1";
}
}
false
}
#[cfg(target_os = "linux")]
fn base_device(name: &str) -> &str {
if let Some(index) = name.rfind('p') {
let (head, tail) = name.split_at(index);
let digits = &tail[1..];
if !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()) && head.bytes().last().is_some_and(|byte| byte.is_ascii_digit()) {
return head;
}
}
name.trim_end_matches(|c: char| c.is_ascii_digit())
}
#[cfg(all(test, target_os = "linux"))]
mod mount_tests {
use super::{MountKind, parse_proc_mounts};
const TABLE: &str = "\
devtmpfs /dev devtmpfs rw,nosuid 0 0
run /run tmpfs rw,nosuid 0 0
/dev/nvme0n1p4 / ext4 rw,relatime 0 0
tmpfs /dev/shm tmpfs rw,nosuid 0 0
/dev/nvme0n1p3 /tmp ext4 rw,relatime 0 0
/dev/nvme0n1p5 /mnt/laserbeak ext4 rw,relatime 0 0
/dev/sda1 /mnt/grimlock ext4 rw,relatime 0 0
/dev/nvme0n1p2 /boot vfat rw,relatime 0 0
none /run/credentials/getty@tty1.service tmpfs ro 0 0
tmpfs /run/user/1000 tmpfs rw,nosuid 0 0
pcloud: /mnt/pcloud fuse.rclone rw,nosuid 0 0
portal /run/user/1000/doc fuse.portal rw,nosuid 0 0
gvfsd-fuse /run/user/1000/gvfs fuse.gvfsd-fuse rw,nosuid 0 0
fusectl /sys/fs/fuse/connections fusectl rw,nosuid 0 0
/dev/sdb1 /media/My\\040Backup ext4 rw,relatime 0 0
/dev/sr0 /run/media/colt/AUDIO iso9660 ro,nosuid 0 0
/dev/sdb1 /run/media/colt/ravage ntfs3 rw,nosuid 0 0
/dev/sdc1 /media/stick vfat rw,nosuid 0 0";
#[test]
fn keeps_media_and_drops_machinery() {
let mounts = parse_proc_mounts(TABLE);
let paths: Vec<String> = mounts.iter().map(|m| m.path.display().to_string()).collect();
assert_eq!(
paths,
["/boot", "/media/My Backup", "/media/stick", "/mnt/grimlock", "/mnt/laserbeak", "/mnt/pcloud", "/run/media/colt/AUDIO", "/run/media/colt/ravage", "/tmp",],
"should keep device-backed and user fuse mounts, sorted"
);
assert!(!paths.iter().any(|path| path.starts_with("/run/user")));
assert!(!paths.iter().any(|p| p == "/"));
assert!(!paths.iter().any(|p| p.starts_with("/sys")));
let pcloud = mounts.iter().find(|m| m.label == "pcloud").unwrap();
assert_eq!(pcloud.kind, MountKind::Network);
let boot = mounts.iter().find(|m| m.label == "boot").unwrap();
assert_eq!(boot.kind, MountKind::Disk);
assert!(mounts.iter().any(|m| m.label == "My Backup"));
}
#[test]
fn optical_filesystems_are_recognised() {
assert_eq!(super::classify_mount("/dev/sr0", "iso9660", false), MountKind::Optical);
assert_eq!(super::classify_mount("/dev/sr0", "udf", false), MountKind::Optical);
assert_eq!(super::classify_mount("pcloud:", "fuse.rclone", true), MountKind::Network);
}
#[test]
fn partitions_reduce_to_their_whole_device() {
assert_eq!(super::base_device("sda1"), "sda");
assert_eq!(super::base_device("sda"), "sda");
assert_eq!(super::base_device("nvme0n1p5"), "nvme0n1");
assert_eq!(super::base_device("mmcblk0p1"), "mmcblk0");
assert_eq!(super::base_device("sdp1"), "sdp");
}
}
#[cfg(target_vendor = "apple")]
pub fn list_mounts() -> Vec<Mount> {
let mut mounts = vec![Mount {
path: PathBuf::from("/"),
label: "/".to_string(),
kind: MountKind::Disk,
}];
mounts.extend(home_mount());
let Ok(entries) = read_dir("/Volumes") else {
return mounts;
};
let mut found: Vec<Mount> = entries
.flatten()
.map(|entry| Mount {
label: entry.file_name().to_string_lossy().into_owned(),
path: entry.path(),
kind: MountKind::Disk,
})
.collect();
found.sort_by(|a, b| a.path.cmp(&b.path));
mounts.extend(found);
mounts
}
#[cfg(windows)]
pub fn list_mounts() -> Vec<Mount> {
unsafe extern "system" {
fn GetLogicalDrives() -> u32;
fn GetDriveTypeW(root: *const u16) -> u32;
}
const DRIVE_REMOVABLE: u32 = 2;
const DRIVE_FIXED: u32 = 3;
const DRIVE_REMOTE: u32 = 4;
const DRIVE_CDROM: u32 = 5;
let mask = unsafe { GetLogicalDrives() };
let mut mounts = Vec::new();
mounts.extend(home_mount());
for letter in 0..26u32 {
if mask & (1 << letter) == 0 {
continue;
}
let root = format!("{}:\\", (b'A' + letter as u8) as char);
let wide: Vec<u16> = root.encode_utf16().chain(std::iter::once(0)).collect();
let kind = match unsafe { GetDriveTypeW(wide.as_ptr()) } {
DRIVE_REMOVABLE => MountKind::Removable,
DRIVE_REMOTE => MountKind::Network,
DRIVE_CDROM => MountKind::Optical,
DRIVE_FIXED => MountKind::Disk,
_ => continue,
};
mounts.push(Mount {
path: PathBuf::from(&root),
label: root[..2].to_string(),
kind,
});
}
mounts
}
#[cfg(not(any(target_os = "linux", target_vendor = "apple", windows)))]
pub fn list_mounts() -> Vec<Mount> {
home_mount().into_iter().collect()
}
pub fn nearest_existing_dir(path: &Path) -> Option<PathBuf> {
let mut candidate = Some(path);
while let Some(dir) = candidate {
if dir.is_dir() {
return Some(dir.to_path_buf());
}
candidate = dir.parent();
}
None
}
#[cfg(all(unix, not(target_vendor = "apple")))]
pub fn disk_usage(path: &Path) -> Option<(u64, u64)> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let c_path = CString::new(path.as_os_str().as_bytes()).ok()?;
let stats = unsafe {
let mut stats = std::mem::zeroed::<libc::statvfs>();
if libc::statvfs(c_path.as_ptr(), &mut stats) != 0 {
return None;
}
stats
};
let block = stats.f_frsize as u64;
let total = (stats.f_blocks as u64).checked_mul(block)?;
let free = (stats.f_bfree as u64).checked_mul(block)?;
Some((total.saturating_sub(free), total))
}
#[cfg(target_vendor = "apple")]
pub fn disk_usage(path: &Path) -> Option<(u64, u64)> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let c_path = CString::new(path.as_os_str().as_bytes()).ok()?;
let stats = unsafe {
let mut stats = std::mem::zeroed::<libc::statfs>();
if libc::statfs(c_path.as_ptr(), &mut stats) != 0 {
return None;
}
stats
};
let block = stats.f_bsize as u64;
let total = stats.f_blocks.checked_mul(block)?;
let free = stats.f_bfree.checked_mul(block)?;
Some((total.saturating_sub(free), total))
}
#[cfg(windows)]
pub fn disk_usage(path: &Path) -> Option<(u64, u64)> {
use std::os::windows::ffi::OsStrExt;
unsafe extern "system" {
fn GetDiskFreeSpaceExW(directory: *const u16, free_to_caller: *mut u64, total: *mut u64, total_free: *mut u64) -> i32;
}
let wide: Vec<u16> = path.as_os_str().encode_wide().chain(std::iter::once(0)).collect();
let (mut free_to_caller, mut total, mut total_free) = (0u64, 0u64, 0u64);
let ok = unsafe { GetDiskFreeSpaceExW(wide.as_ptr(), &mut free_to_caller, &mut total, &mut total_free) != 0 };
if !ok {
return None;
}
Some((total.saturating_sub(total_free), total))
}
#[cfg(not(any(unix, windows)))]
pub fn disk_usage(_path: &Path) -> Option<(u64, u64)> {
None
}
pub fn get_current_dir() -> Result<PathBuf, Error> {
env::current_dir()
}
pub fn rename_path(original_path: PathBuf, new_path: PathBuf) -> Result<(), Error> {
rename(original_path, new_path)?;
Ok(())
}
pub fn count_entries(items: &[(PathBuf, bool)]) -> u64 {
fn walk(path: &Path) -> u64 {
let Ok(entries) = read_dir(path) else {
return 1;
};
1 + entries
.flatten()
.map(|entry| match entry.file_type() {
Ok(file_type) if file_type.is_dir() => walk(&entry.path()),
_ => 1,
})
.sum::<u64>()
}
items
.iter()
.map(|(path, is_dir)| {
let link = fs::symlink_metadata(path).map(|meta| meta.file_type().is_symlink()).unwrap_or(false);
if *is_dir && !link { walk(path) } else { 1 }
})
.sum()
}
pub fn delete_path(path: PathBuf, is_dir: bool, report: Report<'_>) -> Result<Transfer, Error> {
let is_symlink = path.symlink_metadata().map(|metadata| metadata.file_type().is_symlink()).unwrap_or(false);
let outcome = if is_dir && !is_symlink { delete_dir_recursive(&path, report)? } else { remove_one(&path, false, report)? };
Ok(outcome.into())
}
pub fn trash_path(path: PathBuf, is_dir: bool, report: Report<'_>) -> Result<Transfer, Error> {
if !report.step(Step::Starting(&path)) {
return Ok(Transfer::Cancelled);
}
loop {
let error = match trash::delete(&path) {
Ok(()) => break,
Err(error) => trash_error(error),
};
match report.failed(&path, &error) {
Reply::Retry => {}
Reply::Skip => break,
Reply::Abort => return Err(error),
Reply::Delete => {
let mut uncounted = Uncounted(&mut *report);
if delete_path(path, is_dir, &mut uncounted)? == Transfer::Cancelled {
return Ok(Transfer::Cancelled);
}
break;
}
}
}
Ok(if report.step(Step::Advanced(1)) { Transfer::Done } else { Transfer::Cancelled })
}
fn trash_error(error: trash::Error) -> Error {
match error {
#[cfg(all(unix, not(target_os = "macos"), not(target_os = "ios"), not(target_os = "android")))]
trash::Error::FileSystem { source, .. } => source,
trash::Error::TargetedRoot => Error::other("A root directory cannot go to the trash"),
trash::Error::CouldNotAccess { target } => Error::other(format!("Cannot reach {target}")),
trash::Error::Os { description, .. } | trash::Error::Unknown { description } => Error::other(description),
other => Error::other(format!("Cannot move to the trash: {other:?}")),
}
}
struct Uncounted<'a, 'b>(&'a mut (dyn Progress + 'b));
impl Progress for Uncounted<'_, '_> {
fn step(&mut self, step: Step<'_>) -> bool {
match step {
Step::Advanced(_) => self.0.step(Step::Advanced(0)),
starting => self.0.step(starting),
}
}
fn failed(&mut self, path: &Path, error: &Error) -> Reply {
self.0.failed(path, error)
}
fn exists(&mut self, source: &Path, dest: &Path) -> Clash {
self.0.exists(source, dest)
}
}
fn remove_one(path: &Path, is_dir: bool, report: Report<'_>) -> Result<Outcome, Error> {
if !report.step(Step::Starting(path)) {
return Ok(Outcome::Cancelled);
}
let removed = attempt(path, report, |_| if is_dir { remove_dir(path) } else { remove_entry(path) })?;
report.step(Step::Advanced(1));
Ok(if removed.is_some() { Outcome::Done } else { Outcome::Skipped })
}
#[cfg(windows)]
fn remove_entry(path: &Path) -> Result<(), Error> {
use std::os::windows::fs::FileTypeExt;
if path.symlink_metadata().is_ok_and(|metadata| metadata.file_type().is_symlink_dir()) {
remove_dir(path)
} else {
remove_file(path)
}
}
#[cfg(not(windows))]
fn remove_entry(path: &Path) -> Result<(), Error> {
remove_file(path)
}
fn delete_dir_recursive(path: &Path, report: Report<'_>) -> Result<Outcome, Error> {
let Some(entries) = attempt(path, report, |_| read_dir(path))? else {
return Ok(Outcome::Skipped);
};
let mut skipped = false;
for entry in entries {
let Some((entry, file_type)) = next_entry(path, entry, report)? else {
skipped = true;
continue;
};
let entry_path = entry.path();
let outcome = if file_type.is_dir() { delete_dir_recursive(&entry_path, &mut *report)? } else { remove_one(&entry_path, false, &mut *report)? };
match outcome {
Outcome::Cancelled => return Ok(Outcome::Cancelled),
Outcome::Skipped => skipped = true,
Outcome::Done => {}
}
}
if skipped {
return Ok(Outcome::Skipped);
}
remove_one(path, true, report)
}
pub fn is_regular_file(path: &Path) -> bool {
fs::metadata(path).is_ok_and(|metadata| metadata.is_file())
}
fn not_regular(path: &Path) -> Error {
Error::new(ErrorKind::InvalidInput, format!("Not a regular file: {}", path.display()))
}
pub fn is_plain_name(name: &str) -> bool {
let mut components = Path::new(name).components();
matches!(
(components.next(), components.next()),
(Some(std::path::Component::Normal(only)), None) if only == name
)
}
#[cfg(unix)]
pub fn is_same_entry(a: &Path, b: &Path) -> bool {
use std::os::unix::fs::MetadataExt;
match (a.symlink_metadata(), b.symlink_metadata()) {
(Ok(a), Ok(b)) => a.dev() == b.dev() && a.ino() == b.ino(),
_ => false,
}
}
#[cfg(windows)]
pub fn is_same_entry(a: &Path, b: &Path) -> bool {
let name = |path: &Path| path.file_name().map(|name| name.to_string_lossy().to_lowercase());
a.parent() == b.parent() && name(a).is_some() && name(a) == name(b) && path_exists(a)
}
#[cfg(not(any(unix, windows)))]
pub fn is_same_entry(a: &Path, b: &Path) -> bool {
a == b
}
pub fn path_exists(path: &Path) -> bool {
path.symlink_metadata().is_ok()
}
pub fn create_file(path: PathBuf) -> Result<(), Error> {
std::fs::OpenOptions::new().write(true).create_new(true).open(path)?;
Ok(())
}
pub fn create_directory(path: PathBuf) -> Result<(), Error> {
create_dir(path)?;
Ok(())
}
pub enum Step<'a> {
Starting(&'a Path),
Advanced(u64),
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Transfer {
Done,
Cancelled,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Outcome {
Done,
Skipped,
Cancelled,
}
impl From<Outcome> for Transfer {
fn from(outcome: Outcome) -> Self {
match outcome {
Outcome::Cancelled => Transfer::Cancelled,
Outcome::Done | Outcome::Skipped => Transfer::Done,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Reply {
Retry,
Skip,
Abort,
Delete,
}
pub trait Progress {
fn step(&mut self, step: Step<'_>) -> bool;
fn failed(&mut self, _path: &Path, _error: &Error) -> Reply {
Reply::Abort
}
fn exists(&mut self, _source: &Path, _dest: &Path) -> Clash {
Clash::Replace
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Clash {
Replace,
Keep,
Abort,
}
impl<F: FnMut(Step<'_>) -> bool> Progress for F {
fn step(&mut self, step: Step<'_>) -> bool {
self(step)
}
}
pub type Report<'a> = &'a mut dyn Progress;
fn attempt<T>(path: &Path, report: Report<'_>, mut op: impl FnMut(Report<'_>) -> Result<T, Error>) -> Result<Option<T>, Error> {
loop {
match op(&mut *report) {
Ok(value) => return Ok(Some(value)),
Err(error) => match report.failed(path, &error) {
Reply::Retry => {}
Reply::Skip | Reply::Delete => return Ok(None),
Reply::Abort => return Err(error),
},
}
}
}
fn next_entry(dir: &Path, entry: Result<fs::DirEntry, Error>, report: Report<'_>) -> Result<Option<(fs::DirEntry, fs::FileType)>, Error> {
match entry.and_then(|entry| entry.file_type().map(|file_type| (entry, file_type))) {
Ok(pair) => Ok(Some(pair)),
Err(error) => match report.failed(dir, &error) {
Reply::Abort => Err(error),
Reply::Retry | Reply::Skip | Reply::Delete => Ok(None),
},
}
}
pub fn rename_in_place(source: &Path, dest: &Path) -> bool {
rename(source, dest).is_ok()
}
pub fn measure(items: &[(PathBuf, PathBuf, bool)]) -> u64 {
items
.iter()
.map(|(source, _, is_dir)| {
if fs::symlink_metadata(source).map(|meta| meta.file_type().is_symlink()).unwrap_or(false) {
0
} else if *is_dir {
calculate_dir_size(source).unwrap_or(0)
} else {
fs::metadata(source).map(|meta| meta.len()).unwrap_or(0)
}
})
.sum()
}
pub fn copies_into_itself(source: &Path, dest: &Path) -> bool {
if fs::symlink_metadata(source).map(|meta| meta.file_type().is_symlink()).unwrap_or(false) || !source.is_dir() {
return false;
}
let resolve = |path: &Path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let source = resolve(source);
let dest = match (dest.parent(), dest.file_name()) {
(Some(parent), Some(name)) => resolve(parent).join(name),
_ => resolve(dest),
};
dest.starts_with(&source)
}
pub fn copy_path(source: PathBuf, dest: PathBuf, is_dir: bool, report: Report<'_>) -> Result<Transfer, Error> {
if copies_into_itself(&source, &dest) {
return Err(Error::new(ErrorKind::InvalidInput, format!("Cannot copy \"{}\" into itself", source.display())));
}
let Some(metadata) = attempt(&source, report, |_| fs::symlink_metadata(&source))? else {
return Ok(Transfer::Done);
};
let outcome = if metadata.file_type().is_symlink() {
settle_clash(&source, &dest, report).map_or_else(|| copy_link(&source, &dest, report), Ok)?
} else if is_dir {
copy_dir_recursive(&source, &dest, report)?
} else {
settle_clash(&source, &dest, report).map_or_else(|| copy_file(&source, &dest, report), Ok)?
};
Ok(outcome.into())
}
fn settle_clash(source: &Path, dest: &Path, report: Report<'_>) -> Option<Outcome> {
let existing = dest.symlink_metadata().ok()?;
if existing.is_dir() {
return None;
}
match report.exists(source, dest) {
Clash::Replace => None,
Clash::Keep => {
let bytes = source.symlink_metadata().map_or(0, |metadata| if metadata.is_file() { metadata.len() } else { 0 });
let carry_on = report.step(Step::Starting(source)) && report.step(Step::Advanced(bytes));
Some(if carry_on { Outcome::Skipped } else { Outcome::Cancelled })
}
Clash::Abort => Some(Outcome::Cancelled),
}
}
fn copy_link(source: &Path, dest: &Path, report: Report<'_>) -> Result<Outcome, Error> {
let copied = attempt(source, report, |_| {
clear_for_link(dest)?;
copy_symlink(source, dest)
})?;
Ok(if copied.is_some() { Outcome::Done } else { Outcome::Skipped })
}
fn copy_file(source: &Path, dest: &Path, report: Report<'_>) -> Result<Outcome, Error> {
Ok(match attempt(source, report, |report| copy_file_content(source, dest, report))? {
Some(Transfer::Done) => Outcome::Done,
Some(Transfer::Cancelled) => Outcome::Cancelled,
None => Outcome::Skipped,
})
}
#[cfg(unix)]
fn copy_symlink(source: &Path, dest: &Path) -> Result<(), Error> {
std::os::unix::fs::symlink(fs::read_link(source)?, dest)
}
#[cfg(windows)]
fn copy_symlink(source: &Path, dest: &Path) -> Result<(), Error> {
let target = fs::read_link(source)?;
if source.is_dir() { std::os::windows::fs::symlink_dir(target, dest) } else { std::os::windows::fs::symlink_file(target, dest) }
}
fn copy_file_content(source: &Path, dest: &Path, report: Report<'_>) -> Result<Transfer, Error> {
let metadata = fs::symlink_metadata(source)?;
if !metadata.is_file() {
return Err(not_regular(source));
}
if !report.step(Step::Starting(source)) {
return Ok(Transfer::Cancelled);
}
let mut src_file = File::open(source)?;
let replacing = dest.symlink_metadata().is_ok();
let (temp, mut dst_file) = temp_beside(dest)?;
let discard = |dst_file: File| {
drop(dst_file);
let _ = remove_file(&temp);
};
loop {
let copied = match io::copy(&mut (&mut src_file).take(COPY_CHUNK), &mut dst_file) {
Ok(copied) => copied,
Err(e) => {
discard(dst_file);
return Err(e);
}
};
if copied == 0 {
keep_times(&dst_file, &metadata);
drop(dst_file);
let _ = fs::set_permissions(&temp, metadata.permissions());
let placed = if replacing {
clear_link(dest).and_then(|()| rename(&temp, dest))
} else if path_exists(dest) {
Err(Error::new(ErrorKind::AlreadyExists, format!("Destination already exists: {}", dest.display())))
} else {
rename(&temp, dest)
};
if let Err(e) = placed {
let _ = remove_file(&temp);
return Err(e);
}
return Ok(Transfer::Done);
}
if !report.step(Step::Advanced(copied)) {
discard(dst_file);
return Ok(Transfer::Cancelled);
}
}
}
fn keep_times(dest: &File, metadata: &fs::Metadata) {
let mut times = fs::FileTimes::new();
if let Ok(modified) = metadata.modified() {
times = times.set_modified(modified);
}
if let Ok(accessed) = metadata.accessed() {
times = times.set_accessed(accessed);
}
let _ = dest.set_times(times);
}
fn keep_dir_times(dest: &Path, metadata: &fs::Metadata) {
if let Ok(dir) = File::open(dest) {
keep_times(&dir, metadata);
}
}
fn copy_dir_recursive(source: &Path, dest: &Path, report: Report<'_>) -> Result<Outcome, Error> {
if attempt(dest, report, |_| fs::create_dir_all(dest))?.is_none() {
return Ok(Outcome::Skipped);
}
let Some(entries) = attempt(source, report, |_| read_dir(source))? else {
return Ok(Outcome::Skipped);
};
let mut skipped = false;
for entry in entries {
let Some((entry, file_type)) = next_entry(source, entry, report)? else {
skipped = true;
continue;
};
let entry_path = entry.path();
let dest_path = dest.join(entry.file_name());
let outcome = if file_type.is_dir() {
copy_dir_recursive(&entry_path, &dest_path, &mut *report)?
} else if let Some(outcome) = settle_clash(&entry_path, &dest_path, &mut *report) {
outcome
} else if file_type.is_symlink() {
copy_link(&entry_path, &dest_path, &mut *report)?
} else {
copy_file(&entry_path, &dest_path, &mut *report)?
};
match outcome {
Outcome::Cancelled => return Ok(Outcome::Cancelled),
Outcome::Skipped => skipped = true,
Outcome::Done => {}
}
}
if let Ok(metadata) = fs::metadata(source) {
keep_dir_times(dest, &metadata);
let _ = fs::set_permissions(dest, metadata.permissions());
}
Ok(if skipped { Outcome::Skipped } else { Outcome::Done })
}
pub fn move_path(source: PathBuf, dest: PathBuf, report: Report<'_>) -> Result<Transfer, Error> {
if path_exists(&dest) {
return Ok(move_across(&source, &dest, report)?.into());
}
let renamed = attempt(&source, report, |_| match rename(&source, &dest) {
Ok(()) => Ok(true),
Err(e) if is_cross_device(&e) => Ok(false),
Err(e) => Err(e),
})?;
match renamed {
Some(true) | None => Ok(Transfer::Done),
Some(false) => Ok(move_across(&source, &dest, report)?.into()),
}
}
fn move_across(source: &Path, dest: &Path, report: Report<'_>) -> Result<Outcome, Error> {
let Some(metadata) = attempt(source, report, |_| fs::symlink_metadata(source))? else {
return Ok(Outcome::Skipped);
};
if !metadata.is_dir() && is_same_entry(source, dest) {
let bytes = if metadata.is_file() { metadata.len() } else { 0 };
if !report.step(Step::Starting(source)) || !report.step(Step::Advanced(bytes)) {
return Ok(Outcome::Cancelled);
}
let removed = attempt(source, report, |_| remove_file(source))?;
return Ok(if removed.is_some() { Outcome::Done } else { Outcome::Skipped });
}
if !metadata.is_dir()
&& let Some(outcome) = settle_clash(source, dest, report)
{
return Ok(outcome);
}
if rename(source, dest).is_ok() {
if !metadata.is_dir() {
let bytes = if metadata.file_type().is_symlink() { 0 } else { metadata.len() };
if !report.step(Step::Starting(source)) || !report.step(Step::Advanced(bytes)) {
return Ok(Outcome::Cancelled);
}
}
return Ok(Outcome::Done);
}
if !metadata.is_dir() {
let copied = if metadata.file_type().is_symlink() { copy_link(source, dest, report)? } else { copy_file(source, dest, report)? };
if copied != Outcome::Done {
return Ok(copied);
}
let removed = attempt(source, report, |_| remove_file(source))?;
return Ok(if removed.is_some() { Outcome::Done } else { Outcome::Skipped });
}
if attempt(dest, report, |_| fs::create_dir_all(dest))?.is_none() {
return Ok(Outcome::Skipped);
}
let Some(entries) = attempt(source, report, |_| read_dir(source))? else {
return Ok(Outcome::Skipped);
};
let mut skipped = false;
for entry in entries {
let Some((entry, _)) = next_entry(source, entry, report)? else {
skipped = true;
continue;
};
match move_across(&entry.path(), &dest.join(entry.file_name()), &mut *report)? {
Outcome::Cancelled => return Ok(Outcome::Cancelled),
Outcome::Skipped => skipped = true,
Outcome::Done => {}
}
}
if skipped {
return Ok(Outcome::Skipped);
}
keep_dir_times(dest, &metadata);
let _ = fs::set_permissions(dest, metadata.permissions());
let removed = attempt(source, report, |_| remove_dir(source))?;
Ok(if removed.is_some() { Outcome::Done } else { Outcome::Skipped })
}
fn is_real_dir(path: &Path) -> bool {
path.symlink_metadata().is_ok_and(|metadata| metadata.is_dir())
}
pub fn transfer_targets(sources: &[(PathBuf, bool)], destination: &Path, names_dir: bool) -> Result<Vec<(PathBuf, PathBuf, bool)>, String> {
if destination.is_dir() {
return Ok(sources.iter().map(|(source, is_dir)| (source.clone(), destination.join(source.file_name().unwrap_or_default()), *is_dir)).collect());
}
if names_dir {
return Err(format!("No such directory: {}", destination.display()));
}
let [(source, is_dir)] = sources else {
return Err(format!("{} items need a directory to go into, and {} is not one", sources.len(), destination.display()));
};
match destination.parent() {
Some(parent) if parent.is_dir() => Ok(vec![(source.clone(), destination.to_path_buf(), *is_dir)]),
Some(parent) => Err(format!("No such directory: {}", parent.display())),
None => Err(format!("Cannot copy to {}", destination.display())),
}
}
pub fn check_destinations(items: &[(PathBuf, PathBuf, bool)]) -> Result<Vec<PathBuf>, String> {
let mut taken = Vec::new();
for (source, dest, _) in items {
if !path_exists(dest) {
continue;
}
if is_same_entry(source, dest) {
return Err(format!("\"{}\" is already there", source.display()));
}
match (is_real_dir(source), is_real_dir(dest)) {
(false, true) => return Err(format!("Cannot replace directory {} with a file", dest.display())),
(true, false) => return Err(format!("Cannot replace file {} with a directory", dest.display())),
_ => {}
}
taken.push(dest.clone());
}
if let Some((source, _, _)) = items.iter().find(|(source, dest, _)| copies_into_itself(source, dest)) {
return Err(format!("Cannot copy \"{}\" into itself", source.display()));
}
Ok(taken)
}
pub fn save_file(path: &Path, content: &[u8]) -> Result<(), Error> {
let target = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let in_place = || fs::write(&target, content);
let Ok(metadata) = fs::metadata(&target) else {
return in_place();
};
if !metadata.is_file() {
return Err(not_regular(&target));
}
File::options().write(true).open(&target)?;
if has_other_links(&metadata) {
return in_place();
}
let (temp, mut file) = match temp_beside(&target) {
Ok(pair) => pair,
Err(e) if e.kind() == ErrorKind::PermissionDenied => return in_place(),
Err(e) => return Err(e),
};
if keep_owner(&file, &metadata).is_err() {
drop(file);
let _ = remove_file(&temp);
return in_place();
}
let written = file.write_all(content).and_then(|()| file.set_permissions(metadata.permissions())).and_then(|()| file.sync_all());
drop(file);
if let Err(e) = written.and_then(|()| rename(&temp, &target)) {
let _ = remove_file(&temp);
return Err(e);
}
Ok(())
}
#[cfg(unix)]
fn has_other_links(metadata: &fs::Metadata) -> bool {
use std::os::unix::fs::MetadataExt;
metadata.nlink() > 1
}
#[cfg(not(unix))]
fn has_other_links(_metadata: &fs::Metadata) -> bool {
false
}
#[cfg(unix)]
fn keep_owner(file: &File, metadata: &fs::Metadata) -> Result<(), Error> {
use std::os::unix::fs::MetadataExt;
std::os::unix::fs::fchown(file, Some(metadata.uid()), Some(metadata.gid()))
}
#[cfg(not(unix))]
fn keep_owner(_file: &File, _metadata: &fs::Metadata) -> Result<(), Error> {
Ok(())
}
fn temp_beside(dest: &Path) -> Result<(PathBuf, File), Error> {
let name: String = dest.file_name().unwrap_or_default().to_string_lossy().chars().take(64).collect();
for attempt in 0..1000u32 {
let candidate = dest.with_file_name(format!(".{name}.fm84-{}-{attempt}", std::process::id()));
match File::options().write(true).create_new(true).open(&candidate) {
Ok(file) => return Ok((candidate, file)),
Err(e) if e.kind() == ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(Error::new(ErrorKind::AlreadyExists, format!("Cannot make room beside {}", dest.display())))
}
fn clear_link(dest: &Path) -> Result<(), Error> {
match dest.symlink_metadata() {
Ok(metadata) if metadata.file_type().is_symlink() => remove_file(dest).or_else(|_| remove_dir(dest)),
_ => Ok(()),
}
}
fn clear_for_link(dest: &Path) -> Result<(), Error> {
match dest.symlink_metadata() {
Ok(metadata) if metadata.is_dir() => Err(Error::new(ErrorKind::AlreadyExists, format!("Cannot replace directory {} with a link", dest.display()))),
Ok(_) => clear_link(dest).and_then(|()| if path_exists(dest) { remove_file(dest) } else { Ok(()) }),
Err(_) => Ok(()),
}
}
fn is_cross_device(error: &Error) -> bool {
error.kind() == ErrorKind::CrossesDevices
}
pub fn calculate_dir_size(path: &Path) -> Result<u64, Error> {
walk_dir_size(path, &AtomicBool::new(false), &mut |_| {}).map(Option::unwrap_or_default)
}
pub fn walk_dir_size(path: &Path, cancel: &AtomicBool, found: &mut dyn FnMut(u64)) -> Result<Option<u64>, Error> {
let mut total = 0u64;
for entry in read_dir(path)? {
if cancel.load(Ordering::Relaxed) {
return Ok(None);
}
let Ok(entry) = entry else { continue };
let Ok(file_type) = entry.file_type() else { continue };
if file_type.is_dir() {
match walk_dir_size(&entry.path(), cancel, found) {
Ok(Some(size)) => total += size,
Ok(None) => return Ok(None),
Err(_) => {}
}
} else if let Ok(metadata) = entry.metadata() {
total += metadata.len();
found(metadata.len());
}
}
Ok(Some(total))
}
#[cfg(test)]
mod live_mount_probe {
#[test]
#[ignore = "machine-specific; run with --ignored to eyeball this host's mounts"]
fn show() {
for mount in super::list_mounts() {
println!(" {:<24} {:<14} {:?}", mount.path.display(), mount.label, mount.kind);
}
}
}
#[cfg(test)]
mod transfer_tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("fm84-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn a_save_replaces_the_content_and_leaves_nothing_beside_it() {
let dir = scratch("save");
let file = dir.join("notes.txt");
fs::write(&file, "old and longer than the new").unwrap();
save_file(&file, b"new").unwrap();
assert_eq!(fs::read_to_string(&file).unwrap(), "new");
assert_eq!(fs::read_dir(&dir).unwrap().count(), 1, "a temporary was left behind");
fs::remove_dir_all(&dir).unwrap();
}
#[cfg(unix)]
#[test]
fn a_save_keeps_the_mode_and_writes_through_a_link() {
use std::os::unix::fs::PermissionsExt;
let dir = scratch("save-link");
let file = dir.join("run.sh");
fs::write(&file, "echo old").unwrap();
fs::set_permissions(&file, fs::Permissions::from_mode(0o750)).unwrap();
std::os::unix::fs::symlink(&file, dir.join("link.sh")).unwrap();
save_file(&dir.join("link.sh"), b"echo new").unwrap();
assert!(fs::symlink_metadata(dir.join("link.sh")).unwrap().file_type().is_symlink());
assert_eq!(fs::read_to_string(&file).unwrap(), "echo new");
assert_eq!(fs::metadata(&file).unwrap().permissions().mode() & 0o777, 0o750);
fs::remove_dir_all(&dir).unwrap();
}
#[cfg(unix)]
#[test]
fn a_save_keeps_a_hard_link_joined() {
let dir = scratch("save-hardlink");
let file = dir.join("a.txt");
fs::write(&file, "old").unwrap();
fs::hard_link(&file, dir.join("b.txt")).unwrap();
save_file(&file, b"new").unwrap();
assert_eq!(fs::read_to_string(dir.join("b.txt")).unwrap(), "new");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_read_only_file_is_not_replaced() {
let dir = scratch("save-readonly");
let file = dir.join("locked.txt");
fs::write(&file, "old").unwrap();
let mut permissions = fs::metadata(&file).unwrap().permissions();
permissions.set_readonly(true);
fs::set_permissions(&file, permissions.clone()).unwrap();
if File::options().write(true).open(&file).is_err() {
assert!(save_file(&file, b"new").is_err());
assert_eq!(fs::read_to_string(&file).unwrap(), "old");
}
#[allow(clippy::permissions_set_readonly_false)]
permissions.set_readonly(false);
fs::set_permissions(&file, permissions).unwrap();
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_size_walk_counts_what_it_can_and_stops_when_asked() {
let dir = scratch("walk-size");
fs::write(dir.join("a.bin"), vec![0u8; 1000]).unwrap();
fs::create_dir(dir.join("sub")).unwrap();
fs::write(dir.join("sub").join("b.bin"), vec![0u8; 2500]).unwrap();
let mut reported = Vec::new();
let size = walk_dir_size(&dir, &AtomicBool::new(false), &mut |bytes| reported.push(bytes)).unwrap();
assert_eq!(size, Some(3500));
reported.sort();
assert_eq!(reported, [1000, 2500]);
assert_eq!(walk_dir_size(&dir, &AtomicBool::new(true), &mut |_| {}).unwrap(), None);
assert!(walk_dir_size(&dir.join("gone"), &AtomicBool::new(false), &mut |_| {}).is_err());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn measure_adds_up_files_and_walks_directories() {
let dir = scratch("measure");
fs::write(dir.join("a.bin"), vec![0u8; 1000]).unwrap();
fs::create_dir(dir.join("sub")).unwrap();
fs::write(dir.join("sub").join("b.bin"), vec![0u8; 2500]).unwrap();
let items = vec![(dir.join("a.bin"), dir.join("copy-a.bin"), false), (dir.join("sub"), dir.join("copy-sub"), true)];
assert_eq!(measure(&items), 3500);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_copy_reports_every_file_and_every_byte() {
let dir = scratch("report");
let source = dir.join("src");
fs::create_dir(&source).unwrap();
fs::write(source.join("big.bin"), vec![7u8; COPY_CHUNK as usize * 2 + 13]).unwrap();
fs::write(source.join("small.bin"), vec![7u8; 10]).unwrap();
let dest = dir.join("dst");
let mut started = Vec::new();
let mut bytes = 0u64;
{
let mut report = |step: Step<'_>| {
match step {
Step::Starting(path) => started.push(path.file_name().unwrap().to_string_lossy().into_owned()),
Step::Advanced(n) => bytes += n,
}
true
};
assert_eq!(copy_path(source.clone(), dest.clone(), true, &mut report).unwrap(), Transfer::Done);
}
started.sort();
assert_eq!(started, ["big.bin", "small.bin"]);
assert_eq!(bytes, measure(&[(source, dest, true)]));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_cancelled_overwrite_leaves_the_old_file_alone() {
let dir = scratch("overwrite-cancel").canonicalize().unwrap();
let source = dir.join("new.bin");
let dest = dir.join("old.bin");
fs::write(&source, vec![7u8; COPY_CHUNK as usize * 3]).unwrap();
fs::write(&dest, b"the file that was already here").unwrap();
let mut chunks = 0;
{
let mut report = |step: Step<'_>| {
if let Step::Advanced(_) = step {
chunks += 1;
}
chunks < 1
};
assert_eq!(copy_path(source, dest.clone(), false, &mut report).unwrap(), Transfer::Cancelled);
}
assert_eq!(fs::read(&dest).unwrap(), b"the file that was already here");
let strays: Vec<_> = fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name()).filter(|n| n.to_string_lossy().contains("fm84-")).collect();
assert!(strays.is_empty(), "left behind {strays:?}");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn an_overwrite_that_finishes_replaces_the_file() {
let dir = scratch("overwrite-done").canonicalize().unwrap();
let source = dir.join("new.bin");
let dest = dir.join("old.bin");
fs::write(&source, b"replacement").unwrap();
fs::write(&dest, b"the file that was already here").unwrap();
let mut report = |_: Step<'_>| true;
assert_eq!(copy_path(source, dest.clone(), false, &mut report).unwrap(), Transfer::Done);
assert_eq!(fs::read(&dest).unwrap(), b"replacement");
assert_eq!(fs::read_dir(&dir).unwrap().count(), 2, "a temporary was left behind");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn cancelling_clears_the_half_written_file() {
let dir = scratch("cancel");
let source = dir.join("big.bin");
let dest = dir.join("copy.bin");
fs::write(&source, vec![7u8; COPY_CHUNK as usize * 3]).unwrap();
let mut chunks = 0;
{
let mut report = |step: Step<'_>| {
if let Step::Advanced(_) = step {
chunks += 1;
}
chunks < 1
};
assert_eq!(copy_path(source, dest.clone(), false, &mut report).unwrap(), Transfer::Cancelled);
}
assert!(!dest.exists());
assert_eq!(fs::read_dir(&dir).unwrap().count(), 1, "a temporary was left behind");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_copy_under_way_is_not_under_its_own_name() {
let dir = scratch("copy-temp");
let source = dir.join("big.bin");
let dest = dir.join("copy.bin");
fs::write(&source, vec![7u8; COPY_CHUNK as usize * 2]).unwrap();
let mut seen_early = None;
{
let mut report = |step: Step<'_>| {
if let Step::Advanced(_) = step {
seen_early.get_or_insert(dest.exists());
}
true
};
assert_eq!(copy_path(source.clone(), dest.clone(), false, &mut report).unwrap(), Transfer::Done);
}
assert_eq!(seen_early, Some(false));
assert_eq!(fs::read(&dest).unwrap(), fs::read(&source).unwrap());
assert_eq!(fs::read_dir(&dir).unwrap().count(), 2, "a temporary was left behind");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_copy_keeps_its_times() {
let dir = scratch("copy-times");
let source = dir.join("src");
fs::create_dir_all(source.join("sub")).unwrap();
fs::write(source.join("sub").join("old.txt"), "old").unwrap();
let then = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000_000);
let times = fs::FileTimes::new().set_modified(then).set_accessed(then);
File::options().write(true).open(source.join("sub").join("old.txt")).unwrap().set_times(times).unwrap();
for directory in [source.join("sub"), source.clone()] {
if let Ok(handle) = File::open(&directory) {
let _ = handle.set_times(times);
}
}
let mut report = |_: Step<'_>| true;
let copied = dir.join("copied");
assert_eq!(copy_path(source.clone(), copied.clone(), true, &mut report).unwrap(), Transfer::Done);
let modified = |path: &Path| fs::metadata(path).unwrap().modified().unwrap();
assert_eq!(modified(&copied.join("sub").join("old.txt")), then);
if modified(&source.join("sub")) == then {
assert_eq!(modified(&copied.join("sub")), then);
assert_eq!(modified(&copied), then);
}
fs::remove_dir_all(&dir).unwrap();
}
#[cfg(unix)]
fn fifo(path: &Path) {
use std::os::unix::ffi::OsStrExt;
let name = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap();
assert_eq!(unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, 0);
}
struct Clashing {
answer: Clash,
asked: Vec<(PathBuf, PathBuf)>,
}
impl Clashing {
fn new(answer: Clash) -> Self {
Clashing { answer, asked: Vec::new() }
}
}
impl Progress for Clashing {
fn step(&mut self, _step: Step<'_>) -> bool {
true
}
fn exists(&mut self, source: &Path, dest: &Path) -> Clash {
self.asked.push((source.to_path_buf(), dest.to_path_buf()));
self.answer
}
}
struct Answering {
reply: Reply,
asked: Vec<PathBuf>,
}
impl Answering {
fn new(reply: Reply) -> Self {
Answering { reply, asked: Vec::new() }
}
}
impl Progress for Answering {
fn step(&mut self, _step: Step<'_>) -> bool {
true
}
fn failed(&mut self, path: &Path, _error: &Error) -> Reply {
self.asked.push(path.to_path_buf());
self.reply
}
}
#[cfg(target_os = "linux")]
#[test]
fn trashing_moves_it_away_and_what_will_not_go_can_be_deleted() {
const ROOT: &str = "FM84_TRASH_TEST_ROOT";
let Some(root) = std::env::var_os(ROOT).map(PathBuf::from) else {
let root = std::env::temp_dir().join(format!("fm84-trash-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "fs_ops::transfer_tests::trashing_moves_it_away_and_what_will_not_go_can_be_deleted", "--nocapture"])
.env(ROOT, &root)
.env("XDG_DATA_HOME", root.join("data"))
.output()
.unwrap();
let _ = fs::remove_dir_all(&root);
let said = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success() && said.contains("1 passed"), "the child run failed:\n{said}");
return;
};
let data = root.join("data");
let file = root.join("note.txt");
fs::write(&file, "kept").unwrap();
let dir = root.join("folder");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("inside.txt"), "").unwrap();
let mut answering = Answering::new(Reply::Abort);
assert_eq!(trash_path(file.clone(), false, &mut answering).unwrap(), Transfer::Done);
assert_eq!(trash_path(dir.clone(), true, &mut answering).unwrap(), Transfer::Done);
assert!(!file.exists() && !dir.exists());
assert_eq!(fs::read_to_string(data.join("Trash/files/note.txt")).unwrap(), "kept");
assert!(data.join("Trash/files/folder/inside.txt").exists());
assert!(data.join("Trash/info/note.txt.trashinfo").exists());
assert!(answering.asked.is_empty());
fs::remove_dir_all(&data).unwrap();
fs::write(&data, "").unwrap();
let other = root.join("other");
fs::create_dir_all(&other).unwrap();
fs::write(other.join("inside.txt"), "").unwrap();
let mut skipping = Answering::new(Reply::Skip);
assert_eq!(trash_path(other.clone(), true, &mut skipping).unwrap(), Transfer::Done);
assert!(other.exists());
assert_eq!(skipping.asked, std::slice::from_ref(&other));
let mut deleting = Answering::new(Reply::Delete);
assert_eq!(trash_path(other.clone(), true, &mut deleting).unwrap(), Transfer::Done);
assert!(!other.exists());
}
#[test]
fn a_delete_inside_another_step_counts_nothing() {
let dir = std::env::temp_dir().join(format!("fm84-uncounted-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("sub")).unwrap();
fs::write(dir.join("sub").join("a"), "").unwrap();
let mut counted = 0;
let mut started = 0;
let mut report = |step: Step<'_>| {
match step {
Step::Advanced(amount) => counted += amount,
Step::Starting(_) => started += 1,
}
true
};
let mut uncounted = Uncounted(&mut report);
assert_eq!(delete_path(dir.clone(), true, &mut uncounted).unwrap(), Transfer::Done);
assert!(!dir.exists());
assert_eq!(counted, 0);
assert_eq!(started, 3);
}
#[cfg(unix)]
fn locked(dir: &Path) -> Option<()> {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(dir, fs::Permissions::from_mode(0o555)).unwrap();
if File::create(dir.join(".probe")).is_ok() {
let _ = remove_file(dir.join(".probe"));
unlock(dir);
return None;
}
Some(())
}
#[cfg(unix)]
fn unlock(dir: &Path) {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(dir, fs::Permissions::from_mode(0o755)).unwrap();
}
#[cfg(unix)]
#[test]
fn a_skipped_pipe_leaves_the_rest_of_the_copy_whole() {
let dir = scratch("skip-pipe");
let tree = dir.join("tree");
fs::create_dir_all(tree.join("sub")).unwrap();
fs::write(tree.join("a.txt"), "a").unwrap();
fifo(&tree.join("pipe"));
fs::write(tree.join("sub").join("b.txt"), "b").unwrap();
let mut progress = Answering::new(Reply::Skip);
let dest = dir.join("copy");
assert_eq!(copy_path(tree.clone(), dest.clone(), true, &mut progress).unwrap(), Transfer::Done);
assert_eq!(progress.asked, [tree.join("pipe")]);
assert_eq!(fs::read_to_string(dest.join("a.txt")).unwrap(), "a");
assert_eq!(fs::read_to_string(dest.join("sub").join("b.txt")).unwrap(), "b");
assert!(!path_exists(&dest.join("pipe")));
let mut progress = Answering::new(Reply::Abort);
assert!(copy_path(tree, dir.join("aborted"), true, &mut progress).is_err());
fs::remove_dir_all(&dir).unwrap();
}
#[cfg(unix)]
#[test]
fn a_skipped_delete_keeps_only_what_holds_it() {
let dir = scratch("skip-delete");
let tree = dir.join("tree");
fs::create_dir_all(tree.join("locked")).unwrap();
fs::create_dir_all(tree.join("open")).unwrap();
fs::write(tree.join("locked").join("stuck.txt"), "").unwrap();
fs::write(tree.join("open").join("free.txt"), "").unwrap();
fs::write(tree.join("top.txt"), "").unwrap();
if locked(&tree.join("locked")).is_none() {
return fs::remove_dir_all(&dir).unwrap();
}
let mut progress = Answering::new(Reply::Skip);
assert_eq!(delete_path(tree.clone(), true, &mut progress).unwrap(), Transfer::Done);
assert_eq!(progress.asked, [tree.join("locked").join("stuck.txt")]);
assert!(path_exists(&tree.join("locked").join("stuck.txt")));
assert!(!path_exists(&tree.join("open")));
assert!(!path_exists(&tree.join("top.txt")));
unlock(&tree.join("locked"));
fs::remove_dir_all(&dir).unwrap();
}
#[cfg(unix)]
#[test]
fn a_retry_tries_the_same_entry_again() {
struct UnlockThenRetry(PathBuf, u32);
impl Progress for UnlockThenRetry {
fn step(&mut self, _step: Step<'_>) -> bool {
true
}
fn failed(&mut self, _path: &Path, _error: &Error) -> Reply {
unlock(&self.0);
self.1 += 1;
Reply::Retry
}
}
let dir = scratch("retry");
fs::create_dir(dir.join("locked")).unwrap();
fs::write(dir.join("locked").join("file"), "").unwrap();
if locked(&dir.join("locked")).is_none() {
return fs::remove_dir_all(&dir).unwrap();
}
let mut progress = UnlockThenRetry(dir.join("locked"), 0);
assert_eq!(delete_path(dir.join("locked"), true, &mut progress).unwrap(), Transfer::Done);
assert_eq!(progress.1, 1);
assert!(!path_exists(&dir.join("locked")));
fs::remove_dir_all(&dir).unwrap();
}
#[cfg(unix)]
#[test]
fn a_skipped_move_leaves_the_source_of_what_it_skipped() {
let dir = scratch("skip-move");
let source = dir.join("a");
fs::create_dir_all(source.join("locked")).unwrap();
fs::write(source.join("locked").join("stuck.txt"), "stuck").unwrap();
fs::write(source.join("free.txt"), "free").unwrap();
let dest = dir.join("b");
fs::create_dir_all(dest.join("locked")).unwrap();
fs::write(dest.join("locked").join("other.txt"), "").unwrap();
if locked(&source.join("locked")).is_none() {
return fs::remove_dir_all(&dir).unwrap();
}
let mut progress = Answering::new(Reply::Skip);
assert_eq!(move_path(source.clone(), dest.clone(), &mut progress).unwrap(), Transfer::Done);
assert_eq!(progress.asked, [source.join("locked").join("stuck.txt")]);
assert_eq!(fs::read_to_string(dest.join("free.txt")).unwrap(), "free");
assert!(!path_exists(&source.join("free.txt")));
assert_eq!(fs::read_to_string(source.join("locked").join("stuck.txt")).unwrap(), "stuck");
assert_eq!(fs::read_to_string(dest.join("locked").join("stuck.txt")).unwrap(), "stuck");
unlock(&source.join("locked"));
fs::remove_dir_all(&dir).unwrap();
}
#[cfg(unix)]
#[test]
fn special_files_are_never_opened() {
let dir = scratch("fifo");
let pipe = dir.join("pipe");
fifo(&pipe);
assert!(!is_regular_file(&pipe));
assert!(!is_regular_file(Path::new("/dev/zero")));
assert!(!is_regular_file(&dir));
fs::write(dir.join("plain"), "x").unwrap();
assert!(is_regular_file(&dir.join("plain")));
assert!(matches!(crate::viewer::load_preview(&pipe, 1024, 10), crate::viewer::Preview::Lines(lines) if lines == ["Not a regular file"]));
assert!(crate::viewer::load_file_content(&pipe, None).is_err());
assert!(crate::viewer::load_file_content(Path::new("/dev/zero"), None).is_err());
let mut report = |_: Step<'_>| true;
assert!(copy_path(pipe.clone(), dir.join("pipe-copy"), false, &mut report).is_err());
let tree = dir.join("tree");
fs::create_dir(&tree).unwrap();
fifo(&tree.join("pipe"));
assert!(copy_path(tree, dir.join("tree-copy"), true, &mut report).is_err());
fs::remove_dir_all(&dir).unwrap();
}
#[cfg(target_os = "linux")]
#[test]
fn a_failed_copy_leaves_no_fragment() {
let source = Path::new("/proc/self/mem");
let dir = scratch("failed");
let dest = dir.join("mem-copy");
let mut report = |_: Step<'_>| true;
assert!(copy_path(source.to_path_buf(), dest.clone(), false, &mut report).is_err());
assert!(!path_exists(&dest));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_plain_name_stays_where_it_is_put() {
for name in ["notes.txt", ".hidden", "with space", "ünïcode", "a.b.c"] {
assert!(is_plain_name(name), "{name}");
}
for name in ["", ".", "..", "../x", "sub/x", "x/", "/x", "./x"] {
assert!(!is_plain_name(name), "{name}");
}
#[cfg(windows)]
for name in ["sub\\x", "C:x", "C:"] {
assert!(!is_plain_name(name), "{name}");
}
}
#[cfg(unix)]
#[test]
fn an_entry_is_itself_and_nothing_else() {
let dir = scratch("same");
fs::write(dir.join("a"), "a").unwrap();
fs::write(dir.join("b"), "b").unwrap();
std::os::unix::fs::symlink(dir.join("a"), dir.join("link")).unwrap();
assert!(is_same_entry(&dir.join("a"), &dir.join("a")));
assert!(!is_same_entry(&dir.join("a"), &dir.join("b")));
assert!(!is_same_entry(&dir.join("a"), &dir.join("link")));
assert!(!is_same_entry(&dir.join("a"), &dir.join("missing")));
fs::remove_dir_all(&dir).unwrap();
}
#[cfg(unix)]
#[test]
fn only_a_cross_device_rename_falls_back_to_copying() {
assert!(is_cross_device(&Error::from_raw_os_error(libc::EXDEV)));
assert!(!is_cross_device(&Error::from_raw_os_error(libc::EEXIST)));
assert!(!is_cross_device(&Error::from_raw_os_error(libc::ENOTEMPTY)));
assert!(!is_cross_device(&Error::from_raw_os_error(libc::EACCES)));
}
#[cfg(unix)]
#[test]
fn a_copy_keeps_its_permissions() {
use std::os::unix::fs::PermissionsExt;
let mode = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o7777;
let set = |path: &Path, mode: u32| fs::set_permissions(path, fs::Permissions::from_mode(mode)).unwrap();
let dir = scratch("modes");
let source = dir.join("src");
fs::create_dir(&source).unwrap();
fs::write(source.join("script"), "#!/bin/sh\n").unwrap();
set(&source.join("script"), 0o755);
fs::write(source.join("secret"), "x").unwrap();
set(&source.join("secret"), 0o600);
set(&source, 0o555);
let dest = dir.join("dst");
let mut report = |_: Step<'_>| true;
assert_eq!(copy_path(source.clone(), dest.clone(), true, &mut report).unwrap(), Transfer::Done);
assert_eq!(mode(&dest.join("script")), 0o755);
assert_eq!(mode(&dest.join("secret")), 0o600);
assert_eq!(mode(&dest), 0o555);
set(&source, 0o755);
set(&dest, 0o755);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn refusing_the_first_file_copies_nothing() {
let dir = scratch("refuse");
let source = dir.join("src");
fs::create_dir(&source).unwrap();
fs::write(source.join("a.bin"), vec![1u8; 10]).unwrap();
let dest = dir.join("dst");
let mut report = |_: Step<'_>| false;
assert_eq!(copy_path(source, dest.clone(), true, &mut report).unwrap(), Transfer::Cancelled);
assert!(!dest.join("a.bin").exists());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_directory_will_not_be_copied_into_itself() {
let dir = scratch("into-itself").canonicalize().unwrap();
let source = dir.join("sub");
fs::create_dir(&source).unwrap();
fs::write(source.join("f.bin"), vec![1u8; 32]).unwrap();
assert!(copies_into_itself(&source, &source.join("sub")));
assert!(copies_into_itself(&source, &source.join("a").join("b")));
assert!(!copies_into_itself(&source, &dir.join("elsewhere")));
assert!(!copies_into_itself(&source, &dir.join("sub2")));
assert!(!copies_into_itself(&source, &dir.join("subfoo").join("sub")));
assert!(!copies_into_itself(&source.join("f.bin"), &source.join("f.bin").join("x")));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
#[cfg(unix)]
fn a_link_into_the_source_is_caught_and_a_link_source_is_not() {
let dir = scratch("into-itself-links").canonicalize().unwrap();
let source = dir.join("sub");
fs::create_dir(&source).unwrap();
let link = dir.join("link");
std::os::unix::fs::symlink(&source, &link).unwrap();
assert!(copies_into_itself(&source, &link.join("copy")));
assert!(!copies_into_itself(&link, &source.join("copy")));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_runaway_copy_is_refused_before_anything_is_written() {
let dir = scratch("runaway").canonicalize().unwrap();
let source = dir.join("sub");
fs::create_dir(&source).unwrap();
fs::write(source.join("f.bin"), vec![9u8; 64]).unwrap();
let mut report = |_: Step<'_>| true;
let error = copy_path(source.clone(), source.join("sub"), true, &mut report).unwrap_err();
assert_eq!(error.kind(), ErrorKind::InvalidInput);
assert!(error.to_string().contains("into itself"), "{error}");
assert!(!source.join("sub").exists());
assert_eq!(fs::read_dir(&source).unwrap().count(), 1);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_delete_counts_and_removes_every_entry() {
let dir = scratch("delete").canonicalize().unwrap();
let tree = dir.join("tree");
fs::create_dir(&tree).unwrap();
fs::write(tree.join("a.bin"), b"a").unwrap();
fs::create_dir(tree.join("inner")).unwrap();
fs::write(tree.join("inner").join("b.bin"), b"b").unwrap();
let total = count_entries(&[(tree.clone(), true)]);
assert_eq!(total, 4);
let mut removed = 0u64;
let mut named = Vec::new();
{
let mut report = |step: Step<'_>| {
match step {
Step::Starting(path) => named.push(path.file_name().unwrap().to_string_lossy().into_owned()),
Step::Advanced(n) => removed += n,
}
true
};
assert_eq!(delete_path(tree.clone(), true, &mut report).unwrap(), Transfer::Done);
}
assert!(!tree.exists());
assert_eq!(removed, total);
named.sort();
assert_eq!(named, ["a.bin", "b.bin", "inner", "tree"]);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn cancelling_a_delete_leaves_the_rest_alone() {
let dir = scratch("delete-cancel").canonicalize().unwrap();
let tree = dir.join("tree");
fs::create_dir(&tree).unwrap();
for index in 0..8 {
fs::write(tree.join(format!("f{index}.bin")), b"x").unwrap();
}
let mut removed = 0;
{
let mut report = |step: Step<'_>| {
if let Step::Advanced(_) = step {
removed += 1;
}
removed < 1
};
assert_eq!(delete_path(tree.clone(), true, &mut report).unwrap(), Transfer::Cancelled);
}
assert!(tree.exists());
assert_eq!(fs::read_dir(&tree).unwrap().count(), 7);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
#[cfg(unix)]
fn deleting_a_link_to_a_directory_spares_what_it_points_at() {
let dir = scratch("delete-link").canonicalize().unwrap();
let target = dir.join("target");
fs::create_dir(&target).unwrap();
fs::write(target.join("keep.bin"), b"keep").unwrap();
let link = dir.join("link");
std::os::unix::fs::symlink(&target, &link).unwrap();
assert_eq!(count_entries(&[(link.clone(), true)]), 1);
let mut report = |_: Step<'_>| true;
assert_eq!(delete_path(link.clone(), true, &mut report).unwrap(), Transfer::Done);
assert!(!link.exists());
assert!(target.join("keep.bin").exists(), "the link's target was followed");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_destination_is_read_as_cp_reads_it() {
let dir = scratch("targets");
fs::create_dir(dir.join("into")).unwrap();
let one = vec![(dir.join("a.txt"), false)];
let two = vec![(dir.join("a.txt"), false), (dir.join("sub"), true)];
for names_dir in [false, true] {
assert_eq!(
transfer_targets(&two, &dir.join("into"), names_dir).unwrap(),
[(dir.join("a.txt"), dir.join("into").join("a.txt"), false), (dir.join("sub"), dir.join("into").join("sub"), true)]
);
}
assert_eq!(transfer_targets(&one, &dir.join("b.txt"), false).unwrap(), [(dir.join("a.txt"), dir.join("b.txt"), false)]);
assert!(transfer_targets(&two, &dir.join("b.txt"), false).unwrap_err().contains("2 items need a directory"));
assert!(transfer_targets(&one, &dir.join("missing").join("b.txt"), false).unwrap_err().starts_with("No such directory"));
assert!(transfer_targets(&one, &dir.join("missing"), true).unwrap_err().starts_with("No such directory"));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn destinations_are_checked_before_anything_is_written() {
let dir = scratch("check");
let (from, to) = (dir.join("from"), dir.join("to"));
fs::create_dir_all(from.join("tree")).unwrap();
fs::create_dir_all(to.join("tree")).unwrap();
for name in ["same", "new", "file-vs-dir"] {
fs::write(from.join(name), name).unwrap();
}
fs::write(to.join("same"), "old").unwrap();
fs::create_dir(to.join("file-vs-dir")).unwrap();
let item = |name: &str| (from.join(name), to.join(name), from.join(name).is_dir());
assert_eq!(check_destinations(&[item("new")]).unwrap(), Vec::<PathBuf>::new());
assert_eq!(check_destinations(&[item("new"), item("same"), item("tree")]).unwrap(), [to.join("same"), to.join("tree")]);
assert!(check_destinations(&[item("file-vs-dir")]).unwrap_err().contains("Cannot replace directory"));
assert!(check_destinations(&[(to.join("tree"), from.join("same"), true)]).unwrap_err().contains("Cannot replace file"));
assert!(check_destinations(&[(from.join("same"), from.join("same"), false)]).unwrap_err().contains("already there"));
assert!(check_destinations(&[(from.clone(), from.join("inside"), true)]).unwrap_err().contains("into itself"));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_move_onto_a_directory_merges_into_it() {
let dir = scratch("merge");
let (source, dest) = (dir.join("src"), dir.join("dst"));
fs::create_dir_all(source.join("sub")).unwrap();
fs::write(source.join("clash"), "new").unwrap();
fs::write(source.join("sub").join("only-in-source"), "s").unwrap();
fs::create_dir_all(dest.join("sub")).unwrap();
fs::write(dest.join("clash"), "old").unwrap();
fs::write(dest.join("only-in-dest"), "d").unwrap();
let mut keeping = Clashing::new(Clash::Keep);
assert_eq!(move_path(source.clone(), dest.clone(), &mut keeping).unwrap(), Transfer::Done);
assert_eq!(keeping.asked, [(source.join("clash"), dest.join("clash"))]);
assert_eq!(fs::read_to_string(dest.join("clash")).unwrap(), "old");
assert_eq!(fs::read_to_string(source.join("clash")).unwrap(), "new");
assert!(dest.join("sub").join("only-in-source").exists());
let mut report = |_: Step<'_>| true;
assert_eq!(move_path(source.clone(), dest.clone(), &mut report).unwrap(), Transfer::Done);
assert!(!source.exists());
assert_eq!(fs::read_to_string(dest.join("clash")).unwrap(), "new");
assert_eq!(fs::read_to_string(dest.join("only-in-dest")).unwrap(), "d");
assert_eq!(fs::read_to_string(dest.join("sub").join("only-in-source")).unwrap(), "s");
fs::remove_dir_all(&dir).unwrap();
}
#[cfg(unix)]
#[test]
fn a_merge_on_one_filesystem_renames_rather_than_copies() {
use std::os::unix::fs::MetadataExt;
let inode = |path: &Path| fs::symlink_metadata(path).unwrap().ino();
let dir = scratch("merge-rename");
let (source, dest) = (dir.join("src"), dir.join("dst"));
fs::create_dir_all(source.join("shared")).unwrap();
fs::create_dir_all(source.join("fresh")).unwrap();
fs::write(source.join("shared").join("big.bin"), vec![0u8; 4096]).unwrap();
fs::write(source.join("fresh").join("inner.txt"), "i").unwrap();
fs::write(source.join("clash"), "new").unwrap();
fs::create_dir_all(dest.join("shared")).unwrap();
fs::write(dest.join("shared").join("keep.txt"), "k").unwrap();
fs::write(dest.join("clash"), "old").unwrap();
let before = [inode(&source.join("shared").join("big.bin")), inode(&source.join("fresh")), inode(&source.join("clash"))];
let mut report = |_: Step<'_>| true;
assert_eq!(move_path(source.clone(), dest.clone(), &mut report).unwrap(), Transfer::Done);
let after = [inode(&dest.join("shared").join("big.bin")), inode(&dest.join("fresh")), inode(&dest.join("clash"))];
assert_eq!(before, after);
assert_eq!(fs::read_to_string(dest.join("clash")).unwrap(), "new");
assert_eq!(fs::read_to_string(dest.join("shared").join("keep.txt")).unwrap(), "k");
assert!(!source.exists());
fs::remove_dir_all(&dir).unwrap();
}
#[cfg(unix)]
#[test]
fn a_move_onto_its_own_hard_link_takes_the_old_name_away() {
let dir = scratch("move-hardlink");
let (source, dest) = (dir.join("src"), dir.join("dst"));
fs::create_dir_all(&source).unwrap();
fs::create_dir_all(&dest).unwrap();
fs::write(source.join("shared"), "one file, two names").unwrap();
fs::hard_link(source.join("shared"), dest.join("shared")).unwrap();
fs::write(source.join("other"), "o").unwrap();
let mut asking = Clashing::new(Clash::Keep);
assert_eq!(move_path(source.clone(), dest.clone(), &mut asking).unwrap(), Transfer::Done);
assert!(asking.asked.is_empty(), "{:?}", asking.asked);
assert!(!source.exists(), "the source directory is gone, emptied");
assert_eq!(fs::read_to_string(dest.join("shared")).unwrap(), "one file, two names");
assert_eq!(fs::read_to_string(dest.join("other")).unwrap(), "o");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_move_onto_a_file_replaces_it() {
let dir = scratch("replace");
fs::write(dir.join("a"), "new").unwrap();
fs::write(dir.join("b"), "old").unwrap();
let mut report = |_: Step<'_>| true;
assert_eq!(move_path(dir.join("a"), dir.join("b"), &mut report).unwrap(), Transfer::Done);
assert_eq!(fs::read_to_string(dir.join("b")).unwrap(), "new");
assert!(!dir.join("a").exists());
fs::remove_dir_all(&dir).unwrap();
}
#[cfg(unix)]
#[test]
fn overwriting_replaces_a_link_rather_than_writing_through_it() {
let dir = scratch("through");
fs::write(dir.join("precious"), "keep me").unwrap();
fs::write(dir.join("new"), "new").unwrap();
std::os::unix::fs::symlink(dir.join("precious"), dir.join("link")).unwrap();
let mut report = |_: Step<'_>| true;
copy_path(dir.join("new"), dir.join("link"), false, &mut report).unwrap();
assert_eq!(fs::read_to_string(dir.join("precious")).unwrap(), "keep me");
assert!(!dir.join("link").symlink_metadata().unwrap().file_type().is_symlink());
assert_eq!(fs::read_to_string(dir.join("link")).unwrap(), "new");
fs::remove_dir_all(&dir).unwrap();
}
#[cfg(unix)]
#[test]
fn overwriting_a_read_only_file_replaces_it() {
use std::os::unix::fs::PermissionsExt;
let dir = scratch("readonly");
fs::write(dir.join("new"), "new").unwrap();
fs::write(dir.join("locked"), "old").unwrap();
fs::set_permissions(dir.join("locked"), fs::Permissions::from_mode(0o444)).unwrap();
let mut report = |_: Step<'_>| true;
copy_path(dir.join("new"), dir.join("locked"), false, &mut report).unwrap();
assert_eq!(fs::read_to_string(dir.join("locked")).unwrap(), "new");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_rename_within_one_filesystem_moves_no_bytes() {
let dir = scratch("rename");
let source = dir.join("a.bin");
let dest = dir.join("b.bin");
fs::write(&source, vec![3u8; 4096]).unwrap();
assert!(rename_in_place(&source, &dest));
assert!(!source.exists() && dest.exists());
assert!(!rename_in_place(&dest, Path::new("/proc/fm84-cannot-go-here")));
fs::remove_dir_all(&dir).unwrap();
}
}
pub struct Description {
pub size_bytes: Option<u64>,
pub modified: Option<std::time::SystemTime>,
pub owner: String,
pub attributes: String,
pub link: Option<String>,
}
pub fn describe(path: &Path, is_dir: bool) -> Option<Description> {
let metadata = fs::symlink_metadata(path).ok()?;
let is_symlink = metadata.file_type().is_symlink();
let followed = if is_symlink { fs::metadata(path).ok() } else { None };
let size_bytes = if is_symlink { followed.as_ref().map(|target| target.len()) } else { (!is_dir).then_some(metadata.len()) };
Some(Description {
size_bytes,
modified: metadata.modified().ok(),
owner: owner_of(&metadata),
attributes: format_attributes(&metadata, is_dir, is_symlink),
link: is_symlink.then(|| fs::read_link(path).ok()).flatten().map(|target| target.display().to_string()),
})
}
#[cfg(unix)]
fn owner_of(metadata: &fs::Metadata) -> String {
use std::os::unix::fs::MetadataExt;
let (uid, gid) = (metadata.uid(), metadata.gid());
let user = lookup_name(|entry: &mut libc::passwd, buffer, length, found| unsafe { libc::getpwuid_r(uid, entry, buffer, length, found) }, |entry| entry.pw_name);
let group = lookup_name(|entry: &mut libc::group, buffer, length, found| unsafe { libc::getgrgid_r(gid, entry, buffer, length, found) }, |entry| entry.gr_name);
format!("{}:{}", user.unwrap_or_else(|| uid.to_string()), group.unwrap_or_else(|| gid.to_string()))
}
#[cfg(unix)]
fn lookup_name<E>(call: impl Fn(&mut E, *mut libc::c_char, libc::size_t, *mut *mut E) -> libc::c_int, name: impl Fn(&E) -> *const libc::c_char) -> Option<String> {
let mut buffer: Vec<libc::c_char> = vec![0; 1024];
loop {
let mut entry: E = unsafe { std::mem::zeroed() };
let mut found: *mut E = std::ptr::null_mut();
let status = call(&mut entry, buffer.as_mut_ptr(), buffer.len(), &mut found);
if status == libc::ERANGE && buffer.len() < 1 << 20 {
buffer.resize(buffer.len() * 2, 0);
continue;
}
if status != 0 || found.is_null() {
return None;
}
let pointer = name(&entry);
return (!pointer.is_null()).then(|| unsafe { std::ffi::CStr::from_ptr(pointer) }.to_string_lossy().into_owned());
}
}
#[cfg(not(unix))]
fn owner_of(_metadata: &fs::Metadata) -> String {
String::new()
}