Skip to main content

trash_core/
ios.rs

1//! Read-only reader for the iOS **Photos "Recently Deleted"** trash state in
2//! `Photos.sqlite`.
3//!
4//! iOS has no filesystem-level recycle bin; "Recently Deleted" is app-level
5//! `SQLite` soft-delete. In the Photos library database
6//! (`/private/var/mobile/Media/PhotoData/Photos.sqlite`) a trashed photo or video
7//! keeps its row in the `ZASSET` table (named `ZGENERICASSET` on iOS 8–13) with:
8//!
9//! * `ZTRASHEDSTATE = 1` — the asset is in Recently Deleted, and
10//! * `ZTRASHEDDATE` — when it was trashed, in **Mac Absolute Time** (the Cocoa /
11//!   Core Data epoch, 2001-01-01; add 978 307 200 s to get a Unix timestamp).
12//!
13//! The asset survives a ~30-day retention window before the row is purged.
14//! Recovery of *purged* rows (WAL, freelist, carving) is out of scope here — it is
15//! the job of the underlying [`sqlite_core`] engine, which this module reuses for
16//! all `SQLite` access (no `libsqlite3`).
17//!
18//! Sources: The Forensic Scooter, "Photos.sqlite Query Documentation"
19//! (<https://theforensicscooter.com/2022/05/02/photos-sqlite-query-documentation-notable-artifacts/>);
20//! kacos2000 `Photos_sqlite.sql`. This module reports the live trashed rows;
21//! `trash-forensic` grades them.
22
23use chrono::{DateTime, TimeZone, Utc};
24use sqlite_core::{Database, Value};
25use thiserror::Error;
26
27/// Seconds between the Mac Absolute Time epoch (2001-01-01) and the Unix epoch.
28const MAC_ABSOLUTE_EPOCH_OFFSET: i64 = 978_307_200;
29
30/// Errors returned while reading trashed assets from a `Photos.sqlite`.
31#[derive(Debug, Error, PartialEq, Eq)]
32pub enum IosError {
33    /// The underlying `SQLite` engine could not open or read the database. Carries
34    /// the engine's message.
35    #[error("Photos.sqlite read failed: {0}")]
36    Sqlite(String),
37
38    /// Neither `ZASSET` nor `ZGENERICASSET` is present — not a Photos library DB.
39    #[error("no ZASSET/ZGENERICASSET table found; not a Photos.sqlite")]
40    NoAssetTable,
41
42    /// The asset table has no `ZTRASHEDSTATE` column — an unexpected schema.
43    #[error("asset table has no ZTRASHEDSTATE column")]
44    NoTrashedColumn,
45}
46
47/// A single iOS Photos asset currently in "Recently Deleted".
48#[derive(Debug, Clone, PartialEq, Eq)]
49#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
50pub struct TrashedAsset {
51    /// The asset's primary key (`Z_PK` / row id).
52    pub rowid: i64,
53    /// The on-disk filename (`ZFILENAME`), if recorded.
54    pub filename: Option<String>,
55    /// The asset's directory within the library (`ZDIRECTORY`), if recorded.
56    pub directory: Option<String>,
57    /// When the asset was trashed (`ZTRASHEDDATE` converted to UTC), or `None`
58    /// when the column is NULL/zero.
59    pub trashed_at: Option<DateTime<Utc>>,
60}
61
62/// Read every currently-trashed (`ZTRASHEDSTATE = 1`) asset from the bytes of a
63/// `Photos.sqlite`, sorted by row id.
64///
65/// # Errors
66///
67/// Returns [`IosError`] when the bytes are not a readable `SQLite` database, carry
68/// no `ZASSET`/`ZGENERICASSET` table, or that table lacks `ZTRASHEDSTATE`.
69pub fn parse_trashed_assets(db_bytes: Vec<u8>) -> Result<Vec<TrashedAsset>, IosError> {
70    let db = Database::open(db_bytes).map_err(|e| IosError::Sqlite(format!("{e:?}")))?;
71    extract_trashed(&db)
72}
73
74/// As [`parse_trashed_assets`], but layering a `Photos.sqlite-wal` over the main
75/// database so the trashed/restored state committed only in the WAL is seen.
76///
77/// # Errors
78///
79/// As [`parse_trashed_assets`].
80pub fn parse_trashed_assets_with_wal(
81    db_bytes: Vec<u8>,
82    wal: &[u8],
83) -> Result<Vec<TrashedAsset>, IosError> {
84    let db =
85        Database::open_with_wal(db_bytes, wal).map_err(|e| IosError::Sqlite(format!("{e:?}")))?;
86    extract_trashed(&db)
87}
88
89/// Find the `ZASSET`/`ZGENERICASSET` table, map the trashed columns by name, and
90/// return the live rows whose `ZTRASHEDSTATE` is 1.
91fn extract_trashed(db: &Database) -> Result<Vec<TrashedAsset>, IosError> {
92    // `sqlite_master` rows are `[type, name, tbl_name, rootpage, sql]`.
93    let schema = db.live_schema_rows();
94    let (root_page, sql) = find_asset_table(&schema).ok_or(IosError::NoAssetTable)?;
95    let columns = parse_column_names(&sql);
96    let idx_state = column_index(&columns, "ZTRASHEDSTATE").ok_or(IosError::NoTrashedColumn)?;
97    let idx_date = column_index(&columns, "ZTRASHEDDATE");
98    let idx_filename = column_index(&columns, "ZFILENAME");
99    let idx_directory = column_index(&columns, "ZDIRECTORY");
100
101    let rows = db
102        .read_table(root_page, columns.len())
103        .map_err(|e| IosError::Sqlite(format!("{e:?}")))?;
104
105    let mut assets: Vec<TrashedAsset> = rows
106        .into_iter()
107        .filter(|row| matches!(row.values.get(idx_state), Some(Value::Integer(1))))
108        .map(|row| TrashedAsset {
109            rowid: row.rowid,
110            filename: idx_filename.and_then(|i| text_at(&row.values, i)),
111            directory: idx_directory.and_then(|i| text_at(&row.values, i)),
112            trashed_at: idx_date.and_then(|i| date_at(&row.values, i)),
113        })
114        .collect();
115    assets.sort_by_key(|a| a.rowid);
116    Ok(assets)
117}
118
119/// Locate the Photos asset table in the schema rows, returning its root page and
120/// `CREATE` SQL.
121fn find_asset_table(schema: &[Vec<Value>]) -> Option<(u32, String)> {
122    schema.iter().find_map(|row| {
123        if row.first().and_then(value_text) != Some("table") {
124            return None;
125        }
126        let name = row.get(1).and_then(value_text)?;
127        if name != "ZASSET" && name != "ZGENERICASSET" {
128            return None;
129        }
130        let root = row.get(3).and_then(value_int)?;
131        let sql = row.get(4).and_then(value_text)?;
132        Some((u32::try_from(root).ok()?, sql.to_string()))
133    })
134}
135
136/// Extract the ordered column names from a `CREATE TABLE` statement, skipping
137/// table-level constraints (`PRIMARY KEY(...)`, `FOREIGN KEY`, …).
138fn parse_column_names(sql: &str) -> Vec<String> {
139    let Some(open) = sql.find('(') else {
140        return Vec::new();
141    };
142    // The column list lies between the first `(` and the final `)`.
143    let inner = &sql[open + 1..];
144    let body = inner.rfind(')').map_or(inner, |close| &inner[..close]);
145
146    let mut columns = Vec::new();
147    for part in split_top_level(body) {
148        let Some(first) = part.split_whitespace().next() else {
149            continue;
150        };
151        let name = first.trim_matches(|c| matches!(c, '"' | '`' | '[' | ']' | '\''));
152        if name.is_empty() {
153            continue;
154        }
155        let is_constraint = matches!(
156            name.to_ascii_uppercase().as_str(),
157            "PRIMARY" | "FOREIGN" | "UNIQUE" | "CHECK" | "CONSTRAINT" | "KEY"
158        );
159        if !is_constraint {
160            columns.push(name.to_string());
161        }
162    }
163    columns
164}
165
166/// Split a string on commas that sit at parenthesis depth zero.
167fn split_top_level(s: &str) -> Vec<&str> {
168    let mut parts = Vec::new();
169    let mut depth: i32 = 0;
170    let mut start = 0usize;
171    for (i, ch) in s.char_indices() {
172        match ch {
173            '(' => depth += 1,
174            ')' => depth -= 1,
175            ',' if depth == 0 => {
176                parts.push(&s[start..i]);
177                start = i + 1;
178            }
179            _ => {}
180        }
181    }
182    parts.push(&s[start..]);
183    parts
184}
185
186/// Case-insensitive column-name lookup.
187fn column_index(columns: &[String], name: &str) -> Option<usize> {
188    columns.iter().position(|c| c.eq_ignore_ascii_case(name))
189}
190
191/// The text value at `index`, or `None` if absent or not text.
192fn text_at(values: &[Value], index: usize) -> Option<String> {
193    match values.get(index) {
194        Some(Value::Text(s)) => Some(s.clone()),
195        _ => None,
196    }
197}
198
199/// The Mac-Absolute-Time value at `index` (`REAL` or `INTEGER`) as UTC.
200fn date_at(values: &[Value], index: usize) -> Option<DateTime<Utc>> {
201    let seconds = match values.get(index)? {
202        Value::Real(r) => *r,
203        Value::Integer(i) => *i as f64,
204        _ => return None,
205    };
206    mac_absolute_to_utc(seconds)
207}
208
209/// Borrow a [`Value`] as text.
210fn value_text(value: &Value) -> Option<&str> {
211    match value {
212        Value::Text(s) => Some(s),
213        _ => None,
214    }
215}
216
217/// Borrow a [`Value`] as an integer.
218fn value_int(value: &Value) -> Option<i64> {
219    match value {
220        Value::Integer(i) => Some(*i),
221        _ => None,
222    }
223}
224
225/// Convert a Mac Absolute Time value (Cocoa epoch seconds, possibly fractional)
226/// to a UTC datetime. `None` for zero or out-of-range values.
227#[must_use]
228fn mac_absolute_to_utc(seconds: f64) -> Option<DateTime<Utc>> {
229    if seconds == 0.0 {
230        return None;
231    }
232    let whole = seconds.trunc() as i64;
233    let nanos = (seconds.fract() * 1_000_000_000.0).round() as u32;
234    let unix = whole.checked_add(MAC_ABSOLUTE_EPOCH_OFFSET)?;
235    Utc.timestamp_opt(unix, nanos).single()
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    /// A real `Photos.sqlite` (Python `sqlite3`-minted) with two trashed assets
243    /// and one live asset; oracle decode by the `sqlite3` CLI.
244    const FIXTURE: &[u8] = include_bytes!("../tests/data/Photos.sqlite");
245
246    fn at(unix: i64) -> DateTime<Utc> {
247        Utc.timestamp_opt(unix, 0).single().unwrap()
248    }
249
250    /// Only the two `ZTRASHEDSTATE = 1` assets are returned; the live one is not.
251    #[test]
252    fn returns_only_trashed_assets() {
253        let assets = parse_trashed_assets(FIXTURE.to_vec()).unwrap();
254        assert_eq!(assets.len(), 2);
255        assert_eq!(
256            assets.iter().map(|a| a.rowid).collect::<Vec<_>>(),
257            vec![1, 2]
258        );
259    }
260
261    /// Field decode matches the sqlite3 oracle (filename, dir, Mac-Absolute date).
262    #[test]
263    fn decodes_fields_to_oracle_values() {
264        let assets = parse_trashed_assets(FIXTURE.to_vec()).unwrap();
265        let a = &assets[0];
266        assert_eq!(a.rowid, 1);
267        assert_eq!(a.filename.as_deref(), Some("IMG_0001.HEIC"));
268        assert_eq!(a.directory.as_deref(), Some("DCIM/100APPLE"));
269        // 700000000 (Mac Absolute) + 978307200 = 1678307200 == 2023-03-08 20:26:40Z
270        assert_eq!(a.trashed_at, Some(at(1_678_307_200)));
271        assert_eq!(assets[1].trashed_at, Some(at(701_234_567 + 978_307_200)));
272    }
273
274    /// Non-`SQLite` bytes are a typed error, not a panic.
275    #[test]
276    fn invalid_database_is_error() {
277        assert!(parse_trashed_assets(vec![0u8; 100]).is_err());
278        assert!(parse_trashed_assets(Vec::new()).is_err());
279    }
280
281    /// Mac Absolute Time conversion: zero -> None, a known value -> the oracle UTC.
282    #[test]
283    fn mac_absolute_conversion() {
284        assert_eq!(mac_absolute_to_utc(0.0), None);
285        assert_eq!(mac_absolute_to_utc(700_000_000.0), Some(at(1_678_307_200)));
286    }
287
288    /// The WAL-overlay entry point with an empty overlay decodes the same rows.
289    #[test]
290    fn with_empty_wal_matches_plain() {
291        let assets = parse_trashed_assets_with_wal(FIXTURE.to_vec(), &[]).unwrap();
292        assert_eq!(assets.len(), 2);
293    }
294
295    /// `find_asset_table` skips a non-table row and a non-matching table name.
296    #[test]
297    fn find_asset_table_skips_non_matches() {
298        let row = |t: &str, n: &str, root: i64, sql: &str| {
299            vec![
300                Value::Text(t.into()),
301                Value::Text(n.into()),
302                Value::Text(n.into()),
303                Value::Integer(root),
304                Value::Text(sql.into()),
305            ]
306        };
307        let schema = vec![
308            row("index", "idx", 9, "CREATE INDEX idx ON ZASSET(x)"),
309            row("table", "Other", 3, "CREATE TABLE Other ( a )"),
310            row(
311                "table",
312                "ZASSET",
313                4,
314                "CREATE TABLE ZASSET ( ZTRASHEDSTATE INTEGER )",
315            ),
316        ];
317        let (root, _sql) = find_asset_table(&schema).unwrap();
318        assert_eq!(root, 4);
319    }
320
321    /// `parse_column_names` handles missing parens, nested parens (sized types),
322    /// empty/quoted-empty parts, and table-level constraints.
323    #[test]
324    fn parse_column_names_edge_ddl() {
325        assert!(parse_column_names("CREATE TABLE x").is_empty());
326        let cols = parse_column_names(
327            "CREATE TABLE x ( a INTEGER, '' TEXT, b VARCHAR(50), , PRIMARY KEY(a) )",
328        );
329        assert_eq!(cols, vec!["a".to_string(), "b".to_string()]);
330    }
331
332    /// Value accessors return `None` for the wrong variant; `date_at` reads `REAL`.
333    #[test]
334    fn value_accessors_reject_wrong_type() {
335        assert_eq!(value_text(&Value::Integer(1)), None);
336        assert_eq!(value_int(&Value::Text("x".into())), None);
337        assert_eq!(text_at(&[Value::Null], 0), None);
338        assert_eq!(
339            date_at(&[Value::Real(700_000_000.0)], 0),
340            Some(at(1_678_307_200))
341        );
342        assert_eq!(date_at(&[Value::Null], 0), None);
343    }
344}