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;
221 }
222 if visited.len() > budget {
223 return Err(DsStoreError::OutOfBounds {
224 what: "b-tree node budget",
225 });
226 }
227 let block = block_by_id(data, &offsets, node)?;
228 let mut c = Cursor::new(block);
229 let next_node = c.u32("node next pointer")?;
230 let record_count = c.u32("node record count")?;
231 for _ in 0..record_count {
232 if next_node != 0 {
234 let child = c.u32("internal child pointer")?;
235 stack.push(child);
236 }
237 read_record(&mut c, &mut put_back)?;
238 }
239 if next_node != 0 {
240 stack.push(next_node);
241 }
242 }
243
244 Ok(put_back
245 .into_iter()
246 .map(|(trash_name, (original_name, original_location))| PutBack {
247 trash_name,
248 original_name,
249 original_location,
250 })
251 .collect())
252}
253
254struct Cursor<'a> {
257 buf: &'a [u8],
258 pos: usize,
259}
260
261impl<'a> Cursor<'a> {
262 fn new(buf: &'a [u8]) -> Self {
263 Self { buf, pos: 0 }
264 }
265
266 fn take(&mut self, n: usize, what: &'static str) -> Result<&'a [u8], DsStoreError> {
267 let end = self
268 .pos
269 .checked_add(n)
270 .ok_or(DsStoreError::OutOfBounds { what })?;
271 let slice = self
272 .buf
273 .get(self.pos..end)
274 .ok_or(DsStoreError::OutOfBounds { what })?;
275 self.pos = end;
276 Ok(slice)
277 }
278
279 fn array4(&mut self, what: &'static str) -> Result<[u8; 4], DsStoreError> {
280 let bytes = self.take(4, what)?;
281 bytes
282 .try_into()
283 .map_err(|_| DsStoreError::OutOfBounds { what })
284 }
285
286 fn u32(&mut self, what: &'static str) -> Result<u32, DsStoreError> {
287 Ok(u32::from_be_bytes(self.array4(what)?))
288 }
289
290 fn u8(&mut self, what: &'static str) -> Result<u8, DsStoreError> {
291 Ok(self.take(1, what)?[0])
292 }
293
294 fn skip(&mut self, n: usize, what: &'static str) -> Result<(), DsStoreError> {
295 self.take(n, what).map(|_| ())
296 }
297}
298
299fn block_slice<'a>(
302 data: &'a [u8],
303 offset: u32,
304 size: u32,
305 what: &'static str,
306) -> Result<&'a [u8], DsStoreError> {
307 let start = (offset as usize)
308 .checked_add(4)
309 .ok_or(DsStoreError::OutOfBounds { what })?;
310 let end = start
311 .checked_add(size as usize)
312 .ok_or(DsStoreError::OutOfBounds { what })?;
313 data.get(start..end)
314 .ok_or(DsStoreError::OutOfBounds { what })
315}
316
317fn block_by_id<'a>(data: &'a [u8], offsets: &[u32], id: u32) -> Result<&'a [u8], DsStoreError> {
320 let addr = *offsets
321 .get(id as usize)
322 .ok_or(DsStoreError::OutOfBounds { what: "block id" })?;
323 let offset = addr & !0x1F;
324 let size = 1u32 << (addr & 0x1F);
325 block_slice(data, offset, size, "block")
326}
327
328fn read_record(
332 c: &mut Cursor,
333 out: &mut BTreeMap<String, (Option<String>, Option<String>)>,
334) -> Result<(), DsStoreError> {
335 let nlen = c.u32("record name length")? as usize;
336 let name_bytes = c.take(2 * nlen, "record name")?;
337 let filename = decode_utf16be(name_bytes);
338 let code = c.array4("record code")?;
339 let typecode = c.array4("record data type")?;
340 let value = read_value(c, typecode)?;
341 match &code {
342 b"ptbN" => out.entry(filename).or_default().0 = value,
343 b"ptbL" => out.entry(filename).or_default().1 = value,
344 _ => {}
345 }
346 Ok(())
347}
348
349fn read_value(c: &mut Cursor, typecode: [u8; 4]) -> Result<Option<String>, DsStoreError> {
352 match &typecode {
353 b"bool" => c.skip(1, "bool value").map(|()| None),
354 b"long" | b"shor" | b"type" => c.skip(4, "fixed value").map(|()| None),
355 b"comp" | b"dutc" => c.skip(8, "8-byte value").map(|()| None),
356 b"blob" => {
357 let vlen = c.u32("blob length")? as usize;
358 c.skip(vlen, "blob value").map(|()| None)
359 }
360 b"ustr" => {
361 let vlen = c.u32("ustr length")? as usize;
362 let bytes = c.take(2 * vlen, "ustr value")?;
363 Ok(Some(decode_utf16be(bytes)))
364 }
365 other => Err(DsStoreError::UnknownDataType { typecode: *other }),
366 }
367}
368
369fn decode_utf16be(bytes: &[u8]) -> String {
372 let units: Vec<u16> = bytes
373 .chunks_exact(2)
374 .map(|pair| u16::from_be_bytes([pair[0], pair[1]]))
375 .collect();
376 String::from_utf16_lossy(&units)
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 const FIXTURE: &[u8] = include_bytes!("../tests/data/putback.DS_Store");
386
387 fn get<'a>(records: &'a [PutBack], name: &str) -> &'a PutBack {
388 records.iter().find(|r| r.trash_name == name).unwrap()
389 }
390
391 #[test]
393 fn recovers_both_put_back_items() {
394 let records = parse_put_back(FIXTURE).unwrap();
395 assert_eq!(records.len(), 2);
396 }
397
398 #[test]
401 fn clean_item_decodes_to_oracle_values() {
402 let records = parse_put_back(FIXTURE).unwrap();
403 let r = get(&records, "Reference Letter.png");
404 assert_eq!(r.original_name.as_deref(), Some("Reference Letter.png"));
405 assert_eq!(
406 r.original_location.as_deref(),
407 Some("System/Volumes/Data/Users/4n6h4x0r/Downloads/")
408 );
409 assert_eq!(
410 r.original_path().as_deref(),
411 Some("/Users/4n6h4x0r/Downloads/Reference Letter.png")
412 );
413 }
414
415 #[test]
418 fn deduped_trash_name_diverges_from_original() {
419 let records = parse_put_back(FIXTURE).unwrap();
420 let r = get(&records, "report 2.pdf");
421 assert_eq!(r.original_name.as_deref(), Some("report.pdf"));
422 assert_eq!(
423 r.original_path().as_deref(),
424 Some("/Users/4n6h4x0r/Documents/report.pdf")
425 );
426 }
427
428 #[test]
430 fn bad_magic_is_error() {
431 let data = vec![0u8; 64];
432 assert!(matches!(
433 parse_put_back(&data).unwrap_err(),
434 DsStoreError::BadMagic { .. }
435 ));
436 }
437
438 #[test]
440 fn truncated_is_error_not_panic() {
441 assert!(parse_put_back(&FIXTURE[..20]).is_err());
442 assert!(parse_put_back(&[]).is_err());
443 }
444
445 #[test]
448 fn firmlink_normalisation() {
449 assert_eq!(
450 normalize_firmlink("System/Volumes/Data/Users/x/Desktop/"),
451 "/Users/x/Desktop/"
452 );
453 assert_eq!(normalize_firmlink("/Users/x/Desktop/"), "/Users/x/Desktop/");
454 }
455}