Skip to main content

trash_forensic/
macos.rs

1//! Forensic anomaly analysis for the macOS **Trash** put-back metadata recovered
2//! by [`trash_core::macos`] from a Trash folder's `.DS_Store`.
3//!
4//! | Code | Category | Severity | Meaning |
5//! |---|---|---|---|
6//! | `TRASH-ORPHAN-METADATA` | Residue | Medium | a `.DS_Store` put-back record survives but the named item is gone from the Trash |
7//! | `TRASH-PUTBACK-TRAVERSAL` | Concealment | High | the stored `ptbN`/`ptbL` escapes its directory via `..` |
8//!
9//! A trashed item that simply has *no* put-back record is **not** anomalous —
10//! Finder writes `.DS_Store` lazily and `rm` never writes one — so the absence of
11//! a record is normal and is deliberately not flagged here. Findings are
12//! observations, never legal conclusions: the analyst concludes.
13
14use forensicnomicon::report::{Category, Evidence, Finding, Location, Severity, Source};
15use trash_core::macos::PutBack;
16
17use crate::{has_path_traversal, ANALYZER};
18
19/// A macOS Trash put-back anomaly, with the offending evidence attached.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum DsStoreAnomaly {
22    /// A put-back record survives in `.DS_Store` but the named item is no longer
23    /// present in the Trash directory: the content was emptied/removed while its
24    /// metadata remained (analogous to a Windows `$I` without its `$R`).
25    OrphanMetadata {
26        /// The reconstructed original path, or the trash name when the original
27        /// path could not be reconstructed.
28        evidence: String,
29    },
30    /// The stored put-back name or location contains a `..` component — restoring
31    /// it could escape the intended tree, consistent with a crafted record.
32    PutBackTraversal {
33        /// The offending `ptbN`/`ptbL` value, surfaced verbatim.
34        offending: String,
35    },
36}
37
38impl DsStoreAnomaly {
39    /// Stable, scheme-prefixed machine code (a published contract).
40    fn code(&self) -> &'static str {
41        match self {
42            DsStoreAnomaly::OrphanMetadata { .. } => "TRASH-ORPHAN-METADATA",
43            DsStoreAnomaly::PutBackTraversal { .. } => "TRASH-PUTBACK-TRAVERSAL",
44        }
45    }
46
47    /// Canonical severity for the anomaly.
48    fn severity(&self) -> Severity {
49        match self {
50            DsStoreAnomaly::OrphanMetadata { .. } => Severity::Medium,
51            DsStoreAnomaly::PutBackTraversal { .. } => Severity::High,
52        }
53    }
54
55    /// Analytical lens for the anomaly.
56    fn category(&self) -> Category {
57        match self {
58            DsStoreAnomaly::OrphanMetadata { .. } => Category::Residue,
59            DsStoreAnomaly::PutBackTraversal { .. } => Category::Concealment,
60        }
61    }
62
63    /// The evidence field name + offending value carried into the finding.
64    fn evidence(&self) -> (&'static str, &str) {
65        match self {
66            DsStoreAnomaly::OrphanMetadata { evidence } => ("original_path", evidence),
67            DsStoreAnomaly::PutBackTraversal { offending } => ("put_back_path", offending),
68        }
69    }
70
71    /// Human-readable, consistent-with note.
72    fn note(&self) -> String {
73        match self {
74            DsStoreAnomaly::OrphanMetadata { evidence } => format!(
75                "a .DS_Store put-back record for {evidence} survives but the item is absent from \
76                 the Trash — consistent with the content having been emptied while its metadata \
77                 remains"
78            ),
79            DsStoreAnomaly::PutBackTraversal { offending } => format!(
80                "stored put-back path {offending} contains a parent-directory ('..') component — \
81                 consistent with a crafted record whose restore would escape the intended tree"
82            ),
83        }
84    }
85
86    /// Convert this anomaly into a canonical [`Finding`].
87    fn to_finding(&self, source: Source) -> Finding {
88        let (field, value) = self.evidence();
89        Finding::observation(self.severity(), self.category(), self.code())
90            .note(self.note())
91            .source(source)
92            .evidence_item(Evidence {
93                field: field.to_string(),
94                value: value.to_string(),
95                location: Some(Location::Path(value.to_string())),
96            })
97            .build()
98    }
99}
100
101/// Build the [`Source`] stamped on every finding (analyzer + version + scope).
102fn source_for(record: &PutBack) -> Source {
103    Source {
104        analyzer: ANALYZER.to_string(),
105        scope: record.trash_name.clone(),
106        version: Some(env!("CARGO_PKG_VERSION").to_string()),
107    }
108}
109
110/// Audit a recovered macOS put-back record. `item_present` is whether the item
111/// named by [`PutBack::trash_name`] still exists in the Trash directory (the
112/// caller lists the directory; the `.DS_Store` itself does not).
113///
114/// Detects an orphaned put-back record (metadata without its item) and a
115/// path-traversal stored name/location. A present item with clean paths yields no
116/// findings.
117#[must_use]
118pub fn audit_put_back(record: &PutBack, item_present: bool) -> Vec<Finding> {
119    let source = source_for(record);
120    let mut anomalies = Vec::new();
121
122    if !item_present {
123        let evidence = record
124            .original_path()
125            .unwrap_or_else(|| record.trash_name.clone());
126        anomalies.push(DsStoreAnomaly::OrphanMetadata { evidence });
127    }
128
129    // One traversal finding per record, whether the `..` is in `ptbL` or `ptbN`.
130    if let Some(offending) = [
131        record.original_location.as_deref(),
132        record.original_name.as_deref(),
133    ]
134    .into_iter()
135    .flatten()
136    .find(|value| has_path_traversal(value))
137    {
138        anomalies.push(DsStoreAnomaly::PutBackTraversal {
139            offending: offending.to_string(),
140        });
141    }
142
143    anomalies
144        .iter()
145        .map(|a| a.to_finding(source.clone()))
146        .collect()
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    fn put_back(name: &str, original_name: Option<&str>, location: Option<&str>) -> PutBack {
154        PutBack {
155            trash_name: name.to_string(),
156            original_name: original_name.map(str::to_string),
157            original_location: location.map(str::to_string),
158        }
159    }
160
161    fn clean() -> PutBack {
162        put_back(
163            "report.pdf",
164            Some("report.pdf"),
165            Some("System/Volumes/Data/Users/x/Downloads/"),
166        )
167    }
168
169    /// A present item with clean paths yields nothing.
170    #[test]
171    fn present_clean_item_has_no_findings() {
172        assert!(audit_put_back(&clean(), true).is_empty());
173    }
174
175    /// A record whose item is gone from the Trash => Residue/Medium orphan.
176    #[test]
177    fn orphan_metadata_detected() {
178        let findings = audit_put_back(&clean(), false);
179        assert_eq!(findings.len(), 1);
180        assert_eq!(findings[0].code, "TRASH-ORPHAN-METADATA");
181        assert_eq!(findings[0].category, Category::Residue);
182        assert_eq!(findings[0].severity, Some(Severity::Medium));
183        // The reconstructed original path is surfaced as evidence.
184        assert_eq!(
185            findings[0].evidence[0].value,
186            "/Users/x/Downloads/report.pdf"
187        );
188    }
189
190    /// A `..` in the put-back location => Concealment/High traversal.
191    #[test]
192    fn traversal_in_location_detected() {
193        let r = put_back(
194            "p",
195            Some("p"),
196            Some("System/Volumes/Data/Users/x/../../etc/"),
197        );
198        let findings = audit_put_back(&r, true);
199        assert_eq!(findings.len(), 1);
200        assert_eq!(findings[0].code, "TRASH-PUTBACK-TRAVERSAL");
201        assert_eq!(findings[0].category, Category::Concealment);
202        assert_eq!(findings[0].severity, Some(Severity::High));
203    }
204
205    /// A `..` in the put-back *name* is also caught.
206    #[test]
207    fn traversal_in_name_detected() {
208        let r = put_back("p", Some("../escape"), Some("System/Volumes/Data/Users/x/"));
209        let findings = audit_put_back(&r, true);
210        assert_eq!(findings.len(), 1);
211        assert_eq!(findings[0].code, "TRASH-PUTBACK-TRAVERSAL");
212    }
213
214    /// An item that is both orphaned and traversal-pathed yields both findings.
215    #[test]
216    fn orphan_and_traversal_stack() {
217        let r = put_back("p", Some("p"), Some("System/Volumes/Data/Users/x/../etc/"));
218        let findings = audit_put_back(&r, false);
219        let codes: Vec<&str> = findings.iter().map(|f| f.code.as_ref()).collect();
220        assert_eq!(findings.len(), 2);
221        assert!(codes.contains(&"TRASH-ORPHAN-METADATA"));
222        assert!(codes.contains(&"TRASH-PUTBACK-TRAVERSAL"));
223    }
224
225    /// Every finding is stamped with the analyzer name and the item's trash name.
226    #[test]
227    fn source_carries_analyzer_and_scope() {
228        let findings = audit_put_back(&clean(), false);
229        assert_eq!(findings[0].source.analyzer, ANALYZER);
230        assert_eq!(findings[0].source.scope, "report.pdf");
231    }
232}