Skip to main content

trash_forensic/
linux.rs

1//! Forensic anomaly analysis for the Linux freedesktop.org / XDG **Trash**
2//! artifact.
3//!
4//! [`trash_core::linux`] is the reader: it parses a `.trashinfo` into a
5//! [`TrashInfo`] and pairs `info/`↔`files/`. This module grades a parsed record +
6//! its pairing into canonical [`forensicnomicon::report::Finding`]s.
7//!
8//! | Code | Category | Severity | Meaning |
9//! |---|---|---|---|
10//! | `TRASH-CONTENT-PURGED` | Residue | Medium | `info/<name>.trashinfo` survives but `files/<name>` is gone |
11//! | `TRASH-PATH-TRAVERSAL` | Concealment | High | the stored `Path=` escapes its directory via `..` (spec-forbidden) |
12//! | `TRASH-DELETION-TIME-MISSING` | Integrity | Medium | `DeletionDate=` was absent or unparseable |
13//!
14//! Findings are observations, never legal conclusions: the analyst concludes.
15
16use forensicnomicon::report::{Category, Evidence, Finding, Location, Severity, Source};
17use trash_core::linux::{TrashEntry, TrashInfo};
18
19use crate::{has_path_traversal, ANALYZER};
20
21/// An XDG-trash anomaly, with the offending evidence attached.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum TrashAnomaly {
24    /// A `.trashinfo` whose `files/<name>` content is absent: the metadata of a
25    /// trashed file survives but its content has been purged.
26    ContentPurged {
27        /// The original path recorded in the surviving `.trashinfo`.
28        original_path: String,
29    },
30    /// The stored `Path=` escapes its directory via a `..` component — forbidden
31    /// by the spec for relative paths, consistent with a crafted entry.
32    PathTraversal {
33        /// The offending stored path, surfaced verbatim for the investigator.
34        original_path: String,
35    },
36    /// `DeletionDate=` was absent or unparseable, so the deletion time is unknown.
37    DeletionTimeMissing {
38        /// The path of the record whose deletion time is missing.
39        original_path: String,
40    },
41}
42
43impl TrashAnomaly {
44    /// Stable, scheme-prefixed machine code (a published contract).
45    fn code(&self) -> &'static str {
46        match self {
47            TrashAnomaly::ContentPurged { .. } => "TRASH-CONTENT-PURGED",
48            TrashAnomaly::PathTraversal { .. } => "TRASH-PATH-TRAVERSAL",
49            TrashAnomaly::DeletionTimeMissing { .. } => "TRASH-DELETION-TIME-MISSING",
50        }
51    }
52
53    /// Canonical severity for the anomaly.
54    fn severity(&self) -> Severity {
55        match self {
56            TrashAnomaly::PathTraversal { .. } => Severity::High,
57            TrashAnomaly::ContentPurged { .. } | TrashAnomaly::DeletionTimeMissing { .. } => {
58                Severity::Medium
59            }
60        }
61    }
62
63    /// Analytical lens for the anomaly.
64    fn category(&self) -> Category {
65        match self {
66            TrashAnomaly::ContentPurged { .. } => Category::Residue,
67            TrashAnomaly::PathTraversal { .. } => Category::Concealment,
68            TrashAnomaly::DeletionTimeMissing { .. } => Category::Integrity,
69        }
70    }
71
72    /// The offending original path, common to every variant.
73    fn original_path(&self) -> &str {
74        match self {
75            TrashAnomaly::ContentPurged { original_path }
76            | TrashAnomaly::PathTraversal { original_path }
77            | TrashAnomaly::DeletionTimeMissing { original_path } => original_path,
78        }
79    }
80
81    /// Human-readable, consistent-with note.
82    fn note(&self) -> String {
83        match self {
84            TrashAnomaly::ContentPurged { original_path } => format!(
85                "`.trashinfo` metadata for {original_path} survives but its `files/` content \
86                 is absent — consistent with the content having been purged while its metadata \
87                 remains"
88            ),
89            TrashAnomaly::PathTraversal { original_path } => format!(
90                "stored Path= {original_path} contains a parent-directory ('..') component, \
91                 which the Trash spec forbids — consistent with a crafted entry rather than a \
92                 normal deletion"
93            ),
94            TrashAnomaly::DeletionTimeMissing { original_path } => format!(
95                "DeletionDate= for {original_path} was absent or unparseable — the deletion \
96                 time is unknown"
97            ),
98        }
99    }
100
101    /// Convert this anomaly into a canonical [`Finding`].
102    fn to_finding(&self, source: Source) -> Finding {
103        let path = self.original_path().to_string();
104        Finding::observation(self.severity(), self.category(), self.code())
105            .note(self.note())
106            .source(source)
107            .evidence_item(Evidence {
108                field: "original_path".to_string(),
109                value: path.clone(),
110                location: Some(Location::Path(path)),
111            })
112            .build()
113    }
114}
115
116/// Build the [`Source`] stamped on every finding (analyzer + version + scope).
117fn source_for(entry: &TrashEntry) -> Source {
118    let scope = entry
119        .info_path
120        .file_name()
121        .and_then(|n| n.to_str())
122        .unwrap_or("info")
123        .to_string();
124    Source {
125        analyzer: ANALYZER.to_string(),
126        scope,
127        version: Some(env!("CARGO_PKG_VERSION").to_string()),
128    }
129}
130
131/// Audit a parsed `.trashinfo` record together with its `info/`↔`files/` pairing,
132/// returning a canonical [`Finding`] for each anomaly detected.
133///
134/// Detects purged content (`info/` without `files/`), a path-traversal stored
135/// `Path=`, and a missing/unparseable deletion time. A well-formed record with
136/// content and a deletion time yields no findings.
137#[must_use]
138pub fn audit_entry(info: &TrashInfo, entry: &TrashEntry) -> Vec<Finding> {
139    let source = source_for(entry);
140    let mut anomalies = Vec::new();
141
142    if entry.content_path.is_none() {
143        anomalies.push(TrashAnomaly::ContentPurged {
144            original_path: info.original_path.clone(),
145        });
146    }
147
148    if has_path_traversal(&info.original_path) {
149        anomalies.push(TrashAnomaly::PathTraversal {
150            original_path: info.original_path.clone(),
151        });
152    }
153
154    if info.deleted_at.is_none() {
155        anomalies.push(TrashAnomaly::DeletionTimeMissing {
156            original_path: info.original_path.clone(),
157        });
158    }
159
160    anomalies
161        .iter()
162        .map(|a| a.to_finding(source.clone()))
163        .collect()
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use chrono::NaiveDate;
170    use std::path::PathBuf;
171
172    fn info(path: &str, dated: bool) -> TrashInfo {
173        TrashInfo {
174            original_path: path.to_string(),
175            deleted_at: dated.then(|| {
176                NaiveDate::from_ymd_opt(2024, 1, 15)
177                    .unwrap()
178                    .and_hms_opt(13, 45, 9)
179                    .unwrap()
180            }),
181        }
182    }
183
184    fn entry(content: bool) -> TrashEntry {
185        TrashEntry {
186            info_path: PathBuf::from("/t/info/report.pdf.trashinfo"),
187            content_path: content.then(|| PathBuf::from("/t/files/report.pdf")),
188        }
189    }
190
191    /// A well-formed entry — content present, path clean, date set — yields nothing.
192    #[test]
193    fn clean_entry_has_no_findings() {
194        assert!(audit_entry(&info("/home/u/report.pdf", true), &entry(true)).is_empty());
195    }
196
197    /// Missing `files/<name>` => one Residue/Medium `TRASH-CONTENT-PURGED`.
198    #[test]
199    fn content_purged_detected() {
200        let findings = audit_entry(&info("/home/u/report.pdf", true), &entry(false));
201        assert_eq!(findings.len(), 1);
202        let f = &findings[0];
203        assert_eq!(f.code, "TRASH-CONTENT-PURGED");
204        assert_eq!(f.category, Category::Residue);
205        assert_eq!(f.severity, Some(Severity::Medium));
206        // The offending path is surfaced as evidence.
207        assert_eq!(f.evidence[0].field, "original_path");
208        assert_eq!(f.evidence[0].value, "/home/u/report.pdf");
209    }
210
211    /// A `..` component in `Path=` => Concealment/High `TRASH-PATH-TRAVERSAL`.
212    #[test]
213    fn path_traversal_detected() {
214        let findings = audit_entry(&info("../../etc/shadow", true), &entry(true));
215        assert_eq!(findings.len(), 1);
216        assert_eq!(findings[0].code, "TRASH-PATH-TRAVERSAL");
217        assert_eq!(findings[0].category, Category::Concealment);
218        assert_eq!(findings[0].severity, Some(Severity::High));
219    }
220
221    /// A normal filename containing `..` (not a path component) is not flagged.
222    #[test]
223    fn embedded_dots_not_flagged() {
224        assert!(audit_entry(&info("/home/u/my..notes.txt", true), &entry(true)).is_empty());
225    }
226
227    /// Absent/unparseable date => Integrity/Medium `TRASH-DELETION-TIME-MISSING`.
228    #[test]
229    fn deletion_time_missing_detected() {
230        let findings = audit_entry(&info("/home/u/report.pdf", false), &entry(true));
231        assert_eq!(findings.len(), 1);
232        assert_eq!(findings[0].code, "TRASH-DELETION-TIME-MISSING");
233        assert_eq!(findings[0].category, Category::Integrity);
234        assert_eq!(findings[0].severity, Some(Severity::Medium));
235    }
236
237    /// Anomalies stack: a purged, traversal-pathed, undated entry yields all three.
238    #[test]
239    fn multiple_anomalies_stack() {
240        let findings = audit_entry(&info("../../../secret", false), &entry(false));
241        let codes: Vec<&str> = findings.iter().map(|f| f.code.as_ref()).collect();
242        assert_eq!(findings.len(), 3);
243        assert!(codes.contains(&"TRASH-CONTENT-PURGED"));
244        assert!(codes.contains(&"TRASH-PATH-TRAVERSAL"));
245        assert!(codes.contains(&"TRASH-DELETION-TIME-MISSING"));
246    }
247
248    /// Every finding is stamped with the analyzer name and the `.trashinfo` scope.
249    #[test]
250    fn source_carries_analyzer_and_scope() {
251        let findings = audit_entry(&info("/home/u/report.pdf", true), &entry(false));
252        let src = &findings[0].source;
253        assert_eq!(src.analyzer, ANALYZER);
254        assert_eq!(src.scope, "report.pdf.trashinfo");
255    }
256}