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 return NaiveDateTime::parse_from_str(&normalised, FORMAT).ok();
164 }
165 None
166}
167
168/// A trashed item discovered by scanning a trash directory: its `info/` metadata
169/// file paired with its `files/` content (if the content is still present).
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct TrashEntry {
172 /// The `info/<name>.trashinfo` metadata file path.
173 pub info_path: PathBuf,
174 /// The paired `files/<name>` content path, if it still exists. A trashed
175 /// directory is a directory here, so existence — not file-ness — is checked.
176 pub content_path: Option<PathBuf>,
177}
178
179/// Scan a trash directory (the parent of `info/` and `files/`) and pair every
180/// `info/<name>.trashinfo` with its `files/<name>` content.
181///
182/// The content name is derived from the trashinfo basename, never from the
183/// `Path=` stored inside it. A `.trashinfo` whose `files/<name>` is absent yields
184/// an entry with `content_path == None`.
185///
186/// # Errors
187///
188/// Propagates any I/O error from reading the `info/` directory.
189pub fn scan_trash(trash_dir: &Path) -> std::io::Result<Vec<TrashEntry>> {
190 let info_dir = trash_dir.join("info");
191 let files_dir = trash_dir.join("files");
192 let mut entries = Vec::new();
193 for entry in std::fs::read_dir(&info_dir)? {
194 let entry = entry?; // cov:unreachable: per-entry `?` needs a mid-scan I/O fault tests cannot force
195 let name = entry.file_name();
196 let Some(name) = name.to_str() else {
197 continue; // cov:unreachable: non-UTF-8 entry is OS-specific, not portably constructible
198 };
199 let Some(stem) = name.strip_suffix(".trashinfo") else {
200 continue;
201 };
202 let candidate = files_dir.join(stem);
203 // A trashed directory exists as a directory in `files/`, so test for
204 // existence rather than file-ness.
205 let content_path = candidate.exists().then_some(candidate);
206 entries.push(TrashEntry {
207 info_path: entry.path(),
208 content_path,
209 });
210 }
211 Ok(entries)
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use chrono::NaiveDate;
218
219 fn naive(y: i32, mo: u32, d: u32, h: u32, mi: u32, s: u32) -> NaiveDateTime {
220 NaiveDate::from_ymd_opt(y, mo, d)
221 .unwrap()
222 .and_hms_opt(h, mi, s)
223 .unwrap()
224 }
225
226 /// The spec's own verbatim example, including its *basic* `YYYYMMDD` date.
227 #[test]
228 fn parses_spec_example() {
229 let data = b"[Trash Info]\nPath=foo/bar/meow.bow-wow\nDeletionDate=20040831T22:32:08\n";
230 let info = parse_trashinfo(data).unwrap();
231 assert_eq!(info.original_path, "foo/bar/meow.bow-wow");
232 assert_eq!(info.deleted_at, Some(naive(2004, 8, 31, 22, 32, 8)));
233 }
234
235 /// Real-world extended date plus percent-encoded UTF-8 path.
236 #[test]
237 fn parses_extended_date_and_percent_decodes_path() {
238 let data =
239 b"[Trash Info]\nPath=/home/u/My%20Docs/r%C3%A9sum%C3%A9.pdf\nDeletionDate=2024-01-15T13:45:09\n";
240 let info = parse_trashinfo(data).unwrap();
241 assert_eq!(info.original_path, "/home/u/My Docs/résumé.pdf");
242 assert_eq!(info.deleted_at, Some(naive(2024, 1, 15, 13, 45, 9)));
243 }
244
245 /// RFC 2396 percent-encoding, not form encoding: `+` stays a literal plus.
246 #[test]
247 fn plus_is_literal_not_space() {
248 let data = b"[Trash Info]\nPath=/tmp/a+b.txt\n";
249 let info = parse_trashinfo(data).unwrap();
250 assert_eq!(info.original_path, "/tmp/a+b.txt");
251 }
252
253 /// Duplicate keys: the first `Path=` and first `DeletionDate=` win (footnote [8]).
254 #[test]
255 fn first_path_and_date_win() {
256 let data = b"[Trash Info]\nPath=/first\nDeletionDate=2024-01-01T00:00:00\nPath=/second\nDeletionDate=2025-06-06T06:06:06\n";
257 let info = parse_trashinfo(data).unwrap();
258 assert_eq!(info.original_path, "/first");
259 assert_eq!(info.deleted_at, Some(naive(2024, 1, 1, 0, 0, 0)));
260 }
261
262 /// The pre-1.0 `[Trash info]` lowercase casing is tolerated (matched
263 /// case-insensitively); the analyzer flags the deviation, the reader decodes.
264 #[test]
265 fn case_insensitive_header_accepted() {
266 let data = b"[Trash info]\nPath=/x\n";
267 let info = parse_trashinfo(data).unwrap();
268 assert_eq!(info.original_path, "/x");
269 }
270
271 /// No group header => `MissingHeader` carrying the offending first line.
272 #[test]
273 fn missing_header_is_error() {
274 let data = b"Path=/x\n";
275 let err = parse_trashinfo(data).unwrap_err();
276 assert!(matches!(err, TrashInfoError::MissingHeader { found } if found == "Path=/x"));
277 }
278
279 /// Header present but no `Path=` => `MissingPath`.
280 #[test]
281 fn missing_path_is_error() {
282 let data = b"[Trash Info]\nDeletionDate=2024-01-15T13:45:09\n";
283 assert_eq!(
284 parse_trashinfo(data).unwrap_err(),
285 TrashInfoError::MissingPath
286 );
287 }
288
289 /// A present-but-garbage date yields `None`, not an error.
290 #[test]
291 fn unparseable_date_is_none() {
292 let data = b"[Trash Info]\nPath=/x\nDeletionDate=not-a-date\n";
293 let info = parse_trashinfo(data).unwrap();
294 assert_eq!(info.original_path, "/x");
295 assert_eq!(info.deleted_at, None);
296 }
297
298 /// An absent date yields `None`.
299 #[test]
300 fn missing_date_is_none() {
301 let data = b"[Trash Info]\nPath=/x\n";
302 assert_eq!(parse_trashinfo(data).unwrap().deleted_at, None);
303 }
304
305 /// A leading UTF-8 BOM and CRLF line endings are tolerated.
306 #[test]
307 fn bom_and_crlf_tolerated() {
308 let data = b"\xEF\xBB\xBF[Trash Info]\r\nPath=/x\r\nDeletionDate=2024-01-15T13:45:09\r\n";
309 let info = parse_trashinfo(data).unwrap();
310 assert_eq!(info.original_path, "/x");
311 assert_eq!(info.deleted_at, Some(naive(2024, 1, 15, 13, 45, 9)));
312 }
313
314 /// `scan_trash` pairs `info/<name>.trashinfo` to `files/<name>` by name, and
315 /// leaves an orphaned info file (no `files/<name>`) with `content_path` None.
316 #[test]
317 fn scan_trash_pairs_info_to_files() {
318 let dir = std::env::temp_dir().join(format!("trash-core-linux-{}", std::process::id()));
319 let _ = std::fs::remove_dir_all(&dir);
320 std::fs::create_dir_all(dir.join("info")).unwrap();
321 std::fs::create_dir_all(dir.join("files")).unwrap();
322 // paired
323 std::fs::write(
324 dir.join("info/report.pdf.trashinfo"),
325 b"[Trash Info]\nPath=/x\n",
326 )
327 .unwrap();
328 std::fs::write(dir.join("files/report.pdf"), b"data").unwrap();
329 // orphan info (content purged)
330 std::fs::write(
331 dir.join("info/gone.txt.trashinfo"),
332 b"[Trash Info]\nPath=/y\n",
333 )
334 .unwrap();
335 // a non-trashinfo file in info/ is ignored
336 std::fs::write(dir.join("info/notes.md"), b"x").unwrap();
337
338 let mut entries = scan_trash(&dir).unwrap();
339 entries.sort_by_key(|e| e.info_path.clone());
340 assert_eq!(entries.len(), 2);
341
342 let paired = entries
343 .iter()
344 .find(|e| e.info_path.ends_with("report.pdf.trashinfo"))
345 .unwrap();
346 assert!(paired
347 .content_path
348 .as_ref()
349 .unwrap()
350 .ends_with("report.pdf"));
351
352 let orphan = entries
353 .iter()
354 .find(|e| e.info_path.ends_with("gone.txt.trashinfo"))
355 .unwrap();
356 assert!(orphan.content_path.is_none());
357
358 std::fs::remove_dir_all(&dir).unwrap();
359 }
360
361 /// A line without `=` is skipped, and a `DeletionDate` with a `T` but a
362 /// non-8-digit date part falls through the basic-form parse to `None`.
363 #[test]
364 fn junk_line_skipped_and_basic_date_fallthrough() {
365 let data =
366 b"[Trash Info]\n; a comment line without an equals sign\nPath=/x\nDeletionDate=12345T00:00:00\n";
367 let info = parse_trashinfo(data).unwrap();
368 assert_eq!(info.original_path, "/x");
369 assert_eq!(info.deleted_at, None);
370 }
371}