Skip to main content

trash_forensic/
ios.rs

1//! Forensic anomaly analysis for iOS Photos "Recently Deleted" assets recovered
2//! by [`trash_core::ios`] from `Photos.sqlite`.
3//!
4//! | Code | Category | Severity | Meaning |
5//! |---|---|---|---|
6//! | `TRASH-DELETION-TIME-MISSING` | Integrity | Medium | the asset is trashed (`ZTRASHEDSTATE=1`) but its `ZTRASHEDDATE` is NULL/zero |
7//! | `TRASH-EXPIRED-RESIDUE` | Residue | Low | the asset is still trashed past the ~30-day retention window |
8//!
9//! The retention 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, Duration, Utc};
14use forensicnomicon::report::{Category, Evidence, Finding, Location, Severity, Source};
15use trash_core::ios::TrashedAsset;
16
17use crate::ANALYZER;
18
19/// The iOS Photos Recently-Deleted retention window (~30 days).
20const RETENTION_DAYS: i64 = 30;
21
22/// An iOS Photos trashed-asset anomaly, with the offending asset reference.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum IosAssetAnomaly {
25    /// The asset is flagged trashed but carries no `ZTRASHEDDATE` — the deletion
26    /// time is unknown (broken/old-schema/tampered).
27    DeletionTimeMissing {
28        /// The asset reference (filename, or `Z_PK <rowid>`), surfaced verbatim.
29        evidence: String,
30    },
31    /// The asset is still in Recently Deleted past its retention window — it
32    /// outlived the nominal 30-day purge and remains recoverable.
33    ExpiredResidue {
34        /// The asset reference (filename, or `Z_PK <rowid>`), surfaced verbatim.
35        evidence: String,
36    },
37}
38
39impl IosAssetAnomaly {
40    /// Stable, scheme-prefixed machine code (a published contract).
41    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    /// Analytical lens for the anomaly.
49    fn category(&self) -> Category {
50        match self {
51            IosAssetAnomaly::DeletionTimeMissing { .. } => Category::Integrity,
52            IosAssetAnomaly::ExpiredResidue { .. } => Category::Residue,
53        }
54    }
55
56    /// Canonical severity for the anomaly.
57    fn severity(&self) -> Severity {
58        match self {
59            IosAssetAnomaly::DeletionTimeMissing { .. } => Severity::Medium,
60            IosAssetAnomaly::ExpiredResidue { .. } => Severity::Low,
61        }
62    }
63
64    /// The asset reference (filename or `Z_PK <rowid>`), common to both variants.
65    fn evidence(&self) -> &str {
66        match self {
67            IosAssetAnomaly::DeletionTimeMissing { evidence }
68            | IosAssetAnomaly::ExpiredResidue { evidence } => evidence,
69        }
70    }
71
72    /// Human-readable, consistent-with note.
73    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    /// Convert this anomaly into a canonical [`Finding`].
88    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/// Audit one trashed Photos asset. `now` is the reference time the retention
103/// window is measured against (the caller passes the acquisition/analysis time).
104///
105/// Flags a trashed asset with no deletion timestamp, and one still present past
106/// its ~30-day retention. A normally-trashed, recently-deleted asset with a
107/// timestamp yields no findings.
108#[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    /// A recently-trashed asset with a timestamp yields nothing.
160    #[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    /// A trashed asset with no `ZTRASHEDDATE` => Integrity/Medium missing time.
168    #[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    /// A trashed asset older than the 30-day window => Residue/Low expired residue.
179    #[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    /// An asset trashed exactly within the window is not yet expired.
191    #[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    /// Findings fall back to `Z_PK <rowid>` when no filename is recorded.
199    #[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}