git.lucas.co / cce-files
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 }