use chrono::{DateTime, Utc};
use forensicnomicon::report::{Category, Evidence, Finding, Location, Severity, Source};
use trash_core::android::parse_trashed_name;
use crate::ANALYZER;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TrashedNameAnomaly {
ExpiredResidue {
name: String,
},
MalformedName {
name: String,
},
}
impl TrashedNameAnomaly {
fn code(&self) -> &'static str {
match self {
TrashedNameAnomaly::ExpiredResidue { .. } => "TRASH-EXPIRED-RESIDUE",
TrashedNameAnomaly::MalformedName { .. } => "TRASH-MALFORMED-NAME",
}
}
fn category(&self) -> Category {
match self {
TrashedNameAnomaly::ExpiredResidue { .. } => Category::Residue,
TrashedNameAnomaly::MalformedName { .. } => Category::Structure,
}
}
fn name(&self) -> &str {
match self {
TrashedNameAnomaly::ExpiredResidue { name }
| TrashedNameAnomaly::MalformedName { name } => name,
}
}
fn note(&self) -> String {
match self {
TrashedNameAnomaly::ExpiredResidue { name } => format!(
"trashed item {name} is still present though its dateExpires has passed — \
consistent with the file having survived the idle-maintenance sweep and \
remaining recoverable"
),
TrashedNameAnomaly::MalformedName { name } => format!(
"name {name} carries a trashed/pending prefix but does not parse as a valid \
MediaStore trash token — surfaced verbatim for inspection"
),
}
}
fn to_finding(&self, source: Source) -> Finding {
let name = self.name().to_string();
Finding::observation(Severity::Low, self.category(), self.code())
.note(self.note())
.source(source)
.evidence_item(Evidence {
field: "name".to_string(),
value: name.clone(),
location: Some(Location::Path(name)),
})
.build()
}
}
fn has_trashed_prefix(name: &str) -> bool {
name.get(..9).is_some_and(|head| {
let lower = head.to_ascii_lowercase();
lower == ".trashed-" || lower == ".pending-"
})
}
#[must_use]
pub fn audit_trashed_name(name: &str, now: DateTime<Utc>) -> Vec<Finding> {
let mut anomalies = Vec::new();
match parse_trashed_name(name) {
Some(parsed) => {
if parsed.expires_at().is_some_and(|expires| expires < now) {
anomalies.push(TrashedNameAnomaly::ExpiredResidue {
name: name.to_string(),
});
}
}
None if has_trashed_prefix(name) => {
anomalies.push(TrashedNameAnomaly::MalformedName {
name: name.to_string(),
});
}
None => {}
}
let source = Source {
analyzer: ANALYZER.to_string(),
scope: name.to_string(),
version: Some(env!("CARGO_PKG_VERSION").to_string()),
};
anomalies
.iter()
.map(|a| a.to_finding(source.clone()))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn at(secs: i64) -> DateTime<Utc> {
Utc.timestamp_opt(secs, 0).single().unwrap()
}
#[test]
fn unexpired_item_has_no_findings() {
assert!(audit_trashed_name(".trashed-1700000000-photo.jpg", at(1_699_000_000)).is_empty());
}
#[test]
fn plain_name_has_no_findings() {
assert!(audit_trashed_name("vacation.jpg", at(1_700_000_000)).is_empty());
}
#[test]
fn expired_item_flagged() {
let findings = audit_trashed_name(".trashed-1700000000-photo.jpg", at(1_800_000_000));
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].code, "TRASH-EXPIRED-RESIDUE");
assert_eq!(findings[0].category, Category::Residue);
assert_eq!(findings[0].severity, Some(Severity::Low));
assert_eq!(
findings[0].evidence[0].value,
".trashed-1700000000-photo.jpg"
);
}
#[test]
fn malformed_token_flagged() {
let findings = audit_trashed_name(".trashed-not-a-number.png", at(1_700_000_000));
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].code, "TRASH-MALFORMED-NAME");
assert_eq!(findings[0].category, Category::Structure);
assert_eq!(findings[0].severity, Some(Severity::Low));
}
#[test]
fn source_carries_analyzer_and_scope() {
let findings = audit_trashed_name(".trashed-1700000000-photo.jpg", at(1_800_000_000));
assert_eq!(findings[0].source.analyzer, ANALYZER);
assert_eq!(findings[0].source.scope, ".trashed-1700000000-photo.jpg");
}
}