trash_core/linux.rs
1//! Read-only reader for the Linux freedesktop.org / XDG **Trash** artifact.
2//!
3//! When a file is trashed on a freedesktop.org desktop (GNOME, KDE, XFCE, …) the
4//! implementation moves the bytes into a trash directory's `files/` subdirectory
5//! and writes a sibling **`info/<name>.trashinfo`** metadata file recording where
6//! the file came from and when it was deleted.
7//!
8//! A trash directory therefore holds two sibling subdirectories:
9//!
10//! * **`info/`** — one `<name>.trashinfo` INI file per trashed item, and
11//! * **`files/`** — the trashed bytes (a file *or* a directory), named `<name>`.
12//!
13//! The pairing is `info/<name>.trashinfo` ⇄ `files/<name>`, where `<name>` is
14//! identical minus the `.trashinfo` extension. Per the spec the content name is
15//! derived from the *trashinfo* name and **never** from any path stored inside
16//! the file. This crate parses the `.trashinfo` metadata and pairs the two
17//! subdirectories; it produces no findings — the [`trash-forensic`] analyzer
18//! layers anomaly detection on top.
19//!
20//! # `.trashinfo` format
21//!
22//! Per the freedesktop.org **Trash Specification v1.0** (2014-01-02,
23//! <https://specifications.freedesktop.org/trash/latest/>) the file is a
24//! `.desktop`-like INI:
25//!
26//! ```text
27//! [Trash Info]
28//! Path=foo/bar/meow.bow-wow
29//! DeletionDate=20040831T22:32:08
30//! ```
31//!
32//! * The first line is the group header `[Trash Info]`.
33//! * **`Path=`** holds the original location, percent-encoded per RFC 2396
34//! section 2 (<https://www.rfc-editor.org/rfc/rfc2396#section-2>). This is URI
35//! escaping, **not** form encoding: `+` is a literal plus, not a space.
36//! * **`DeletionDate=`** holds the deletion time as `YYYY-MM-DDThh:mm:ss`. The
37//! spec's own example uses the *basic* form `20040831T22:32:08`; real writers
38//! emit the *extended* form `2024-01-15T13:45:09`. Both are accepted. The value
39//! carries **no timezone** — it is naive *local* time, so it is decoded into a
40//! [`NaiveDateTime`] and must never be treated as UTC.
41//! * If `Path=` or `DeletionDate=` appears more than once, the **first**
42//! occurrence wins (spec footnote [8]).
43//!
44//! [`trash-forensic`]: https://docs.rs/trash-forensic
45
46use std::path::{Path, PathBuf};
47
48use chrono::NaiveDateTime;
49use percent_encoding::percent_decode_str;
50use thiserror::Error;
51
52/// Errors returned while parsing a `.trashinfo` file.
53#[derive(Debug, Error, PartialEq, Eq)]
54pub enum TrashInfoError {
55 /// The first non-blank line was not the `[Trash Info]` group header. Carries
56 /// the offending line verbatim so the examiner can see what was there.
57 #[error("missing `[Trash Info]` group header; first non-blank line was {found:?}")]
58 MissingHeader {
59 /// The first non-blank line actually found (empty string if the file had
60 /// no non-blank lines at all).
61 found: String,
62 },
63
64 /// The file has a valid header but no `Path=` key — the original location is
65 /// unrecoverable from metadata (the spec's "emergency case").
66 #[error("`.trashinfo` has no `Path=` key")]
67 MissingPath,
68}
69
70/// Decoded metadata from a single `.trashinfo` file.
71#[derive(Debug, Clone, PartialEq, Eq)]
72#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
73pub struct TrashInfo {
74 /// Original location of the trashed file, percent-decoded (RFC 2396) and then
75 /// UTF-8 decoded lossily. Absolute (`/…`) or relative to the directory that
76 /// holds the trash root. This is the **first** `Path=` value.
77 pub original_path: String,
78 /// Deletion timestamp as recorded — naive **local** time, with no timezone.
79 /// `None` when `DeletionDate=` is absent or unparseable.
80 pub deleted_at: Option<NaiveDateTime>,
81}
82
83/// Parse the raw bytes of a `.trashinfo` file.
84///
85/// # Errors
86///
87/// Returns [`TrashInfoError::MissingHeader`] when the first non-blank line is not
88/// `[Trash Info]` (matched case-insensitively to tolerate the pre-1.0 `[Trash
89/// info]` casing), and [`TrashInfoError::MissingPath`] when no `Path=` key is
90/// present. A missing or unparseable `DeletionDate=` is **not** an error — it
91/// yields `deleted_at == None`. Never panics on hostile input.
92pub fn parse_trashinfo(data: &[u8]) -> Result<TrashInfo, TrashInfoError> {
93 // `.trashinfo` files are ASCII (the path is percent-encoded), so a lossy
94 // UTF-8 view is faithful; any stray non-UTF-8 byte becomes U+FFFD rather than
95 // aborting the parse.
96 let text = String::from_utf8_lossy(strip_utf8_bom(data));
97 let mut lines = text.lines();
98
99 // The first non-blank line must be the `[Trash Info]` group header. Advancing
100 // `lines` by reference leaves the iterator positioned just after the header so
101 // the key/value scan below resumes from the next line.
102 let header = lines
103 .by_ref()
104 .find(|line| !line.trim().is_empty())
105 .map_or("", str::trim);
106 if !header.eq_ignore_ascii_case("[Trash Info]") {
107 return Err(TrashInfoError::MissingHeader {
108 found: header.to_string(),
109 });
110 }
111
112 // First `Path=` and first `DeletionDate=` win (spec footnote [8]).
113 let mut path_enc: Option<&str> = None;
114 let mut date_raw: Option<&str> = None;
115 for line in lines {
116 let Some((key, value)) = line.split_once('=') else {
117 continue;
118 };
119 let (key, value) = (key.trim(), value.trim());
120 if path_enc.is_none() && key == "Path" {
121 path_enc = Some(value);
122 } else if date_raw.is_none() && key == "DeletionDate" {
123 date_raw = Some(value);
124 }
125 }
126
127 let Some(path_enc) = path_enc else {
128 return Err(TrashInfoError::MissingPath);
129 };
130
131 // RFC 2396 percent-decoding (not form encoding): `%XX` -> byte, `+` left
132 // literal. Decoded bytes are interpreted as UTF-8 lossily.
133 let original_path = percent_decode_str(path_enc)
134 .decode_utf8_lossy()
135 .into_owned();
136 let deleted_at = date_raw.and_then(parse_deletion_date);
137
138 Ok(TrashInfo {
139 original_path,
140 deleted_at,
141 })
142}
143
144/// Strip a leading UTF-8 BOM (`EF BB BF`) if present.
145fn strip_utf8_bom(data: &[u8]) -> &[u8] {
146 data.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(data)
147}
148
149/// Parse a `DeletionDate=` value as naive local time, accepting both the extended
150/// `YYYY-MM-DDThh:mm:ss` form real writers emit and the basic `YYYYMMDDThh:mm:ss`
151/// form the spec's own example uses. Returns `None` for any unparseable value.
152fn parse_deletion_date(value: &str) -> Option<NaiveDateTime> {
153 const FORMAT: &str = "%Y-%m-%dT%H:%M:%S";
154 if let Ok(dt) = NaiveDateTime::parse_from_str(value, FORMAT) {
155 return Some(dt);
156 }
157 // chrono's `%Y` consumes digits greedily, so the basic form cannot be parsed
158 // directly against a separator-less pattern; normalise it to the extended
159 // form (`YYYYMMDD` -> `YYYY-MM-DD`) and reparse.
160 let (date, time) = value.split_once('T')?;
161 if date.len() == 8 && date.bytes().all(|b| b.is_ascii_digit()) {
162 let normalised = format!("{}-{}-{}T{}", &date[0..4], &date[4..6], &date[6..8], time);
163 if let Ok(dt) = NaiveDateTime::parse_from_str(&normalised, FORMAT) {
164 return Some(dt);
165 }
166 }
167 None
168}
169
170/// A trashed item discovered by scanning a trash directory: its `info/` metadata
171/// file paired with its `files/` content (if the content is still present).
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct TrashEntry {
174 /// The `info/<name>.trashinfo` metadata file path.
175 pub info_path: PathBuf,
176 /// The paired `files/<name>` content path, if it still exists. A trashed
177 /// directory is a directory here, so existence — not file-ness — is checked.
178 pub content_path: Option<PathBuf>,
179}
180
181/// Scan a trash directory (the parent of `info/` and `files/`) and pair every
182/// `info/<name>.trashinfo` with its `files/<name>` content.
183///
184/// The content name is derived from the trashinfo basename, never from the
185/// `Path=` stored inside it. A `.trashinfo` whose `files/<name>` is absent yields
186/// an entry with `content_path == None`.
187///
188/// # Errors
189///
190/// Propagates any I/O error from reading the `info/` directory.
191pub fn scan_trash(trash_dir: &Path) -> std::io::Result<Vec<TrashEntry>> {
192 let info_dir = trash_dir.join("info");
193 let files_dir = trash_dir.join("files");
194 let mut entries = Vec::new();
195 for entry in std::fs::read_dir(&info_dir)? {
196 let entry = entry?; // cov:unreachable: per-entry `?` needs a mid-scan I/O fault tests cannot force
197 let name = entry.file_name();
198 let Some(name) = name.to_str() else {
199 continue; // cov:unreachable: non-UTF-8 entry is OS-specific, not portably constructible
200 };
201 let Some(stem) = name.strip_suffix(".trashinfo") else {
202 continue;
203 };
204 let candidate = files_dir.join(stem);
205 // A trashed directory exists as a directory in `files/`, so test for
206 // existence rather than file-ness.
207 let content_path = candidate.exists().then_some(candidate);
208 entries.push(TrashEntry {
209 info_path: entry.path(),
210 content_path,
211 });
212 }
213 Ok(entries)
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use chrono::NaiveDate;
220
221 fn naive(y: i32, mo: u32, d: u32, h: u32, mi: u32, s: u32) -> NaiveDateTime {
222 NaiveDate::from_ymd_opt(y, mo, d)
223 .unwrap()
224 .and_hms_opt(h, mi, s)
225 .unwrap()
226 }
227
228 /// The spec's own verbatim example, including its *basic* `YYYYMMDD` date.
229 #[test]
230 fn parses_spec_example() {
231 let data = b"[Trash Info]\nPath=foo/bar/meow.bow-wow\nDeletionDate=20040831T22:32:08\n";
232 let info = parse_trashinfo(data).unwrap();
233 assert_eq!(info.original_path, "foo/bar/meow.bow-wow");
234 assert_eq!(info.deleted_at, Some(naive(2004, 8, 31, 22, 32, 8)));
235 }
236
237 /// Real-world extended date plus percent-encoded UTF-8 path.
238 #[test]
239 fn parses_extended_date_and_percent_decodes_path() {
240 let data =
241 b"[Trash Info]\nPath=/home/u/My%20Docs/r%C3%A9sum%C3%A9.pdf\nDeletionDate=2024-01-15T13:45:09\n";
242 let info = parse_trashinfo(data).unwrap();
243 assert_eq!(info.original_path, "/home/u/My Docs/résumé.pdf");
244 assert_eq!(info.deleted_at, Some(naive(2024, 1, 15, 13, 45, 9)));
245 }
246
247 /// RFC 2396 percent-encoding, not form encoding: `+` stays a literal plus.
248 #[test]
249 fn plus_is_literal_not_space() {
250 let data = b"[Trash Info]\nPath=/tmp/a+b.txt\n";
251 let info = parse_trashinfo(data).unwrap();
252 assert_eq!(info.original_path, "/tmp/a+b.txt");
253 }
254
255 /// Duplicate keys: the first `Path=` and first `DeletionDate=` win (footnote [8]).
256 #[test]
257 fn first_path_and_date_win() {
258 let data = b"[Trash Info]\nPath=/first\nDeletionDate=2024-01-01T00:00:00\nPath=/second\nDeletionDate=2025-06-06T06:06:06\n";
259 let info = parse_trashinfo(data).unwrap();
260 assert_eq!(info.original_path, "/first");
261 assert_eq!(info.deleted_at, Some(naive(2024, 1, 1, 0, 0, 0)));
262 }
263
264 /// The pre-1.0 `[Trash info]` lowercase casing is tolerated (matched
265 /// case-insensitively); the analyzer flags the deviation, the reader decodes.
266 #[test]
267 fn case_insensitive_header_accepted() {
268 let data = b"[Trash info]\nPath=/x\n";
269 let info = parse_trashinfo(data).unwrap();
270 assert_eq!(info.original_path, "/x");
271 }
272
273 /// No group header => `MissingHeader` carrying the offending first line.
274 #[test]
275 fn missing_header_is_error() {
276 let data = b"Path=/x\n";
277 let err = parse_trashinfo(data).unwrap_err();
278 assert!(matches!(err, TrashInfoError::MissingHeader { found } if found == "Path=/x"));
279 }
280
281 /// Header present but no `Path=` => `MissingPath`.
282 #[test]
283 fn missing_path_is_error() {
284 let data = b"[Trash Info]\nDeletionDate=2024-01-15T13:45:09\n";
285 assert_eq!(
286 parse_trashinfo(data).unwrap_err(),
287 TrashInfoError::MissingPath
288 );
289 }
290
291 /// A present-but-garbage date yields `None`, not an error.
292 #[test]
293 fn unparseable_date_is_none() {
294 let data = b"[Trash Info]\nPath=/x\nDeletionDate=not-a-date\n";
295 let info = parse_trashinfo(data).unwrap();
296 assert_eq!(info.original_path, "/x");
297 assert_eq!(info.deleted_at, None);
298 }
299
300 /// An absent date yields `None`.
301 #[test]
302 fn missing_date_is_none() {
303 let data = b"[Trash Info]\nPath=/x\n";
304 assert_eq!(parse_trashinfo(data).unwrap().deleted_at, None);
305 }
306
307 /// A leading UTF-8 BOM and CRLF line endings are tolerated.
308 #[test]
309 fn bom_and_crlf_tolerated() {
310 let data = b"\xEF\xBB\xBF[Trash Info]\r\nPath=/x\r\nDeletionDate=2024-01-15T13:45:09\r\n";
311 let info = parse_trashinfo(data).unwrap();
312 assert_eq!(info.original_path, "/x");
313 assert_eq!(info.deleted_at, Some(naive(2024, 1, 15, 13, 45, 9)));
314 }
315
316 /// `scan_trash` pairs `info/<name>.trashinfo` to `files/<name>` by name, and
317 /// leaves an orphaned info file (no `files/<name>`) with `content_path` None.
318 #[test]
319 fn scan_trash_pairs_info_to_files() {
320 let dir = std::env::temp_dir().join(format!("trash-core-linux-{}", std::process::id()));
321 let _ = std::fs::remove_dir_all(&dir);
322 std::fs::create_dir_all(dir.join("info")).unwrap();
323 std::fs::create_dir_all(dir.join("files")).unwrap();
324 // paired
325 std::fs::write(
326 dir.join("info/report.pdf.trashinfo"),
327 b"[Trash Info]\nPath=/x\n",
328 )
329 .unwrap();
330 std::fs::write(dir.join("files/report.pdf"), b"data").unwrap();
331 // orphan info (content purged)
332 std::fs::write(
333 dir.join("info/gone.txt.trashinfo"),
334 b"[Trash Info]\nPath=/y\n",
335 )
336 .unwrap();
337 // a non-trashinfo file in info/ is ignored
338 std::fs::write(dir.join("info/notes.md"), b"x").unwrap();
339
340 let mut entries = scan_trash(&dir).unwrap();
341 entries.sort_by_key(|e| e.info_path.clone());
342 assert_eq!(entries.len(), 2);
343
344 let paired = entries
345 .iter()
346 .find(|e| e.info_path.ends_with("report.pdf.trashinfo"))
347 .unwrap();
348 assert!(paired
349 .content_path
350 .as_ref()
351 .unwrap()
352 .ends_with("report.pdf"));
353
354 let orphan = entries
355 .iter()
356 .find(|e| e.info_path.ends_with("gone.txt.trashinfo"))
357 .unwrap();
358 assert!(orphan.content_path.is_none());
359
360 std::fs::remove_dir_all(&dir).unwrap();
361 }
362}