From 574da9389cf95270a419b9c3096c3b551f03bcc8 Mon Sep 17 00:00:00 2001 From: ooonea <35407790+ooonea@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:17:52 +0100 Subject: [PATCH 1/2] feat(linux): browse, restore and empty the freedesktop Trash Expose the primary Trash in Locations, restore original paths from standard trash metadata without overwriting collisions, and clean sidecar metadata when emptying or deleting trashed items. Run filesystem work on the existing workers. Assisted-by: OpenAI Codex --- CHANGELOG.md | 2 + crates/ferail-fs-native/src/lib.rs | 54 +++++--- crates/ferail-fs-native/src/linux_trash.rs | 142 +++++++++++++++++++++ crates/ferail-fs-native/src/paths.rs | 7 + crates/ferail-fs-native/src/trash_empty.rs | 7 +- crates/ferail-gpui/src/shell/file_ops.rs | 122 +++++++++++------- 6 files changed, 269 insertions(+), 65 deletions(-) create mode 100644 crates/ferail-fs-native/src/linux_trash.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e362c3..9cdcffd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ separately in [CHANGELOG-DEPS.md](CHANGELOG-DEPS.md). ## Unreleased +- Linux: add the Trash sidebar location, restore from freedesktop metadata without replacing existing files, and empty trash with metadata cleanup. + - **Going up to a parent folder selects the folder you came from and scrolls it into view, whichever way you go up.** Backspace and Back already did this; clicking a breadcrumb segment, a folder in the sidebar tree, or typing diff --git a/crates/ferail-fs-native/src/lib.rs b/crates/ferail-fs-native/src/lib.rs index 97d4784..8ee9c83 100644 --- a/crates/ferail-fs-native/src/lib.rs +++ b/crates/ferail-fs-native/src/lib.rs @@ -27,6 +27,8 @@ mod dupes; pub mod file_ops; mod icons; pub mod image_meta; +#[cfg(target_os = "linux")] +pub mod linux_trash; mod magic; pub mod media; pub mod paths; @@ -227,6 +229,14 @@ impl NativeFs { if !buffer.is_empty() { on_batch(buffer); } + #[cfg(target_os = "linux")] + if result + .as_ref() + .is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound) + && path == linux_trash::home_files() + { + return None; + } result.err().map(|error| map_io_error(&error)) } @@ -585,9 +595,10 @@ pub fn home_trash_dir() -> PathBuf { /// Trash directories visible to this user: `~/.Trash` plus each /// mounted volume's `.Trashes/` when it exists. Used by Empty /// Trash to cover per-volume trashes, not just the boot volume's. +/// Linux uses the freedesktop home and mounted-volume payload directories. /// [win-parity: `SHEmptyRecycleBinW` handles this wholesale] #[cfg(target_os = "macos")] -pub fn trash_dirs() -> Vec { +pub fn trash_dirs() -> std::io::Result> { let mut dirs = Vec::new(); let home_trash = paths::home_dir().join(".Trash"); if home_trash.is_dir() { @@ -616,12 +627,17 @@ pub fn trash_dirs() -> Vec { dirs.push(t); } } - dirs + Ok(dirs) +} + +#[cfg(target_os = "linux")] +pub fn trash_dirs() -> std::io::Result> { + linux_trash::directories() } -#[cfg(not(any(target_os = "macos", target_os = "aros")))] -pub fn trash_dirs() -> Vec { - Vec::new() +#[cfg(not(any(target_os = "macos", target_os = "aros", target_os = "linux")))] +pub fn trash_dirs() -> std::io::Result> { + Ok(Vec::new()) } /// Whether `path` is a trash directory, or something inside one. @@ -632,7 +648,7 @@ pub fn trash_dirs() -> Vec { /// /// - macOS: `~/.Trash`, and each volume's `.Trashes/`; /// - Linux: `$XDG_DATA_HOME/Trash` (or `~/.local/share/Trash`), and each -/// volume's `.Trash-`; +/// volume's `.Trash-` or `.Trash/`; /// - AROS: a volume's `Trashcan` drawer. /// /// The Windows Recycle Bin is deliberately absent: it has no filesystem path a @@ -662,21 +678,23 @@ pub fn trash_root_of(path: &Path) -> Option { #[cfg(target_os = "linux")] { - let data_home = std::env::var_os("XDG_DATA_HOME") - .map(PathBuf::from) - .unwrap_or_else(|| paths::home_dir().join(".local/share")); - if let Some(root) = under(data_home.join("Trash")) { + if let Some(root) = under(linux_trash::home_files().parent()?.to_path_buf()) { return Some(root); } - // The freedesktop per-volume form is `.Trash-` at the mount root. + // Both freedesktop per-volume layouts live at the mount root. let uid = unsafe { libc::getuid() }; let name = format!(".Trash-{uid}"); + let shared = uid.to_string(); let mut walk = path; loop { - if walk - .file_name() - .is_some_and(|component| component == name.as_str()) - { + if walk.file_name().is_some_and(|component| { + component == name.as_str() + || (component == shared.as_str() + && walk + .parent() + .and_then(Path::file_name) + .is_some_and(|name| name == ".Trash")) + }) { return Some(walk.to_path_buf()); } walk = walk.parent()?; @@ -918,12 +936,12 @@ pub(crate) fn aros_trashcan_for(path: &Path) -> Option { /// AROS: the per-volume Trashcan drawers that exist, for Empty Trash, over /// the volumes DOS has mounted. #[cfg(target_os = "aros")] -pub fn trash_dirs() -> Vec { - crate::list_volumes() +pub fn trash_dirs() -> std::io::Result> { + Ok(crate::list_volumes() .into_iter() .map(|v| v.path.join("Trashcan")) .filter(|p| p.is_dir()) - .collect() + .collect()) } #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "aros", windows)))] diff --git a/crates/ferail-fs-native/src/linux_trash.rs b/crates/ferail-fs-native/src/linux_trash.rs new file mode 100644 index 0000000..3dea51f --- /dev/null +++ b/crates/ferail-fs-native/src/linux_trash.rs @@ -0,0 +1,142 @@ +use std::path::{Path, PathBuf}; + +pub fn home_files() -> PathBuf { + std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .unwrap_or_else(|| crate::paths::home_dir().join(".local/share")) + .join("Trash/files") +} + +pub fn directories() -> std::io::Result> { + ferail_core::path_guard::assert_off_ui_thread("linux_trash::directories"); + trash::os_limited::trash_folders() + .map(|dirs| dirs.into_iter().map(|dir| dir.join("files")).collect()) + .map_err(|error| std::io::Error::other(error.to_string())) +} + +fn info_path(path: &Path) -> Option { + let files = path.parent()?; + let root = crate::trash_root_of(path)?; + if files != root.join("files") { + return None; + } + let mut name = path.file_name()?.to_os_string(); + name.push(".trashinfo"); + Some(root.join("info").join(name)) +} + +pub fn forget(path: &Path) -> std::io::Result<()> { + ferail_core::path_guard::assert_off_ui_thread("linux_trash::forget"); + if let Some(info) = info_path(path) { + match std::fs::remove_file(info) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + result => result?, + } + } + Ok(()) +} + +pub fn restore(path: &Path) -> std::io::Result { + ferail_core::path_guard::assert_off_ui_thread("linux_trash::restore"); + let info = info_path(path).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "select a top-level trash item", + ) + })?; + let item = trash::os_limited::list() + .map_err(|error| std::io::Error::other(error.to_string()))? + .into_iter() + .find(|item| Path::new(&item.id) == info) + .ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::NotFound, "trash metadata not found") + })?; + let target = item.original_path(); + std::fs::create_dir_all(&item.original_parent)?; + crate::rename::rename_no_replace(path, &target)?; + forget(path)?; + Ok(target) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip() { + if std::env::var_os("FERAIL_TRASH_TEST_CHILD").is_none() { + let home = std::env::temp_dir().join(format!("ferail-trash-{}", std::process::id())); + std::fs::create_dir(&home).unwrap(); + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "linux_trash::tests::round_trip", "--nocapture"]) + .env("FERAIL_TRASH_TEST_CHILD", "1") + .env("HOME", &home) + .env("XDG_DATA_HOME", home.join("data")) + .status() + .unwrap(); + std::fs::remove_dir_all(home).unwrap(); + assert!(status.success()); + return; + } + let home = crate::paths::home_dir(); + let source = home.join("documento con accento รจ.txt"); + let payload = home_files().join(source.file_name().unwrap()); + let fs = crate::NativeFs::new(); + assert!(fs + .enumerate_streaming( + &home_files(), + 32, + &std::sync::atomic::AtomicBool::new(false), + |_| {} + ) + .is_none()); + assert!(crate::paths::well_known_locations() + .iter() + .any(|location| location.label == "Trash" && location.path == home_files())); + std::fs::write(&source, b"original").unwrap(); + trash::delete(&source).unwrap(); + std::fs::write(&source, b"replacement").unwrap(); + assert_eq!( + restore(&payload).unwrap_err().kind(), + std::io::ErrorKind::AlreadyExists + ); + assert_eq!(std::fs::read(&source).unwrap(), b"replacement"); + assert_eq!(std::fs::read(&payload).unwrap(), b"original"); + std::fs::remove_file(&source).unwrap(); + assert_eq!(restore(&payload).unwrap(), source); + assert_eq!(std::fs::read(&source).unwrap(), b"original"); + assert!(!info_path(&payload).unwrap().exists()); + let directory = home.join("directory"); + std::fs::create_dir(&directory).unwrap(); + std::fs::write(directory.join("nested"), b"nested").unwrap(); + std::os::unix::fs::symlink(&source, directory.join("link")).unwrap(); + trash::delete(&directory).unwrap(); + let dirs = directories().unwrap(); + assert!(dirs.contains(&home_files())); + let outcome = crate::trash_empty::empty_dirs(&[home_files()], &mut |_| {}); + assert!(outcome.failures.is_empty()); + assert_eq!(outcome.items_deleted, 1); + assert_eq!(std::fs::read(&source).unwrap(), b"original"); + assert_eq!(std::fs::read_dir(home_files()).unwrap().count(), 0); + assert_eq!( + std::fs::read_dir(home_files().parent().unwrap().join("info")) + .unwrap() + .count(), + 0 + ); + let uid = unsafe { libc::getuid() }; + let shared = PathBuf::from(format!("/volume/.Trash/{uid}/files/item")); + assert!(crate::is_in_trash(&shared)); + assert_eq!( + info_path(&shared), + Some(PathBuf::from(format!( + "/volume/.Trash/{uid}/info/item.trashinfo" + ))) + ); + assert!(!crate::is_in_trash(Path::new(&format!( + "/volume/.Trash/{}/files/item", + uid + 1 + )))); + } +} diff --git a/crates/ferail-fs-native/src/paths.rs b/crates/ferail-fs-native/src/paths.rs index 5080c0c..6b1e40e 100644 --- a/crates/ferail-fs-native/src/paths.rs +++ b/crates/ferail-fs-native/src/paths.rs @@ -517,6 +517,13 @@ pub fn well_known_locations_for(_mode: SpecialFolderMode) -> Vec outcome.items_deleted += 1, diff --git a/crates/ferail-gpui/src/shell/file_ops.rs b/crates/ferail-gpui/src/shell/file_ops.rs index 54b929b..06c8593 100644 --- a/crates/ferail-gpui/src/shell/file_ops.rs +++ b/crates/ferail-gpui/src/shell/file_ops.rs @@ -3165,6 +3165,8 @@ impl Shell { Ok(_) => std::fs::remove_file(p), Err(e) => Err(e), }; + #[cfg(target_os = "linux")] + let removed = removed.and_then(|()| ferail_fs_native::linux_trash::forget(p)); match removed { Ok(()) => deleted += 1, Err(e) => { @@ -3254,6 +3256,7 @@ impl Shell { if paths.is_empty() { return; } + #[cfg(not(target_os = "linux"))] let Some(db) = self.process.db_snapshot() else { window.push_notice( Notification::error(tr!("Ferail cannot reach its database to restore items.")), @@ -3274,59 +3277,78 @@ impl Shell { .background_executor() .spawn(async move { let mut restored: Vec = Vec::new(); + #[cfg(target_os = "linux")] + let unknown = 0usize; + #[cfg(not(target_os = "linux"))] let mut unknown = 0usize; let mut failures: Vec = Vec::new(); let mut touched: Vec = Vec::new(); for path in &paths { - let target = db - .lock() - .ok() - .and_then(|db| db.put_back_target(path).ok().flatten()); - let Some(target) = target else { - unknown += 1; - continue; - }; - // The folder it came from may itself be gone, or its - // name may have been taken since. Recreate the folder; - // refuse the name. - if let Some(parent) = target.parent() { - if let Err(error) = std::fs::create_dir_all(parent) { + #[cfg(target_os = "linux")] + { + match ferail_fs_native::linux_trash::restore(path) { + Ok(target) => { + touched.extend(path.parent().map(Path::to_path_buf)); + touched.extend(target.parent().map(Path::to_path_buf)); + restored.push(target); + } + Err(error) => failures.push( + ferail_fs_native::file_ops::FileOpError::from_io(&error, path), + ), + } + } + #[cfg(not(target_os = "linux"))] + { + let target = db + .lock() + .ok() + .and_then(|db| db.put_back_target(path).ok().flatten()); + let Some(target) = target else { + unknown += 1; + continue; + }; + // The folder it came from may itself be gone, or its + // name may have been taken since. Recreate the folder; + // refuse the name. + if let Some(parent) = target.parent() { + if let Err(error) = std::fs::create_dir_all(parent) { + failures.push(ferail_fs_native::file_ops::FileOpError::from_io( + &error, path, + )); + continue; + } + } + if target.symlink_metadata().is_ok() { failures.push(ferail_fs_native::file_ops::FileOpError::from_io( - &error, path, + &std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "something else is there now", + ), + &target, )); continue; } - } - if target.symlink_metadata().is_ok() { - failures.push(ferail_fs_native::file_ops::FileOpError::from_io( - &std::io::Error::new( - std::io::ErrorKind::AlreadyExists, - "something else is there now", - ), - &target, - )); - continue; - } - #[cfg(target_os = "aros")] - let renamed = ferail_fs_native::rename::rename_no_replace(path, &target); - #[cfg(not(target_os = "aros"))] - let renamed = std::fs::rename(path, &target); - match renamed { - Ok(()) => { - if let Ok(db) = db.lock() { - let _ = db.forget_put_back(path); - } - if let Some(parent) = path.parent() { - touched.push(parent.to_path_buf()); - } - if let Some(parent) = target.parent() { - touched.push(parent.to_path_buf()); + #[cfg(target_os = "aros")] + let renamed = ferail_fs_native::rename::rename_no_replace(path, &target); + #[cfg(not(target_os = "aros"))] + let renamed = std::fs::rename(path, &target); + match renamed { + Ok(()) => { + if let Ok(db) = db.lock() { + let _ = db.forget_put_back(path); + } + if let Some(parent) = path.parent() { + touched.push(parent.to_path_buf()); + } + if let Some(parent) = target.parent() { + touched.push(parent.to_path_buf()); + } + restored.push(target); } - restored.push(target); + Err(error) => failures.push( + ferail_fs_native::file_ops::FileOpError::from_io(&error, path), + ), } - Err(error) => failures.push( - ferail_fs_native::file_ops::FileOpError::from_io(&error, path), - ), } } (restored, unknown, failures, touched) @@ -3404,7 +3426,7 @@ impl Shell { let preview = cx .background_executor() .spawn(async move { - let dirs = ferail_fs_native::trash_dirs(); + let dirs = ferail_fs_native::trash_dirs()?; let mut items = 0usize; let mut unreadable = false; for d in &dirs { @@ -3416,10 +3438,18 @@ impl Shell { Err(_) => unreadable = true, } } - (dirs, items, unreadable) + Ok::<_, std::io::Error>((dirs, items, unreadable)) }) .await; - let (dirs, items, unreadable) = preview; + let (dirs, items, unreadable) = match preview { + Ok(preview) => preview, + Err(error) => { + let _ = win.update(cx, |_, window, cx| { + window.push_notice(Notification::error(error.to_string()), cx); + }); + return; + } + }; if items == 0 && !unreadable { let _ = win.update(cx, |_, window, cx| { window.push_notice(Notification::info(tr!("Trash is already empty")), cx); From 4e1a8bad52f925d9ad59a2604cb661879c95b646 Mon Sep 17 00:00:00 2001 From: ooonea Date: Sun, 1 Nov 2026 20:14:25 +0100 Subject: [PATCH 2/2] trash: validate roots and restore selected metadata Assisted-by: OpenAI Codex --- crates/ferail-fs-native/src/linux_trash.rs | 188 +++++++++++++++++++-- crates/ferail-gpui/src/shell/file_ops.rs | 9 +- 2 files changed, 177 insertions(+), 20 deletions(-) diff --git a/crates/ferail-fs-native/src/linux_trash.rs b/crates/ferail-fs-native/src/linux_trash.rs index 3dea51f..ab930ae 100644 --- a/crates/ferail-fs-native/src/linux_trash.rs +++ b/crates/ferail-fs-native/src/linux_trash.rs @@ -1,4 +1,5 @@ use std::path::{Path, PathBuf}; +use std::{io::Read, os::unix::ffi::OsStringExt, os::unix::fs::OpenOptionsExt}; pub fn home_files() -> PathBuf { std::env::var_os("XDG_DATA_HOME") @@ -10,9 +11,31 @@ pub fn home_files() -> PathBuf { pub fn directories() -> std::io::Result> { ferail_core::path_guard::assert_off_ui_thread("linux_trash::directories"); - trash::os_limited::trash_folders() - .map(|dirs| dirs.into_iter().map(|dir| dir.join("files")).collect()) - .map_err(|error| std::io::Error::other(error.to_string())) + let mut files = Vec::new(); + for root in trash::os_limited::trash_folders() + .map_err(|error| std::io::Error::other(error.to_string()))? + { + if check_directory(&root)? && check_directory(&root.join("files"))? { + check_directory(&root.join("info"))?; + files.push(root.join("files")); + } + } + Ok(files) +} + +fn check_directory(path: &Path) -> std::io::Result { + match std::fs::symlink_metadata(path) { + Ok(meta) if meta.is_dir() => Ok(true), + Ok(_) => Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "trash directory is not a real directory: {}", + path.display() + ), + )), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error), + } } fn info_path(path: &Path) -> Option { @@ -28,7 +51,7 @@ fn info_path(path: &Path) -> Option { pub fn forget(path: &Path) -> std::io::Result<()> { ferail_core::path_guard::assert_off_ui_thread("linux_trash::forget"); - if let Some(info) = info_path(path) { + if let Some(info) = checked_info_path(path)? { match std::fs::remove_file(info) { Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} result => result?, @@ -37,23 +60,93 @@ pub fn forget(path: &Path) -> std::io::Result<()> { Ok(()) } +fn checked_info_path(path: &Path) -> std::io::Result> { + let Some(info) = info_path(path) else { + return Ok(None); + }; + let root = info.parent().unwrap().parent().unwrap(); + if root != home_files().parent().unwrap() + && !trash::os_limited::trash_folders() + .map_err(|error| std::io::Error::other(error.to_string()))? + .contains(root) + { + return Ok(None); + } + check_directory(root)?; + check_directory(&root.join("files"))?; + check_directory(&root.join("info"))?; + Ok(Some(info)) +} + pub fn restore(path: &Path) -> std::io::Result { ferail_core::path_guard::assert_off_ui_thread("linux_trash::restore"); - let info = info_path(path).ok_or_else(|| { + let info = checked_info_path(path)?.ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::InvalidInput, "select a top-level trash item", ) })?; - let item = trash::os_limited::list() - .map_err(|error| std::io::Error::other(error.to_string()))? - .into_iter() - .find(|item| Path::new(&item.id) == info) - .ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::NotFound, "trash metadata not found") - })?; - let target = item.original_path(); - std::fs::create_dir_all(&item.original_parent)?; + let file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(&info)?; + let invalid = || std::io::Error::new(std::io::ErrorKind::InvalidData, "invalid trash metadata"); + if !file.metadata()?.is_file() { + return Err(invalid()); + } + let mut bytes = Vec::new(); + const MAX_INFO_BYTES: u64 = 64 * 1024; + file.take(MAX_INFO_BYTES + 1).read_to_end(&mut bytes)?; + if bytes.len() > MAX_INFO_BYTES as usize { + return Err(invalid()); + } + let mut lines = bytes + .split(|byte| *byte == b'\n') + .map(|line| line.strip_suffix(b"\r").unwrap_or(line)); + if lines.next() != Some(b"[Trash Info]".as_slice()) { + return Err(invalid()); + } + let mut encoded = lines + .find_map(|line| line.strip_prefix(b"Path=")) + .ok_or_else(invalid)? + .iter(); + let mut decoded = Vec::new(); + while let Some(&byte) = encoded.next() { + decoded.push(if byte == b'%' { + let high = encoded + .next() + .and_then(|&b| (b as char).to_digit(16)) + .ok_or_else(invalid)?; + let low = encoded + .next() + .and_then(|&b| (b as char).to_digit(16)) + .ok_or_else(invalid)?; + (high * 16 + low) as u8 + } else { + byte + }); + } + if decoded.is_empty() || decoded.contains(&0) { + return Err(invalid()); + } + let mut target = PathBuf::from(std::ffi::OsString::from_vec(decoded)); + if target.is_relative() { + if target + .components() + .any(|part| part == std::path::Component::ParentDir) + { + return Err(invalid()); + } + let root = info.parent().unwrap().parent().unwrap(); + let mut base = root.parent().ok_or_else(invalid)?; + if root != home_files().parent().unwrap() + && base.file_name().is_some_and(|name| name == ".Trash") + { + base = base.parent().ok_or_else(invalid)?; + } + target = base.join(target); + } + std::fs::create_dir_all(target.parent().ok_or_else(invalid)?)?; crate::rename::rename_no_replace(path, &target)?; forget(path)?; Ok(target) @@ -125,7 +218,74 @@ mod tests { .count(), 0 ); + + let info = info_path(&payload).unwrap(); + let other = info.with_file_name("other.trashinfo"); + std::fs::write(&other, b"[Trash Info]\n# unrelated metadata\n").unwrap(); + for value in [ + "relative/space%20and%FF".to_owned(), + format!("{}/absolute%FF", home.display()), + ] { + std::fs::write(&payload, b"restore me").unwrap(); + std::fs::write( + &info, + format!("[Trash Info]\n# extra line\nPath={value}\nPath=/wrong\n"), + ) + .unwrap(); + let restored = restore(&payload).unwrap(); + assert_eq!(std::fs::read(&restored).unwrap(), b"restore me"); + if value.starts_with("relative/") { + assert_eq!(restored.parent().unwrap(), home.join("data/relative")); + } + assert_eq!(restored.as_os_str().as_encoded_bytes().last(), Some(&0xff)); + std::fs::remove_file(restored).unwrap(); + } + std::fs::remove_file(other).unwrap(); + for value in ["../escape", "%2E%2E/escape", "%00", "%GG", ""] { + std::fs::write(&payload, b"keep me").unwrap(); + std::fs::write(&info, format!("[Trash Info]\nPath={value}\n")).unwrap(); + assert_eq!( + restore(&payload).unwrap_err().kind(), + std::io::ErrorKind::InvalidData + ); + assert_eq!(std::fs::read(&payload).unwrap(), b"keep me"); + assert!(info.exists()); + } + std::fs::write( + &info, + format!("[Trash Info]\nPath={}/oversized\n", home.display()), + ) + .unwrap(); + std::fs::OpenOptions::new() + .write(true) + .open(&info) + .unwrap() + .set_len(65 * 1024) + .unwrap(); + assert_eq!( + restore(&payload).unwrap_err().kind(), + std::io::ErrorKind::InvalidData + ); + assert_eq!(std::fs::read(&payload).unwrap(), b"keep me"); + std::fs::remove_file(&payload).unwrap(); + std::fs::remove_file(&info).unwrap(); + + let files = home_files(); + let saved = files.with_file_name("saved-files"); + std::fs::rename(&files, &saved).unwrap(); + std::os::unix::fs::symlink(&home, &files).unwrap(); + assert!(directories().is_err()); + assert_eq!(std::fs::read(&source).unwrap(), b"original"); + std::fs::remove_file(&files).unwrap(); + std::fs::rename(&saved, &files).unwrap(); + let uid = unsafe { libc::getuid() }; + let ordinary = home.join(format!("project/.Trash/{uid}")); + std::fs::create_dir_all(ordinary.join("info")).unwrap(); + let ordinary_info = ordinary.join("info/item.trashinfo"); + std::fs::write(&ordinary_info, b"not trash metadata").unwrap(); + forget(&ordinary.join("files/item")).unwrap(); + assert!(ordinary_info.exists()); let shared = PathBuf::from(format!("/volume/.Trash/{uid}/files/item")); assert!(crate::is_in_trash(&shared)); assert_eq!( diff --git a/crates/ferail-gpui/src/shell/file_ops.rs b/crates/ferail-gpui/src/shell/file_ops.rs index 06c8593..6b5bfad 100644 --- a/crates/ferail-gpui/src/shell/file_ops.rs +++ b/crates/ferail-gpui/src/shell/file_ops.rs @@ -3234,12 +3234,9 @@ impl Shell { /// Put the selected trashed items back where they came from. /// - /// The original location comes from the put-back record Ferail wrote when - /// it trashed the item. Nothing else knows it: on macOS the Finder keeps - /// its own put-back information in a private store, so an item Ferail did - /// not trash cannot be restored and says so rather than guessing at a - /// destination. Restoring never overwrites: a name taken since is a - /// refusal, not a replacement. + /// Linux uses freedesktop trash metadata. Other platforms use Ferail's + /// own put-back record; Finder's private records are unavailable. + /// Restoring never overwrites an existing destination. pub(super) fn on_restore_from_trash( &mut self, _: &RestoreFromTrash,