Skip to main content

trash_forensic/
android.rs

1//! Forensic anomaly analysis for Android `MediaStore` `.trashed-`/`.pending-`
2//! filenames decoded by [`trash_core::android`].
3//!
4//! | Code | Category | Severity | Meaning |
5//! |---|---|---|---|
6//! | `TRASH-EXPIRED-RESIDUE` | Residue | Low | a `.trashed-` item still present though its `dateExpires` has passed (survived the idle sweep, still recoverable) |
7//! | `TRASH-MALFORMED-NAME` | Structure | Low | a name with a `trashed`/`pending` prefix that does not parse as a valid token (raw name surfaced) |
8//!
9//! The expiry check needs a reference time, supplied by the caller, so the
10//! analysis stays deterministic and testable. Findings are observations, never
11//! legal conclusions: the analyst concludes.
12
13use chrono::{DateTime, Utc};
14use forensicnomicon::report::{Category, Evidence, Finding, Location, Severity, Source};
15use trash_core::android::parse_trashed_name;
16
17use crate::ANALYZER;
18
19/// An Android `MediaStore` trash-filename anomaly, with the offending name attached.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum TrashedNameAnomaly {
22    /// A `.trashed-` item whose `dateExpires` is in the past but which is still
23    /// present — it outlived the idle-maintenance sweep and remains recoverable.
24    ExpiredResidue {
25        /// The full `.trashed-…` filename, surfaced verbatim.
26        name: String,
27    },
28    /// A name that carries a `trashed`/`pending` prefix yet does not parse as a
29    /// valid token (non-numeric expiry, missing field, …).
30    MalformedName {
31        /// The offending filename, surfaced verbatim.
32        name: String,
33    },
34}
35
36impl TrashedNameAnomaly {
37    /// Stable, scheme-prefixed machine code (a published contract).
38    fn code(&self) -> &'static str {
39        match self {
40            TrashedNameAnomaly::ExpiredResidue { .. } => "TRASH-EXPIRED-RESIDUE",
41            TrashedNameAnomaly::MalformedName { .. } => "TRASH-MALFORMED-NAME",
42        }
43    }
44
45    /// Analytical lens for the anomaly.
46    fn category(&self) -> Category {
47        match self {
48            TrashedNameAnomaly::ExpiredResidue { .. } => Category::Residue,
49            TrashedNameAnomaly::MalformedName { .. } => Category::Structure,
50        }
51    }
52
53    /// The offending filename, common to both variants.
54    fn name(&self) -> &str {
55        match self {
56            TrashedNameAnomaly::ExpiredResidue { name }
57            | TrashedNameAnomaly::MalformedName { name } => name,
58        }
59    }
60
61    /// Human-readable, consistent-with note.
62    fn note(&self) -> String {
63        match self {
64            TrashedNameAnomaly::ExpiredResidue { name } => format!(
65                "trashed item {name} is still present though its dateExpires has passed — \
66                 consistent with the file having survived the idle-maintenance sweep and \
67                 remaining recoverable"
68            ),
69            TrashedNameAnomaly::MalformedName { name } => format!(
70                "name {name} carries a trashed/pending prefix but does not parse as a valid \
71                 MediaStore trash token — surfaced verbatim for inspection"
72            ),
73        }
74    }
75
76    /// Convert this anomaly into a canonical [`Finding`]. Both variants are Low
77    /// severity.
78    fn to_finding(&self, source: Source) -> Finding {
79        let name = self.name().to_string();
80        Finding::observation(Severity::Low, self.category(), self.code())
81            .note(self.note())
82            .source(source)
83            .evidence_item(Evidence {
84                field: "name".to_string(),
85                value: name.clone(),
86                location: Some(Location::Path(name)),
87            })
88            .build()
89    }
90}
91
92/// Whether a name carries a case-insensitive `.trashed-`/`.pending-` prefix
93/// (each nine bytes). Boundary-safe: a leading multi-byte char yields `false`.
94fn has_trashed_prefix(name: &str) -> bool {
95    name.get(..9).is_some_and(|head| {
96        let lower = head.to_ascii_lowercase();
97        lower == ".trashed-" || lower == ".pending-"
98    })
99}
100
101/// Audit a single directory-entry name against the `MediaStore` trash codec.
102/// `now` is the reference time the item's expiry is compared against (the caller
103/// passes the acquisition/analysis time).
104///
105/// Flags an expired-but-present `.trashed-` item and a malformed trash token. A
106/// well-formed, unexpired name — or a name that is not a trash token at all —
107/// yields no findings.
108#[must_use]
109pub fn audit_trashed_name(name: &str, now: DateTime<Utc>) -> Vec<Finding> {
110    let mut anomalies = Vec::new();
111    match parse_trashed_name(name) {
112        Some(parsed) => {
113            if parsed.expires_at().is_some_and(|expires| expires < now) {
114                anomalies.push(TrashedNameAnomaly::ExpiredResidue {
115                    name: name.to_string(),
116                });
117            }
118        }
119        None if has_trashed_prefix(name) => {
120            anomalies.push(TrashedNameAnomaly::MalformedName {
121                name: name.to_string(),
122            });
123        }
124        None => {}
125    }
126
127    let source = Source {
128        analyzer: ANALYZER.to_string(),
129        scope: name.to_string(),
130        version: Some(env!("CARGO_PKG_VERSION").to_string()),
131    };
132    anomalies
133        .iter()
134        .map(|a| a.to_finding(source.clone()))
135        .collect()
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use chrono::TimeZone;
142
143    fn at(secs: i64) -> DateTime<Utc> {
144        Utc.timestamp_opt(secs, 0).single().unwrap()
145    }
146
147    /// A trashed item whose expiry is still in the future yields nothing.
148    #[test]
149    fn unexpired_item_has_no_findings() {
150        // expires at 1_700_000_000; now is well before that.
151        assert!(audit_trashed_name(".trashed-1700000000-photo.jpg", at(1_699_000_000)).is_empty());
152    }
153
154    /// A non-trash filename yields nothing.
155    #[test]
156    fn plain_name_has_no_findings() {
157        assert!(audit_trashed_name("vacation.jpg", at(1_700_000_000)).is_empty());
158    }
159
160    /// A trashed item present past its expiry => Residue/Low expired residue.
161    #[test]
162    fn expired_item_flagged() {
163        let findings = audit_trashed_name(".trashed-1700000000-photo.jpg", at(1_800_000_000));
164        assert_eq!(findings.len(), 1);
165        assert_eq!(findings[0].code, "TRASH-EXPIRED-RESIDUE");
166        assert_eq!(findings[0].category, Category::Residue);
167        assert_eq!(findings[0].severity, Some(Severity::Low));
168        assert_eq!(
169            findings[0].evidence[0].value,
170            ".trashed-1700000000-photo.jpg"
171        );
172    }
173
174    /// A `trashed`/`pending`-prefixed name that does not parse => Structure/Low.
175    #[test]
176    fn malformed_token_flagged() {
177        let findings = audit_trashed_name(".trashed-not-a-number.png", at(1_700_000_000));
178        assert_eq!(findings.len(), 1);
179        assert_eq!(findings[0].code, "TRASH-MALFORMED-NAME");
180        assert_eq!(findings[0].category, Category::Structure);
181        assert_eq!(findings[0].severity, Some(Severity::Low));
182    }
183
184    /// Every finding is stamped with the analyzer name and the filename scope.
185    #[test]
186    fn source_carries_analyzer_and_scope() {
187        let findings = audit_trashed_name(".trashed-1700000000-photo.jpg", at(1_800_000_000));
188        assert_eq!(findings[0].source.analyzer, ANALYZER);
189        assert_eq!(findings[0].source.scope, ".trashed-1700000000-photo.jpg");
190    }
191}