use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct TrashItem {
pub trash: PathBuf,
pub name: String,
pub original: PathBuf,
pub deleted: String,
}
impl TrashItem {
pub fn path(&self) -> PathBuf {
self.trash.join("files").join(&self.name)
}
fn info_path(&self) -> PathBuf {
self.trash
.join("info")
.join(format!("{}.trashinfo", self.name))
}
pub fn restore(&self) -> io::Result<PathBuf> {
if std::fs::symlink_metadata(&self.original).is_ok() {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("{} exists already", self.original.display()),
));
}
if let Some(parent) = self.original.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::rename(self.path(), &self.original)?;
std::fs::remove_file(self.info_path())?;
Ok(self.original.clone())
}
pub fn purge(&self) -> io::Result<()> {
let path = self.path();
match std::fs::symlink_metadata(&path) {
Ok(meta) if meta.is_dir() => std::fs::remove_dir_all(&path)?,
Ok(_) => std::fs::remove_file(&path)?,
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
std::fs::remove_file(self.info_path())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Trash {
dir: PathBuf,
topdir: Option<PathBuf>,
}
impl Trash {
pub fn at(dir: impl Into<PathBuf>) -> Trash {
Trash {
dir: dir.into(),
topdir: None,
}
}
pub fn of_mount(dir: impl Into<PathBuf>, topdir: impl Into<PathBuf>) -> Trash {
Trash {
dir: dir.into(),
topdir: Some(topdir.into()),
}
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn put(&self, path: &Path) -> io::Result<TrashItem> {
let original = std::path::absolute(path)?;
std::fs::symlink_metadata(&original)?;
if self.dir.starts_with(&original) || original.starts_with(&self.dir) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"the trash can't go in the trash",
));
}
let files = self.dir.join("files");
let info = self.dir.join("info");
std::fs::create_dir_all(&files)?;
std::fs::create_dir_all(&info)?;
let base = original
.file_name()
.map_or_else(|| "item".to_string(), |n| n.to_string_lossy().into_owned());
let recorded = match &self.topdir {
Some(top) => original
.strip_prefix(top)
.unwrap_or(&original)
.to_path_buf(),
None => original.clone(),
};
let deleted = timestamp(SystemTime::now());
for n in 1u32.. {
let name = if n == 1 {
base.clone()
} else {
format!("{base}.{n}")
};
let info_file = info.join(format!("{name}.trashinfo"));
let mut file = match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&info_file)
{
Ok(file) => file,
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error),
};
if std::fs::symlink_metadata(files.join(&name)).is_ok() {
drop(file);
std::fs::remove_file(&info_file)?;
continue;
}
let body = format!(
"[Trash Info]\nPath={}\nDeletionDate={deleted}\n",
encode(&recorded)
);
let written = file
.write_all(body.as_bytes())
.and_then(|_| file.sync_all());
let moved = written.and_then(|_| std::fs::rename(&original, files.join(&name)));
if let Err(error) = moved {
let _ = std::fs::remove_file(&info_file);
return Err(error);
}
return Ok(TrashItem {
trash: self.dir.clone(),
name,
original,
deleted,
});
}
unreachable!("a free name")
}
pub fn list(&self) -> io::Result<Vec<TrashItem>> {
let read = match std::fs::read_dir(self.dir.join("info")) {
Ok(read) => read,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(error),
};
let mut items: Vec<TrashItem> = read
.filter_map(Result::ok)
.filter_map(|entry| {
let file = entry.file_name().to_string_lossy().into_owned();
let name = file.strip_suffix(".trashinfo")?.to_string();
let text = std::fs::read_to_string(entry.path()).ok()?;
let (path, deleted) = parse_info(&text)?;
let original = match &self.topdir {
Some(top) if path.is_relative() => top.join(path),
_ => path,
};
Some(TrashItem {
trash: self.dir.clone(),
name,
original,
deleted,
})
})
.collect();
items.sort_by(|a, b| a.deleted.cmp(&b.deleted).then(a.name.cmp(&b.name)));
Ok(items)
}
}
fn parse_info(text: &str) -> Option<(PathBuf, String)> {
let mut lines = text.lines().map(str::trim);
lines.by_ref().find(|l| *l == "[Trash Info]")?;
let (mut path, mut deleted) = (None, String::new());
for line in lines {
if line.starts_with('[') {
break;
}
if let Some(value) = line.strip_prefix("Path=") {
path = Some(decode(value)?);
} else if let Some(value) = line.strip_prefix("DeletionDate=") {
deleted = value.to_string();
}
}
Some((path?, deleted))
}
fn encode(path: &Path) -> String {
let bytes = path_bytes(path);
let mut out = String::new();
for b in bytes {
if b.is_ascii_alphanumeric() || b"-_.~!*'()/".contains(&b) {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}
fn decode(text: &str) -> Option<PathBuf> {
let bytes = text.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
let hex = std::str::from_utf8(bytes.get(i + 1..i + 3)?).ok()?;
out.push(u8::from_str_radix(hex, 16).ok()?);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
Some(path_from_bytes(out))
}
#[cfg(unix)]
fn path_bytes(path: &Path) -> Vec<u8> {
use std::os::unix::ffi::OsStrExt;
path.as_os_str().as_bytes().to_vec()
}
#[cfg(not(unix))]
fn path_bytes(path: &Path) -> Vec<u8> {
path.to_string_lossy().replace('\\', "/").into_bytes()
}
#[cfg(unix)]
fn path_from_bytes(bytes: Vec<u8>) -> PathBuf {
use std::os::unix::ffi::OsStringExt;
PathBuf::from(std::ffi::OsString::from_vec(bytes))
}
#[cfg(not(unix))]
fn path_from_bytes(bytes: Vec<u8>) -> PathBuf {
PathBuf::from(String::from_utf8_lossy(&bytes).into_owned())
}
fn timestamp(time: SystemTime) -> String {
let secs = time
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64);
let (y, mo, d, h, mi, s) = local_parts(secs);
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}")
}
#[cfg(unix)]
#[allow(unsafe_code)]
fn local_parts(secs: i64) -> (i64, u32, u32, u32, u32, u32) {
let t = secs as libc::time_t;
let mut tm = std::mem::MaybeUninit::<libc::tm>::zeroed();
let filled = unsafe { !libc::localtime_r(&t, tm.as_mut_ptr()).is_null() };
if !filled {
return utc_parts(secs);
}
let tm = unsafe { tm.assume_init() };
(
tm.tm_year as i64 + 1900,
(tm.tm_mon + 1) as u32,
tm.tm_mday as u32,
tm.tm_hour as u32,
tm.tm_min as u32,
tm.tm_sec as u32,
)
}
#[cfg(not(unix))]
fn local_parts(secs: i64) -> (i64, u32, u32, u32, u32, u32) {
utc_parts(secs)
}
fn utc_parts(secs: i64) -> (i64, u32, u32, u32, u32, u32) {
let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let y = yoe + era * 400 + i64::from(m <= 2);
(
y,
m,
d,
(rem / 3600) as u32,
(rem % 3600 / 60) as u32,
(rem % 60) as u32,
)
}
#[cfg(any(not(unix), target_os = "macos"))]
fn unsupported() -> io::Error {
io::Error::new(
io::ErrorKind::Unsupported,
"the FreeDesktop trash is for Linux and the BSDs; macOS and Windows keep \
their own bins, which this does not write to",
)
}
#[cfg(all(unix, not(target_os = "macos")))]
pub fn home() -> io::Result<Trash> {
let data = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.filter(|p| p.is_absolute())
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no home directory"))?;
Ok(Trash::at(data.join("Trash")))
}
#[cfg(any(not(unix), target_os = "macos"))]
pub fn home() -> io::Result<Trash> {
Err(unsupported())
}
#[cfg(all(unix, not(target_os = "macos")))]
pub fn put(path: &Path) -> io::Result<TrashItem> {
use std::os::unix::fs::MetadataExt;
let home = home()?;
let original = std::path::absolute(path)?;
let device = std::fs::symlink_metadata(&original)?.dev();
let home_device = home
.dir()
.ancestors()
.find_map(|p| std::fs::metadata(p).ok())
.map(|m| m.dev());
if home_device == Some(device) {
return home.put(&original);
}
mount_trash(&original, device)?.put(&original)
}
#[cfg(any(not(unix), target_os = "macos"))]
pub fn put(path: &Path) -> io::Result<TrashItem> {
let _ = path;
Err(unsupported())
}
pub fn send(path: &Path) -> io::Result<()> {
let original = std::path::absolute(path)?;
std::fs::symlink_metadata(&original)?;
send_to_bin(&original)
}
#[cfg(all(unix, not(target_os = "macos")))]
fn send_to_bin(original: &Path) -> io::Result<()> {
put(original).map(|_| ())
}
#[cfg(target_os = "macos")]
fn send_to_bin(original: &Path) -> io::Result<()> {
let out = finder_delete(original).output()?;
if out.status.success() {
return Ok(());
}
let said = String::from_utf8_lossy(&out.stderr).trim().to_string();
Err(io::Error::other(if said.is_empty() {
format!("the Finder could not trash it ({})", out.status)
} else {
said
}))
}
#[cfg(windows)]
#[allow(unsafe_code)]
fn send_to_bin(original: &Path) -> io::Result<()> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::UI::Shell::{
FO_DELETE, FOF_ALLOWUNDO, FOF_NOCONFIRMATION, FOF_NOERRORUI, FOF_SILENT, SHFILEOPSTRUCTW,
SHFileOperationW,
};
let shown = original.to_string_lossy();
let plain = match shown.strip_prefix(r"\\?\") {
Some(rest) if !rest.starts_with("UNC") => PathBuf::from(rest),
_ => original.to_path_buf(),
};
let from = nul_list([plain.as_os_str().encode_wide().collect()]);
let mut op = SHFILEOPSTRUCTW {
hwnd: std::ptr::null_mut(),
wFunc: FO_DELETE,
pFrom: from.as_ptr(),
pTo: std::ptr::null(),
fFlags: (FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_NOERRORUI | FOF_SILENT) as u16,
fAnyOperationsAborted: 0,
hNameMappings: std::ptr::null_mut(),
lpszProgressTitle: std::ptr::null(),
};
let code = unsafe { SHFileOperationW(&mut op) };
if code != 0 {
return Err(io::Error::other(format!(
"the Recycle Bin refused it (shell error {code:#x})"
)));
}
if op.fAnyOperationsAborted != 0 {
return Err(io::Error::new(io::ErrorKind::Interrupted, "cancelled"));
}
Ok(())
}
#[cfg(not(any(unix, windows)))]
fn send_to_bin(_original: &Path) -> io::Result<()> {
Err(unsupported())
}
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn finder_delete(original: &Path) -> std::process::Command {
let mut command = std::process::Command::new("osascript");
for line in [
"on run argv",
"repeat with p in argv",
"tell application \"Finder\" to delete (POSIX file (contents of p) as alias)",
"end repeat",
"end run",
] {
command.arg("-e").arg(line);
}
command.arg(original);
command
}
#[cfg_attr(not(windows), allow(dead_code))]
fn nul_list(paths: impl IntoIterator<Item = Vec<u16>>) -> Vec<u16> {
let mut out = Vec::new();
for path in paths {
out.extend(path.into_iter().filter(|u| *u != 0));
out.push(0);
}
out.push(0);
out
}
#[cfg(all(unix, not(target_os = "macos")))]
fn mount_trash(original: &Path, device: u64) -> io::Result<Trash> {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let mut top = original.parent().unwrap_or(original).to_path_buf();
while let Some(up) = top.parent() {
match std::fs::metadata(up) {
Ok(meta) if meta.dev() == device => top = up.to_path_buf(),
_ => break,
}
}
let uid = rustix::process::getuid().as_raw();
let shared = top.join(".Trash");
if let Ok(meta) = std::fs::symlink_metadata(&shared)
&& meta.is_dir()
&& meta.permissions().mode() & 0o1000 != 0
{
return Ok(Trash::of_mount(shared.join(uid.to_string()), top));
}
Ok(Trash::of_mount(top.join(format!(".Trash-{uid}")), top))
}
pub fn list() -> io::Result<Vec<TrashItem>> {
home()?.list()
}
pub fn restore(item: &TrashItem) -> io::Result<PathBuf> {
item.restore()
}
pub fn purge(item: &TrashItem) -> io::Result<()> {
item.purge()
}
#[cfg(test)]
mod tests {
use super::*;
fn base(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("rich-ext-trash-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn put_list_restore_and_purge() {
let dir = base("cycle");
let trash = Trash::at(dir.join("Trash"));
let a = dir.join("a file.txt");
std::fs::write(&a, "one").unwrap();
let first = trash.put(&a).unwrap();
std::fs::write(&a, "two").unwrap();
let second = trash.put(&a).unwrap();
assert_eq!(first.name, "a file.txt");
assert_eq!(second.name, "a file.txt.2");
let info = std::fs::read_to_string(dir.join("Trash/info/a file.txt.trashinfo")).unwrap();
assert!(info.starts_with("[Trash Info]\nPath=/"), "{info}");
assert!(info.contains("a%20file.txt\n"), "{info}");
let items = trash.list().unwrap();
assert_eq!(items.len(), 2);
assert!(items.iter().all(|i| i.original == a));
assert_eq!(items[0].deleted.len(), "2026-10-19T14:05:00".len());
std::fs::write(&a, "three").unwrap();
let refused = first.restore().unwrap_err();
assert_eq!(refused.kind(), io::ErrorKind::AlreadyExists);
std::fs::remove_file(&a).unwrap();
assert_eq!(first.restore().unwrap(), a);
assert_eq!(std::fs::read_to_string(&a).unwrap(), "one");
second.purge().unwrap();
assert!(trash.list().unwrap().is_empty());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn directories_and_mount_relative_paths() {
let dir = base("mount");
std::fs::create_dir_all(dir.join("proj/src")).unwrap();
std::fs::write(dir.join("proj/src/main.rs"), "fn main() {}").unwrap();
let trash = Trash::of_mount(dir.join(".Trash-1000"), &dir);
let item = trash.put(&dir.join("proj")).unwrap();
let info = std::fs::read_to_string(dir.join(".Trash-1000/info/proj.trashinfo")).unwrap();
assert!(info.contains("\nPath=proj\n"), "{info}");
assert_eq!(trash.list().unwrap()[0].original, dir.join("proj"));
assert!(item.path().join("src/main.rs").exists());
item.restore().unwrap();
assert!(dir.join("proj/src/main.rs").exists());
assert!(trash.put(&dir).is_err(), "the trash itself");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn info_files_round_trip() {
let path = Path::new("/home/ada/notes 100%/ΓΌ.md");
let encoded = encode(path);
assert_eq!(encoded, "/home/ada/notes%20100%25/%C3%BC.md");
assert_eq!(decode(&encoded).unwrap(), path);
let (read, when) =
parse_info("[Trash Info]\nPath=%2Ftmp%2Fx\nDeletionDate=2004-08-31T22:32:08\n")
.unwrap();
assert_eq!(read, Path::new("/tmp/x"));
assert_eq!(when, "2004-08-31T22:32:08");
assert!(parse_info("[Other]\nPath=/x\n").is_none());
assert_eq!(utc_parts(1_093_991_528), (2004, 8, 31, 22, 32, 8));
}
#[test]
fn the_finder_is_asked_with_the_path_as_an_argument() {
let command = finder_delete(Path::new("/Users/ada/it's \"here\".txt"));
assert_eq!(command.get_program(), "osascript");
let args: Vec<String> = command
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_eq!(args.len(), 11);
assert_eq!(args[0], "-e");
assert!(args[5].starts_with("tell application \"Finder\" to delete"));
assert!(args[..10].iter().all(|a| !a.contains("ada")));
assert_eq!(args[10], "/Users/ada/it's \"here\".txt");
}
#[test]
fn the_shell_takes_a_list_ending_in_two_nuls() {
let wide = |s: &str| s.encode_utf16().collect::<Vec<u16>>();
let list = nul_list([wide(r"C:\a.txt"), wide(r"C:\b")]);
let mut expected = wide(r"C:\a.txt");
expected.push(0);
expected.extend(wide(r"C:\b"));
expected.extend([0, 0]);
assert_eq!(list, expected);
assert_eq!(nul_list(Vec::<Vec<u16>>::new()), [0]);
}
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn send_puts_in_the_freedesktop_trash_here() {
assert_eq!(
send(Path::new("/no/such/file/here")).unwrap_err().kind(),
io::ErrorKind::NotFound
);
}
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn the_home_trash_follows_xdg_data_home() {
let trash = home().unwrap();
assert!(trash.dir().ends_with("Trash"));
}
}