use std::collections::{BTreeMap, HashSet};
use thiserror::Error;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum DsStoreError {
#[error(".DS_Store truncated: {got} bytes, need at least {needed} for the Bud1 header")]
TruncatedHeader {
got: usize,
needed: usize,
},
#[error("bad .DS_Store magic: word0={word0:#010x}, magic={magic:?} (expected 1 / b\"Bud1\")")]
BadMagic {
word0: u32,
magic: [u8; 4],
},
#[error(".DS_Store root offsets differ: {first:#x} vs {second:#x}")]
RootOffsetMismatch {
first: u32,
second: u32,
},
#[error(".DS_Store read out of bounds while reading {what}")]
OutOfBounds {
what: &'static str,
},
#[error(".DS_Store has no DSDB B-tree entry")]
NoDsdb,
#[error("unknown .DS_Store record data type {typecode:?}")]
UnknownDataType {
typecode: [u8; 4],
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PutBack {
pub trash_name: String,
pub original_name: Option<String>,
pub original_location: Option<String>,
}
impl PutBack {
#[must_use]
pub fn original_path(&self) -> Option<String> {
let location = self.original_location.as_deref()?;
let name = self.original_name.as_deref()?;
let dir = normalize_firmlink(location);
Some(if dir.ends_with('/') {
format!("{dir}{name}")
} else {
format!("{dir}/{name}")
})
}
}
fn normalize_firmlink(location: &str) -> String {
let trimmed = location.strip_prefix('/').unwrap_or(location);
let rest = trimmed
.strip_prefix("System/Volumes/Data/")
.unwrap_or(trimmed);
format!("/{rest}")
}
pub fn parse_put_back(data: &[u8]) -> Result<Vec<PutBack>, DsStoreError> {
const HEADER_LEN: usize = 36;
if data.len() < HEADER_LEN {
return Err(DsStoreError::TruncatedHeader {
got: data.len(),
needed: HEADER_LEN,
});
}
let mut head = Cursor::new(data);
let word0 = head.u32("header word")?;
let magic = head.array4("magic")?;
if word0 != 1 || &magic != b"Bud1" {
return Err(DsStoreError::BadMagic { word0, magic });
}
let root_offset = head.u32("root offset")?;
let root_size = head.u32("root size")?;
let root_offset_copy = head.u32("root offset copy")?;
if root_offset != root_offset_copy {
return Err(DsStoreError::RootOffsetMismatch {
first: root_offset,
second: root_offset_copy,
});
}
let root = block_slice(data, root_offset, root_size, "root block")?;
let mut r = Cursor::new(root);
let count = r.u32("offset count")? as usize;
let _unknown = r.u32("offset count guard")?;
if count > root.len() / 4 {
return Err(DsStoreError::OutOfBounds {
what: "offset table count",
});
}
let padded = count.div_ceil(256) * 256;
let mut offsets = Vec::with_capacity(count);
for i in 0..padded {
let entry = r.u32("offset entry")?;
if i < count {
offsets.push(entry);
}
}
let toc_count = r.u32("toc count")?;
let mut dsdb: Option<u32> = None;
for _ in 0..toc_count {
let nlen = r.u8("toc name length")? as usize;
let name = r.take(nlen, "toc name")?;
let block_id = r.u32("toc block id")?;
if name == b"DSDB" {
dsdb = Some(block_id);
}
}
let dsdb = dsdb.ok_or(DsStoreError::NoDsdb)?;
let master = block_by_id(data, &offsets, dsdb)?;
let mut m = Cursor::new(master);
let root_node = m.u32("dsdb root node")?;
let _levels = m.u32("dsdb levels")?;
let _records = m.u32("dsdb record count")?;
let node_count = m.u32("dsdb node count")? as usize;
let mut put_back: BTreeMap<String, (Option<String>, Option<String>)> = BTreeMap::new();
let mut visited: HashSet<u32> = HashSet::new();
let mut stack = vec![root_node];
let budget = node_count.saturating_mul(2).max(1024);
while let Some(node) = stack.pop() {
if !visited.insert(node) {
continue; }
if visited.len() > budget {
#[rustfmt::skip]
let over_budget = DsStoreError::OutOfBounds { what: "node budget" }; return Err(over_budget); }
let block = block_by_id(data, &offsets, node)?;
let mut c = Cursor::new(block);
let next_node = c.u32("node next pointer")?;
let record_count = c.u32("node record count")?;
for _ in 0..record_count {
if next_node != 0 {
let child = c.u32("internal child pointer")?; stack.push(child); }
read_record(&mut c, &mut put_back)?;
}
if next_node != 0 {
stack.push(next_node); }
}
Ok(put_back
.into_iter()
.map(|(trash_name, (original_name, original_location))| PutBack {
trash_name,
original_name,
original_location,
})
.collect())
}
struct Cursor<'a> {
buf: &'a [u8],
pos: usize,
}
impl<'a> Cursor<'a> {
fn new(buf: &'a [u8]) -> Self {
Self { buf, pos: 0 }
}
fn take(&mut self, n: usize, what: &'static str) -> Result<&'a [u8], DsStoreError> {
let end = self
.pos
.checked_add(n)
.ok_or(DsStoreError::OutOfBounds { what })?;
let slice = self
.buf
.get(self.pos..end)
.ok_or(DsStoreError::OutOfBounds { what })?;
self.pos = end;
Ok(slice)
}
fn array4(&mut self, what: &'static str) -> Result<[u8; 4], DsStoreError> {
let bytes = self.take(4, what)?;
bytes
.try_into()
.map_err(|_| DsStoreError::OutOfBounds { what })
}
fn u32(&mut self, what: &'static str) -> Result<u32, DsStoreError> {
Ok(u32::from_be_bytes(self.array4(what)?))
}
fn u8(&mut self, what: &'static str) -> Result<u8, DsStoreError> {
Ok(self.take(1, what)?[0])
}
fn skip(&mut self, n: usize, what: &'static str) -> Result<(), DsStoreError> {
self.take(n, what).map(|_| ())
}
}
fn block_slice<'a>(
data: &'a [u8],
offset: u32,
size: u32,
what: &'static str,
) -> Result<&'a [u8], DsStoreError> {
let start = (offset as usize)
.checked_add(4)
.ok_or(DsStoreError::OutOfBounds { what })?;
let end = start
.checked_add(size as usize)
.ok_or(DsStoreError::OutOfBounds { what })?;
data.get(start..end)
.ok_or(DsStoreError::OutOfBounds { what })
}
fn block_by_id<'a>(data: &'a [u8], offsets: &[u32], id: u32) -> Result<&'a [u8], DsStoreError> {
let addr = *offsets
.get(id as usize)
.ok_or(DsStoreError::OutOfBounds { what: "block id" })?;
let offset = addr & !0x1F;
let size = 1u32 << (addr & 0x1F);
block_slice(data, offset, size, "block")
}
fn read_record(
c: &mut Cursor,
out: &mut BTreeMap<String, (Option<String>, Option<String>)>,
) -> Result<(), DsStoreError> {
let nlen = c.u32("record name length")? as usize;
let name_bytes = c.take(2 * nlen, "record name")?;
let filename = decode_utf16be(name_bytes);
let code = c.array4("record code")?;
let typecode = c.array4("record data type")?;
let value = read_value(c, typecode)?;
match &code {
b"ptbN" => out.entry(filename).or_default().0 = value,
b"ptbL" => out.entry(filename).or_default().1 = value,
_ => {}
}
Ok(())
}
fn read_value(c: &mut Cursor, typecode: [u8; 4]) -> Result<Option<String>, DsStoreError> {
match &typecode {
b"bool" => c.skip(1, "bool value").map(|()| None),
b"long" | b"shor" | b"type" => c.skip(4, "fixed value").map(|()| None),
b"comp" | b"dutc" => c.skip(8, "8-byte value").map(|()| None),
b"blob" => {
let vlen = c.u32("blob length")? as usize;
c.skip(vlen, "blob value").map(|()| None)
}
b"ustr" => {
let vlen = c.u32("ustr length")? as usize;
let bytes = c.take(2 * vlen, "ustr value")?;
Ok(Some(decode_utf16be(bytes)))
}
other => Err(DsStoreError::UnknownDataType { typecode: *other }), }
}
fn decode_utf16be(bytes: &[u8]) -> String {
let units: Vec<u16> = bytes
.chunks_exact(2)
.map(|pair| u16::from_be_bytes([pair[0], pair[1]]))
.collect();
String::from_utf16_lossy(&units)
}
#[cfg(test)]
mod tests {
use super::*;
const FIXTURE: &[u8] = include_bytes!("../tests/data/putback.DS_Store");
fn get<'a>(records: &'a [PutBack], name: &str) -> &'a PutBack {
records.iter().find(|r| r.trash_name == name).unwrap()
}
#[test]
fn recovers_both_put_back_items() {
let records = parse_put_back(FIXTURE).unwrap();
assert_eq!(records.len(), 2);
}
#[test]
fn clean_item_decodes_to_oracle_values() {
let records = parse_put_back(FIXTURE).unwrap();
let r = get(&records, "Reference Letter.png");
assert_eq!(r.original_name.as_deref(), Some("Reference Letter.png"));
assert_eq!(
r.original_location.as_deref(),
Some("System/Volumes/Data/Users/4n6h4x0r/Downloads/")
);
assert_eq!(
r.original_path().as_deref(),
Some("/Users/4n6h4x0r/Downloads/Reference Letter.png")
);
}
#[test]
fn deduped_trash_name_diverges_from_original() {
let records = parse_put_back(FIXTURE).unwrap();
let r = get(&records, "report 2.pdf");
assert_eq!(r.original_name.as_deref(), Some("report.pdf"));
assert_eq!(
r.original_path().as_deref(),
Some("/Users/4n6h4x0r/Documents/report.pdf")
);
}
#[test]
fn bad_magic_is_error() {
let data = vec![0u8; 64];
assert!(matches!(
parse_put_back(&data).unwrap_err(),
DsStoreError::BadMagic { .. }
));
}
#[test]
fn truncated_is_error_not_panic() {
assert!(parse_put_back(&FIXTURE[..20]).is_err());
assert!(parse_put_back(&[]).is_err());
}
#[test]
fn firmlink_normalisation() {
assert_eq!(
normalize_firmlink("System/Volumes/Data/Users/x/Desktop/"),
"/Users/x/Desktop/"
);
assert_eq!(normalize_firmlink("/Users/x/Desktop/"), "/Users/x/Desktop/");
}
#[test]
fn skips_all_non_ustr_data_types() {
const TYPES: &[u8] = include_bytes!("../tests/data/putback_types.DS_Store");
let records = parse_put_back(TYPES).unwrap();
assert_eq!(records.len(), 2);
assert_eq!(
get(&records, "a.jpg").original_name.as_deref(),
Some("a.jpg")
);
}
#[test]
fn original_path_adds_separator_when_missing() {
let pb = PutBack {
trash_name: "x".into(),
original_name: Some("file.txt".into()),
original_location: Some("System/Volumes/Data/Users/x/Desktop".into()), };
assert_eq!(
pb.original_path().as_deref(),
Some("/Users/x/Desktop/file.txt")
);
}
#[test]
fn mismatched_root_offsets_is_error() {
let mut data = FIXTURE.to_vec();
data[19] ^= 0xFF;
assert!(matches!(
parse_put_back(&data).unwrap_err(),
DsStoreError::RootOffsetMismatch { .. }
));
}
#[test]
fn oversized_offset_count_is_error() {
let mut data = FIXTURE.to_vec();
let root_offset = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
let count_pos = root_offset + 4;
data[count_pos..count_pos + 4].copy_from_slice(&0x00FF_FFFFu32.to_be_bytes());
assert!(matches!(
parse_put_back(&data).unwrap_err(),
DsStoreError::OutOfBounds { .. }
));
}
}