trash_forensic/
android.rs1use 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#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum TrashedNameAnomaly {
22 ExpiredResidue {
25 name: String,
27 },
28 MalformedName {
31 name: String,
33 },
34}
35
36impl TrashedNameAnomaly {
37 fn code(&self) -> &'static str {
39 match self {
40 TrashedNameAnomaly::ExpiredResidue { .. } => "TRASH-EXPIRED-RESIDUE",
41 TrashedNameAnomaly::MalformedName { .. } => "TRASH-MALFORMED-NAME",
42 }
43 }
44
45 fn category(&self) -> Category {
47 match self {
48 TrashedNameAnomaly::ExpiredResidue { .. } => Category::Residue,
49 TrashedNameAnomaly::MalformedName { .. } => Category::Structure,
50 }
51 }
52
53 fn name(&self) -> &str {
55 match self {
56 TrashedNameAnomaly::ExpiredResidue { name }
57 | TrashedNameAnomaly::MalformedName { name } => name,
58 }
59 }
60
61 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 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
92fn 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#[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 #[test]
149 fn unexpired_item_has_no_findings() {
150 assert!(audit_trashed_name(".trashed-1700000000-photo.jpg", at(1_699_000_000)).is_empty());
152 }
153
154 #[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 #[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 #[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 #[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}