1#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
19
20pub mod decmpfs;
21pub mod findings;
22#[cfg(feature = "vfs")]
23pub mod vfs;
24
25pub(crate) const VOLUME_HEADER_OFFSET: usize = 1024;
27pub(crate) const SIG_HFS_PLUS: u16 = 0x482B;
29pub(crate) const SIG_HFSX: u16 = 0x4858;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum HfsKind {
35 HfsPlus,
37 Hfsx,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub struct HfsVolume {
44 pub kind: HfsKind,
45 pub version: u16,
47 pub file_count: u32,
49 pub folder_count: u32,
51 pub block_size: u32,
53 pub total_blocks: u32,
55 pub free_blocks: u32,
57}
58
59impl HfsVolume {
60 #[must_use]
62 pub fn volume_size(&self) -> u64 {
63 u64::from(self.block_size) * u64::from(self.total_blocks)
64 }
65}
66
67#[must_use]
71pub fn parse(volume: &[u8]) -> Option<HfsVolume> {
72 let h = VOLUME_HEADER_OFFSET;
73 if volume.len() < h + 52 {
74 return None;
75 }
76 let hdr = &volume[h..];
77 let kind = match be16(&hdr[0..2]) {
78 SIG_HFS_PLUS => HfsKind::HfsPlus,
79 SIG_HFSX => HfsKind::Hfsx,
80 _ => return None,
81 };
82 Some(HfsVolume {
83 kind,
84 version: be16(&hdr[2..4]),
85 file_count: be32(&hdr[32..36]),
86 folder_count: be32(&hdr[36..40]),
87 block_size: be32(&hdr[40..44]),
88 total_blocks: be32(&hdr[44..48]),
89 free_blocks: be32(&hdr[48..52]),
90 })
91}
92
93const ROOT_FOLDER_CNID: u32 = 2;
95const RECORD_FOLDER: i16 = 1;
97const RECORD_FILE: i16 = 2;
98pub(crate) const MAX_LEAF_NODES: u32 = 65536;
100
101#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct HfsEntry {
104 pub name: String,
106 pub is_dir: bool,
108 pub cnid: u32,
110}
111
112pub(crate) struct CatalogLoc {
114 pub(crate) cat_base: usize,
115 pub(crate) node_size: usize,
116 pub(crate) first_leaf: u32,
117 pub(crate) block_size: usize,
118}
119
120pub(crate) const EXTENTS_FORK_OFFSET: usize = 192;
124pub(crate) const CATALOG_FORK_OFFSET: usize = 272;
126pub(crate) const ATTRIBUTES_FORK_OFFSET: usize = 352;
130
131pub(crate) fn locate_catalog(volume: &[u8]) -> Option<CatalogLoc> {
133 locate_btree(volume, CATALOG_FORK_OFFSET)
134}
135
136pub(crate) fn locate_attributes(volume: &[u8]) -> Option<CatalogLoc> {
139 locate_btree(volume, ATTRIBUTES_FORK_OFFSET)
140}
141
142pub(crate) fn locate_extents(volume: &[u8]) -> Option<CatalogLoc> {
144 locate_btree(volume, EXTENTS_FORK_OFFSET)
145}
146
147pub(crate) fn locate_btree(volume: &[u8], fork_offset_in_header: usize) -> Option<CatalogLoc> {
151 let h = VOLUME_HEADER_OFFSET;
152 let fork = h.checked_add(fork_offset_in_header)?;
153 if volume.len() < fork + 20 {
154 return None;
155 }
156 match be16(&volume[h..h + 2]) {
157 SIG_HFS_PLUS | SIG_HFSX => {}
158 _ => return None,
159 }
160 let block_size = be32(&volume[h + 40..h + 44]) as usize;
161 if block_size == 0 {
162 return None;
163 }
164 if be32(&volume[fork + 12..fork + 16]) == 0 {
167 return None;
168 }
169 let start_block = be32(&volume[fork + 16..fork + 20]) as usize;
171 let cat_base = start_block.checked_mul(block_size)?;
172 let hdr = cat_base.checked_add(14)?;
174 if volume.len() < hdr + 20 {
175 return None;
176 }
177 let first_leaf = be32(&volume[hdr + 10..hdr + 14]);
178 let node_size = be16(&volume[hdr + 18..hdr + 20]) as usize;
179 if node_size < 14 {
180 return None;
181 }
182 Some(CatalogLoc {
183 cat_base,
184 node_size,
185 first_leaf,
186 block_size,
187 })
188}
189
190pub(crate) fn for_each_record(volume: &[u8], loc: &CatalogLoc, mut f: impl FnMut(&[u8])) {
192 let mut node = loc.first_leaf;
193 let mut walked = 0u32;
194 while node != 0 && walked < MAX_LEAF_NODES {
195 walked += 1;
196 let Some(node_off) = (node as usize)
197 .checked_mul(loc.node_size)
198 .and_then(|x| x.checked_add(loc.cat_base))
199 else {
200 break;
201 };
202 if volume.len() < node_off + loc.node_size {
203 break;
204 }
205 let nd = &volume[node_off..node_off + loc.node_size];
206 let f_link = be32(&nd[0..4]);
207 let num_records = be16(&nd[10..12]) as usize;
208 for i in 0..num_records {
209 let Some(slot) = loc.node_size.checked_sub(2 * (i + 1)) else {
211 break;
212 };
213 let rec = be16(&nd[slot..slot + 2]) as usize;
214 if rec + 8 <= loc.node_size {
215 f(&nd[rec..]);
216 }
217 }
218 node = f_link;
219 }
220}
221
222#[must_use]
224pub fn list_root(volume: &[u8]) -> Option<Vec<HfsEntry>> {
225 list_dir(volume, ROOT_FOLDER_CNID)
226}
227
228#[must_use]
237pub fn list_dir(volume: &[u8], parent_cnid: u32) -> Option<Vec<HfsEntry>> {
238 let loc = locate_catalog(volume)?;
239 let mut entries = Vec::new();
240 for_each_record(volume, &loc, |rec| {
241 if let Some((parent, entry)) = record_entry(rec) {
242 if parent == parent_cnid {
243 entries.push(entry);
244 }
245 }
246 });
247 Some(entries)
248}
249
250#[must_use]
264pub fn read_file(volume: &[u8], cnid: u32) -> Option<Vec<u8>> {
265 let loc = locate_catalog(volume)?;
266 let mut forks: Option<(Fork, Fork)> = None;
267 for_each_record(volume, &loc, |rec| {
268 if forks.is_none() {
269 forks = file_forks(rec, cnid);
270 }
271 });
272 let (data_fork, resource_fork) = forks?;
273
274 if let Some(xattr) = decmpfs_xattr(volume, cnid) {
275 let resource = if resource_fork.logical > 0 {
276 fork_bytes(volume, loc.block_size, &resource_fork)
277 } else {
278 None
279 };
280 return decmpfs::decompress(&xattr, resource.as_deref()).ok();
283 }
284
285 fork_bytes(volume, loc.block_size, &data_fork)
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub struct HfsStat {
293 pub cnid: u32,
295 pub is_dir: bool,
297 pub size: u64,
299 pub created: u32,
301 pub modified: u32,
303 pub accessed: u32,
305}
306
307#[must_use]
315pub fn stat(volume: &[u8], cnid: u32) -> Option<HfsStat> {
316 let loc = locate_catalog(volume)?;
317 let mut found = None;
318 for_each_record(volume, &loc, |rec| {
319 if found.is_none() {
320 found = record_stat(rec, cnid);
321 }
322 });
323 found
324}
325
326fn record_stat(rec: &[u8], cnid: u32) -> Option<HfsStat> {
332 if rec.len() < 8 {
333 return None;
334 }
335 let key_len = be16(&rec[0..2]) as usize;
336 let data = 2 + key_len;
337 if data + 32 > rec.len() {
339 return None;
340 }
341 let is_dir = match i16::from_be_bytes([rec[data], rec[data + 1]]) {
342 RECORD_FOLDER => true,
343 RECORD_FILE => false,
344 _ => return None, };
346 if be32(&rec[data + 8..data + 12]) != cnid {
347 return None;
348 }
349 let created = be32(&rec[data + 16..data + 20]);
350 let modified = be32(&rec[data + 20..data + 24]);
351 let accessed = be32(&rec[data + 28..data + 32]);
352 let size = if is_dir {
355 0
356 } else if data + 96 <= rec.len() {
357 u64::from_be_bytes(rec[data + 88..data + 96].try_into().ok()?)
358 } else {
359 0
360 };
361 Some(HfsStat {
362 cnid,
363 is_dir,
364 size,
365 created,
366 modified,
367 accessed,
368 })
369}
370
371fn record_entry(rec: &[u8]) -> Option<(u32, HfsEntry)> {
373 if rec.len() < 8 {
374 return None;
375 }
376 let key_len = be16(&rec[0..2]) as usize;
377 let parent_id = be32(&rec[2..6]);
378 let name_len = be16(&rec[6..8]) as usize;
379 let name_end = 8 + name_len * 2;
380 if name_end > rec.len() {
381 return None;
382 }
383 let name = decode_utf16(&rec[8..name_end]);
384 let data = 2 + key_len;
385 if data + 12 > rec.len() {
386 return None;
387 }
388 let is_dir = match i16::from_be_bytes([rec[data], rec[data + 1]]) {
389 RECORD_FOLDER => true,
390 RECORD_FILE => false,
391 _ => return None, };
393 let cnid = be32(&rec[data + 8..data + 12]);
395 Some((parent_id, HfsEntry { name, is_dir, cnid }))
396}
397
398struct Fork {
400 logical: u64,
401 extents: Vec<(u32, u32)>,
402}
403
404const DECMPFS_XATTR_NAME: &str = "com.apple.decmpfs";
406const ATTR_INLINE_DATA: u32 = 0x10;
409
410fn file_forks(rec: &[u8], cnid: u32) -> Option<(Fork, Fork)> {
414 if rec.len() < 8 {
415 return None;
416 }
417 let key_len = be16(&rec[0..2]) as usize;
418 let data = 2 + key_len;
419 if data + 168 > rec.len() {
420 return None;
421 }
422 if i16::from_be_bytes([rec[data], rec[data + 1]]) != RECORD_FILE {
423 return None;
424 }
425 if be32(&rec[data + 8..data + 12]) != cnid {
426 return None;
427 }
428 let data_fork = parse_fork(&rec[data + 88..])?;
429 let resource_fork = if data + 248 <= rec.len() {
432 parse_fork(&rec[data + 168..])?
433 } else {
434 Fork {
435 logical: 0,
436 extents: Vec::new(),
437 }
438 };
439 Some((data_fork, resource_fork))
440}
441
442fn parse_fork(fork: &[u8]) -> Option<Fork> {
444 if fork.len() < 80 {
445 return None;
446 }
447 let logical = u64::from_be_bytes(fork[0..8].try_into().ok()?);
448 let mut extents = Vec::new();
449 for i in 0..8 {
450 let e = 16 + i * 8;
451 let start = be32(&fork[e..e + 4]);
452 let count = be32(&fork[e + 4..e + 8]);
453 if count != 0 {
454 extents.push((start, count));
455 }
456 }
457 Some(Fork { logical, extents })
458}
459
460fn fork_bytes(volume: &[u8], block_size: usize, fork: &Fork) -> Option<Vec<u8>> {
462 let logical = fork.logical as usize;
463 let mut data = Vec::with_capacity(logical.min(1 << 20));
464 for &(start, count) in &fork.extents {
465 if data.len() >= logical {
466 break;
467 }
468 let begin = (start as usize).checked_mul(block_size)?;
469 let len = (count as usize).checked_mul(block_size)?;
470 let end = begin.checked_add(len)?.min(volume.len());
471 if begin >= volume.len() {
472 break;
473 }
474 data.extend_from_slice(&volume[begin..end]);
475 }
476 data.truncate(logical);
477 Some(data)
478}
479
480pub(crate) fn decmpfs_xattr(volume: &[u8], cnid: u32) -> Option<Vec<u8>> {
484 let loc = locate_attributes(volume)?;
485 let mut found = None;
486 for_each_record(volume, &loc, |rec| {
487 if found.is_none() {
488 found = attr_inline_value(rec, cnid, DECMPFS_XATTR_NAME);
489 }
490 });
491 found
492}
493
494fn attr_inline_value(rec: &[u8], cnid: u32, want_name: &str) -> Option<Vec<u8>> {
499 if rec.len() < 14 {
500 return None;
501 }
502 let key_len = be16(&rec[0..2]) as usize;
503 if be32(&rec[4..8]) != cnid {
504 return None;
505 }
506 let name_len = be16(&rec[12..14]) as usize;
507 let name_end = 14usize.checked_add(name_len.checked_mul(2)?)?;
508 if name_end > rec.len() {
509 return None;
510 }
511 if decode_utf16(&rec[14..name_end]) != want_name {
512 return None;
513 }
514 let body = 2 + key_len;
515 if body + 16 > rec.len() {
516 return None;
517 }
518 if be32(&rec[body..body + 4]) != ATTR_INLINE_DATA {
519 return None;
520 }
521 let attr_size = be32(&rec[body + 12..body + 16]) as usize;
522 let end = body.checked_add(16)?.checked_add(attr_size)?;
523 if end > rec.len() {
524 return None;
525 }
526 Some(rec[body + 16..end].to_vec())
527}
528
529pub(crate) fn decode_utf16(bytes: &[u8]) -> String {
531 let units: Vec<u16> = bytes
532 .chunks_exact(2)
533 .map(|c| u16::from_be_bytes([c[0], c[1]]))
534 .collect();
535 String::from_utf16_lossy(&units)
536}
537
538pub(crate) fn be16(b: &[u8]) -> u16 {
539 u16::from_be_bytes([b[0], b[1]])
540}
541pub(crate) fn be32(b: &[u8]) -> u32 {
542 u32::from_be_bytes([b[0], b[1], b[2], b[3]])
543}
544
545#[derive(Debug, Clone, PartialEq, Eq)]
547pub struct HfsPathEntry {
548 pub path: String,
550 pub is_dir: bool,
552 pub cnid: u32,
554}
555
556#[must_use]
562pub fn walk(volume: &[u8]) -> Option<Vec<HfsPathEntry>> {
563 list_dir(volume, ROOT_FOLDER_CNID)?;
565 let mut out = Vec::new();
566 let mut visited = std::collections::HashSet::new();
567 visited.insert(ROOT_FOLDER_CNID);
568 let mut stack = vec![(ROOT_FOLDER_CNID, String::new())];
569 while let Some((parent, prefix)) = stack.pop() {
570 let Some(entries) = list_dir(volume, parent) else {
571 continue;
572 };
573 for e in entries {
574 let path = if prefix.is_empty() {
575 e.name.clone()
576 } else {
577 format!("{prefix}/{}", e.name)
578 };
579 if e.is_dir && visited.insert(e.cnid) {
580 stack.push((e.cnid, path.clone()));
581 }
582 out.push(HfsPathEntry {
583 path,
584 is_dir: e.is_dir,
585 cnid: e.cnid,
586 });
587 }
588 }
589 Some(out)
590}