file manager
git clone https://git.lucas.co/cce-files.git
src/services/trash.rs (11.3K)
1 //! The freedesktop.org Trash spec (v1.0), home-trash only: items move into
2 //! `$XDG_DATA_HOME/Trash/files/` (else `~/.local/share/Trash/files/`) and each
3 //! carries an `info/<name>.trashinfo` recording its origin and deletion date —
4 //! so gio, trash-cli, and the KDE/GNOME file managers all see the same trash.
5 //!
6 //! All functions do blocking IO; they run on the FsService task like the rest
7 //! of `services::fs`.
8
9 use std::fs;
10 use std::io::{self, Write};
11 use std::path::{Path, PathBuf};
12
13 /// `$XDG_DATA_HOME/Trash`, else `~/.local/share/Trash`. None only when HOME
14 /// is unset.
15 pub fn trash_dir() -> Option<PathBuf> {
16 if let Some(data) = std::env::var_os("XDG_DATA_HOME") {
17 let data = PathBuf::from(data);
18 if data.is_absolute() {
19 return Some(data.join("Trash"));
20 }
21 }
22 std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share/Trash"))
23 }
24
25 /// The directory trashed items live in — what the browse page navigates to.
26 pub fn files_dir() -> Option<PathBuf> {
27 trash_dir().map(|t| t.join("files"))
28 }
29
30 /// Whether `dir` IS the trash files directory (the browse page's gate for
31 /// swapping Delete → Restore/Delete Permanently in the row menu).
32 pub fn is_trash_files_dir(dir: &Path) -> bool {
33 files_dir().is_some_and(|f| f == dir)
34 }
35
36 /// Move `path` into the trash: reserve a unique name by exclusively creating
37 /// its .trashinfo, then rename (copy+delete across filesystems).
38 pub fn move_to_trash(path: &Path) -> io::Result<()> {
39 let base = trash_dir()
40 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no HOME — trash unavailable"))?;
41 move_to_trash_in(&base, path)
42 }
43
44 /// Restore a trashed item (a path under `files/`) to the origin its
45 /// .trashinfo records. Refuses to overwrite an existing file at the origin.
46 /// Returns the restored path.
47 pub fn restore(trashed: &Path) -> io::Result<PathBuf> {
48 let base = trash_dir()
49 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no HOME — trash unavailable"))?;
50 restore_in(&base, trashed)
51 }
52
53 /// Delete everything in the trash permanently. Returns how many items went.
54 pub fn empty() -> io::Result<usize> {
55 let base = trash_dir()
56 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no HOME — trash unavailable"))?;
57 empty_in(&base)
58 }
59
60 fn move_to_trash_in(base: &Path, path: &Path) -> io::Result<()> {
61 let files = base.join("files");
62 let info = base.join("info");
63 fs::create_dir_all(&files)?;
64 fs::create_dir_all(&info)?;
65
66 let orig = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
67 let name = orig
68 .file_name()
69 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"))?
70 .to_string_lossy()
71 .to_string();
72
73 // Spec: DeletionDate is local time, no zone suffix.
74 let deleted_at = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
75 let body = format!(
76 "[Trash Info]\nPath={}\nDeletionDate={}\n",
77 percent_encode(&orig.to_string_lossy()),
78 deleted_at
79 );
80
81 // Reserve the trash name by O_EXCL-creating the info file: `name`, then
82 // `name.2`, `name.3`, … — the create-race is the spec's uniqueness lock.
83 let mut n = 1u32;
84 loop {
85 let candidate = if n == 1 { name.clone() } else { format!("{name}.{n}") };
86 let info_path = info.join(format!("{candidate}.trashinfo"));
87 match fs::OpenOptions::new().write(true).create_new(true).open(&info_path) {
88 Ok(mut f) => {
89 f.write_all(body.as_bytes())?;
90 let target = files.join(&candidate);
91 if let Err(e) = rename_or_copy(&orig, &target) {
92 let _ = fs::remove_file(&info_path);
93 return Err(e);
94 }
95 return Ok(());
96 }
97 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
98 n += 1;
99 if n > 10_000 {
100 return Err(io::Error::new(io::ErrorKind::AlreadyExists, "no free trash name"));
101 }
102 }
103 Err(e) => return Err(e),
104 }
105 }
106 }
107
108 /// The origin a trashed item (a path under `files/`) restores to, from its
109 /// .trashinfo — the trash listing shows this. None when the sidecar is
110 /// missing or malformed.
111 pub fn origin_of(trashed: &Path) -> Option<PathBuf> {
112 let base = trash_dir()?;
113 let name = trashed.file_name()?.to_string_lossy().to_string();
114 read_origin(&base.join("info").join(format!("{name}.trashinfo"))).ok()
115 }
116
117 fn read_origin(info_path: &Path) -> io::Result<PathBuf> {
118 let body = fs::read_to_string(info_path)?;
119 let encoded = body
120 .lines()
121 .find_map(|l| l.strip_prefix("Path="))
122 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "trashinfo has no Path"))?;
123 Ok(PathBuf::from(percent_decode(encoded)))
124 }
125
126 fn restore_in(base: &Path, trashed: &Path) -> io::Result<PathBuf> {
127 let name = trashed
128 .file_name()
129 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"))?
130 .to_string_lossy()
131 .to_string();
132 let info_path = base.join("info").join(format!("{name}.trashinfo"));
133 let origin = read_origin(&info_path)?;
134
135 if origin.exists() {
136 return Err(io::Error::new(
137 io::ErrorKind::AlreadyExists,
138 format!("{} already exists", origin.display()),
139 ));
140 }
141 if let Some(parent) = origin.parent() {
142 fs::create_dir_all(parent)?;
143 }
144 rename_or_copy(trashed, &origin)?;
145 fs::remove_file(&info_path)?;
146 Ok(origin)
147 }
148
149 fn empty_in(base: &Path) -> io::Result<usize> {
150 let mut count = 0usize;
151 let files = base.join("files");
152 if let Ok(entries) = fs::read_dir(&files) {
153 for entry in entries.flatten() {
154 let p = entry.path();
155 let removed = if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
156 fs::remove_dir_all(&p)
157 } else {
158 fs::remove_file(&p)
159 };
160 if removed.is_ok() {
161 count += 1;
162 }
163 }
164 }
165 if let Ok(entries) = fs::read_dir(base.join("info")) {
166 for entry in entries.flatten() {
167 let _ = fs::remove_file(entry.path());
168 }
169 }
170 Ok(count)
171 }
172
173 /// Rename, falling back to a recursive copy + delete when source and trash
174 /// live on different filesystems (EXDEV).
175 fn rename_or_copy(from: &Path, to: &Path) -> io::Result<()> {
176 match fs::rename(from, to) {
177 Ok(()) => Ok(()),
178 Err(e) if e.raw_os_error() == Some(libc_exdev()) => {
179 copy_recursive(from, to)?;
180 if from.is_dir() {
181 fs::remove_dir_all(from)
182 } else {
183 fs::remove_file(from)
184 }
185 }
186 Err(e) => Err(e),
187 }
188 }
189
190 const fn libc_exdev() -> i32 {
191 18 // EXDEV on Linux
192 }
193
194 fn copy_recursive(from: &Path, to: &Path) -> io::Result<()> {
195 if from.is_dir() {
196 fs::create_dir_all(to)?;
197 for entry in fs::read_dir(from)? {
198 let entry = entry?;
199 copy_recursive(&entry.path(), &to.join(entry.file_name()))?;
200 }
201 Ok(())
202 } else {
203 fs::copy(from, to).map(|_| ())
204 }
205 }
206
207 /// Percent-encode a path for a trashinfo `Path=` line: unreserved characters
208 /// and `/` pass through, everything else (spaces, non-ASCII bytes, …) encodes.
209 fn percent_encode(s: &str) -> String {
210 let mut out = String::with_capacity(s.len());
211 for b in s.bytes() {
212 match b {
213 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
214 out.push(b as char)
215 }
216 _ => out.push_str(&format!("%{b:02X}")),
217 }
218 }
219 out
220 }
221
222 fn percent_decode(s: &str) -> String {
223 let bytes = s.as_bytes();
224 let mut out = Vec::with_capacity(bytes.len());
225 let mut i = 0;
226 while i < bytes.len() {
227 if bytes[i] == b'%' {
228 if let (Some(h), Some(l)) = (
229 bytes.get(i + 1).and_then(|b| (*b as char).to_digit(16)),
230 bytes.get(i + 2).and_then(|b| (*b as char).to_digit(16)),
231 ) {
232 out.push((h * 16 + l) as u8);
233 i += 3;
234 continue;
235 }
236 }
237 out.push(bytes[i]);
238 i += 1;
239 }
240 String::from_utf8_lossy(&out).into_owned()
241 }
242
243 #[cfg(test)]
244 mod tests {
245 use super::*;
246
247 fn temp_base(tag: &str) -> PathBuf {
248 let base = std::env::temp_dir().join(format!("cce-files-trash-test-{tag}-{}", std::process::id()));
249 let _ = fs::remove_dir_all(&base);
250 fs::create_dir_all(&base).unwrap();
251 base
252 }
253
254 #[test]
255 fn test_percent_roundtrip() {
256 let s = "/home/user/My Files/café ünï.txt";
257 let enc = percent_encode(s);
258 assert!(!enc.contains(' '));
259 assert_eq!(percent_decode(&enc), s);
260 }
261
262 #[test]
263 fn test_trash_restore_roundtrip() {
264 let base = temp_base("roundtrip");
265 let trash = base.join("Trash");
266 let victim = base.join("doomed file.txt");
267 fs::write(&victim, b"contents").unwrap();
268
269 move_to_trash_in(&trash, &victim).unwrap();
270 assert!(!victim.exists());
271 let trashed = trash.join("files/doomed file.txt");
272 assert!(trashed.exists());
273 let info = fs::read_to_string(trash.join("info/doomed file.txt.trashinfo")).unwrap();
274 assert!(info.starts_with("[Trash Info]\n"));
275 assert!(info.contains("DeletionDate="));
276
277 let restored = restore_in(&trash, &trashed).unwrap();
278 assert_eq!(restored, victim.canonicalize().unwrap_or(victim.clone()));
279 assert_eq!(fs::read(&victim).unwrap(), b"contents");
280 assert!(!trashed.exists());
281 assert!(!trash.join("info/doomed file.txt.trashinfo").exists());
282
283 let _ = fs::remove_dir_all(&base);
284 }
285
286 #[test]
287 fn test_name_collision_and_empty() {
288 let base = temp_base("collision");
289 let trash = base.join("Trash");
290 for _ in 0..3 {
291 let victim = base.join("dup.txt");
292 fs::write(&victim, b"x").unwrap();
293 move_to_trash_in(&trash, &victim).unwrap();
294 }
295 assert!(trash.join("files/dup.txt").exists());
296 assert!(trash.join("files/dup.txt.2").exists());
297 assert!(trash.join("files/dup.txt.3").exists());
298
299 // A directory victim exercises the recursive branch of empty.
300 let dir_victim = base.join("nested");
301 fs::create_dir_all(dir_victim.join("inner")).unwrap();
302 fs::write(dir_victim.join("inner/f.txt"), b"y").unwrap();
303 move_to_trash_in(&trash, &dir_victim).unwrap();
304
305 assert_eq!(empty_in(&trash).unwrap(), 4);
306 assert_eq!(fs::read_dir(trash.join("files")).unwrap().count(), 0);
307 assert_eq!(fs::read_dir(trash.join("info")).unwrap().count(), 0);
308
309 let _ = fs::remove_dir_all(&base);
310 }
311
312 #[test]
313 fn test_restore_refuses_overwrite() {
314 let base = temp_base("overwrite");
315 let trash = base.join("Trash");
316 let victim = base.join("clash.txt");
317 fs::write(&victim, b"old").unwrap();
318 move_to_trash_in(&trash, &victim).unwrap();
319 fs::write(&victim, b"new").unwrap();
320
321 let err = restore_in(&trash, &trash.join("files/clash.txt")).unwrap_err();
322 assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
323 assert_eq!(fs::read(&victim).unwrap(), b"new");
324
325 let _ = fs::remove_dir_all(&base);
326 }
327 }