1use chrono::{DateTime, TimeZone, Utc};
24use sqlite_core::{Database, Value};
25use thiserror::Error;
26
27const MAC_ABSOLUTE_EPOCH_OFFSET: i64 = 978_307_200;
29
30#[derive(Debug, Error, PartialEq, Eq)]
32pub enum IosError {
33 #[error("Photos.sqlite read failed: {0}")]
36 Sqlite(String),
37
38 #[error("no ZASSET/ZGENERICASSET table found; not a Photos.sqlite")]
40 NoAssetTable,
41
42 #[error("asset table has no ZTRASHEDSTATE column")]
44 NoTrashedColumn,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
50pub struct TrashedAsset {
51 pub rowid: i64,
53 pub filename: Option<String>,
55 pub directory: Option<String>,
57 pub trashed_at: Option<DateTime<Utc>>,
60}
61
62pub 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
74pub 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
89fn extract_trashed(db: &Database) -> Result<Vec<TrashedAsset>, IosError> {
92 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
119fn 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
136fn parse_column_names(sql: &str) -> Vec<String> {
139 let Some(open) = sql.find('(') else {
140 return Vec::new();
141 };
142 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
166fn 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
186fn column_index(columns: &[String], name: &str) -> Option<usize> {
188 columns.iter().position(|c| c.eq_ignore_ascii_case(name))
189}
190
191fn 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
199fn 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
209fn value_text(value: &Value) -> Option<&str> {
211 match value {
212 Value::Text(s) => Some(s),
213 _ => None,
214 }
215}
216
217fn value_int(value: &Value) -> Option<i64> {
219 match value {
220 Value::Integer(i) => Some(*i),
221 _ => None,
222 }
223}
224
225#[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 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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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}