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..ab930ae --- /dev/null +++ b/crates/ferail-fs-native/src/linux_trash.rs @@ -0,0 +1,302 @@ +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") + .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"); + 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 { + 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) = checked_info_path(path)? { + match std::fs::remove_file(info) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + result => 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 = checked_info_path(path)?.ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "select a top-level trash item", + ) + })?; + 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) +} + +#[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 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!( + 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..6b5bfad 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) => { @@ -3232,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, @@ -3254,6 +3253,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 +3274,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 +3423,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 +3435,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);