1use std::collections::{BTreeMap, HashSet};
35
36use thiserror::Error;
37
38#[derive(Debug, Error, PartialEq, Eq)]
40pub enum DsStoreError {
41 #[error(".DS_Store truncated: {got} bytes, need at least {needed} for the Bud1 header")]
43 TruncatedHeader {
44 got: usize,
46 needed: usize,
48 },
49
50 #[error("bad .DS_Store magic: word0={word0:#010x}, magic={magic:?} (expected 1 / b\"Bud1\")")]
53 BadMagic {
54 word0: u32,
56 magic: [u8; 4],
58 },
59
60 #[error(".DS_Store root offsets differ: {first:#x} vs {second:#x}")]
63 RootOffsetMismatch {
64 first: u32,
66 second: u32,
68 },
69
70 #[error(".DS_Store read out of bounds while reading {what}")]
73 OutOfBounds {
74 what: &'static str,
76 },
77
78 #[error(".DS_Store has no DSDB B-tree entry")]
80 NoDsdb,
81
82 #[error("unknown .DS_Store record data type {typecode:?}")]
85 UnknownDataType {
86 typecode: [u8; 4],
88 },
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
94#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
95pub struct PutBack {
96 pub trash_name: String,
100 pub original_name: Option<String>,
102 pub original_location: Option<String>,
105}
106
107impl PutBack {
108 #[must_use]
112 pub fn original_path(&self) -> Option<String> {
113 let location = self.original_location.as_deref()?;
114 let name = self.original_name.as_deref()?;
115 let dir = normalize_firmlink(location);
116 Some(if dir.ends_with('/') {
117 format!("{dir}{name}")
118 } else {
119 format!("{dir}/{name}")
120 })
121 }
122}
123
124fn normalize_firmlink(location: &str) -> String {
128 let trimmed = location.strip_prefix('/').unwrap_or(location);
129 let rest = trimmed
130 .strip_prefix("System/Volumes/Data/")
131 .unwrap_or(trimmed);
132 format!("/{rest}")
133}
134
135pub fn parse_put_back(data: &[u8]) -> Result<Vec<PutBack>, DsStoreError> {
144 const HEADER_LEN: usize = 36;
146
147 if data.len() < HEADER_LEN {
148 return Err(DsStoreError::TruncatedHeader {
149 got: data.len(),
150 needed: HEADER_LEN,
151 });
152 }
153
154 let mut head = Cursor::new(data);
155 let word0 = head.u32("header word")?;
156 let magic = head.array4("magic")?;
157 if word0 != 1 || &magic != b"Bud1" {
158 return Err(DsStoreError::BadMagic { word0, magic });
159 }
160 let root_offset = head.u32("root offset")?;
161 let root_size = head.u32("root size")?;
162 let root_offset_copy = head.u32("root offset copy")?;
163 if root_offset != root_offset_copy {
164 return Err(DsStoreError::RootOffsetMismatch {
165 first: root_offset,
166 second: root_offset_copy,
167 });
168 }
169
170 let root = block_slice(data, root_offset, root_size, "root block")?;
173 let mut r = Cursor::new(root);
174 let count = r.u32("offset count")? as usize;
175 let _unknown = r.u32("offset count guard")?;
176 if count > root.len() / 4 {
179 return Err(DsStoreError::OutOfBounds {
180 what: "offset table count",
181 });
182 }
183 let padded = count.div_ceil(256) * 256;
184 let mut offsets = Vec::with_capacity(count);
185 for i in 0..padded {
186 let entry = r.u32("offset entry")?;
187 if i < count {
188 offsets.push(entry);
189 }
190 }
191
192 let toc_count = r.u32("toc count")?;
193 let mut dsdb: Option<u32> = None;
194 for _ in 0..toc_count {
195 let nlen = r.u8("toc name length")? as usize;
196 let name = r.take(nlen, "toc name")?;
197 let block_id = r.u32("toc block id")?;
198 if name == b"DSDB" {
199 dsdb = Some(block_id);
200 }
201 }
202 let dsdb = dsdb.ok_or(DsStoreError::NoDsdb)?;
203
204 let master = block_by_id(data, &offsets, dsdb)?;
206 let mut m = Cursor::new(master);
207 let root_node = m.u32("dsdb root node")?;
208 let _levels = m.u32("dsdb levels")?;
209 let _records = m.u32("dsdb record count")?;
210 let node_count = m.u32("dsdb node count")? as usize;
211
212 let mut put_back: BTreeMap<String, (Option<String>, Option<String>)> = BTreeMap::new();
215 let mut visited: HashSet<u32> = HashSet::new();
216 let mut stack = vec![root_node];
217 let budget = node_count.saturating_mul(2).max(1024);
218 while let Some(node) = stack.pop() {
219 if !visited.insert(node) {
220 continue; }
222 if visited.len() > budget {
225 #[rustfmt::skip]
226 let over_budget = DsStoreError::OutOfBounds { what: "node budget" }; return Err(over_budget); }
229 let block = block_by_id(data, &offsets, node)?;
230 let mut c = Cursor::new(block);
231 let next_node = c.u32("node next pointer")?;
232 let record_count = c.u32("node record count")?;
233 for _ in 0..record_count {
238 if next_node != 0 {
239 let child = c.u32("internal child pointer")?; stack.push(child); }
242 read_record(&mut c, &mut put_back)?;
243 }
244 if next_node != 0 {
245 stack.push(next_node); }
247 }
248
249 Ok(put_back
250 .into_iter()
251 .map(|(trash_name, (original_name, original_location))| PutBack {
252 trash_name,
253 original_name,
254 original_location,
255 })
256 .collect())
257}
258
259struct Cursor<'a> {
262 buf: &'a [u8],
263 pos: usize,
264}
265
266impl<'a> Cursor<'a> {
267 fn new(buf: &'a [u8]) -> Self {
268 Self { buf, pos: 0 }
269 }
270
271 fn take(&mut self, n: usize, what: &'static str) -> Result<&'a [u8], DsStoreError> {
272 let end = self
273 .pos
274 .checked_add(n)
275 .ok_or(DsStoreError::OutOfBounds { what })?;
276 let slice = self
277 .buf
278 .get(self.pos..end)
279 .ok_or(DsStoreError::OutOfBounds { what })?;
280 self.pos = end;
281 Ok(slice)
282 }
283
284 fn array4(&mut self, what: &'static str) -> Result<[u8; 4], DsStoreError> {
285 let bytes = self.take(4, what)?;
286 bytes
287 .try_into()
288 .map_err(|_| DsStoreError::OutOfBounds { what })
289 }
290
291 fn u32(&mut self, what: &'static str) -> Result<u32, DsStoreError> {
292 Ok(u32::from_be_bytes(self.array4(what)?))
293 }
294
295 fn u8(&mut self, what: &'static str) -> Result<u8, DsStoreError> {
296 Ok(self.take(1, what)?[0])
297 }
298
299 fn skip(&mut self, n: usize, what: &'static str) -> Result<(), DsStoreError> {
300 self.take(n, what).map(|_| ())
301 }
302}
303
304fn block_slice<'a>(
307 data: &'a [u8],
308 offset: u32,
309 size: u32,
310 what: &'static str,
311) -> Result<&'a [u8], DsStoreError> {
312 let start = (offset as usize)
313 .checked_add(4)
314 .ok_or(DsStoreError::OutOfBounds { what })?;
315 let end = start
316 .checked_add(size as usize)
317 .ok_or(DsStoreError::OutOfBounds { what })?;
318 data.get(start..end)
319 .ok_or(DsStoreError::OutOfBounds { what })
320}
321
322fn block_by_id<'a>(data: &'a [u8], offsets: &[u32], id: u32) -> Result<&'a [u8], DsStoreError> {
325 let addr = *offsets
326 .get(id as usize)
327 .ok_or(DsStoreError::OutOfBounds { what: "block id" })?;
328 let offset = addr & !0x1F;
329 let size = 1u32 << (addr & 0x1F);
330 block_slice(data, offset, size, "block")
331}
332
333fn read_record(
337 c: &mut Cursor,
338 out: &mut BTreeMap<String, (Option<String>, Option<String>)>,
339) -> Result<(), DsStoreError> {
340 let nlen = c.u32("record name length")? as usize;
341 let name_bytes = c.take(2 * nlen, "record name")?;
342 let filename = decode_utf16be(name_bytes);
343 let code = c.array4("record code")?;
344 let typecode = c.array4("record data type")?;
345 let value = read_value(c, typecode)?;
346 match &code {
347 b"ptbN" => out.entry(filename).or_default().0 = value,
348 b"ptbL" => out.entry(filename).or_default().1 = value,
349 _ => {}
350 }
351 Ok(())
352}
353
354fn read_value(c: &mut Cursor, typecode: [u8; 4]) -> Result<Option<String>, DsStoreError> {
357 match &typecode {
358 b"bool" => c.skip(1, "bool value").map(|()| None),
359 b"long" | b"shor" | b"type" => c.skip(4, "fixed value").map(|()| None),
360 b"comp" | b"dutc" => c.skip(8, "8-byte value").map(|()| None),
361 b"blob" => {
362 let vlen = c.u32("blob length")? as usize;
363 c.skip(vlen, "blob value").map(|()| None)
364 }
365 b"ustr" => {
366 let vlen = c.u32("ustr length")? as usize;
367 let bytes = c.take(2 * vlen, "ustr value")?;
368 Ok(Some(decode_utf16be(bytes)))
369 }
370 other => Err(DsStoreError::UnknownDataType { typecode: *other }), }
372}
373
374fn decode_utf16be(bytes: &[u8]) -> String {
377 let units: Vec<u16> = bytes
378 .chunks_exact(2)
379 .map(|pair| u16::from_be_bytes([pair[0], pair[1]]))
380 .collect();
381 String::from_utf16_lossy(&units)
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 const FIXTURE: &[u8] = include_bytes!("../tests/data/putback.DS_Store");
391
392 fn get<'a>(records: &'a [PutBack], name: &str) -> &'a PutBack {
393 records.iter().find(|r| r.trash_name == name).unwrap()
394 }
395
396 #[test]
398 fn recovers_both_put_back_items() {
399 let records = parse_put_back(FIXTURE).unwrap();
400 assert_eq!(records.len(), 2);
401 }
402
403 #[test]
406 fn clean_item_decodes_to_oracle_values() {
407 let records = parse_put_back(FIXTURE).unwrap();
408 let r = get(&records, "Reference Letter.png");
409 assert_eq!(r.original_name.as_deref(), Some("Reference Letter.png"));
410 assert_eq!(
411 r.original_location.as_deref(),
412 Some("System/Volumes/Data/Users/4n6h4x0r/Downloads/")
413 );
414 assert_eq!(
415 r.original_path().as_deref(),
416 Some("/Users/4n6h4x0r/Downloads/Reference Letter.png")
417 );
418 }
419
420 #[test]
423 fn deduped_trash_name_diverges_from_original() {
424 let records = parse_put_back(FIXTURE).unwrap();
425 let r = get(&records, "report 2.pdf");
426 assert_eq!(r.original_name.as_deref(), Some("report.pdf"));
427 assert_eq!(
428 r.original_path().as_deref(),
429 Some("/Users/4n6h4x0r/Documents/report.pdf")
430 );
431 }
432
433 #[test]
435 fn bad_magic_is_error() {
436 let data = vec![0u8; 64];
437 assert!(matches!(
438 parse_put_back(&data).unwrap_err(),
439 DsStoreError::BadMagic { .. }
440 ));
441 }
442
443 #[test]
445 fn truncated_is_error_not_panic() {
446 assert!(parse_put_back(&FIXTURE[..20]).is_err());
447 assert!(parse_put_back(&[]).is_err());
448 }
449
450 #[test]
453 fn firmlink_normalisation() {
454 assert_eq!(
455 normalize_firmlink("System/Volumes/Data/Users/x/Desktop/"),
456 "/Users/x/Desktop/"
457 );
458 assert_eq!(normalize_firmlink("/Users/x/Desktop/"), "/Users/x/Desktop/");
459 }
460
461 #[test]
465 fn skips_all_non_ustr_data_types() {
466 const TYPES: &[u8] = include_bytes!("../tests/data/putback_types.DS_Store");
467 let records = parse_put_back(TYPES).unwrap();
468 assert_eq!(records.len(), 2);
469 assert_eq!(
470 get(&records, "a.jpg").original_name.as_deref(),
471 Some("a.jpg")
472 );
473 }
474
475 #[test]
477 fn original_path_adds_separator_when_missing() {
478 let pb = PutBack {
479 trash_name: "x".into(),
480 original_name: Some("file.txt".into()),
481 original_location: Some("System/Volumes/Data/Users/x/Desktop".into()), };
483 assert_eq!(
484 pb.original_path().as_deref(),
485 Some("/Users/x/Desktop/file.txt")
486 );
487 }
488
489 #[test]
492 fn mismatched_root_offsets_is_error() {
493 let mut data = FIXTURE.to_vec();
494 data[19] ^= 0xFF;
497 assert!(matches!(
498 parse_put_back(&data).unwrap_err(),
499 DsStoreError::RootOffsetMismatch { .. }
500 ));
501 }
502
503 #[test]
506 fn oversized_offset_count_is_error() {
507 let mut data = FIXTURE.to_vec();
508 let root_offset = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
511 let count_pos = root_offset + 4;
512 data[count_pos..count_pos + 4].copy_from_slice(&0x00FF_FFFFu32.to_be_bytes());
513 assert!(matches!(
514 parse_put_back(&data).unwrap_err(),
515 DsStoreError::OutOfBounds { .. }
516 ));
517 }
518}