use crate::archives;
use crate::fs_model::{home_dir, human_size, trash_files_dir, trash_info_dir};
use crate::open_with::{self, DesktopApp};
use std::env;
use std::ffi::{OsStr, OsString};
use std::fs::{self, Metadata};
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{self, Receiver, SyncSender};
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};
const OPERATION_EVENT_CAPACITY: usize = 64;
pub const CHILD_REAPER_LIMIT: usize = 32;
pub const OPERATION_SOURCE_LIMIT: usize = 20_000;
pub const OPERATION_ITEM_LIMIT: usize = 250_000;
pub const OPERATION_DEPTH_LIMIT: usize = 128;
static ACTIVE_CHILD_REAPERS: AtomicUsize = AtomicUsize::new(0);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ClipboardMode {
Copy,
Cut,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ClipboardPayload {
pub mode: ClipboardMode,
pub paths: Vec<PathBuf>,
}
impl ClipboardPayload {
pub fn label(&self) -> &'static str {
match self.mode {
ClipboardMode::Copy => "COPY BUFFER",
ClipboardMode::Cut => "CUT BUFFER",
}
}
}
#[derive(Clone, Debug)]
pub enum OperationRequest {
Copy {
sources: Vec<PathBuf>,
target_dir: PathBuf,
},
Move {
sources: Vec<PathBuf>,
target_dir: PathBuf,
},
Duplicate {
sources: Vec<PathBuf>,
},
Trash {
sources: Vec<PathBuf>,
},
Delete {
sources: Vec<PathBuf>,
},
RestoreTrash {
records: Vec<TrashRecord>,
},
Extract {
archive: PathBuf,
},
Archive {
sources: Vec<PathBuf>,
},
ArchiveZip {
sources: Vec<PathBuf>,
},
Undo(UndoAction),
}
impl OperationRequest {
pub fn title(&self) -> &'static str {
match self {
Self::Copy { .. } => "COPY",
Self::Move { .. } => "MOVE",
Self::Duplicate { .. } => "DUPLICATE",
Self::Trash { .. } => "MOVE TO TRASH",
Self::Delete { .. } => "PERMANENT DELETE",
Self::RestoreTrash { .. } => "RESTORE FROM TRASH",
Self::Extract { .. } => "EXTRACT",
Self::Archive { .. } => "ARCHIVE",
Self::ArchiveZip { .. } => "ARCHIVE ZIP",
Self::Undo(_) => "UNDO",
}
}
}
#[derive(Clone, Debug)]
pub enum UndoAction {
RemoveCreated {
entries: Vec<CreatedEntry>,
label: String,
},
MoveBack {
moves: Vec<FileMove>,
label: String,
},
RestoreTrash {
records: Vec<TrashRecord>,
label: String,
},
RenameBack {
from: PathBuf,
to: PathBuf,
},
}
impl UndoAction {
pub fn label(&self) -> &str {
match self {
Self::RemoveCreated { label, .. }
| Self::MoveBack { label, .. }
| Self::RestoreTrash { label, .. } => label,
Self::RenameBack { .. } => "UNDO RENAME",
}
}
}
#[derive(Clone, Debug)]
pub struct FileMove {
pub from: PathBuf,
pub to: PathBuf,
}
#[derive(Clone, Debug)]
pub struct CreatedEntry {
path: PathBuf,
snapshot: Result<CreatedTreeSnapshot, String>,
}
impl CreatedEntry {
fn capture(path: PathBuf) -> Self {
let snapshot = capture_created_tree(&path);
Self { path, snapshot }
}
pub fn path(&self) -> &Path {
&self.path
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct CreatedTreeSnapshot {
entries: Vec<CreatedTreeItem>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct CreatedTreeItem {
relative_path: PathBuf,
identity: CreatedEntryIdentity,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CreatedEntryKind {
Directory,
File,
Symlink,
Other,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct CreatedEntryIdentity {
kind: CreatedEntryKind,
len: u64,
symlink_target: Option<PathBuf>,
#[cfg(unix)]
device: u64,
#[cfg(unix)]
inode: u64,
#[cfg(unix)]
mode: u32,
#[cfg(unix)]
links: u64,
#[cfg(unix)]
uid: u32,
#[cfg(unix)]
gid: u32,
#[cfg(unix)]
modified_seconds: i64,
#[cfg(unix)]
modified_nanoseconds: i64,
#[cfg(unix)]
changed_seconds: i64,
#[cfg(unix)]
changed_nanoseconds: i64,
#[cfg(not(unix))]
readonly: bool,
#[cfg(not(unix))]
modified: Option<(u64, u32)>,
}
#[derive(Debug)]
struct RetainedDestinationError(String);
impl std::fmt::Display for RetainedDestinationError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
impl std::error::Error for RetainedDestinationError {}
fn retained_destination_warning(error: &io::Error) -> Option<String> {
error
.get_ref()
.and_then(|inner| inner.downcast_ref::<RetainedDestinationError>())
.map(|inner| inner.0.clone())
}
#[derive(Clone, Debug)]
pub struct TrashRecord {
pub original_path: PathBuf,
pub trashed_path: PathBuf,
pub info_path: PathBuf,
}
pub fn trash_records_for_paths(paths: &[PathBuf]) -> Result<Vec<TrashRecord>, String> {
if paths.is_empty() || paths.len() > OPERATION_SOURCE_LIMIT {
return Err("Trash restore selection is empty or too large".to_string());
}
let files_dir = fs::canonicalize(trash_files_dir())
.map_err(|error| format!("Cannot locate trash files: {error}"))?;
let info_dir = fs::canonicalize(trash_info_dir())
.map_err(|error| format!("Cannot locate trash metadata: {error}"))?;
let mut records = Vec::with_capacity(paths.len());
for path in paths {
let name = path
.file_name()
.ok_or_else(|| format!("{} has no trash name", path.display()))?;
let parent = path
.parent()
.and_then(|parent| fs::canonicalize(parent).ok())
.ok_or_else(|| format!("Cannot locate trash parent for {}", path.display()))?;
if parent != files_dir || fs::symlink_metadata(path).is_err() {
return Err(format!("{} is not a restorable trash item", path.display()));
}
let info_path = info_dir.join(trash_info_name(name));
let text = read_trash_info_file(&info_path)?;
let mut original = None;
for line in text.lines() {
if let Some(path) = line.strip_prefix("Path=") {
if original.is_some() {
return Err(format!("{} has duplicate paths", info_path.display()));
}
original = Some(percent_decode_path(path.as_bytes())?);
}
}
let original_path = original
.filter(|path| path.is_absolute() && !path.starts_with(&files_dir))
.ok_or_else(|| format!("{} has no valid original path", info_path.display()))?;
records.push(TrashRecord {
original_path,
trashed_path: path.clone(),
info_path,
});
}
Ok(records)
}
fn read_trash_info_file(info_path: &Path) -> Result<String, String> {
const TRASH_INFO_BYTES_LIMIT: u64 = 16 * 1024;
let owned_fd = rustix::fs::open(
info_path,
rustix::fs::OFlags::RDONLY
| rustix::fs::OFlags::CLOEXEC
| rustix::fs::OFlags::NOFOLLOW
| rustix::fs::OFlags::NONBLOCK,
rustix::fs::Mode::empty(),
)
.map_err(|error| format!("Cannot safely open {}: {error}", info_path.display()))?;
let file = fs::File::from(owned_fd);
let metadata = file
.metadata()
.map_err(|error| format!("Cannot inspect {}: {error}", info_path.display()))?;
if !metadata.is_file() || metadata.len() > TRASH_INFO_BYTES_LIMIT {
return Err(format!(
"{} is not valid trash metadata",
info_path.display()
));
}
let mut bytes = Vec::with_capacity(metadata.len() as usize);
file.take(TRASH_INFO_BYTES_LIMIT + 1)
.read_to_end(&mut bytes)
.map_err(|error| format!("Cannot read {}: {error}", info_path.display()))?;
if bytes.len() as u64 > TRASH_INFO_BYTES_LIMIT {
return Err(format!("{} is too large", info_path.display()));
}
String::from_utf8(bytes)
.map_err(|_| format!("{} must contain UTF-8 metadata", info_path.display()))
}
#[derive(Clone, Debug)]
pub enum CompletedAction {
Copied(Vec<CreatedEntry>),
Moved(Vec<FileMove>),
MoveRecovered {
moves: Vec<FileMove>,
recovery: PathBuf,
},
Duplicated(Vec<CreatedEntry>),
Trashed(Vec<TrashRecord>),
Deleted(Vec<PathBuf>),
Restored(Vec<PathBuf>),
Extracted(Vec<CreatedEntry>),
Archived(Vec<CreatedEntry>),
UndoApplied,
}
#[derive(Clone, Debug)]
pub struct OperationOutcome {
pub summary: String,
pub action: CompletedAction,
pub warning: Option<String>,
}
#[derive(Clone, Debug)]
pub struct OperationProgress {
pub done_items: usize,
pub total_items: usize,
pub done_bytes: u64,
pub total_bytes: u64,
pub current: String,
}
#[derive(Clone, Debug)]
pub enum OperationEvent {
Started {
title: String,
total_items: usize,
total_bytes: u64,
},
Progress(OperationProgress),
Finished(Result<OperationOutcome, String>),
}
pub fn start_operation(request: OperationRequest) -> Receiver<OperationEvent> {
let (tx, rx) = mpsc::sync_channel(OPERATION_EVENT_CAPACITY);
let worker_tx = tx.clone();
if let Err(error) = thread::Builder::new()
.name("guth-file-operation".to_string())
.spawn(move || {
let result = run_operation(request, &worker_tx);
let _ = worker_tx.send(OperationEvent::Finished(result));
})
{
let _ = tx.send(OperationEvent::Finished(Err(format!(
"Could not start operation worker: {error}"
))));
}
rx
}
pub fn undo_for_created_folder(path: PathBuf) -> UndoAction {
UndoAction::RemoveCreated {
entries: vec![CreatedEntry::capture(path)],
label: "UNDO NEW FOLDER".to_string(),
}
}
pub fn undo_for_created_file(path: PathBuf) -> UndoAction {
UndoAction::RemoveCreated {
entries: vec![CreatedEntry::capture(path)],
label: "UNDO NEW FILE".to_string(),
}
}
pub fn undo_for_rename(from: PathBuf, to: PathBuf) -> UndoAction {
UndoAction::RenameBack { from, to }
}
pub fn launch_default(path: &Path) -> Result<(), String> {
let mut xdg_open = Command::new("xdg-open");
xdg_open.arg(path);
spawn_managed_child(xdg_open, &format!("default opener {}", path.display())).or_else(
|first_error| {
let mut gio = Command::new("gio");
gio.arg("open").arg(path);
spawn_managed_child(gio, &format!("gio open {}", path.display())).map_err(
|second_error| {
format!(
"No default opener accepted {}: {first_error}; {second_error}",
path.display()
)
},
)
},
)
}
pub fn launch_application(exec: &str, paths: &[&Path]) -> Result<(), String> {
let argv = crate::open_with::exec_command_os(exec, paths)
.ok_or_else(|| "Application launcher has no command".to_string())?;
let Some((program, args)) = argv.split_first() else {
return Err("Application launcher has no command".to_string());
};
let mut command = Command::new(program);
command.args(args);
let program_label = program.to_string_lossy();
spawn_managed_child(command, &format!("open-with {program_label}"))
.map_err(|error| format!("Could not launch {program_label}: {error}"))
}
pub fn launch_desktop_application(app: &DesktopApp, paths: &[&Path]) -> Result<(), String> {
let argv = open_with::desktop_exec_command_os(app, paths)
.ok_or_else(|| "Application launcher has no command".to_string())?;
let Some((program, args)) = argv.split_first() else {
return Err("Application launcher has no command".to_string());
};
let cwd = if let Some(working_dir) = app.working_dir.clone() {
if !working_dir.is_dir() {
return Err(format!(
"Desktop application working directory is unavailable: {}",
working_dir.display()
));
}
Some(working_dir)
} else {
paths
.first()
.and_then(|path| path.parent().map(Path::to_path_buf))
};
let program_label = program.to_string_lossy().into_owned();
if app.terminal {
return launch_terminal_application(program, args, cwd.as_deref(), &program_label);
}
let mut command = Command::new(program);
command.args(args);
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
spawn_managed_child(command, &format!("open-with {program_label}"))
.map_err(|error| format!("Could not launch {program_label}: {error}"))
}
fn launch_terminal_application(
program: &OsStr,
args: &[OsString],
cwd: Option<&Path>,
program_label: &str,
) -> Result<(), String> {
let mut candidates = Vec::new();
if let Ok(terminal) = env::var("TERMINAL") {
if !terminal.trim().is_empty() {
candidates.push((terminal, vec!["-e"]));
}
}
candidates.extend([
("kgx".to_string(), vec!["--"]),
("gnome-terminal".to_string(), vec!["--"]),
("x-terminal-emulator".to_string(), vec!["-e"]),
("konsole".to_string(), vec!["-e"]),
("xfce4-terminal".to_string(), vec!["--command"]),
("alacritty".to_string(), vec!["-e"]),
("kitty".to_string(), vec!["-e"]),
("xterm".to_string(), vec!["-e"]),
]);
for (terminal, prefix) in candidates {
let mut command = Command::new(&terminal);
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
command.args(prefix).arg(program).args(args);
if spawn_managed_child(
command,
&format!("open-with terminal {terminal} {program_label}"),
)
.is_ok()
{
return Ok(());
}
}
Err(format!("No terminal emulator launched {program_label}"))
}
pub fn launch_terminal_at(path: &Path) -> Result<(), String> {
let cwd = if path.is_dir() {
path.to_path_buf()
} else {
path.parent()
.unwrap_or_else(|| Path::new("/"))
.to_path_buf()
};
let mut candidates = Vec::new();
if let Ok(terminal) = env::var("TERMINAL") {
candidates.push(terminal);
}
candidates.extend([
"kgx".to_string(),
"gnome-terminal".to_string(),
"x-terminal-emulator".to_string(),
"konsole".to_string(),
"xfce4-terminal".to_string(),
"alacritty".to_string(),
"kitty".to_string(),
"xterm".to_string(),
]);
for binary in candidates {
let mut command = Command::new(&binary);
command.current_dir(&cwd);
if spawn_managed_child(command, &format!("terminal {binary}")).is_ok() {
return Ok(());
}
}
Err(format!(
"No terminal emulator launched in {}",
cwd.display()
))
}
pub fn launch_new_window(path: &Path) -> Result<(), String> {
let current_exe =
env::current_exe().map_err(|error| format!("Cannot locate executable: {error}"))?;
let mut command = Command::new(current_exe);
command.arg(path);
spawn_managed_child(command, &format!("new window {}", path.display()))
.map_err(|error| format!("Cannot open new window: {error}"))
}
pub fn active_child_reapers() -> usize {
ACTIVE_CHILD_REAPERS.load(Ordering::Relaxed)
}
fn spawn_managed_child(mut command: Command, label: &str) -> Result<(), String> {
let permit = ChildReaperPermit::try_acquire()?;
let (child_tx, child_rx) = mpsc::sync_channel::<Child>(1);
thread::Builder::new()
.name("guth-child-reaper".to_string())
.spawn(move || {
let _permit = permit;
if let Ok(mut child) = child_rx.recv() {
let _ = child.wait();
}
})
.map_err(|error| format!("Could not start child reaper for {label}: {error}"))?;
let child = command
.spawn()
.map_err(|error| format!("Could not launch {label}: {error}"))?;
match child_tx.send(child) {
Ok(()) => Ok(()),
Err(error) => {
let mut child = error.0;
let _ = child.kill();
let _ = child.wait();
Err(format!("Child reaper stopped before receiving {label}"))
}
}
}
struct ChildReaperPermit;
impl ChildReaperPermit {
fn try_acquire() -> Result<Self, String> {
let mut current = ACTIVE_CHILD_REAPERS.load(Ordering::Relaxed);
loop {
if current >= CHILD_REAPER_LIMIT {
return Err(format!(
"Child process reaper limit reached: {current}/{CHILD_REAPER_LIMIT}"
));
}
match ACTIVE_CHILD_REAPERS.compare_exchange_weak(
current,
current + 1,
Ordering::AcqRel,
Ordering::Relaxed,
) {
Ok(_) => return Ok(Self),
Err(actual) => current = actual,
}
}
}
}
impl Drop for ChildReaperPermit {
fn drop(&mut self) {
ACTIVE_CHILD_REAPERS.fetch_sub(1, Ordering::AcqRel);
}
}
fn run_operation(
request: OperationRequest,
tx: &SyncSender<OperationEvent>,
) -> Result<OperationOutcome, String> {
match request {
OperationRequest::Copy {
sources,
target_dir,
} => run_copy(prepare_sources(sources)?, target_dir, tx),
OperationRequest::Move {
sources,
target_dir,
} => run_move(prepare_sources(sources)?, target_dir, tx),
OperationRequest::Duplicate { sources } => run_duplicate(prepare_sources(sources)?, tx),
OperationRequest::Trash { sources } => run_trash(prepare_sources(sources)?, tx),
OperationRequest::Delete { sources } => run_delete(prepare_sources(sources)?, tx),
OperationRequest::RestoreTrash { records } => run_restore_trash(records, tx),
OperationRequest::Extract { archive } => run_extract(archive, tx),
OperationRequest::Archive { sources } => {
run_archive(prepare_sources(sources)?, ArchiveOutput::TarGz, tx)
}
OperationRequest::ArchiveZip { sources } => {
run_archive(prepare_sources(sources)?, ArchiveOutput::Zip, tx)
}
OperationRequest::Undo(action) => run_undo(action, tx),
}
}
fn run_extract(
archive: PathBuf,
tx: &SyncSender<OperationEvent>,
) -> Result<OperationOutcome, String> {
if archives::detect_format(&archive).is_none() {
return Err(format!("Unsupported archive type: {}", archive.display()));
}
let parent = archive
.parent()
.ok_or_else(|| format!("{} has no parent folder", archive.display()))?;
let parent = fs::canonicalize(parent)
.map_err(|error| format!("Cannot resolve {}: {error}", parent.display()))?;
if !parent.is_dir() {
return Err(format!(
"Archive parent is not a folder: {}",
parent.display()
));
}
let folder_name = archives::extraction_folder_name(&archive)
.ok_or_else(|| format!("Unsupported archive type: {}", archive.display()))?;
let destination = unique_child_path(&parent, &folder_name);
let archive_bytes = fs::metadata(&archive).map(|meta| meta.len()).unwrap_or(0);
send_started(
tx,
"EXTRACT",
SourceMeasure {
items: 1,
bytes: archive_bytes,
},
);
let mut tracker = ProgressTracker::new(
tx,
SourceMeasure {
items: 1,
bytes: archive_bytes,
},
);
fs::create_dir(&destination).map_err(|error| {
format!(
"Cannot create extraction folder {}: {error}",
destination.display()
)
})?;
let result = archives::extract(&archive, &destination, |member, size| {
let _ = tracker.step(member, size);
});
match result {
Ok(summary) => Ok(OperationOutcome {
summary: format!(
"EXTRACTED {} // {} FILES, {} FOLDERS",
plural_items(summary.entries),
summary.files,
summary.dirs
),
action: CompletedAction::Extracted(vec![CreatedEntry::capture(destination)]),
warning: None,
}),
Err(error) => {
let destination_is_empty = fs::read_dir(&destination)
.ok()
.and_then(|mut entries| entries.next().transpose().ok())
.flatten()
.is_none();
if destination_is_empty && fs::remove_dir(&destination).is_ok() {
return Err(error);
}
Ok(OperationOutcome {
summary: "PARTIAL EXTRACTION BEFORE FAILURE".to_string(),
action: CompletedAction::Extracted(vec![CreatedEntry::capture(destination)]),
warning: Some(error),
})
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ArchiveOutput {
TarGz,
Zip,
}
impl ArchiveOutput {
fn suffix(self) -> &'static str {
match self {
Self::TarGz => ".tar.gz",
Self::Zip => ".zip",
}
}
fn operation_label(self) -> &'static str {
match self {
Self::TarGz => "ARCHIVE",
Self::Zip => "ARCHIVE ZIP",
}
}
}
fn run_archive(
sources: Vec<PathBuf>,
output_format: ArchiveOutput,
tx: &SyncSender<OperationEvent>,
) -> Result<OperationOutcome, String> {
let parent = sources[0]
.parent()
.ok_or_else(|| format!("{} has no parent folder", sources[0].display()))?;
let parent = fs::canonicalize(parent)
.map_err(|error| format!("Cannot resolve {}: {error}", parent.display()))?;
let base = if sources.len() == 1 {
archives::extraction_folder_name(&sources[0])
.or_else(|| {
file_name_os(&sources[0])
.ok()
.and_then(|name| name.into_string().ok())
})
.unwrap_or_else(|| "archive".to_string())
} else {
parent
.file_name()
.and_then(|name| name.to_str())
.map(str::to_string)
.unwrap_or_else(|| "archive".to_string())
};
let destination = unique_child_path(&parent, format!("{base}{}", output_format.suffix()));
let temporary_name = destination
.file_name()
.and_then(|name| name.to_str())
.map(|name| format!(".{name}.{}.tmp", std::process::id()))
.ok_or_else(|| "Archive name is not valid UTF-8".to_string())?;
let temporary = parent.join(temporary_name);
let total = measure_sources(&sources)?;
send_started(tx, output_format.operation_label(), total);
let mut tracker = ProgressTracker::new(tx, total);
let result = match output_format {
ArchiveOutput::TarGz => archives::create_archive(&sources, &temporary, |member, size| {
let _ = tracker.step(member, size);
}),
ArchiveOutput::Zip => archives::create_zip_archive(&sources, &temporary, |member, size| {
let _ = tracker.step(member, size);
}),
};
match result {
Ok(summary) => {
let rename_result =
place_archive_no_replace(&temporary, &destination).map_err(|error| {
format!("Cannot place archive {}: {error}", destination.display())
});
if let Err(error) = rename_result {
let _ = fs::remove_file(&temporary);
return Err(error);
}
Ok(OperationOutcome {
summary: format!(
"ARCHIVED {} // {}",
plural_items(summary.entries),
file_name_os(&destination)
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default()
),
action: CompletedAction::Archived(vec![CreatedEntry::capture(destination)]),
warning: if summary.skipped > 0 {
Some(format!("{} special items were skipped", summary.skipped))
} else {
None
},
})
}
Err(error) => {
let _ = fs::remove_file(&temporary);
Err(error)
}
}
}
fn place_archive_no_replace(temporary: &Path, destination: &Path) -> io::Result<()> {
rename_no_replace(temporary, destination)
}
fn run_copy(
sources: Vec<PathBuf>,
target_dir: PathBuf,
tx: &SyncSender<OperationEvent>,
) -> Result<OperationOutcome, String> {
let target_dir = fs::canonicalize(&target_dir).map_err(|error| {
format!(
"Cannot resolve copy target {}: {error}",
target_dir.display()
)
})?;
if !target_dir.is_dir() {
return Err(format!(
"Copy target is not a folder: {}",
target_dir.display()
));
}
let total = measure_sources(&sources)?;
send_started(tx, "COPY", total);
let mut tracker = ProgressTracker::new(tx, total);
let mut created = Vec::new();
for source in sources {
let result = (|| {
let name = file_name_os(&source).map_err(|error| (error, None))?;
let destination = unique_child_path(&target_dir, &name);
ensure_destination_is_not_inside_source(&source, &destination)
.map_err(|error| (error, None))?;
let (copy_result, destination_created) =
copy_path_tracked(&source, &destination, &mut tracker);
if let Err(error) = copy_result {
return Err((
format!(
"Copy failed for {} -> {}: {error}; a partial destination may remain",
source.display(),
destination.display()
),
destination_created.then_some(destination),
));
}
Ok::<_, (String, Option<PathBuf>)>(destination)
})();
match result {
Ok(destination) => created.push(CreatedEntry::capture(destination)),
Err((error, partial)) => {
if let Some(partial) = partial {
created.push(CreatedEntry::capture(partial));
}
if created.is_empty() {
return Err(error);
}
return Ok(OperationOutcome {
summary: format!("CREATED {} BEFORE FAILURE", plural_items(created.len())),
action: CompletedAction::Copied(created),
warning: Some(error),
});
}
}
}
Ok(OperationOutcome {
summary: format!("COPIED {}", plural_items(created.len())),
action: CompletedAction::Copied(created),
warning: None,
})
}
fn run_move(
sources: Vec<PathBuf>,
target_dir: PathBuf,
tx: &SyncSender<OperationEvent>,
) -> Result<OperationOutcome, String> {
let target_dir = fs::canonicalize(&target_dir).map_err(|error| {
format!(
"Cannot resolve move target {}: {error}",
target_dir.display()
)
})?;
if !target_dir.is_dir() {
return Err(format!(
"Move target is not a folder: {}",
target_dir.display()
));
}
let mut movable_sources = Vec::new();
for source in sources {
let parent = source
.parent()
.ok_or_else(|| format!("{} has no source parent", source.display()))?;
let parent = fs::canonicalize(parent).map_err(|error| {
format!("Cannot resolve source parent {}: {error}", parent.display())
})?;
if parent != target_dir {
movable_sources.push(source);
}
}
if movable_sources.is_empty() {
return Err("All selected items are already in the destination folder".to_string());
}
let sources = movable_sources;
let total = measure_sources(&sources)?;
send_started(tx, "MOVE", total);
let mut tracker = ProgressTracker::new(tx, total);
let mut moves = Vec::new();
for source in sources {
let result = (|| {
let name = file_name_os(&source)?;
let destination = unique_child_path(&target_dir, &name);
ensure_destination_is_not_inside_source(&source, &destination)?;
let warning = match move_path(&source, &destination, &mut tracker) {
Ok(()) => None,
Err(error) => {
if let Some(warning) = retained_destination_warning(&error) {
Some(warning)
} else {
return Err(format!(
"Move failed for {} -> {}: {error}",
source.display(),
destination.display()
));
}
}
};
Ok::<_, String>((
FileMove {
from: source,
to: destination,
},
warning,
))
})();
match result {
Ok((file_move, None)) => moves.push(file_move),
Ok((file_move, Some(warning))) => {
let recovery = file_move.to;
return Ok(OperationOutcome {
summary: format!("RETAINED DESTINATION AFTER {}", plural_items(moves.len())),
action: CompletedAction::MoveRecovered { moves, recovery },
warning: Some(warning),
});
}
Err(error) if moves.is_empty() => return Err(error),
Err(error) => {
return Ok(OperationOutcome {
summary: format!("MOVED {} BEFORE FAILURE", plural_items(moves.len())),
action: CompletedAction::Moved(moves),
warning: Some(error),
});
}
}
}
Ok(OperationOutcome {
summary: format!("MOVED {}", plural_items(moves.len())),
action: CompletedAction::Moved(moves),
warning: None,
})
}
fn run_duplicate(
sources: Vec<PathBuf>,
tx: &SyncSender<OperationEvent>,
) -> Result<OperationOutcome, String> {
let total = measure_sources(&sources)?;
send_started(tx, "DUPLICATE", total);
let mut tracker = ProgressTracker::new(tx, total);
let mut created = Vec::new();
for source in sources {
let result = (|| {
let parent = source.parent().ok_or_else(|| {
(
format!("Cannot duplicate {} without a parent", source.display()),
None,
)
})?;
let name = file_name_os(&source).map_err(|error| (error, None))?;
let destination = unique_copy_path(parent, &name);
let (copy_result, destination_created) =
copy_path_tracked(&source, &destination, &mut tracker);
if let Err(error) = copy_result {
return Err((
format!(
"Duplicate failed for {} -> {}: {error}; a partial destination may remain",
source.display(),
destination.display()
),
destination_created.then_some(destination),
));
}
Ok::<_, (String, Option<PathBuf>)>(destination)
})();
match result {
Ok(destination) => created.push(CreatedEntry::capture(destination)),
Err((error, partial)) => {
if let Some(partial) = partial {
created.push(CreatedEntry::capture(partial));
}
if created.is_empty() {
return Err(error);
}
return Ok(OperationOutcome {
summary: format!("CREATED {} BEFORE FAILURE", plural_items(created.len())),
action: CompletedAction::Duplicated(created),
warning: Some(error),
});
}
}
}
Ok(OperationOutcome {
summary: format!("DUPLICATED {}", plural_items(created.len())),
action: CompletedAction::Duplicated(created),
warning: None,
})
}
fn run_trash(
sources: Vec<PathBuf>,
tx: &SyncSender<OperationEvent>,
) -> Result<OperationOutcome, String> {
let total = measure_sources(&sources)?;
send_started(tx, "MOVE TO TRASH", total);
let mut tracker = ProgressTracker::new(tx, total);
let files_dir = trash_files_dir();
let info_dir = trash_info_dir();
fs::create_dir_all(&files_dir)
.map_err(|error| format!("Cannot create trash files dir: {error}"))?;
fs::create_dir_all(&info_dir)
.map_err(|error| format!("Cannot create trash info dir: {error}"))?;
let files_dir = fs::canonicalize(&files_dir)
.map_err(|error| format!("Cannot resolve trash files dir: {error}"))?;
let info_dir = fs::canonicalize(&info_dir)
.map_err(|error| format!("Cannot resolve trash info dir: {error}"))?;
let mut records = Vec::new();
for source in sources {
let result = (|| {
let source_metadata = fs::symlink_metadata(&source).map_err(|error| {
format!("Cannot inspect trash source {}: {error}", source.display())
})?;
let canonical_source = if source_metadata.file_type().is_symlink() {
let parent = source
.parent()
.ok_or_else(|| format!("{} has no parent", source.display()))?;
fs::canonicalize(parent)
.map_err(|error| {
format!("Cannot resolve trash parent {}: {error}", parent.display())
})?
.join(
source
.file_name()
.ok_or_else(|| format!("{} has no name", source.display()))?,
)
} else {
fs::canonicalize(&source).map_err(|error| {
format!("Cannot resolve trash source {}: {error}", source.display())
})?
};
if canonical_source.starts_with(&files_dir) {
return Err(format!("{} is already in trash", source.display()));
}
let name = file_name_os(&source)?;
let (trashed_path, info_path) = unique_trash_paths(&files_dir, &info_dir, &name);
ensure_destination_is_not_inside_source(&source, &trashed_path)?;
write_trash_info(&info_path, &source)?;
move_path(&source, &trashed_path, &mut tracker).map_err(|error| {
let _ = fs::remove_file(&info_path);
format!("Trash failed for {}: {error}", source.display())
})?;
Ok::<_, String>(TrashRecord {
original_path: source,
trashed_path,
info_path,
})
})();
match result {
Ok(record) => records.push(record),
Err(error) if records.is_empty() => return Err(error),
Err(error) => {
return Ok(OperationOutcome {
summary: format!("TRASHED {} BEFORE FAILURE", plural_items(records.len())),
action: CompletedAction::Trashed(records),
warning: Some(error),
});
}
}
}
Ok(OperationOutcome {
summary: format!("TRASHED {}", plural_items(records.len())),
action: CompletedAction::Trashed(records),
warning: None,
})
}
fn run_delete(
sources: Vec<PathBuf>,
tx: &SyncSender<OperationEvent>,
) -> Result<OperationOutcome, String> {
let total = measure_sources(&sources)?;
send_started(tx, "PERMANENT DELETE", total);
let mut tracker = ProgressTracker::new(tx, total);
let mut deleted = Vec::new();
for source in sources {
remove_path_with_progress(&source, &mut tracker)
.map_err(|error| format!("Delete failed for {}: {error}", source.display()))?;
deleted.push(source);
}
Ok(OperationOutcome {
summary: format!("PERMANENTLY DELETED {}", plural_items(deleted.len())),
action: CompletedAction::Deleted(deleted),
warning: None,
})
}
fn run_restore_trash(
records: Vec<TrashRecord>,
tx: &SyncSender<OperationEvent>,
) -> Result<OperationOutcome, String> {
if records.is_empty() || records.len() > OPERATION_SOURCE_LIMIT {
return Err("Restore selection is empty or too large".to_string());
}
let sources = records
.iter()
.map(|record| record.trashed_path.clone())
.collect::<Vec<_>>();
let total = measure_sources(&sources)?;
send_started(tx, "RESTORE FROM TRASH", total);
let mut tracker = ProgressTracker::new(tx, total);
let restored = restore_trash_records(records, &mut tracker)?;
Ok(OperationOutcome {
summary: format!("RESTORED {}", plural_items(restored.len())),
action: CompletedAction::Restored(restored),
warning: None,
})
}
fn run_undo(
action: UndoAction,
tx: &SyncSender<OperationEvent>,
) -> Result<OperationOutcome, String> {
match action {
UndoAction::RemoveCreated { entries, .. } => {
let total = verify_created_entries(&entries)?;
send_started(tx, "UNDO REMOVE CREATED", total);
let mut tracker = ProgressTracker::new(tx, total);
for entry in entries {
remove_verified_created_entry(&entry, &mut tracker).map_err(|error| {
format!(
"Undo remove stopped for {}; remaining data was left in place: {error}",
entry.path.display()
)
})?;
}
}
UndoAction::MoveBack { moves, .. } => {
let sources: Vec<PathBuf> =
moves.iter().map(|file_move| file_move.to.clone()).collect();
let total = measure_sources(&sources)?;
send_started(tx, "UNDO MOVE", total);
let mut tracker = ProgressTracker::new(tx, total);
for file_move in moves {
let destination = if path_entry_exists(&file_move.from) {
let parent = file_move.from.parent().ok_or_else(|| {
format!("{} has no restore parent", file_move.from.display())
})?;
let name = file_name_os(&file_move.from)?;
unique_child_path(parent, &name)
} else {
file_move.from.clone()
};
move_path(&file_move.to, &destination, &mut tracker).map_err(|error| {
format!(
"Undo move failed for {} -> {}: {error}",
file_move.to.display(),
destination.display()
)
})?;
}
}
UndoAction::RestoreTrash { records, .. } => {
let sources: Vec<PathBuf> = records
.iter()
.map(|record| record.trashed_path.clone())
.collect();
let total = measure_sources(&sources)?;
send_started(tx, "UNDO TRASH", total);
let mut tracker = ProgressTracker::new(tx, total);
restore_trash_records(records, &mut tracker)?;
}
UndoAction::RenameBack { from, to } => {
let total = measure_sources(std::slice::from_ref(&to))?;
send_started(tx, "UNDO RENAME", total);
let mut tracker = ProgressTracker::new(tx, total);
let destination = if path_entry_exists(&from) {
let parent = from
.parent()
.ok_or_else(|| format!("{} has no restore parent", from.display()))?;
let name = file_name_os(&from)?;
unique_child_path(parent, &name)
} else {
from
};
move_path(&to, &destination, &mut tracker).map_err(|error| {
format!(
"Undo rename failed for {} -> {}: {error}",
to.display(),
destination.display()
)
})?;
}
}
Ok(OperationOutcome {
summary: "UNDO APPLIED".to_string(),
action: CompletedAction::UndoApplied,
warning: None,
})
}
fn restore_trash_records(
records: Vec<TrashRecord>,
tracker: &mut ProgressTracker<'_>,
) -> Result<Vec<PathBuf>, String> {
let mut restored = Vec::with_capacity(records.len());
for record in records {
let destination = if path_entry_exists(&record.original_path) {
let parent = record
.original_path
.parent()
.unwrap_or_else(|| Path::new("/"));
let name = file_name_os(&record.original_path)?;
unique_child_path(parent, &name)
} else {
record.original_path.clone()
};
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!(
"Cannot recreate restore parent {}: {error}",
parent.display()
)
})?;
}
move_path(&record.trashed_path, &destination, tracker).map_err(|error| {
format!(
"Restore failed for {} -> {}: {error}",
record.trashed_path.display(),
destination.display()
)
})?;
fs::remove_file(&record.info_path).map_err(|error| {
format!(
"Restored data but could not remove {}: {error}",
record.info_path.display()
)
})?;
restored.push(destination);
}
Ok(restored)
}
#[derive(Clone, Copy, Debug, Default)]
struct SourceMeasure {
items: usize,
bytes: u64,
}
fn capture_created_tree(root: &Path) -> Result<CreatedTreeSnapshot, String> {
let mut entries = Vec::new();
let mut stack = vec![(root.to_path_buf(), PathBuf::new(), 0usize)];
while let Some((path, relative_path, depth)) = stack.pop() {
if depth > OPERATION_DEPTH_LIMIT {
return Err(format!(
"Cannot capture undo state: depth limit reached at {} (limit {})",
path.display(),
OPERATION_DEPTH_LIMIT
));
}
if entries.len() >= OPERATION_ITEM_LIMIT {
return Err(format!(
"Cannot capture undo state: item limit reached at {} items",
OPERATION_ITEM_LIMIT
));
}
let metadata = fs::symlink_metadata(&path).map_err(|error| {
format!("Cannot capture undo state for {}: {error}", path.display())
})?;
let identity = created_entry_identity(&path, &metadata).map_err(|error| {
format!(
"Cannot capture undo identity for {}: {error}",
path.display()
)
})?;
let is_directory = identity.kind == CreatedEntryKind::Directory;
entries.push(CreatedTreeItem {
relative_path: relative_path.clone(),
identity,
});
if is_directory {
let mut children = fs::read_dir(&path)
.map_err(|error| {
format!("Cannot capture undo directory {}: {error}", path.display())
})?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| {
format!("Cannot capture an entry in {}: {error}", path.display())
})?;
children.sort_by_key(|child| child.file_name());
for child in children.into_iter().rev() {
let name = child.file_name();
stack.push((child.path(), relative_path.join(name), depth + 1));
}
}
}
entries.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
Ok(CreatedTreeSnapshot { entries })
}
fn created_entry_identity(path: &Path, metadata: &Metadata) -> io::Result<CreatedEntryIdentity> {
let file_type = metadata.file_type();
let kind = if file_type.is_dir() {
CreatedEntryKind::Directory
} else if file_type.is_file() {
CreatedEntryKind::File
} else if file_type.is_symlink() {
CreatedEntryKind::Symlink
} else {
CreatedEntryKind::Other
};
let symlink_target = if kind == CreatedEntryKind::Symlink {
Some(fs::read_link(path)?)
} else {
None
};
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
Ok(CreatedEntryIdentity {
kind,
len: metadata.len(),
symlink_target,
device: metadata.dev(),
inode: metadata.ino(),
mode: metadata.mode(),
links: metadata.nlink(),
uid: metadata.uid(),
gid: metadata.gid(),
modified_seconds: metadata.mtime(),
modified_nanoseconds: metadata.mtime_nsec(),
changed_seconds: metadata.ctime(),
changed_nanoseconds: metadata.ctime_nsec(),
})
}
#[cfg(not(unix))]
{
let modified = metadata
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| (duration.as_secs(), duration.subsec_nanos()));
Ok(CreatedEntryIdentity {
kind,
len: metadata.len(),
symlink_target,
readonly: metadata.permissions().readonly(),
modified,
})
}
}
impl CreatedEntryIdentity {
fn is_same_object(&self, current: &Self) -> bool {
if self.kind != current.kind {
return false;
}
#[cfg(unix)]
{
self.device == current.device && self.inode == current.inode
}
#[cfg(not(unix))]
{
true
}
}
#[cfg(target_os = "linux")]
fn is_same_after_root_rename(&self, current: &Self) -> bool {
self.is_same_object(current)
&& self.len == current.len
&& self.symlink_target == current.symlink_target
&& self.mode == current.mode
&& self.links == current.links
&& self.uid == current.uid
&& self.gid == current.gid
&& self.modified_seconds == current.modified_seconds
&& self.modified_nanoseconds == current.modified_nanoseconds
}
}
fn verify_created_entries(entries: &[CreatedEntry]) -> Result<SourceMeasure, String> {
validate_source_count(
&entries
.iter()
.map(|entry| entry.path.clone())
.collect::<Vec<_>>(),
)?;
let mut total = SourceMeasure::default();
for entry in entries {
let expected = entry.snapshot.as_ref().map_err(|error| {
format!(
"Cannot safely undo {} because its original identity was not captured; it was left in place: {error}",
entry.path.display()
)
})?;
if expected.entries.is_empty() {
return Err(format!(
"Cannot safely undo {} because its captured identity is empty; it was left in place",
entry.path.display()
));
}
let actual = capture_created_tree(&entry.path).map_err(|error| {
format!(
"Refusing to undo {} because it can no longer be verified; it was left in place: {error}",
entry.path.display()
)
})?;
if expected != &actual {
return Err(format!(
"Refusing to undo {} because it or its descendants changed after creation; it was left in place",
entry.path.display()
));
}
if total.items.saturating_add(expected.entries.len()) > OPERATION_ITEM_LIMIT {
return Err(format!(
"Cannot safely undo created items: item limit exceeds {}",
OPERATION_ITEM_LIMIT
));
}
total.items = total.items.saturating_add(expected.entries.len());
total.bytes = expected.entries.iter().fold(total.bytes, |bytes, item| {
bytes.saturating_add(item.identity.len)
});
}
if total.items == 0 {
total.items = entries.len().max(1);
}
Ok(total)
}
fn remove_verified_created_entry(
entry: &CreatedEntry,
tracker: &mut ProgressTracker<'_>,
) -> io::Result<()> {
let snapshot = entry.snapshot.as_ref().map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("captured undo identity is unavailable: {error}"),
)
})?;
#[cfg(target_os = "linux")]
{
remove_verified_created_entry_linux(entry, snapshot, tracker)
}
#[cfg(not(target_os = "linux"))]
{
remove_verified_created_tree(&entry.path, snapshot, tracker)
}
}
#[cfg(target_os = "linux")]
fn remove_verified_created_entry_linux(
entry: &CreatedEntry,
snapshot: &CreatedTreeSnapshot,
tracker: &mut ProgressTracker<'_>,
) -> io::Result<()> {
let parent = entry.path.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("{} has no undo recovery parent", entry.path.display()),
)
})?;
let name = entry.path.file_name().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("{} has no undo recovery name", entry.path.display()),
)
})?;
let recovery = unique_undo_recovery_path(parent, name);
rename_no_replace(&entry.path, &recovery)?;
let isolated_snapshot = match capture_created_tree(&recovery) {
Ok(actual) if created_tree_same_objects(snapshot, &actual) => actual,
Ok(_) => {
restore_undo_recovery(&recovery, &entry.path)?;
return Err(io::Error::other(format!(
"{} changed while undo was starting; it was restored in place",
entry.path.display()
)));
}
Err(error) => {
restore_undo_recovery(&recovery, &entry.path)?;
return Err(io::Error::other(format!(
"could not re-verify {} after isolating it for undo; it was restored in place: {error}",
entry.path.display()
)));
}
};
match remove_verified_created_tree(&recovery, &isolated_snapshot, tracker) {
Ok(()) => Ok(()),
Err(error) => {
if path_entry_exists(&recovery) && !path_entry_exists(&entry.path) {
if let Err(restore_error) = rename_no_replace(&recovery, &entry.path) {
return Err(io::Error::new(
error.kind(),
format!(
"{error}; remaining data is recoverable at {}; restoring it to {} failed: {restore_error}",
recovery.display(),
entry.path.display()
),
));
}
}
Err(error)
}
}
}
#[cfg(target_os = "linux")]
fn created_tree_same_objects(expected: &CreatedTreeSnapshot, actual: &CreatedTreeSnapshot) -> bool {
expected.entries.len() == actual.entries.len()
&& expected
.entries
.iter()
.zip(&actual.entries)
.all(|(expected, actual)| {
expected.relative_path == actual.relative_path
&& if expected.relative_path.as_os_str().is_empty() {
expected
.identity
.is_same_after_root_rename(&actual.identity)
} else {
expected.identity == actual.identity
}
})
}
#[cfg(target_os = "linux")]
fn restore_undo_recovery(recovery: &Path, original: &Path) -> io::Result<()> {
match rename_no_replace(recovery, original) {
Ok(()) => Ok(()),
Err(error) => Err(io::Error::new(
error.kind(),
format!(
"remaining data is recoverable at {}; restoring it to {} failed: {error}",
recovery.display(),
original.display()
),
)),
}
}
#[cfg(target_os = "linux")]
fn unique_undo_recovery_path(parent: &Path, name: &OsStr) -> PathBuf {
let mut recovery_name = OsString::from(".guth-undo-recovery-");
recovery_name.push(name);
unique_child_path(parent, recovery_name)
}
fn remove_verified_created_tree(
root: &Path,
snapshot: &CreatedTreeSnapshot,
tracker: &mut ProgressTracker<'_>,
) -> io::Result<()> {
let mut items = snapshot.entries.iter().collect::<Vec<_>>();
items.sort_by(|left, right| {
left.relative_path
.components()
.count()
.cmp(&right.relative_path.components().count())
.then_with(|| left.relative_path.cmp(&right.relative_path))
.reverse()
});
for item in items {
let path = if item.relative_path.as_os_str().is_empty() {
root.to_path_buf()
} else {
root.join(&item.relative_path)
};
let metadata = fs::symlink_metadata(&path)?;
let current = created_entry_identity(&path, &metadata)?;
let identity_matches = if item.identity.kind == CreatedEntryKind::Directory {
item.identity.is_same_object(¤t)
} else {
item.identity == current
};
if !identity_matches {
return Err(io::Error::other(format!(
"{} changed after undo verification",
path.display()
)));
}
tracker.progress(path.display().to_string(), item.identity.len);
if item.identity.kind == CreatedEntryKind::Directory {
fs::remove_dir(&path)?;
} else {
fs::remove_file(&path)?;
}
tracker.bump_item(item.identity.len)?;
}
Ok(())
}
#[cfg(target_os = "linux")]
fn remove_verified_tree_untracked(root: &Path, snapshot: &CreatedTreeSnapshot) -> io::Result<()> {
let mut items = snapshot.entries.iter().collect::<Vec<_>>();
items.sort_by(|left, right| {
left.relative_path
.components()
.count()
.cmp(&right.relative_path.components().count())
.then_with(|| left.relative_path.cmp(&right.relative_path))
.reverse()
});
for item in items {
let path = if item.relative_path.as_os_str().is_empty() {
root.to_path_buf()
} else {
root.join(&item.relative_path)
};
let metadata = fs::symlink_metadata(&path)?;
let current = created_entry_identity(&path, &metadata)?;
let identity_matches = if item.identity.kind == CreatedEntryKind::Directory {
item.identity.is_same_object(¤t)
} else {
item.identity == current
};
if !identity_matches {
return Err(io::Error::other(format!(
"{} changed after source cleanup verification",
path.display()
)));
}
if item.identity.kind == CreatedEntryKind::Directory {
fs::remove_dir(&path)?;
} else {
fs::remove_file(&path)?;
}
}
Ok(())
}
fn prepare_sources(sources: Vec<PathBuf>) -> Result<Vec<PathBuf>, String> {
validate_source_count(&sources)?;
if sources.is_empty() {
return Err("Operation has no sources".to_string());
}
let mut identified = sources
.into_iter()
.map(|source| source_identity(&source).map(|identity| (source, identity)))
.collect::<Result<Vec<_>, _>>()?;
identified.sort_by_key(|(_, identity)| identity.components().count());
let mut prepared = Vec::new();
let mut identities = Vec::<PathBuf>::new();
for (source, identity) in identified {
if identities
.iter()
.any(|parent| identity == *parent || identity.starts_with(parent))
{
continue;
}
identities.push(identity);
prepared.push(source);
}
Ok(prepared)
}
fn source_identity(source: &Path) -> Result<PathBuf, String> {
let metadata = fs::symlink_metadata(source)
.map_err(|error| format!("Cannot inspect source {}: {error}", source.display()))?;
if metadata.file_type().is_symlink() {
let parent = source
.parent()
.ok_or_else(|| format!("{} has no parent", source.display()))?;
let parent = fs::canonicalize(parent).map_err(|error| {
format!("Cannot resolve source parent {}: {error}", parent.display())
})?;
let name = source
.file_name()
.ok_or_else(|| format!("{} has no file name", source.display()))?;
Ok(parent.join(name))
} else {
fs::canonicalize(source)
.map_err(|error| format!("Cannot resolve source {}: {error}", source.display()))
}
}
fn measure_sources(sources: &[PathBuf]) -> Result<SourceMeasure, String> {
validate_source_count(sources)?;
let mut total = SourceMeasure::default();
for source in sources {
measure_path(source, &mut total, 0)?;
}
if total.items == 0 {
total.items = sources.len().max(1);
}
Ok(total)
}
fn validate_source_count(sources: &[PathBuf]) -> Result<(), String> {
if sources.len() > OPERATION_SOURCE_LIMIT {
return Err(format!(
"Operation source limit reached: {} selected, limit {}",
sources.len(),
OPERATION_SOURCE_LIMIT
));
}
Ok(())
}
fn measure_path(path: &Path, total: &mut SourceMeasure, depth: usize) -> Result<(), String> {
if depth > OPERATION_DEPTH_LIMIT {
return Err(format!(
"Operation depth limit reached at {} (limit {})",
path.display(),
OPERATION_DEPTH_LIMIT
));
}
let metadata = fs::symlink_metadata(path)
.map_err(|error| format!("Cannot inspect {}: {error}", path.display()))?;
if total.items >= OPERATION_ITEM_LIMIT {
return Err(format!(
"Operation item limit reached at {} (limit {})",
path.display(),
OPERATION_ITEM_LIMIT
));
}
total.items = total.items.saturating_add(1);
total.bytes = total.bytes.saturating_add(metadata.len());
if metadata.file_type().is_dir() {
let read_dir = fs::read_dir(path)
.map_err(|error| format!("Cannot read directory {}: {error}", path.display()))?;
for child in read_dir {
let child = child
.map_err(|error| format!("Cannot read entry in {}: {error}", path.display()))?;
measure_path(&child.path(), total, depth + 1)?;
}
}
Ok(())
}
struct ProgressTracker<'a> {
tx: &'a SyncSender<OperationEvent>,
done_items: usize,
processed_items: usize,
done_bytes: u64,
total: SourceMeasure,
}
impl<'a> ProgressTracker<'a> {
fn new(tx: &'a SyncSender<OperationEvent>, total: SourceMeasure) -> Self {
Self {
tx,
done_items: 0,
processed_items: 0,
done_bytes: 0,
total,
}
}
fn bump_item(&mut self, bytes: u64) -> io::Result<()> {
if self.processed_items >= OPERATION_ITEM_LIMIT {
return Err(operation_limit_error(format!(
"operation item limit reached at {} items",
OPERATION_ITEM_LIMIT
)));
}
self.processed_items = self.processed_items.saturating_add(1);
self.done_items = self
.done_items
.saturating_add(1)
.min(self.total.items.max(1));
self.done_bytes = self.done_bytes.saturating_add(bytes).min(self.total.bytes);
Ok(())
}
fn progress(&self, current: String, bytes: u64) {
let _ = self.tx.send(OperationEvent::Progress(OperationProgress {
done_items: self.done_items,
total_items: self.total.items.max(1),
done_bytes: self.done_bytes.saturating_add(bytes).min(self.total.bytes),
total_bytes: self.total.bytes,
current,
}));
}
fn step(&mut self, path: &Path, bytes: u64) -> io::Result<()> {
self.progress(path.display().to_string(), bytes);
self.bump_item(bytes)
}
}
fn send_started(tx: &SyncSender<OperationEvent>, title: &str, total: SourceMeasure) {
let _ = tx.send(OperationEvent::Started {
title: title.to_string(),
total_items: total.items.max(1),
total_bytes: total.bytes,
});
}
#[cfg(test)]
fn copy_path(
source: &Path,
destination: &Path,
tracker: &mut ProgressTracker<'_>,
) -> io::Result<()> {
copy_path_tracked(source, destination, tracker).0
}
fn copy_path_tracked(
source: &Path,
destination: &Path,
tracker: &mut ProgressTracker<'_>,
) -> (io::Result<()>, bool) {
let mut destination_created = false;
let result = copy_path_at_depth(source, destination, tracker, 0, &mut destination_created);
(result, destination_created)
}
fn copy_path_at_depth(
source: &Path,
destination: &Path,
tracker: &mut ProgressTracker<'_>,
depth: usize,
root_created: &mut bool,
) -> io::Result<()> {
if depth > OPERATION_DEPTH_LIMIT {
return Err(operation_limit_error(format!(
"operation depth limit reached at {} (limit {})",
source.display(),
OPERATION_DEPTH_LIMIT
)));
}
let metadata = fs::symlink_metadata(source)?;
if metadata.file_type().is_dir() {
fs::create_dir(destination)?;
if depth == 0 {
*root_created = true;
}
tracker.step(destination, 0)?;
for child in fs::read_dir(source)? {
let child = child?;
copy_path_at_depth(
&child.path(),
&destination.join(child.file_name()),
tracker,
depth + 1,
root_created,
)?;
}
fs::set_permissions(destination, metadata.permissions())?;
return Ok(());
}
if metadata.file_type().is_symlink() {
copy_symlink(source, destination, &metadata)?;
if depth == 0 {
*root_created = true;
}
tracker.step(destination, metadata.len())?;
return Ok(());
}
if !metadata.file_type().is_file() {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
format!(
"refusing to copy unsupported special file {}",
source.display()
),
));
}
tracker.progress(destination.display().to_string(), metadata.len());
let mut source_file = fs::File::open(source)?;
let mut destination_file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(destination)?;
if depth == 0 {
*root_created = true;
}
io::copy(&mut source_file, &mut destination_file)?;
destination_file.sync_all()?;
let _ = fs::set_permissions(destination, metadata.permissions());
tracker.bump_item(metadata.len())?;
Ok(())
}
fn operation_limit_error(message: String) -> io::Error {
io::Error::new(io::ErrorKind::InvalidInput, message)
}
#[cfg(unix)]
fn copy_symlink(source: &Path, destination: &Path, _metadata: &Metadata) -> io::Result<()> {
use std::os::unix::fs::symlink;
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
let target = fs::read_link(source)?;
symlink(target, destination)
}
#[cfg(not(unix))]
fn copy_symlink(source: &Path, destination: &Path, _metadata: &Metadata) -> io::Result<()> {
fs::copy(source, destination).map(|_| ())
}
fn move_path(
source: &Path,
destination: &Path,
tracker: &mut ProgressTracker<'_>,
) -> io::Result<()> {
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
tracker.progress(destination.display().to_string(), 0);
match rename_no_replace(source, destination) {
Ok(()) => {
tracker.bump_item(0)?;
Ok(())
}
Err(rename_error) if is_cross_device_error(&rename_error) => {
move_path_cross_device(source, destination, tracker, &rename_error)
}
Err(rename_error) => Err(rename_error),
}
}
fn is_cross_device_error(error: &io::Error) -> bool {
error.raw_os_error() == Some(rustix::io::Errno::XDEV.raw_os_error())
}
fn move_path_cross_device(
source: &Path,
destination: &Path,
tracker: &mut ProgressTracker<'_>,
rename_error: &io::Error,
) -> io::Result<()> {
let source_snapshot = capture_created_tree(source).map_err(|error| {
io::Error::other(format!(
"rename failed: {rename_error}; cannot capture source before fallback copy: {error}"
))
})?;
let (copy_result, destination_created) = copy_path_tracked(source, destination, tracker);
if let Err(copy_error) = copy_result {
let detail = format!(
"rename failed: {rename_error}; fallback copy failed: {copy_error}; partial destination retained at {}",
destination.display()
);
return Err(if destination_created {
io::Error::new(copy_error.kind(), RetainedDestinationError(detail))
} else {
io::Error::new(copy_error.kind(), detail)
});
}
if let Err(sync_error) = sync_destination_parent(destination) {
return Err(io::Error::new(
sync_error.kind(),
RetainedDestinationError(format!(
"rename failed: {rename_error}; destination durability check failed: {sync_error}; source and complete destination copy were retained at {}",
destination.display()
)),
));
}
#[cfg(target_os = "linux")]
let cleanup_result = remove_cross_device_source_linux(source, &source_snapshot);
#[cfg(not(target_os = "linux"))]
let cleanup_result = remove_path(source);
cleanup_result.map_err(|cleanup_error| {
io::Error::new(
cleanup_error.kind(),
RetainedDestinationError(format!(
"rename failed: {rename_error}; source cleanup stopped safely: {cleanup_error}; complete destination copy retained at {}",
destination.display()
)),
)
})
}
fn sync_destination_parent(destination: &Path) -> io::Result<()> {
let parent = destination.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("{} has no destination parent", destination.display()),
)
})?;
fs::File::open(parent)?.sync_all()
}
#[cfg(target_os = "linux")]
fn remove_cross_device_source_linux(
source: &Path,
expected: &CreatedTreeSnapshot,
) -> io::Result<()> {
let parent = source.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("{} has no source recovery parent", source.display()),
)
})?;
let name = source.file_name().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("{} has no source recovery name", source.display()),
)
})?;
let recovery = unique_move_recovery_path(parent, name);
rename_no_replace(source, &recovery)?;
let isolated_snapshot = match capture_created_tree(&recovery) {
Ok(actual) if created_tree_same_objects(expected, &actual) => actual,
Ok(_) => {
restore_undo_recovery(&recovery, source)?;
return Err(io::Error::other(format!(
"{} changed while the fallback copy was running; the source was restored and left intact",
source.display()
)));
}
Err(error) => {
restore_undo_recovery(&recovery, source)?;
return Err(io::Error::other(format!(
"could not re-verify {} after isolating it for cleanup; the source was restored: {error}",
source.display()
)));
}
};
match remove_verified_tree_untracked(&recovery, &isolated_snapshot) {
Ok(()) => Ok(()),
Err(error) => {
if path_entry_exists(&recovery) && !path_entry_exists(source) {
if let Err(restore_error) = rename_no_replace(&recovery, source) {
return Err(io::Error::new(
error.kind(),
format!(
"{error}; remaining source data is recoverable at {}; restoring it to {} failed: {restore_error}",
recovery.display(),
source.display()
),
));
}
}
Err(error)
}
}
}
#[cfg(target_os = "linux")]
fn unique_move_recovery_path(parent: &Path, name: &OsStr) -> PathBuf {
let mut recovery_name = OsString::from(".guth-move-recovery-");
recovery_name.push(name);
unique_child_path(parent, recovery_name)
}
#[cfg(target_os = "linux")]
fn rename_no_replace(source: &Path, destination: &Path) -> io::Result<()> {
rustix::fs::renameat_with(
rustix::fs::CWD,
source,
rustix::fs::CWD,
destination,
rustix::fs::RenameFlags::NOREPLACE,
)
.map_err(|error| io::Error::from_raw_os_error(error.raw_os_error()))
}
#[cfg(not(target_os = "linux"))]
fn rename_no_replace(_source: &Path, _destination: &Path) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"atomic no-clobber rename is unavailable",
))
}
#[cfg(any(test, not(target_os = "linux")))]
fn remove_path(path: &Path) -> io::Result<()> {
remove_path_bounded(path, |_| Ok(()))
}
fn remove_path_with_progress(path: &Path, tracker: &mut ProgressTracker<'_>) -> io::Result<()> {
remove_path_bounded(path, |removed| {
tracker.progress(removed.display().to_string(), 0);
tracker.bump_item(0)
})
}
#[cfg(not(target_os = "linux"))]
enum RemoveFrame {
Enter { path: PathBuf, depth: usize },
Exit { path: PathBuf },
}
fn remove_path_bounded<F>(path: &Path, mut on_removed: F) -> io::Result<()>
where
F: FnMut(&Path) -> io::Result<()>,
{
#[cfg(target_os = "linux")]
{
remove_path_bounded_linux(path, &mut on_removed)
}
#[cfg(not(target_os = "linux"))]
{
let mut scheduled = 1usize;
let mut stack = vec![RemoveFrame::Enter {
path: path.to_path_buf(),
depth: 0,
}];
while let Some(frame) = stack.pop() {
match frame {
RemoveFrame::Enter { path, depth } => {
if depth > OPERATION_DEPTH_LIMIT {
return Err(operation_limit_error(format!(
"operation depth limit reached at {} (limit {})",
path.display(),
OPERATION_DEPTH_LIMIT
)));
}
let metadata = fs::symlink_metadata(&path)?;
if metadata.file_type().is_dir() {
stack.push(RemoveFrame::Exit { path: path.clone() });
for child in fs::read_dir(&path)? {
let child = child?;
if scheduled >= OPERATION_ITEM_LIMIT {
return Err(operation_limit_error(format!(
"operation item limit reached at {} items",
OPERATION_ITEM_LIMIT
)));
}
scheduled = scheduled.saturating_add(1);
stack.push(RemoveFrame::Enter {
path: child.path(),
depth: depth + 1,
});
}
} else {
fs::remove_file(&path)?;
on_removed(&path)?;
}
}
RemoveFrame::Exit { path } => {
fs::remove_dir(&path)?;
on_removed(&path)?;
}
}
}
Ok(())
}
}
#[cfg(target_os = "linux")]
fn remove_path_bounded_linux(
path: &Path,
on_removed: &mut impl FnMut(&Path) -> io::Result<()>,
) -> io::Result<()> {
let name = path.file_name().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("{} has no removable file name", path.display()),
)
})?;
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let parent = fs::canonicalize(parent)?;
let parent_fd = open_absolute_directory_no_follow(&parent)?;
let mut visited = 0usize;
remove_entry_at_linux(&parent_fd, name, path, 0, &mut visited, on_removed)
}
#[cfg(target_os = "linux")]
fn open_absolute_directory_no_follow(path: &Path) -> io::Result<rustix::fd::OwnedFd> {
use rustix::fs::{openat, Mode, OFlags, CWD};
if !path.is_absolute() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("Resolved parent is not absolute: {}", path.display()),
));
}
let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
let mut directory = openat(CWD, Path::new("/"), flags, Mode::empty()).map_err(errno_to_io)?;
for component in path.components() {
match component {
std::path::Component::RootDir | std::path::Component::CurDir => {}
std::path::Component::Normal(name) => {
directory = openat(&directory, name, flags, Mode::empty()).map_err(errno_to_io)?;
}
_ => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("Invalid resolved parent path: {}", path.display()),
));
}
}
}
Ok(directory)
}
#[cfg(target_os = "linux")]
#[allow(clippy::too_many_arguments)]
fn remove_entry_at_linux<Fd: std::os::fd::AsFd>(
parent: &Fd,
name: &OsStr,
display_path: &Path,
depth: usize,
visited: &mut usize,
on_removed: &mut impl FnMut(&Path) -> io::Result<()>,
) -> io::Result<()> {
use rustix::fs::{fstat, openat, statat, unlinkat, AtFlags, FileType, Mode, OFlags};
use std::os::unix::ffi::OsStringExt as _;
if depth > OPERATION_DEPTH_LIMIT {
return Err(operation_limit_error(format!(
"operation depth limit reached at {} (limit {})",
display_path.display(),
OPERATION_DEPTH_LIMIT
)));
}
if *visited >= OPERATION_ITEM_LIMIT {
return Err(operation_limit_error(format!(
"operation item limit reached at {} items",
OPERATION_ITEM_LIMIT
)));
}
*visited = visited.saturating_add(1);
let before = statat(parent, name, AtFlags::SYMLINK_NOFOLLOW).map_err(errno_to_io)?;
if FileType::from_raw_mode(before.st_mode) == FileType::Directory {
let fd = openat(
parent,
name,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)
.map_err(errno_to_io)?;
let opened = fstat(&fd).map_err(errno_to_io)?;
ensure_stat_identity(&before, &opened, display_path)?;
let directory_file = fs::File::from(fd);
let mut directory = rustix::fs::Dir::read_from(&directory_file).map_err(errno_to_io)?;
let mut children = Vec::<OsString>::new();
while let Some(entry) = directory.read() {
let entry = entry.map_err(errno_to_io)?;
let bytes = entry.file_name().to_bytes();
if bytes == b"." || bytes == b".." {
continue;
}
children.push(OsString::from_vec(bytes.to_vec()));
if children.len() > OPERATION_ITEM_LIMIT.saturating_sub(*visited) {
return Err(operation_limit_error(format!(
"operation item limit reached at {} items",
OPERATION_ITEM_LIMIT
)));
}
}
for child in children {
remove_entry_at_linux(
&directory_file,
&child,
&display_path.join(&child),
depth + 1,
visited,
on_removed,
)?;
}
let current = statat(parent, name, AtFlags::SYMLINK_NOFOLLOW).map_err(errno_to_io)?;
ensure_stat_identity(&opened, ¤t, display_path)?;
unlinkat(parent, name, AtFlags::REMOVEDIR).map_err(errno_to_io)?;
} else {
let fd = openat(
parent,
name,
OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)
.map_err(errno_to_io)?;
let opened = fstat(&fd).map_err(errno_to_io)?;
ensure_stat_identity(&before, &opened, display_path)?;
let current = statat(parent, name, AtFlags::SYMLINK_NOFOLLOW).map_err(errno_to_io)?;
ensure_stat_identity(&opened, ¤t, display_path)?;
unlinkat(parent, name, AtFlags::empty()).map_err(errno_to_io)?;
}
on_removed(display_path)
}
#[cfg(target_os = "linux")]
fn ensure_stat_identity(
expected: &rustix::fs::Stat,
actual: &rustix::fs::Stat,
path: &Path,
) -> io::Result<()> {
if expected.st_dev != actual.st_dev
|| expected.st_ino != actual.st_ino
|| rustix::fs::FileType::from_raw_mode(expected.st_mode)
!= rustix::fs::FileType::from_raw_mode(actual.st_mode)
{
return Err(io::Error::other(format!(
"{} changed during removal; deletion stopped",
path.display()
)));
}
Ok(())
}
#[cfg(target_os = "linux")]
fn errno_to_io(error: rustix::io::Errno) -> io::Error {
io::Error::from_raw_os_error(error.raw_os_error())
}
fn write_trash_info(info_path: &Path, original_path: &Path) -> Result<(), String> {
if let Some(parent) = info_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("Cannot create trash info parent: {error}"))?;
}
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(info_path)
.map_err(|error| {
format!(
"Cannot write trash metadata {}: {error}",
info_path.display()
)
})?;
let result = (|| {
writeln!(file, "[Trash Info]").map_err(|error| error.to_string())?;
writeln!(file, "Path={}", percent_encode_path(original_path))
.map_err(|error| error.to_string())?;
writeln!(file, "DeletionDate={}", trash_timestamp()).map_err(|error| error.to_string())?;
file.sync_all().map_err(|error| {
format!(
"Cannot persist trash metadata {}: {error}",
info_path.display()
)
})
})();
if result.is_err() {
drop(file);
let _ = fs::remove_file(info_path);
}
result
}
fn file_name_os(path: &Path) -> Result<OsString, String> {
path.file_name()
.map(OsStr::to_os_string)
.ok_or_else(|| format!("{} has no file name", path.display()))
}
fn trash_info_name(name: &OsStr) -> OsString {
let mut info_name = name.to_os_string();
info_name.push(".trashinfo");
info_name
}
fn ensure_destination_is_not_inside_source(
source: &Path,
destination: &Path,
) -> Result<(), String> {
let metadata = fs::symlink_metadata(source)
.map_err(|error| format!("Cannot inspect source {}: {error}", source.display()))?;
if metadata.file_type().is_symlink() {
return Ok(());
}
let source = fs::canonicalize(source)
.map_err(|error| format!("Cannot resolve source {}: {error}", source.display()))?;
let destination_parent = destination
.parent()
.ok_or_else(|| format!("{} has no destination parent", destination.display()))?;
let destination_parent = fs::canonicalize(destination_parent).map_err(|error| {
format!(
"Cannot resolve destination parent {}: {error}",
destination_parent.display()
)
})?;
let destination = destination_parent.join(
destination
.file_name()
.ok_or_else(|| format!("{} has no destination name", destination.display()))?,
);
if destination.starts_with(&source) {
return Err(format!(
"Refusing to place {} inside selected source {}",
destination.display(),
source.display()
));
}
Ok(())
}
fn unique_child_path(parent: &Path, name: impl AsRef<OsStr>) -> PathBuf {
let name = name.as_ref();
let direct = parent.join(name);
if !path_entry_exists(&direct) {
return direct;
}
unique_copy_path(parent, name)
}
fn unique_copy_path(parent: &Path, name: impl AsRef<OsStr>) -> PathBuf {
let name = name.as_ref();
for index in 1_u64..10_000 {
let candidate = parent.join(copy_candidate_name(name, index));
if !path_entry_exists(&candidate) {
return candidate;
}
}
unique_suffixed_copy_path(parent, name, process_suffix())
}
fn copy_candidate_name(name: &OsStr, index: u64) -> OsString {
let (stem, extension) = split_extension(name);
let mut candidate_name = stem.to_os_string();
if index == 1 {
candidate_name.push(" copy");
} else {
candidate_name.push(format!(" copy {index}"));
}
if let Some(extension) = extension {
candidate_name.push(".");
candidate_name.push(extension);
}
candidate_name
}
fn unique_trash_paths(files_dir: &Path, info_dir: &Path, name: &OsStr) -> (PathBuf, PathBuf) {
for index in 0_u64..10_000 {
let candidate_name = if index == 0 {
name.to_os_string()
} else {
copy_candidate_name(name, index)
};
let trashed_path = files_dir.join(&candidate_name);
let info_path = info_dir.join(trash_info_name(&candidate_name));
if !path_entry_exists(&trashed_path) && !path_entry_exists(&info_path) {
return (trashed_path, info_path);
}
}
let mut suffix = process_suffix();
let mut index = 1_u64;
loop {
let mut candidate_name = name.to_os_string();
candidate_name.push(if index == 1 {
format!(" copy {suffix}")
} else {
format!(" copy {suffix} {index}")
});
let trashed_path = files_dir.join(&candidate_name);
let info_path = info_dir.join(trash_info_name(&candidate_name));
if !path_entry_exists(&trashed_path) && !path_entry_exists(&info_path) {
return (trashed_path, info_path);
}
if index == u64::MAX {
suffix = suffix.wrapping_add(1);
index = 1;
} else {
index += 1;
}
}
}
fn unique_suffixed_copy_path(parent: &Path, name: impl AsRef<OsStr>, mut suffix: u128) -> PathBuf {
let name = name.as_ref();
let mut index = 1_u64;
loop {
let copy_suffix = if index == 1 {
format!(" copy {suffix}")
} else {
format!(" copy {suffix} {index}")
};
let mut candidate_name = name.to_os_string();
candidate_name.push(copy_suffix);
let candidate = parent.join(candidate_name);
if !path_entry_exists(&candidate) {
return candidate;
}
if index == u64::MAX {
suffix = suffix.wrapping_add(1);
index = 1;
} else {
index += 1;
}
}
}
fn path_entry_exists(path: &Path) -> bool {
match fs::symlink_metadata(path) {
Ok(_) => true,
Err(error) => error.kind() != io::ErrorKind::NotFound,
}
}
fn split_extension(name: &OsStr) -> (&OsStr, Option<&OsStr>) {
let path = Path::new(name);
if let (Some(stem), Some(extension)) = (path.file_stem(), path.extension()) {
if !stem.is_empty() && !extension.is_empty() {
return (stem, Some(extension));
}
}
(name, None)
}
fn process_suffix() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or_default()
}
fn plural_items(count: usize) -> String {
if count == 1 {
"1 ITEM".to_string()
} else {
format!("{count} ITEMS")
}
}
fn percent_encode_path(path: &Path) -> String {
#[cfg(unix)]
let bytes = {
use std::os::unix::ffi::OsStrExt;
path.as_os_str().as_bytes()
};
#[cfg(not(unix))]
let bytes = path.to_string_lossy().as_bytes();
let mut encoded = String::new();
for byte in bytes {
let is_safe =
byte.is_ascii_alphanumeric() || matches!(*byte, b'/' | b'.' | b'-' | b'_' | b'~');
if is_safe {
encoded.push(*byte as char);
} else {
encoded.push_str(&format!("%{byte:02X}"));
}
}
encoded
}
fn percent_decode_path(encoded: &[u8]) -> Result<PathBuf, String> {
let mut decoded = Vec::with_capacity(encoded.len());
let mut index = 0;
while index < encoded.len() {
if encoded[index] == b'%' {
if index + 2 >= encoded.len() {
return Err("Trash path has an invalid escape".to_string());
}
let high = hex_value(encoded[index + 1])?;
let low = hex_value(encoded[index + 2])?;
decoded.push((high << 4) | low);
index += 3;
} else {
decoded.push(encoded[index]);
index += 1;
}
}
if decoded.contains(&0) {
return Err("Trash path contains a NUL byte".to_string());
}
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
Ok(PathBuf::from(std::ffi::OsString::from_vec(decoded)))
}
#[cfg(not(unix))]
{
String::from_utf8(decoded)
.map(PathBuf::from)
.map_err(|_| "Trash path is not UTF-8".to_string())
}
}
fn hex_value(byte: u8) -> Result<u8, String> {
match byte {
b'0'..=b'9' => Ok(byte - b'0'),
b'a'..=b'f' => Ok(byte - b'a' + 10),
b'A'..=b'F' => Ok(byte - b'A' + 10),
_ => Err("Trash path has an invalid escape".to_string()),
}
}
fn trash_timestamp() -> String {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or_default();
let days = (seconds / 86_400) as i64;
let seconds_of_day = seconds % 86_400;
let (year, month, day) = civil_from_days(days);
let hour = seconds_of_day / 3_600;
let minute = (seconds_of_day % 3_600) / 60;
let second = seconds_of_day % 60;
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}")
}
fn civil_from_days(days_since_epoch: i64) -> (i64, u32, u32) {
let z = days_since_epoch + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = mp + if mp < 10 { 3 } else { -9 };
let year = y + if month <= 2 { 1 } else { 0 };
(year, month as u32, day as u32)
}
pub fn operation_percent(done_items: usize, total_items: usize) -> f32 {
if total_items == 0 {
0.0
} else {
done_items as f32 / total_items as f32
}
}
pub fn operation_detail(progress: &OperationProgress) -> String {
let byte_detail = if progress.total_bytes > 0 {
format!(
" // {} / {}",
human_size(progress.done_bytes),
human_size(progress.total_bytes)
)
} else {
String::new()
};
format!(
"{} / {} ITEMS{byte_detail} // {}",
progress.done_items, progress.total_items, progress.current
)
}
pub fn path_is_inside_home(path: &Path) -> bool {
path.starts_with(home_dir())
}
#[cfg(test)]
mod tests {
use super::*;
struct TestDir(PathBuf);
impl TestDir {
fn new(label: &str) -> Self {
let path = env::temp_dir().join(format!(
"guth-ops-{label}-{}-{}",
std::process::id(),
process_suffix()
));
fs::create_dir_all(&path).unwrap();
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn unique_copy_names_keep_extensions() {
let parent = env::temp_dir().join(format!("guth-test-{}", process_suffix()));
fs::create_dir_all(&parent).unwrap();
fs::write(parent.join("alpha.txt"), b"x").unwrap();
assert_eq!(
unique_child_path(&parent, "alpha.txt"),
parent.join("alpha copy.txt")
);
let _ = fs::remove_dir_all(parent);
}
#[test]
fn archive_placement_never_replaces_a_racing_destination() {
let root = TestDir::new("archive-no-replace");
let temporary = root.path().join(".archive.tmp");
let destination = root.path().join("archive.tar.gz");
fs::write(&temporary, b"new archive").unwrap();
fs::write(&destination, b"racing destination").unwrap();
let error = place_archive_no_replace(&temporary, &destination).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(fs::read(&destination).unwrap(), b"racing destination");
assert_eq!(fs::read(&temporary).unwrap(), b"new archive");
}
#[test]
fn zip_archive_operation_is_identity_tracked_and_undoable() {
let root = TestDir::new("archive-zip-operation");
let source = root.path().join("project");
fs::create_dir(&source).unwrap();
fs::write(source.join("README.txt"), b"zip me").unwrap();
let (tx, _rx) = mpsc::sync_channel(OPERATION_EVENT_CAPACITY);
let outcome = run_archive(vec![source], ArchiveOutput::Zip, &tx).unwrap();
let entries = match outcome.action {
CompletedAction::Archived(entries) => entries,
other => panic!("unexpected archive action: {other:?}"),
};
assert_eq!(entries.len(), 1);
let archive_path = entries[0].path().to_path_buf();
assert_eq!(
archive_path.extension().and_then(OsStr::to_str),
Some("zip")
);
let file = fs::File::open(&archive_path).unwrap();
let mut archive = zip::ZipArchive::new(file).unwrap();
assert_eq!(
archive.by_name("project/README.txt").unwrap().size(),
b"zip me".len() as u64
);
drop(archive);
let undo = run_undo(
UndoAction::RemoveCreated {
entries,
label: "UNDO ZIP ARCHIVE".to_string(),
},
&tx,
)
.unwrap();
assert!(matches!(undo.action, CompletedAction::UndoApplied));
assert!(!path_entry_exists(&archive_path));
}
#[test]
fn extraction_preflight_failure_removes_its_empty_destination() {
let root = TestDir::new("extract-preflight-cleanup");
let archive = root.path().join("broken.zip");
fs::write(&archive, b"not a zip archive").unwrap();
let (tx, _rx) = mpsc::sync_channel(OPERATION_EVENT_CAPACITY);
let error = run_extract(archive, &tx).unwrap_err();
assert!(!error.is_empty());
assert!(!path_entry_exists(&root.path().join("broken")));
}
#[cfg(unix)]
#[test]
fn copy_move_and_duplicate_preserve_non_utf8_names_and_extensions() {
use std::os::unix::ffi::{OsStrExt, OsStringExt};
let root = TestDir::new("non-utf8-copy-name");
let source_dir = root.path().join("source");
let target_dir = root.path().join("target");
fs::create_dir(&source_dir).unwrap();
fs::create_dir(&target_dir).unwrap();
let name = OsString::from_vec(b"report-\xff.txt".to_vec());
let source = source_dir.join(&name);
fs::write(&source, b"source data").unwrap();
fs::write(target_dir.join(&name), b"existing data").unwrap();
let (tx, _rx) = mpsc::sync_channel(OPERATION_EVENT_CAPACITY);
let copied = run_copy(vec![source.clone()], target_dir.clone(), &tx).unwrap();
let copied_path = match copied.action {
CompletedAction::Copied(entries) => {
entries.into_iter().next().unwrap().path().to_path_buf()
}
other => panic!("unexpected copy action: {other:?}"),
};
assert_eq!(
copied_path.file_name().unwrap().as_bytes(),
b"report-\xff copy.txt"
);
assert_eq!(fs::read(&copied_path).unwrap(), b"source data");
assert_eq!(fs::read(target_dir.join(&name)).unwrap(), b"existing data");
let duplicated = run_duplicate(vec![source.clone()], &tx).unwrap();
let duplicated_path = match duplicated.action {
CompletedAction::Duplicated(entries) => {
entries.into_iter().next().unwrap().path().to_path_buf()
}
other => panic!("unexpected duplicate action: {other:?}"),
};
assert_eq!(
duplicated_path.file_name().unwrap().as_bytes(),
b"report-\xff copy.txt"
);
assert_eq!(fs::read(duplicated_path).unwrap(), b"source data");
let moved = run_move(vec![source.clone()], target_dir.clone(), &tx).unwrap();
let moved_path = match moved.action {
CompletedAction::Moved(moves) => moves.into_iter().next().unwrap().to,
other => panic!("unexpected move action: {other:?}"),
};
assert_eq!(
moved_path.file_name().unwrap().as_bytes(),
b"report-\xff copy 2.txt"
);
assert_eq!(fs::read(&moved_path).unwrap(), b"source data");
assert!(!source.exists());
assert_eq!(
trash_info_name(&name).as_bytes(),
b"report-\xff.txt.trashinfo"
);
}
#[test]
fn suffixed_copy_names_are_always_checked() {
let root = TestDir::new("suffixed-copy");
fs::write(root.path().join("item copy 42"), b"one").unwrap();
fs::write(root.path().join("item copy 42 2"), b"two").unwrap();
assert_eq!(
unique_suffixed_copy_path(root.path(), "item", 42),
root.path().join("item copy 42 3")
);
}
#[test]
fn copy_and_move_reject_regular_file_targets() {
let root = TestDir::new("file-target");
let source = root.path().join("source.txt");
let target = root.path().join("target.txt");
fs::write(&source, b"source").unwrap();
fs::write(&target, b"target").unwrap();
let (tx, _rx) = mpsc::sync_channel(OPERATION_EVENT_CAPACITY);
let copy_error = run_copy(vec![source.clone()], target.clone(), &tx).unwrap_err();
assert!(copy_error.contains("not a folder"));
let move_error = run_move(vec![source], target, &tx).unwrap_err();
assert!(move_error.contains("not a folder"));
}
#[test]
fn regular_file_copy_never_overwrites_an_existing_destination() {
let root = TestDir::new("exclusive-copy");
let source = root.path().join("source.txt");
let destination = root.path().join("destination.txt");
fs::write(&source, b"source").unwrap();
fs::write(&destination, b"existing").unwrap();
let (tx, _rx) = mpsc::sync_channel(OPERATION_EVENT_CAPACITY);
let mut tracker =
ProgressTracker::new(&tx, measure_sources(std::slice::from_ref(&source)).unwrap());
let error = copy_path(&source, &destination, &mut tracker).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(fs::read(&destination).unwrap(), b"existing");
}
#[test]
fn move_never_replaces_or_cleans_an_existing_destination() {
let root = TestDir::new("exclusive-move");
let source = root.path().join("source.txt");
let destination = root.path().join("destination.txt");
fs::write(&source, b"source").unwrap();
fs::write(&destination, b"existing").unwrap();
let (tx, _rx) = mpsc::sync_channel(OPERATION_EVENT_CAPACITY);
let mut tracker =
ProgressTracker::new(&tx, measure_sources(std::slice::from_ref(&source)).unwrap());
let error = move_path(&source, &destination, &mut tracker).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(fs::read(&source).unwrap(), b"source");
assert_eq!(fs::read(&destination).unwrap(), b"existing");
}
#[cfg(target_os = "linux")]
#[test]
fn cross_device_cleanup_restores_a_source_changed_during_copy() {
let root = TestDir::new("move-source-changed");
let source = root.path().join("source.txt");
fs::write(&source, b"before").unwrap();
let expected = capture_created_tree(&source).unwrap();
fs::write(&source, b"changed while copying").unwrap();
let error = remove_cross_device_source_linux(&source, &expected).unwrap_err();
assert!(error.to_string().contains("changed while"));
assert_eq!(fs::read(&source).unwrap(), b"changed while copying");
assert!(!fs::read_dir(root.path()).unwrap().any(|entry| entry
.unwrap()
.file_name()
.to_string_lossy()
.starts_with(".guth-move-recovery-")));
}
#[cfg(target_os = "linux")]
#[test]
fn cross_device_cleanup_removes_an_unchanged_isolated_source() {
let root = TestDir::new("move-source-unchanged");
let source = root.path().join("source");
fs::create_dir(&source).unwrap();
fs::write(source.join("child.txt"), b"preserved in destination").unwrap();
let expected = capture_created_tree(&source).unwrap();
remove_cross_device_source_linux(&source, &expected).unwrap();
assert!(!path_entry_exists(&source));
assert!(!fs::read_dir(root.path()).unwrap().any(|entry| entry
.unwrap()
.file_name()
.to_string_lossy()
.starts_with(".guth-move-recovery-")));
}
#[test]
fn cross_device_fallback_copies_then_removes_a_verified_source() {
let root = TestDir::new("cross-device-fallback");
let source = root.path().join("source");
let destination = root.path().join("destination");
fs::create_dir(&source).unwrap();
fs::write(source.join("child.txt"), b"cross-device data").unwrap();
let (tx, _rx) = mpsc::sync_channel(OPERATION_EVENT_CAPACITY);
let mut tracker =
ProgressTracker::new(&tx, measure_sources(std::slice::from_ref(&source)).unwrap());
let cross_device = io::Error::from_raw_os_error(rustix::io::Errno::XDEV.raw_os_error());
move_path_cross_device(&source, &destination, &mut tracker, &cross_device).unwrap();
assert!(!path_entry_exists(&source));
assert_eq!(
fs::read(destination.join("child.txt")).unwrap(),
b"cross-device data"
);
}
#[test]
fn later_copy_destination_failure_keeps_completed_outputs_undoable() {
let root = TestDir::new("partial-copy-setup");
let target = root.path().join("target");
fs::create_dir(&target).unwrap();
let first = root.path().join("first.txt");
fs::write(&first, b"first").unwrap();
let (tx, _rx) = mpsc::sync_channel(OPERATION_EVENT_CAPACITY);
let outcome =
run_copy(vec![first, root.path().to_path_buf()], target.clone(), &tx).unwrap();
assert!(outcome.warning.is_some());
match outcome.action {
CompletedAction::Copied(entries) => assert_eq!(
entries
.iter()
.map(|entry| entry.path().to_path_buf())
.collect::<Vec<_>>(),
vec![target.join("first.txt")]
),
other => panic!("unexpected action: {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn partial_duplicate_is_reported_and_remains_undoable() {
let root = TestDir::new("partial-duplicate");
let source = root.path().join("source");
fs::create_dir(&source).unwrap();
rustix::fs::mkfifoat(
rustix::fs::CWD,
source.join("unsupported.fifo"),
rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR,
)
.unwrap();
let (tx, _rx) = mpsc::sync_channel(OPERATION_EVENT_CAPACITY);
let outcome = run_duplicate(vec![source], &tx).unwrap();
assert!(outcome
.warning
.as_deref()
.is_some_and(|warning| warning.contains("unsupported special file")));
let CompletedAction::Duplicated(entries) = outcome.action else {
panic!("expected recoverable duplicate outcome");
};
assert_eq!(entries.len(), 1);
assert!(entries[0].path().is_dir());
}
#[test]
fn undo_duplicate_refuses_to_remove_a_replacement_path() {
let root = TestDir::new("undo-duplicate-replacement");
let source = root.path().join("item.txt");
fs::write(&source, b"original copied data").unwrap();
let (tx, _rx) = mpsc::sync_channel(OPERATION_EVENT_CAPACITY);
let outcome = run_duplicate(vec![source], &tx).unwrap();
let CompletedAction::Duplicated(entries) = outcome.action else {
panic!("expected duplicate outcome");
};
let duplicate = entries[0].path().to_path_buf();
fs::remove_file(&duplicate).unwrap();
fs::write(&duplicate, b"replacement that must survive").unwrap();
let error = run_undo(
UndoAction::RemoveCreated {
entries,
label: "UNDO DUPLICATE".to_string(),
},
&tx,
)
.unwrap_err();
assert!(error.contains("changed after creation"));
assert_eq!(
fs::read(&duplicate).unwrap(),
b"replacement that must survive"
);
}
#[test]
fn undo_duplicate_removes_an_unchanged_captured_file() {
let root = TestDir::new("undo-duplicate-unchanged-file");
let source = root.path().join("item.txt");
fs::write(&source, b"original copied data").unwrap();
let (tx, _rx) = mpsc::sync_channel(OPERATION_EVENT_CAPACITY);
let outcome = run_duplicate(vec![source], &tx).unwrap();
let CompletedAction::Duplicated(entries) = outcome.action else {
panic!("expected duplicate outcome");
};
let duplicate = entries[0].path().to_path_buf();
run_undo(
UndoAction::RemoveCreated {
entries,
label: "UNDO DUPLICATE".to_string(),
},
&tx,
)
.unwrap();
assert!(!path_entry_exists(&duplicate));
}
#[test]
fn undo_copy_leaves_a_modified_directory_tree_intact() {
let root = TestDir::new("undo-copy-modified-directory");
let source = root.path().join("source");
let target = root.path().join("target");
fs::create_dir(&source).unwrap();
fs::create_dir(&target).unwrap();
fs::write(source.join("document.txt"), b"copied data").unwrap();
let (tx, _rx) = mpsc::sync_channel(OPERATION_EVENT_CAPACITY);
let outcome = run_copy(vec![source], target, &tx).unwrap();
let CompletedAction::Copied(entries) = outcome.action else {
panic!("expected copy outcome");
};
let copied_directory = entries[0].path().to_path_buf();
let copied_document = copied_directory.join("document.txt");
let added_document = copied_directory.join("user-added.txt");
fs::write(&copied_document, b"edited after the copy").unwrap();
fs::write(&added_document, b"new descendant").unwrap();
let error = run_undo(
UndoAction::RemoveCreated {
entries,
label: "UNDO COPY".to_string(),
},
&tx,
)
.unwrap_err();
assert!(error.contains("descendants changed"));
assert_eq!(
fs::read(&copied_document).unwrap(),
b"edited after the copy"
);
assert_eq!(fs::read(&added_document).unwrap(), b"new descendant");
}
#[test]
fn undo_copy_removes_an_unchanged_captured_tree() {
let root = TestDir::new("undo-copy-unchanged-directory");
let source = root.path().join("source");
let target = root.path().join("target");
fs::create_dir_all(source.join("nested")).unwrap();
fs::create_dir(&target).unwrap();
fs::write(source.join("nested/document.txt"), b"copied data").unwrap();
let (tx, _rx) = mpsc::sync_channel(OPERATION_EVENT_CAPACITY);
let outcome = run_copy(vec![source], target, &tx).unwrap();
let CompletedAction::Copied(entries) = outcome.action else {
panic!("expected copy outcome");
};
let copied_directory = entries[0].path().to_path_buf();
run_undo(
UndoAction::RemoveCreated {
entries,
label: "UNDO COPY".to_string(),
},
&tx,
)
.unwrap();
assert!(!path_entry_exists(&copied_directory));
}
#[test]
fn trash_timestamp_has_iso_shape() {
let stamp = trash_timestamp();
assert_eq!(stamp.len(), 19);
assert_eq!(&stamp[10..11], "T");
}
#[test]
fn trash_names_avoid_both_data_and_metadata_collisions() {
let root = TestDir::new("trash-name-collision");
let files = root.path().join("files");
let info = root.path().join("info");
fs::create_dir(&files).unwrap();
fs::create_dir(&info).unwrap();
fs::write(info.join("item.txt.trashinfo"), b"existing metadata").unwrap();
let (data_path, info_path) = unique_trash_paths(&files, &info, OsStr::new("item.txt"));
assert_eq!(data_path, files.join("item copy.txt"));
assert_eq!(info_path, info.join("item copy.txt.trashinfo"));
}
#[test]
fn trash_metadata_creation_never_overwrites_an_existing_entry() {
let root = TestDir::new("trash-info-no-overwrite");
let info = root.path().join("item.trashinfo");
fs::write(&info, b"existing metadata").unwrap();
let error = write_trash_info(&info, Path::new("/tmp/item")).unwrap_err();
assert!(error.contains("Cannot write trash metadata"));
assert_eq!(fs::read(info).unwrap(), b"existing metadata");
}
#[cfg(unix)]
#[test]
fn trash_paths_preserve_non_utf8_bytes() {
use std::os::unix::ffi::OsStringExt;
let path = PathBuf::from(std::ffi::OsString::from_vec(b"/tmp/a\xff b".to_vec()));
let encoded = percent_encode_path(&path);
assert_eq!(encoded, "/tmp/a%FF%20b");
assert_eq!(percent_decode_path(encoded.as_bytes()).unwrap(), path);
}
#[test]
fn restore_trash_preserves_collisions_and_removes_metadata() {
let root = TestDir::new("restore-trash");
let original = root.path().join("item.txt");
let trashed = root.path().join("trashed-item");
let info = root.path().join("trashed-item.trashinfo");
fs::write(&original, b"existing").unwrap();
fs::write(&trashed, b"restored").unwrap();
fs::write(&info, b"trash metadata").unwrap();
let (tx, _rx) = mpsc::sync_channel(OPERATION_EVENT_CAPACITY);
let mut tracker = ProgressTracker::new(
&tx,
measure_sources(std::slice::from_ref(&trashed)).unwrap(),
);
let restored = restore_trash_records(
vec![TrashRecord {
original_path: original.clone(),
trashed_path: trashed.clone(),
info_path: info.clone(),
}],
&mut tracker,
)
.unwrap();
assert_eq!(restored, vec![root.path().join("item copy.txt")]);
assert_eq!(fs::read(&original).unwrap(), b"existing");
assert_eq!(fs::read(&restored[0]).unwrap(), b"restored");
assert!(!path_entry_exists(&trashed));
assert!(!path_entry_exists(&info));
}
#[test]
fn measure_sources_rejects_source_limit() {
let sources: Vec<PathBuf> = (0..=OPERATION_SOURCE_LIMIT)
.map(|index| PathBuf::from(format!("missing-{index}")))
.collect();
let error = measure_sources(&sources).unwrap_err();
assert!(error.contains("source limit"));
}
#[test]
fn prepare_sources_collapses_nested_selections() {
let root = TestDir::new("nested-sources");
let folder = root.path().join("folder");
let child = folder.join("child.txt");
let sibling = root.path().join("sibling.txt");
fs::create_dir_all(&folder).unwrap();
fs::write(&child, b"child").unwrap();
fs::write(&sibling, b"sibling").unwrap();
let prepared = prepare_sources(vec![child, sibling.clone(), folder.clone()]).unwrap();
assert_eq!(prepared, vec![sibling, folder]);
}
#[test]
fn partial_move_returns_completed_items_for_undo() {
let root = TestDir::new("partial-move");
let first = root.path().join("first.txt");
let ancestor = root.path().join("ancestor");
let target = ancestor.join("target");
fs::write(&first, b"first").unwrap();
fs::create_dir_all(&target).unwrap();
let (tx, _rx) = mpsc::sync_channel(OPERATION_EVENT_CAPACITY);
let outcome = run_move(vec![first.clone(), ancestor], target.clone(), &tx).unwrap();
assert!(outcome.warning.is_some());
let CompletedAction::Moved(moves) = outcome.action else {
panic!("expected partial move outcome");
};
assert_eq!(moves.len(), 1);
assert_eq!(moves[0].from, first);
assert!(target.join("first.txt").is_file());
}
#[test]
fn undo_move_does_not_overwrite_replacement_data() {
let root = TestDir::new("undo-move-conflict");
let original = root.path().join("item.txt");
let moved = root.path().join("moved.txt");
fs::write(&original, b"replacement").unwrap();
fs::write(&moved, b"moved-data").unwrap();
let (tx, _rx) = mpsc::sync_channel(OPERATION_EVENT_CAPACITY);
run_undo(
UndoAction::MoveBack {
moves: vec![FileMove {
from: original.clone(),
to: moved,
}],
label: "UNDO MOVE".to_string(),
},
&tx,
)
.unwrap();
assert_eq!(fs::read(&original).unwrap(), b"replacement");
assert_eq!(
fs::read(root.path().join("item copy.txt")).unwrap(),
b"moved-data"
);
}
#[cfg(unix)]
#[test]
fn dangling_symlinks_reserve_destination_names() {
use std::os::unix::fs::symlink;
let root = TestDir::new("dangling-destination");
let dangling = root.path().join("item.txt");
symlink(root.path().join("missing-target"), &dangling).unwrap();
assert!(path_entry_exists(&dangling));
assert_eq!(
unique_child_path(root.path(), "item.txt"),
root.path().join("item copy.txt")
);
}
#[cfg(unix)]
#[test]
fn undo_rename_preserves_dangling_replacement() {
use std::os::unix::fs::symlink;
let root = TestDir::new("undo-rename-dangling");
let original = root.path().join("item.txt");
let renamed = root.path().join("renamed.txt");
let missing_target = root.path().join("missing-target");
symlink(&missing_target, &original).unwrap();
fs::write(&renamed, b"renamed-data").unwrap();
let (tx, _rx) = mpsc::sync_channel(OPERATION_EVENT_CAPACITY);
run_undo(
UndoAction::RenameBack {
from: original.clone(),
to: renamed,
},
&tx,
)
.unwrap();
assert_eq!(fs::read_link(&original).unwrap(), missing_target);
assert_eq!(
fs::read(root.path().join("item copy.txt")).unwrap(),
b"renamed-data"
);
}
#[cfg(unix)]
#[test]
fn descendant_check_resolves_symlinked_destination_parents() {
use std::os::unix::fs::symlink;
let root = TestDir::new("symlink-destination");
let source = root.path().join("source");
let child = source.join("child");
let alias = root.path().join("alias");
fs::create_dir_all(&child).unwrap();
symlink(&child, &alias).unwrap();
let error =
ensure_destination_is_not_inside_source(&source, &alias.join("moved")).unwrap_err();
assert!(error.contains("inside selected source"));
}
#[test]
fn measure_sources_reports_missing_inputs() {
let root = TestDir::new("missing-source");
let error = measure_sources(&[root.path().join("missing")]).unwrap_err();
assert!(error.contains("Cannot inspect"));
}
#[test]
fn measure_sources_rejects_depth_limit() {
let root = TestDir::new("depth-limit");
let mut current = root.path().to_path_buf();
for index in 0..=OPERATION_DEPTH_LIMIT {
current = current.join(format!("d{index}"));
fs::create_dir(¤t).unwrap();
}
let error = measure_sources(&[root.path().to_path_buf()]).unwrap_err();
assert!(error.contains("depth limit"));
}
#[test]
fn progress_tracker_rejects_item_overflow() {
let (tx, _rx) = mpsc::sync_channel(1);
let mut tracker = ProgressTracker::new(
&tx,
SourceMeasure {
items: OPERATION_ITEM_LIMIT,
bytes: 0,
},
);
tracker.processed_items = OPERATION_ITEM_LIMIT;
assert!(tracker.bump_item(0).is_err());
}
#[test]
fn bounded_remove_deletes_nested_tree() {
let root = TestDir::new("bounded-remove");
let nested = root.path().join("a").join("b");
fs::create_dir_all(&nested).unwrap();
fs::write(nested.join("file.txt"), b"x").unwrap();
let mut removed = 0usize;
remove_path_bounded(root.path(), |_| {
removed += 1;
Ok(())
})
.unwrap();
assert_eq!(removed, 4);
assert!(!root.path().exists());
}
#[test]
fn bounded_remove_rejects_depth_limit() {
let root = TestDir::new("remove-depth-limit");
let mut current = root.path().to_path_buf();
for index in 0..=OPERATION_DEPTH_LIMIT {
current = current.join(format!("d{index}"));
fs::create_dir(¤t).unwrap();
}
let error = remove_path(root.path()).unwrap_err();
assert!(error.to_string().contains("depth limit"));
assert!(root.path().exists());
}
#[test]
fn child_reaper_permits_are_bounded() {
let initial = active_child_reapers();
let available = CHILD_REAPER_LIMIT.saturating_sub(initial);
let mut permits = Vec::new();
for _ in 0..available {
permits.push(ChildReaperPermit::try_acquire().unwrap());
}
assert!(ChildReaperPermit::try_acquire().is_err());
drop(permits);
assert_eq!(active_child_reapers(), initial);
}
}