1use chrono::{DateTime, Duration, Utc};
14use forensicnomicon::report::{Category, Evidence, Finding, Location, Severity, Source};
15use trash_core::ios::TrashedAsset;
16
17use crate::ANALYZER;
18
19const RETENTION_DAYS: i64 = 30;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum IosAssetAnomaly {
25 DeletionTimeMissing {
28 evidence: String,
30 },
31 ExpiredResidue {
34 evidence: String,
36 },
37}
38
39impl IosAssetAnomaly {
40 fn code(&self) -> &'static str {
42 match self {
43 IosAssetAnomaly::DeletionTimeMissing { .. } => "TRASH-DELETION-TIME-MISSING",
44 IosAssetAnomaly::ExpiredResidue { .. } => "TRASH-EXPIRED-RESIDUE",
45 }
46 }
47
48 fn category(&self) -> Category {
50 match self {
51 IosAssetAnomaly::DeletionTimeMissing { .. } => Category::Integrity,
52 IosAssetAnomaly::ExpiredResidue { .. } => Category::Residue,
53 }
54 }
55
56 fn severity(&self) -> Severity {
58 match self {
59 IosAssetAnomaly::DeletionTimeMissing { .. } => Severity::Medium,
60 IosAssetAnomaly::ExpiredResidue { .. } => Severity::Low,
61 }
62 }
63
64 fn evidence(&self) -> &str {
66 match self {
67 IosAssetAnomaly::DeletionTimeMissing { evidence }
68 | IosAssetAnomaly::ExpiredResidue { evidence } => evidence,
69 }
70 }
71
72 fn note(&self) -> String {
74 match self {
75 IosAssetAnomaly::DeletionTimeMissing { evidence } => format!(
76 "Photos asset {evidence} is flagged trashed (ZTRASHEDSTATE=1) but carries no \
77 ZTRASHEDDATE — the deletion time is unknown"
78 ),
79 IosAssetAnomaly::ExpiredResidue { evidence } => format!(
80 "Photos asset {evidence} is still in Recently Deleted past its ~30-day retention \
81 — consistent with the asset having outlived the nominal purge and remaining \
82 recoverable"
83 ),
84 }
85 }
86
87 fn to_finding(&self, source: Source) -> Finding {
89 let value = self.evidence().to_string();
90 Finding::observation(self.severity(), self.category(), self.code())
91 .note(self.note())
92 .source(source)
93 .evidence_item(Evidence {
94 field: "asset".to_string(),
95 value: value.clone(),
96 location: Some(Location::Path(value)),
97 })
98 .build()
99 }
100}
101
102#[must_use]
109pub fn audit_trashed_asset(asset: &TrashedAsset, now: DateTime<Utc>) -> Vec<Finding> {
110 let reference = asset
111 .filename
112 .clone()
113 .unwrap_or_else(|| format!("Z_PK {}", asset.rowid));
114
115 let mut anomalies = Vec::new();
116 match asset.trashed_at {
117 None => anomalies.push(IosAssetAnomaly::DeletionTimeMissing {
118 evidence: reference.clone(),
119 }),
120 Some(trashed_at) if now - trashed_at > Duration::days(RETENTION_DAYS) => {
121 anomalies.push(IosAssetAnomaly::ExpiredResidue {
122 evidence: reference.clone(),
123 });
124 }
125 Some(_) => {}
126 }
127
128 let source = Source {
129 analyzer: ANALYZER.to_string(),
130 scope: reference,
131 version: Some(env!("CARGO_PKG_VERSION").to_string()),
132 };
133 anomalies
134 .iter()
135 .map(|a| a.to_finding(source.clone()))
136 .collect()
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142 use chrono::TimeZone;
143
144 fn at(unix: i64) -> DateTime<Utc> {
145 Utc.timestamp_opt(unix, 0).single().unwrap()
146 }
147
148 fn asset(trashed_at: Option<DateTime<Utc>>) -> TrashedAsset {
149 TrashedAsset {
150 rowid: 7,
151 filename: Some("IMG_0001.HEIC".to_string()),
152 directory: Some("DCIM/100APPLE".to_string()),
153 trashed_at,
154 }
155 }
156
157 const DAY: i64 = 86_400;
158
159 #[test]
161 fn recent_trashed_asset_has_no_findings() {
162 let now = at(1_700_000_000);
163 let a = asset(Some(at(1_700_000_000 - 5 * DAY)));
164 assert!(audit_trashed_asset(&a, now).is_empty());
165 }
166
167 #[test]
169 fn missing_trashed_date_flagged() {
170 let findings = audit_trashed_asset(&asset(None), at(1_700_000_000));
171 assert_eq!(findings.len(), 1);
172 assert_eq!(findings[0].code, "TRASH-DELETION-TIME-MISSING");
173 assert_eq!(findings[0].category, Category::Integrity);
174 assert_eq!(findings[0].severity, Some(Severity::Medium));
175 assert_eq!(findings[0].evidence[0].value, "IMG_0001.HEIC");
176 }
177
178 #[test]
180 fn expired_asset_flagged() {
181 let now = at(1_700_000_000);
182 let a = asset(Some(at(1_700_000_000 - 40 * DAY)));
183 let findings = audit_trashed_asset(&a, now);
184 assert_eq!(findings.len(), 1);
185 assert_eq!(findings[0].code, "TRASH-EXPIRED-RESIDUE");
186 assert_eq!(findings[0].category, Category::Residue);
187 assert_eq!(findings[0].severity, Some(Severity::Low));
188 }
189
190 #[test]
192 fn within_window_not_expired() {
193 let now = at(1_700_000_000);
194 let a = asset(Some(at(1_700_000_000 - 20 * DAY)));
195 assert!(audit_trashed_asset(&a, now).is_empty());
196 }
197
198 #[test]
200 fn evidence_falls_back_to_rowid() {
201 let a = TrashedAsset {
202 rowid: 42,
203 filename: None,
204 directory: None,
205 trashed_at: None,
206 };
207 let findings = audit_trashed_asset(&a, at(1_700_000_000));
208 assert_eq!(findings[0].evidence[0].value, "Z_PK 42");
209 assert_eq!(findings[0].source.analyzer, ANALYZER);
210 assert_eq!(findings[0].source.scope, "Z_PK 42");
211 }
212}