1pub mod decmpfs;
17pub mod findings;
18#[cfg(feature = "vfs")]
19pub mod vfs;
20
21pub(crate) const VOLUME_HEADER_OFFSET: usize = 1024;
23pub(crate) const SIG_HFS_PLUS: u16 = 0x482B;
25pub(crate) const SIG_HFSX: u16 = 0x4858;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum HfsKind {
31 HfsPlus,
33 Hfsx,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct HfsVolume {
40 pub kind: HfsKind,
41 pub version: u16,
43 pub file_count: u32,
45 pub folder_count: u32,
47 pub block_size: u32,
49 pub total_blocks: u32,
51 pub free_blocks: u32,
53}
54
55impl HfsVolume {
56 #[must_use]
58 pub fn volume_size(&self) -> u64 {
59 u64::from(self.block_size) * u64::from(self.total_blocks)
60 }
61}
62
63#[must_use]
67pub fn parse(volume: &[u8]) -> Option<HfsVolume> {
68 let h = VOLUME_HEADER_OFFSET;
69 if volume.len() < h + 52 {
70 return None;
71 }
72 let hdr = &volume[h..];
73 let kind = match be16(&hdr[0..2]) {
74 SIG_HFS_PLUS => HfsKind::HfsPlus,
75 SIG_HFSX => HfsKind::Hfsx,
76 _ => return None,
77 };
78 Some(HfsVolume {
79 kind,
80 version: be16(&hdr[2..4]),
81 file_count: be32(&hdr[32..36]),
82 folder_count: be32(&hdr[36..40]),
83 block_size: be32(&hdr[40..44]),
84 total_blocks: be32(&hdr[44..48]),
85 free_blocks: be32(&hdr[48..52]),
86 })
87}
88
89const ROOT_FOLDER_CNID: u32 = 2;
91const RECORD_FOLDER: i16 = 1;
93const RECORD_FILE: i16 = 2;
94pub(crate) const MAX_LEAF_NODES: u32 = 65536;
96
97#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct HfsEntry {
100 pub name: String,
102 pub is_dir: bool,
104 pub cnid: u32,
106}
107
108pub(crate) struct CatalogLoc {
110 pub(crate) cat_base: usize,
111 pub(crate) node_size: usize,
112 pub(crate) first_leaf: u32,
113 pub(crate) block_size: usize,
114}
115
116pub(crate) const EXTENTS_FORK_OFFSET: usize = 192;
120pub(crate) const CATALOG_FORK_OFFSET: usize = 272;
122pub(crate) const ATTRIBUTES_FORK_OFFSET: usize = 352;
126
127pub(crate) fn locate_catalog(volume: &[u8]) -> Option<CatalogLoc> {
129 locate_btree(volume, CATALOG_FORK_OFFSET)
130}
131
132pub(crate) fn locate_attributes(volume: &[u8]) -> Option<CatalogLoc> {
135 locate_btree(volume, ATTRIBUTES_FORK_OFFSET)
136}
137
138pub(crate) fn locate_extents(volume: &[u8]) -> Option<CatalogLoc> {
140 locate_btree(volume, EXTENTS_FORK_OFFSET)
141}
142
143pub(crate) fn locate_btree(volume: &[u8], fork_offset_in_header: usize) -> Option<CatalogLoc> {
147 let h = VOLUME_HEADER_OFFSET;
148 let fork = h.checked_add(fork_offset_in_header)?;
149 if volume.len() < fork + 20 {
150 return None;
151 }
152 match be16(&volume[h..h + 2]) {
153 SIG_HFS_PLUS | SIG_HFSX => {}
154 _ => return None,
155 }
156 let block_size = be32(&volume[h + 40..h + 44]) as usize;
157 if block_size == 0 {
158 return None;
159 }
160 if be32(&volume[fork + 12..fork + 16]) == 0 {
163 return None;
164 }
165 let start_block = be32(&volume[fork + 16..fork + 20]) as usize;
167 let cat_base = start_block.checked_mul(block_size)?;
168 let hdr = cat_base.checked_add(14)?;
170 if volume.len() < hdr + 20 {
171 return None;
172 }
173 let first_leaf = be32(&volume[hdr + 10..hdr + 14]);
174 let node_size = be16(&volume[hdr + 18..hdr + 20]) as usize;
175 if node_size < 14 {
176 return None;
177 }
178 Some(CatalogLoc {
179 cat_base,
180 node_size,
181 first_leaf,
182 block_size,
183 })
184}
185
186pub(crate) fn for_each_record(volume: &[u8], loc: &CatalogLoc, mut f: impl FnMut(&[u8])) {
188 let mut node = loc.first_leaf;
189 let mut walked = 0u32;
190 while node != 0 && walked < MAX_LEAF_NODES {
191 walked += 1;
192 let Some(node_off) = (node as usize)
193 .checked_mul(loc.node_size)
194 .and_then(|x| x.checked_add(loc.cat_base))
195 else {
196 break;
197 };
198 if volume.len() < node_off + loc.node_size {
199 break;
200 }
201 let nd = &volume[node_off..node_off + loc.node_size];
202 let f_link = be32(&nd[0..4]);
203 let num_records = be16(&nd[10..12]) as usize;
204 for i in 0..num_records {
205 let Some(slot) = loc.node_size.checked_sub(2 * (i + 1)) else {
207 break;
208 };
209 let rec = be16(&nd[slot..slot + 2]) as usize;
210 if rec + 8 <= loc.node_size {
211 f(&nd[rec..]);
212 }
213 }
214 node = f_link;
215 }
216}
217
218#[must_use]
220pub fn list_root(volume: &[u8]) -> Option<Vec<HfsEntry>> {
221 list_dir(volume, ROOT_FOLDER_CNID)
222}
223
224#[must_use]
233pub fn list_dir(volume: &[u8], parent_cnid: u32) -> Option<Vec<HfsEntry>> {
234 let loc = locate_catalog(volume)?;
235 let mut entries = Vec::new();
236 for_each_record(volume, &loc, |rec| {
237 if let Some((parent, entry)) = record_entry(rec) {
238 if parent == parent_cnid {
239 entries.push(entry);
240 }
241 }
242 });
243 Some(entries)
244}
245
246#[must_use]
260pub fn read_file(volume: &[u8], cnid: u32) -> Option<Vec<u8>> {
261 let loc = locate_catalog(volume)?;
262 let mut forks: Option<(Fork, Fork)> = None;
263 for_each_record(volume, &loc, |rec| {
264 if forks.is_none() {
265 forks = file_forks(rec, cnid);
266 }
267 });
268 let (data_fork, resource_fork) = forks?;
269
270 if let Some(xattr) = decmpfs_xattr(volume, cnid) {
271 let resource = if resource_fork.logical > 0 {
272 fork_bytes(volume, loc.block_size, &resource_fork)
273 } else {
274 None
275 };
276 return decmpfs::decompress(&xattr, resource.as_deref()).ok();
279 }
280
281 fork_bytes(volume, loc.block_size, &data_fork)
282}
283
284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
288pub struct HfsStat {
289 pub cnid: u32,
291 pub is_dir: bool,
293 pub size: u64,
295 pub created: u32,
297 pub modified: u32,
299 pub accessed: u32,
301}
302
303#[must_use]
311pub fn stat(volume: &[u8], cnid: u32) -> Option<HfsStat> {
312 let loc = locate_catalog(volume)?;
313 let mut found = None;
314 for_each_record(volume, &loc, |rec| {
315 if found.is_none() {
316 found = record_stat(rec, cnid);
317 }
318 });
319 found
320}
321
322fn record_stat(rec: &[u8], cnid: u32) -> Option<HfsStat> {
328 if rec.len() < 8 {
329 return None;
330 }
331 let key_len = be16(&rec[0..2]) as usize;
332 let data = 2 + key_len;
333 if data + 32 > rec.len() {
335 return None;
336 }
337 let is_dir = match i16::from_be_bytes([rec[data], rec[data + 1]]) {
338 RECORD_FOLDER => true,
339 RECORD_FILE => false,
340 _ => return None, };
342 if be32(&rec[data + 8..data + 12]) != cnid {
343 return None;
344 }
345 let created = be32(&rec[data + 16..data + 20]);
346 let modified = be32(&rec[data + 20..data + 24]);
347 let accessed = be32(&rec[data + 28..data + 32]);
348 let size = if is_dir {
351 0
352 } else if data + 96 <= rec.len() {
353 u64::from_be_bytes(rec[data + 88..data + 96].try_into().ok()?)
354 } else {
355 0
356 };
357 Some(HfsStat {
358 cnid,
359 is_dir,
360 size,
361 created,
362 modified,
363 accessed,
364 })
365}
366
367fn record_entry(rec: &[u8]) -> Option<(u32, HfsEntry)> {
369 if rec.len() < 8 {
370 return None;
371 }
372 let key_len = be16(&rec[0..2]) as usize;
373 let parent_id = be32(&rec[2..6]);
374 let name_len = be16(&rec[6..8]) as usize;
375 let name_end = 8 + name_len * 2;
376 if name_end > rec.len() {
377 return None;
378 }
379 let name = decode_utf16(&rec[8..name_end]);
380 let data = 2 + key_len;
381 if data + 12 > rec.len() {
382 return None;
383 }
384 let is_dir = match i16::from_be_bytes([rec[data], rec[data + 1]]) {
385 RECORD_FOLDER => true,
386 RECORD_FILE => false,
387 _ => return None, };
389 let cnid = be32(&rec[data + 8..data + 12]);
391 Some((parent_id, HfsEntry { name, is_dir, cnid }))
392}
393
394struct Fork {
396 logical: u64,
397 extents: Vec<(u32, u32)>,
398}
399
400const DECMPFS_XATTR_NAME: &str = "com.apple.decmpfs";
402const ATTR_INLINE_DATA: u32 = 0x10;
405
406fn file_forks(rec: &[u8], cnid: u32) -> Option<(Fork, Fork)> {
410 if rec.len() < 8 {
411 return None;
412 }
413 let key_len = be16(&rec[0..2]) as usize;
414 let data = 2 + key_len;
415 if data + 168 > rec.len() {
416 return None;
417 }
418 if i16::from_be_bytes([rec[data], rec[data + 1]]) != RECORD_FILE {
419 return None;
420 }
421 if be32(&rec[data + 8..data + 12]) != cnid {
422 return None;
423 }
424 let data_fork = parse_fork(&rec[data + 88..])?;
425 let resource_fork = if data + 248 <= rec.len() {
428 parse_fork(&rec[data + 168..])?
429 } else {
430 Fork {
431 logical: 0,
432 extents: Vec::new(),
433 }
434 };
435 Some((data_fork, resource_fork))
436}
437
438fn parse_fork(fork: &[u8]) -> Option<Fork> {
440 if fork.len() < 80 {
441 return None;
442 }
443 let logical = u64::from_be_bytes(fork[0..8].try_into().ok()?);
444 let mut extents = Vec::new();
445 for i in 0..8 {
446 let e = 16 + i * 8;
447 let start = be32(&fork[e..e + 4]);
448 let count = be32(&fork[e + 4..e + 8]);
449 if count != 0 {
450 extents.push((start, count));
451 }
452 }
453 Some(Fork { logical, extents })
454}
455
456fn fork_bytes(volume: &[u8], block_size: usize, fork: &Fork) -> Option<Vec<u8>> {
458 let logical = fork.logical as usize;
459 let mut data = Vec::with_capacity(logical.min(1 << 20));
460 for &(start, count) in &fork.extents {
461 if data.len() >= logical {
462 break;
463 }
464 let begin = (start as usize).checked_mul(block_size)?;
465 let len = (count as usize).checked_mul(block_size)?;
466 let end = begin.checked_add(len)?.min(volume.len());
467 if begin >= volume.len() {
468 break;
469 }
470 data.extend_from_slice(&volume[begin..end]);
471 }
472 data.truncate(logical);
473 Some(data)
474}
475
476pub(crate) fn decmpfs_xattr(volume: &[u8], cnid: u32) -> Option<Vec<u8>> {
480 let loc = locate_attributes(volume)?;
481 let mut found = None;
482 for_each_record(volume, &loc, |rec| {
483 if found.is_none() {
484 found = attr_inline_value(rec, cnid, DECMPFS_XATTR_NAME);
485 }
486 });
487 found
488}
489
490fn attr_inline_value(rec: &[u8], cnid: u32, want_name: &str) -> Option<Vec<u8>> {
495 if rec.len() < 14 {
496 return None;
497 }
498 let key_len = be16(&rec[0..2]) as usize;
499 if be32(&rec[4..8]) != cnid {
500 return None;
501 }
502 let name_len = be16(&rec[12..14]) as usize;
503 let name_end = 14usize.checked_add(name_len.checked_mul(2)?)?;
504 if name_end > rec.len() {
505 return None;
506 }
507 if decode_utf16(&rec[14..name_end]) != want_name {
508 return None;
509 }
510 let body = 2 + key_len;
511 if body + 16 > rec.len() {
512 return None;
513 }
514 if be32(&rec[body..body + 4]) != ATTR_INLINE_DATA {
515 return None;
516 }
517 let attr_size = be32(&rec[body + 12..body + 16]) as usize;
518 let end = body.checked_add(16)?.checked_add(attr_size)?;
519 if end > rec.len() {
520 return None;
521 }
522 Some(rec[body + 16..end].to_vec())
523}
524
525pub(crate) fn decode_utf16(bytes: &[u8]) -> String {
527 let units: Vec<u16> = bytes
528 .chunks_exact(2)
529 .map(|c| u16::from_be_bytes([c[0], c[1]]))
530 .collect();
531 String::from_utf16_lossy(&units)
532}
533
534pub(crate) fn be16(b: &[u8]) -> u16 {
535 u16::from_be_bytes([b[0], b[1]])
536}
537pub(crate) fn be32(b: &[u8]) -> u32 {
538 u32::from_be_bytes([b[0], b[1], b[2], b[3]])
539}
540
541#[derive(Debug, Clone, PartialEq, Eq)]
543pub struct HfsPathEntry {
544 pub path: String,
546 pub is_dir: bool,
548 pub cnid: u32,
550}
551
552#[must_use]
558pub fn walk(volume: &[u8]) -> Option<Vec<HfsPathEntry>> {
559 list_dir(volume, ROOT_FOLDER_CNID)?;
561 let mut out = Vec::new();
562 let mut visited = std::collections::HashSet::new();
563 visited.insert(ROOT_FOLDER_CNID);
564 let mut stack = vec![(ROOT_FOLDER_CNID, String::new())];
565 while let Some((parent, prefix)) = stack.pop() {
566 let Some(entries) = list_dir(volume, parent) else {
567 continue;
568 };
569 for e in entries {
570 let path = if prefix.is_empty() {
571 e.name.clone()
572 } else {
573 format!("{prefix}/{}", e.name)
574 };
575 if e.is_dir && visited.insert(e.cnid) {
576 stack.push((e.cnid, path.clone()));
577 }
578 out.push(HfsPathEntry {
579 path,
580 is_dir: e.is_dir,
581 cnid: e.cnid,
582 });
583 }
584 }
585 Some(out)
586}