Skip to main content

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}