1pub mod decmpfs;
17pub mod findings;
18
19pub(crate) const VOLUME_HEADER_OFFSET: usize = 1024;
21pub(crate) const SIG_HFS_PLUS: u16 = 0x482B;
23pub(crate) const SIG_HFSX: u16 = 0x4858;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum HfsKind {
29 HfsPlus,
31 Hfsx,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct HfsVolume {
38 pub kind: HfsKind,
39 pub version: u16,
41 pub file_count: u32,
43 pub folder_count: u32,
45 pub block_size: u32,
47 pub total_blocks: u32,
49 pub free_blocks: u32,
51}
52
53impl HfsVolume {
54 #[must_use]
56 pub fn volume_size(&self) -> u64 {
57 u64::from(self.block_size) * u64::from(self.total_blocks)
58 }
59}
60
61#[must_use]
65pub fn parse(volume: &[u8]) -> Option<HfsVolume> {
66 let h = VOLUME_HEADER_OFFSET;
67 if volume.len() < h + 52 {
68 return None;
69 }
70 let hdr = &volume[h..];
71 let kind = match be16(&hdr[0..2]) {
72 SIG_HFS_PLUS => HfsKind::HfsPlus,
73 SIG_HFSX => HfsKind::Hfsx,
74 _ => return None,
75 };
76 Some(HfsVolume {
77 kind,
78 version: be16(&hdr[2..4]),
79 file_count: be32(&hdr[32..36]),
80 folder_count: be32(&hdr[36..40]),
81 block_size: be32(&hdr[40..44]),
82 total_blocks: be32(&hdr[44..48]),
83 free_blocks: be32(&hdr[48..52]),
84 })
85}
86
87const ROOT_FOLDER_CNID: u32 = 2;
89const RECORD_FOLDER: i16 = 1;
91const RECORD_FILE: i16 = 2;
92pub(crate) const MAX_LEAF_NODES: u32 = 65536;
94
95#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct HfsEntry {
98 pub name: String,
100 pub is_dir: bool,
102 pub cnid: u32,
104}
105
106pub(crate) struct CatalogLoc {
108 pub(crate) cat_base: usize,
109 pub(crate) node_size: usize,
110 pub(crate) first_leaf: u32,
111 pub(crate) block_size: usize,
112}
113
114pub(crate) const EXTENTS_FORK_OFFSET: usize = 192;
118pub(crate) const CATALOG_FORK_OFFSET: usize = 272;
120pub(crate) const ATTRIBUTES_FORK_OFFSET: usize = 352;
124
125pub(crate) fn locate_catalog(volume: &[u8]) -> Option<CatalogLoc> {
127 locate_btree(volume, CATALOG_FORK_OFFSET)
128}
129
130pub(crate) fn locate_attributes(volume: &[u8]) -> Option<CatalogLoc> {
133 locate_btree(volume, ATTRIBUTES_FORK_OFFSET)
134}
135
136pub(crate) fn locate_extents(volume: &[u8]) -> Option<CatalogLoc> {
138 locate_btree(volume, EXTENTS_FORK_OFFSET)
139}
140
141pub(crate) fn locate_btree(volume: &[u8], fork_offset_in_header: usize) -> Option<CatalogLoc> {
145 let h = VOLUME_HEADER_OFFSET;
146 let fork = h.checked_add(fork_offset_in_header)?;
147 if volume.len() < fork + 20 {
148 return None;
149 }
150 match be16(&volume[h..h + 2]) {
151 SIG_HFS_PLUS | SIG_HFSX => {}
152 _ => return None,
153 }
154 let block_size = be32(&volume[h + 40..h + 44]) as usize;
155 if block_size == 0 {
156 return None;
157 }
158 if be32(&volume[fork + 12..fork + 16]) == 0 {
161 return None;
162 }
163 let start_block = be32(&volume[fork + 16..fork + 20]) as usize;
165 let cat_base = start_block.checked_mul(block_size)?;
166 let hdr = cat_base.checked_add(14)?;
168 if volume.len() < hdr + 20 {
169 return None;
170 }
171 let first_leaf = be32(&volume[hdr + 10..hdr + 14]);
172 let node_size = be16(&volume[hdr + 18..hdr + 20]) as usize;
173 if node_size < 14 {
174 return None;
175 }
176 Some(CatalogLoc {
177 cat_base,
178 node_size,
179 first_leaf,
180 block_size,
181 })
182}
183
184pub(crate) fn for_each_record(volume: &[u8], loc: &CatalogLoc, mut f: impl FnMut(&[u8])) {
186 let mut node = loc.first_leaf;
187 let mut walked = 0u32;
188 while node != 0 && walked < MAX_LEAF_NODES {
189 walked += 1;
190 let Some(node_off) = (node as usize)
191 .checked_mul(loc.node_size)
192 .and_then(|x| x.checked_add(loc.cat_base))
193 else {
194 break;
195 };
196 if volume.len() < node_off + loc.node_size {
197 break;
198 }
199 let nd = &volume[node_off..node_off + loc.node_size];
200 let f_link = be32(&nd[0..4]);
201 let num_records = be16(&nd[10..12]) as usize;
202 for i in 0..num_records {
203 let Some(slot) = loc.node_size.checked_sub(2 * (i + 1)) else {
205 break;
206 };
207 let rec = be16(&nd[slot..slot + 2]) as usize;
208 if rec + 8 <= loc.node_size {
209 f(&nd[rec..]);
210 }
211 }
212 node = f_link;
213 }
214}
215
216#[must_use]
218pub fn list_root(volume: &[u8]) -> Option<Vec<HfsEntry>> {
219 list_dir(volume, ROOT_FOLDER_CNID)
220}
221
222#[must_use]
231pub fn list_dir(volume: &[u8], parent_cnid: u32) -> Option<Vec<HfsEntry>> {
232 let loc = locate_catalog(volume)?;
233 let mut entries = Vec::new();
234 for_each_record(volume, &loc, |rec| {
235 if let Some((parent, entry)) = record_entry(rec) {
236 if parent == parent_cnid {
237 entries.push(entry);
238 }
239 }
240 });
241 Some(entries)
242}
243
244#[must_use]
258pub fn read_file(volume: &[u8], cnid: u32) -> Option<Vec<u8>> {
259 let loc = locate_catalog(volume)?;
260 let mut forks: Option<(Fork, Fork)> = None;
261 for_each_record(volume, &loc, |rec| {
262 if forks.is_none() {
263 forks = file_forks(rec, cnid);
264 }
265 });
266 let (data_fork, resource_fork) = forks?;
267
268 if let Some(xattr) = decmpfs_xattr(volume, cnid) {
269 let resource = if resource_fork.logical > 0 {
270 fork_bytes(volume, loc.block_size, &resource_fork)
271 } else {
272 None
273 };
274 return decmpfs::decompress(&xattr, resource.as_deref()).ok();
277 }
278
279 fork_bytes(volume, loc.block_size, &data_fork)
280}
281
282fn record_entry(rec: &[u8]) -> Option<(u32, HfsEntry)> {
284 if rec.len() < 8 {
285 return None;
286 }
287 let key_len = be16(&rec[0..2]) as usize;
288 let parent_id = be32(&rec[2..6]);
289 let name_len = be16(&rec[6..8]) as usize;
290 let name_end = 8 + name_len * 2;
291 if name_end > rec.len() {
292 return None;
293 }
294 let name = decode_utf16(&rec[8..name_end]);
295 let data = 2 + key_len;
296 if data + 12 > rec.len() {
297 return None;
298 }
299 let is_dir = match i16::from_be_bytes([rec[data], rec[data + 1]]) {
300 RECORD_FOLDER => true,
301 RECORD_FILE => false,
302 _ => return None, };
304 let cnid = be32(&rec[data + 8..data + 12]);
306 Some((parent_id, HfsEntry { name, is_dir, cnid }))
307}
308
309struct Fork {
311 logical: u64,
312 extents: Vec<(u32, u32)>,
313}
314
315const DECMPFS_XATTR_NAME: &str = "com.apple.decmpfs";
317const ATTR_INLINE_DATA: u32 = 0x10;
320
321fn file_forks(rec: &[u8], cnid: u32) -> Option<(Fork, Fork)> {
325 if rec.len() < 8 {
326 return None;
327 }
328 let key_len = be16(&rec[0..2]) as usize;
329 let data = 2 + key_len;
330 if data + 168 > rec.len() {
331 return None;
332 }
333 if i16::from_be_bytes([rec[data], rec[data + 1]]) != RECORD_FILE {
334 return None;
335 }
336 if be32(&rec[data + 8..data + 12]) != cnid {
337 return None;
338 }
339 let data_fork = parse_fork(&rec[data + 88..])?;
340 let resource_fork = if data + 248 <= rec.len() {
343 parse_fork(&rec[data + 168..])?
344 } else {
345 Fork {
346 logical: 0,
347 extents: Vec::new(),
348 }
349 };
350 Some((data_fork, resource_fork))
351}
352
353fn parse_fork(fork: &[u8]) -> Option<Fork> {
355 if fork.len() < 80 {
356 return None;
357 }
358 let logical = u64::from_be_bytes(fork[0..8].try_into().ok()?);
359 let mut extents = Vec::new();
360 for i in 0..8 {
361 let e = 16 + i * 8;
362 let start = be32(&fork[e..e + 4]);
363 let count = be32(&fork[e + 4..e + 8]);
364 if count != 0 {
365 extents.push((start, count));
366 }
367 }
368 Some(Fork { logical, extents })
369}
370
371fn fork_bytes(volume: &[u8], block_size: usize, fork: &Fork) -> Option<Vec<u8>> {
373 let logical = fork.logical as usize;
374 let mut data = Vec::with_capacity(logical.min(1 << 20));
375 for &(start, count) in &fork.extents {
376 if data.len() >= logical {
377 break;
378 }
379 let begin = (start as usize).checked_mul(block_size)?;
380 let len = (count as usize).checked_mul(block_size)?;
381 let end = begin.checked_add(len)?.min(volume.len());
382 if begin >= volume.len() {
383 break;
384 }
385 data.extend_from_slice(&volume[begin..end]);
386 }
387 data.truncate(logical);
388 Some(data)
389}
390
391pub(crate) fn decmpfs_xattr(volume: &[u8], cnid: u32) -> Option<Vec<u8>> {
395 let loc = locate_attributes(volume)?;
396 let mut found = None;
397 for_each_record(volume, &loc, |rec| {
398 if found.is_none() {
399 found = attr_inline_value(rec, cnid, DECMPFS_XATTR_NAME);
400 }
401 });
402 found
403}
404
405fn attr_inline_value(rec: &[u8], cnid: u32, want_name: &str) -> Option<Vec<u8>> {
410 if rec.len() < 14 {
411 return None;
412 }
413 let key_len = be16(&rec[0..2]) as usize;
414 if be32(&rec[4..8]) != cnid {
415 return None;
416 }
417 let name_len = be16(&rec[12..14]) as usize;
418 let name_end = 14usize.checked_add(name_len.checked_mul(2)?)?;
419 if name_end > rec.len() {
420 return None;
421 }
422 if decode_utf16(&rec[14..name_end]) != want_name {
423 return None;
424 }
425 let body = 2 + key_len;
426 if body + 16 > rec.len() {
427 return None;
428 }
429 if be32(&rec[body..body + 4]) != ATTR_INLINE_DATA {
430 return None;
431 }
432 let attr_size = be32(&rec[body + 12..body + 16]) as usize;
433 let end = body.checked_add(16)?.checked_add(attr_size)?;
434 if end > rec.len() {
435 return None;
436 }
437 Some(rec[body + 16..end].to_vec())
438}
439
440pub(crate) fn decode_utf16(bytes: &[u8]) -> String {
442 let units: Vec<u16> = bytes
443 .chunks_exact(2)
444 .map(|c| u16::from_be_bytes([c[0], c[1]]))
445 .collect();
446 String::from_utf16_lossy(&units)
447}
448
449pub(crate) fn be16(b: &[u8]) -> u16 {
450 u16::from_be_bytes([b[0], b[1]])
451}
452pub(crate) fn be32(b: &[u8]) -> u32 {
453 u32::from_be_bytes([b[0], b[1], b[2], b[3]])
454}
455
456#[derive(Debug, Clone, PartialEq, Eq)]
458pub struct HfsPathEntry {
459 pub path: String,
461 pub is_dir: bool,
463 pub cnid: u32,
465}
466
467#[must_use]
473pub fn walk(volume: &[u8]) -> Option<Vec<HfsPathEntry>> {
474 list_dir(volume, ROOT_FOLDER_CNID)?;
476 let mut out = Vec::new();
477 let mut visited = std::collections::HashSet::new();
478 visited.insert(ROOT_FOLDER_CNID);
479 let mut stack = vec![(ROOT_FOLDER_CNID, String::new())];
480 while let Some((parent, prefix)) = stack.pop() {
481 let Some(entries) = list_dir(volume, parent) else {
482 continue;
483 };
484 for e in entries {
485 let path = if prefix.is_empty() {
486 e.name.clone()
487 } else {
488 format!("{prefix}/{}", e.name)
489 };
490 if e.is_dir && visited.insert(e.cnid) {
491 stack.push((e.cnid, path.clone()));
492 }
493 out.push(HfsPathEntry {
494 path,
495 is_dir: e.is_dir,
496 cnid: e.cnid,
497 });
498 }
499 }
500 Some(out)
501}