1use std::{
8 io::{Read, Seek, SeekFrom},
9 path::{Component, Path},
10};
11
12use hfs_reader::HfsVolume;
13
14use crate::mac_roman::decode_mac_roman;
15
16const HFS_SIGNATURE: u16 = 0x4244;
17const HFS_PLUS_SIGNATURE: u16 = 0x482B;
18const HFSX_SIGNATURE: u16 = 0x4858;
19const MFS_SIGNATURE: u16 = 0xD2D7;
20const DRIVER_DESCRIPTOR_SIGNATURE: u16 = 0x4552;
21const APPLE_PARTITION_MAP_SIGNATURE: u16 = 0x504D;
22const APPLE_HFS_PARTITION_TYPE: &[u8] = b"Apple_HFS";
23const APPLE_HFSX_PARTITION_TYPE: &[u8] = b"Apple_HFSX";
24const HFSPLUS_FORK_DATA: u8 = 0x00;
25const HFSPLUS_FORK_RESOURCE: u8 = 0xFF;
26const HFSPLUS_CATALOG_FILE_RECORD: u16 = 0x0002;
27const HFSPLUS_FILE_USER_INFO_OFFSET: usize = 48;
28const HFS_MDB_VOLUME_NAME_OFFSET: usize = 1024 + 36;
29const HFS_MAX_VOLUME_NAME_LEN: usize = 27;
30
31#[derive(Debug)]
32pub struct DiskImageContents {
33 pub volume_name: String,
34 pub volume_info: DiskImageVolumeInfo,
35 pub dirs: Vec<String>,
36 pub files: Vec<DiskImageFile>,
37}
38
39#[derive(Clone, Debug, Default)]
40pub struct DiskImageVolumeInfo {
41 pub attributes: u16,
42 pub file_count: u16,
43 pub allocation_block_count: u16,
44 pub allocation_block_size: u32,
45 pub clump_size: u32,
46 pub free_blocks: u16,
47 pub bitmap_start: u16,
48 pub allocation_pointer: u16,
49 pub allocation_start: u16,
50 pub next_catalog_id: u32,
51 pub created_date: u32,
52 pub modified_date: u32,
53}
54
55#[derive(Debug)]
56pub struct DiskImageFile {
57 pub path: String,
58 pub data: Vec<u8>,
59 pub rsrc: Vec<u8>,
60 pub file_type: [u8; 4],
61 pub creator: [u8; 4],
62 pub finder_flags: u16,
63}
64
65pub fn looks_like_dc42_or_hfs(bytes: &[u8]) -> bool {
66 raw_filesystem_signature(bytes).is_some()
67 || dc42_data_range(bytes)
68 .and_then(|(start, end)| raw_filesystem_signature(&bytes[start..end]))
69 .is_some()
70 || apple_hfs_partition_range(bytes).is_some()
71}
72
73pub fn extract_dc42_or_hfs(bytes: &[u8]) -> Result<Option<DiskImageContents>, String> {
74 if !looks_like_dc42_or_hfs(bytes) {
75 return Ok(None);
76 }
77
78 let filesystem = filesystem_payload(bytes);
79 match raw_filesystem_signature(filesystem) {
80 Some(HFS_PLUS_SIGNATURE | HFSX_SIGNATURE) => {
81 return extract_hfsplus(filesystem).map(Some);
82 }
83 Some(HFS_SIGNATURE | MFS_SIGNATURE) => {}
84 Some(_) | None => {}
85 }
86
87 let volume =
88 HfsVolume::parse(filesystem).map_err(|e| format!("failed to parse HFS image: {e}"))?;
89 let volume_name = hfs_volume_name_from_mdb(filesystem)
90 .or_else(|| clean_component(&volume.volume_name))
91 .unwrap_or_else(|| "Disk Image".into());
92 let volume_info = hfs_volume_info(filesystem, volume.files.len());
93 let mut dirs = vec![volume_name.clone()];
94
95 for dir in &volume.dirs {
96 if let Some(rel_path) = path_to_vfs_path(&dir.rel_path) {
97 dirs.push(prefixed_path(&volume_name, &rel_path));
98 }
99 }
100
101 let mut files = Vec::with_capacity(volume.files.len());
102 for file in &volume.files {
103 let Some(rel_path) = path_to_vfs_path(&file.rel_path) else {
104 continue;
105 };
106 let path = prefixed_path(&volume_name, &rel_path);
107 let data = volume
108 .read_data_fork(file)
109 .map_err(|e| format!("failed to read HFS data fork for {path}: {e}"))?;
110 let rsrc = volume
111 .read_rsrc_fork(file)
112 .map_err(|e| format!("failed to read HFS resource fork for {path}: {e}"))?;
113
114 files.push(DiskImageFile {
115 path,
116 data,
117 rsrc,
118 file_type: file.file_type,
119 creator: file.creator,
120 finder_flags: 0,
122 });
123 }
124
125 dirs.sort_unstable();
126 dirs.dedup();
127 Ok(Some(DiskImageContents {
128 volume_name,
129 volume_info,
130 dirs,
131 files,
132 }))
133}
134
135fn hfs_volume_info(bytes: &[u8], file_count: usize) -> DiskImageVolumeInfo {
136 let read_u16 = |offset: usize| {
137 bytes
138 .get(offset..offset + 2)
139 .map(|raw| u16::from_be_bytes([raw[0], raw[1]]))
140 .unwrap_or(0)
141 };
142 let read_u32 = |offset: usize| {
143 bytes
144 .get(offset..offset + 4)
145 .map(|raw| u32::from_be_bytes([raw[0], raw[1], raw[2], raw[3]]))
146 .unwrap_or(0)
147 };
148 DiskImageVolumeInfo {
149 attributes: read_u16(1024 + 10),
150 file_count: {
151 let catalog_count = read_u16(1024 + 12);
152 if catalog_count == 0 {
153 file_count.min(u16::MAX as usize) as u16
154 } else {
155 catalog_count
156 }
157 },
158 allocation_block_count: read_u16(1024 + 18),
159 allocation_block_size: read_u32(1024 + 20),
160 clump_size: read_u32(1024 + 24),
161 free_blocks: read_u16(1024 + 34),
162 bitmap_start: read_u16(1024 + 14),
163 allocation_pointer: read_u16(1024 + 16),
164 allocation_start: read_u16(1024 + 28),
165 next_catalog_id: read_u32(1024 + 30),
166 created_date: read_u32(1024 + 2),
167 modified_date: read_u32(1024 + 6),
168 }
169}
170
171fn raw_filesystem_signature(bytes: &[u8]) -> Option<u16> {
172 let sig = bytes
173 .get(1024..1026)
174 .map(|sig| u16::from_be_bytes([sig[0], sig[1]]))?;
175 matches!(
176 sig,
177 HFS_SIGNATURE | HFS_PLUS_SIGNATURE | HFSX_SIGNATURE | MFS_SIGNATURE
178 )
179 .then_some(sig)
180}
181
182fn filesystem_payload(bytes: &[u8]) -> &[u8] {
183 dc42_data_range(bytes)
184 .and_then(|(start, end)| bytes.get(start..end))
185 .or_else(|| apple_hfs_partition_range(bytes).and_then(|(start, end)| bytes.get(start..end)))
186 .unwrap_or(bytes)
187}
188
189fn hfs_volume_name_from_mdb(filesystem: &[u8]) -> Option<String> {
190 if raw_filesystem_signature(filesystem) != Some(HFS_SIGNATURE) {
191 return None;
192 }
193 let name_len = *filesystem.get(HFS_MDB_VOLUME_NAME_OFFSET)? as usize;
197 if name_len == 0 || name_len > HFS_MAX_VOLUME_NAME_LEN {
198 return None;
199 }
200 let name_start = HFS_MDB_VOLUME_NAME_OFFSET + 1;
201 let name = filesystem.get(name_start..name_start.checked_add(name_len)?)?;
202 clean_component(&decode_mac_roman(name))
203}
204
205fn apple_hfs_partition_range(bytes: &[u8]) -> Option<(usize, usize)> {
211 let block_size = read_u16_at(bytes, 2)? as usize;
212 if read_u16_at(bytes, 0)? != DRIVER_DESCRIPTOR_SIGNATURE
213 || block_size < 512
214 || block_size % 512 != 0
215 {
216 return None;
217 }
218
219 let first_entry = block_size;
220 if read_u16_at(bytes, first_entry)? != APPLE_PARTITION_MAP_SIGNATURE {
221 return None;
222 }
223 let map_block_count = read_u32_at(bytes, first_entry + 4)? as usize;
224 if map_block_count == 0 {
225 return None;
226 }
227
228 for map_index in 1..=map_block_count {
229 let entry = block_size.checked_mul(map_index)?;
230 let entry_end = entry.checked_add(512)?;
231 if entry_end > bytes.len() {
232 break;
233 }
234 if read_u16_at(bytes, entry)? != APPLE_PARTITION_MAP_SIGNATURE {
235 continue;
236 }
237 let partition_type = bytes.get(entry + 48..entry + 80)?;
238 if !matches!(
239 partition_type,
240 field if fixed_apm_field_equals(field, APPLE_HFS_PARTITION_TYPE)
241 || fixed_apm_field_equals(field, APPLE_HFSX_PARTITION_TYPE)
242 ) {
243 continue;
244 }
245
246 let partition_start_blocks = read_u32_at(bytes, entry + 8)? as usize;
247 let partition_block_count = read_u32_at(bytes, entry + 12)? as usize;
248 let data_start_blocks = read_u32_at(bytes, entry + 80)? as usize;
249 let data_block_count = read_u32_at(bytes, entry + 84)? as usize;
250 if data_block_count == 0
251 || data_start_blocks > partition_block_count
252 || data_block_count > partition_block_count.saturating_sub(data_start_blocks)
253 {
254 continue;
255 }
256 let filesystem_start_blocks = partition_start_blocks.checked_add(data_start_blocks)?;
257 let start = filesystem_start_blocks.checked_mul(block_size)?;
258 let end = filesystem_start_blocks
259 .checked_add(data_block_count)?
260 .checked_mul(block_size)?;
261 if start >= end || end > bytes.len() {
262 continue;
263 }
264
265 let filesystem = bytes.get(start..end)?;
266 if matches!(
267 raw_filesystem_signature(filesystem),
268 Some(HFS_SIGNATURE | HFS_PLUS_SIGNATURE | HFSX_SIGNATURE)
269 ) {
270 return Some((start, end));
271 }
272 }
273
274 None
275}
276
277fn fixed_apm_field_equals(field: &[u8], expected: &[u8]) -> bool {
278 field
279 .iter()
280 .position(|&byte| byte == 0)
281 .is_some_and(|terminator| field.get(..terminator) == Some(expected))
282}
283
284fn read_u16_at(bytes: &[u8], offset: usize) -> Option<u16> {
285 let raw = bytes.get(offset..offset + 2)?;
286 Some(u16::from_be_bytes([raw[0], raw[1]]))
287}
288
289fn read_u32_at(bytes: &[u8], offset: usize) -> Option<u32> {
290 let raw = bytes.get(offset..offset + 4)?;
291 Some(u32::from_be_bytes([raw[0], raw[1], raw[2], raw[3]]))
292}
293
294fn dc42_data_range(bytes: &[u8]) -> Option<(usize, usize)> {
295 const DC42_HEADER_LEN: usize = 84;
296
297 if bytes.len() < DC42_HEADER_LEN || bytes.get(82..84) != Some(&[0x01, 0x00]) {
298 return None;
299 }
300
301 let name_len = bytes[0] as usize;
302 if name_len > 63 {
303 return None;
304 }
305
306 let data_size = u32::from_be_bytes(bytes[64..68].try_into().ok()?) as usize;
307 if data_size == 0 || data_size % 512 != 0 {
308 return None;
309 }
310
311 let data_end = DC42_HEADER_LEN.checked_add(data_size)?;
312 (data_end <= bytes.len()).then_some((DC42_HEADER_LEN, data_end))
313}
314
315fn extract_hfsplus(bytes: &[u8]) -> Result<DiskImageContents, String> {
316 let mut reader = std::io::Cursor::new(bytes);
317 let volume = hfsplus::volume::VolumeHeader::parse(&mut reader)
318 .map_err(|e| format!("failed to parse HFS+ image: {e}"))?;
319 let catalog =
320 hfsplus::btree::read_btree_header(&mut reader, &volume.catalog_file, volume.block_size)
321 .map_err(|e| format!("failed to read HFS+ catalog B-tree: {e}"))?;
322 let extents = if volume.extents_file.total_blocks == 0 {
323 None
324 } else {
325 Some(
326 hfsplus::btree::read_btree_header(&mut reader, &volume.extents_file, volume.block_size)
327 .map_err(|e| format!("failed to read HFS+ extents B-tree: {e}"))?,
328 )
329 };
330
331 let volume_name = "HFS+ Disk Image".to_string();
335 let mut dirs = vec![volume_name.clone()];
336 let mut files = Vec::new();
337 collect_hfsplus_directory(
338 &mut reader,
339 &volume,
340 &catalog,
341 extents.as_ref(),
342 &volume_name,
343 hfsplus::catalog::CNID_ROOT_FOLDER,
344 "",
345 "",
346 &mut dirs,
347 &mut files,
348 )?;
349
350 dirs.sort_unstable();
351 dirs.dedup();
352 Ok(DiskImageContents {
353 volume_name,
354 volume_info: DiskImageVolumeInfo {
355 attributes: volume.attributes as u16,
356 file_count: volume.file_count.min(u16::MAX as u32) as u16,
357 allocation_block_count: volume.total_blocks.min(u16::MAX as u32) as u16,
358 allocation_block_size: volume.block_size,
359 clump_size: volume.data_clump_size,
360 free_blocks: volume.free_blocks.min(u16::MAX as u32) as u16,
361 bitmap_start: 0,
362 allocation_pointer: volume.next_allocation.min(u16::MAX as u32) as u16,
363 allocation_start: 0,
364 next_catalog_id: volume.next_catalog_id,
365 created_date: volume.create_date,
366 modified_date: volume.modify_date,
367 },
368 dirs,
369 files,
370 })
371}
372
373#[allow(clippy::too_many_arguments)]
374fn collect_hfsplus_directory<R: Read + Seek>(
375 reader: &mut R,
376 volume: &hfsplus::volume::VolumeHeader,
377 catalog: &hfsplus::btree::BTreeHeaderRecord,
378 extents: Option<&hfsplus::btree::BTreeHeaderRecord>,
379 volume_name: &str,
380 parent_cnid: u32,
381 raw_dir: &str,
382 vfs_dir: &str,
383 dirs: &mut Vec<String>,
384 files: &mut Vec<DiskImageFile>,
385) -> Result<(), String> {
386 let entries = hfsplus::catalog::list_directory(reader, volume, catalog, parent_cnid)
387 .map_err(|e| format!("failed to list HFS+ directory {vfs_dir}: {e}"))?;
388
389 for entry in entries {
390 let Some(cleaned_name) = clean_component(&entry.name) else {
391 continue;
392 };
393 let raw_path = join_path(raw_dir, &entry.name);
394 let vfs_path = join_path(vfs_dir, &cleaned_name);
395
396 match entry.kind {
397 hfsplus::EntryKind::Directory => {
398 dirs.push(prefixed_path(volume_name, &vfs_path));
399 collect_hfsplus_directory(
400 reader,
401 volume,
402 catalog,
403 extents,
404 volume_name,
405 entry.cnid,
406 &raw_path,
407 &vfs_path,
408 dirs,
409 files,
410 )?;
411 }
412 hfsplus::EntryKind::File | hfsplus::EntryKind::Symlink => {
413 let lookup_path = format!("/{raw_path}");
414 let (record, _) =
415 hfsplus::catalog::resolve_path(reader, volume, catalog, &lookup_path)
416 .map_err(|e| format!("failed to resolve HFS+ file {vfs_path}: {e}"))?;
417 let hfsplus::catalog::CatalogRecord::File(file) = record else {
418 continue;
419 };
420 let path = prefixed_path(volume_name, &vfs_path);
421 let metadata = hfsplus_file_finder_metadata(
422 reader,
423 catalog,
424 parent_cnid,
425 &entry.name,
426 &vfs_path,
427 )?;
428 let data = read_hfsplus_fork(
429 reader,
430 volume,
431 extents,
432 &file.data_fork,
433 file.file_id,
434 HFSPLUS_FORK_DATA,
435 )
436 .map_err(|e| format!("failed to read HFS+ data fork for {path}: {e}"))?;
437 let rsrc = read_hfsplus_fork(
438 reader,
439 volume,
440 extents,
441 &file.resource_fork,
442 file.file_id,
443 HFSPLUS_FORK_RESOURCE,
444 )
445 .map_err(|e| format!("failed to read HFS+ resource fork for {path}: {e}"))?;
446
447 files.push(DiskImageFile {
448 path,
449 data,
450 rsrc,
451 file_type: metadata.file_type,
452 creator: metadata.creator,
453 finder_flags: metadata.finder_flags,
454 });
455 }
456 }
457 }
458
459 Ok(())
460}
461
462#[derive(Clone, Copy, Debug, Eq, PartialEq)]
463struct HfsPlusFinderMetadata {
464 file_type: [u8; 4],
465 creator: [u8; 4],
466 finder_flags: u16,
467}
468
469impl Default for HfsPlusFinderMetadata {
470 fn default() -> Self {
471 Self {
472 file_type: *b"????",
473 creator: *b"????",
474 finder_flags: 0,
475 }
476 }
477}
478
479fn hfsplus_file_finder_metadata<R: Read + Seek>(
480 reader: &mut R,
481 catalog: &hfsplus::btree::BTreeHeaderRecord,
482 parent_cnid: u32,
483 name: &str,
484 vfs_path: &str,
485) -> Result<HfsPlusFinderMetadata, String> {
486 let name_utf16: Vec<u16> = name.encode_utf16().collect();
487 let records = hfsplus::btree::scan_leaves(
488 reader,
489 catalog,
490 catalog.first_leaf_node,
491 &|record_data| {
492 let Some((key_parent, key_name, _)) = hfsplus_catalog_key(record_data) else {
493 return Some(false);
494 };
495 Some(key_parent == parent_cnid && key_name == name_utf16)
496 },
497 &|record_data| Ok(hfsplus_file_finder_metadata_from_record(record_data)),
498 )
499 .map_err(|e| format!("failed to read HFS+ Finder metadata for {vfs_path}: {e}"))?;
500
501 Ok(records.into_iter().next().flatten().unwrap_or_default())
502}
503
504fn hfsplus_file_finder_metadata_from_record(record_data: &[u8]) -> Option<HfsPlusFinderMetadata> {
505 let (_, _, record_offset) = hfsplus_catalog_key(record_data)?;
506 let record = record_data.get(record_offset..)?;
507 if record.len() < HFSPLUS_FILE_USER_INFO_OFFSET + 10 {
508 return None;
509 }
510 let record_type = u16::from_be_bytes([record[0], record[1]]);
511 if record_type != HFSPLUS_CATALOG_FILE_RECORD {
512 return None;
513 }
514 let finder = &record[HFSPLUS_FILE_USER_INFO_OFFSET..];
515 let file_type = [finder[0], finder[1], finder[2], finder[3]];
516 let creator = [finder[4], finder[5], finder[6], finder[7]];
517 let (file_type, creator) = if file_type == [0; 4] && creator == [0; 4] {
518 (*b"????", *b"????")
519 } else {
520 (file_type, creator)
521 };
522 Some(HfsPlusFinderMetadata {
523 file_type,
524 creator,
525 finder_flags: u16::from_be_bytes([finder[8], finder[9]]),
526 })
527}
528
529fn hfsplus_catalog_key(record_data: &[u8]) -> Option<(u32, Vec<u16>, usize)> {
530 let header = record_data.get(0..8)?;
531 let key_length = u16::from_be_bytes([header[0], header[1]]) as usize;
532 let parent_id = u32::from_be_bytes([header[2], header[3], header[4], header[5]]);
533 let name_len = u16::from_be_bytes([header[6], header[7]]) as usize;
534 let name_end = 8usize.checked_add(name_len.checked_mul(2)?)?;
535 let name_bytes = record_data.get(8..name_end)?;
536 let name = name_bytes
537 .chunks_exact(2)
538 .map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]]))
539 .collect();
540 let record_offset = (2usize.checked_add(key_length)? + 1) & !1;
541 (record_offset <= record_data.len()).then_some((parent_id, name, record_offset))
542}
543
544fn join_path(parent: &str, name: &str) -> String {
545 if parent.is_empty() {
546 name.to_string()
547 } else {
548 format!("{parent}/{name}")
549 }
550}
551
552fn read_hfsplus_fork<R: Read + Seek>(
553 reader: &mut R,
554 volume: &hfsplus::volume::VolumeHeader,
555 extents: Option<&hfsplus::btree::BTreeHeaderRecord>,
556 fork: &hfsplus::volume::ForkData,
557 file_id: u32,
558 fork_type: u8,
559) -> Result<Vec<u8>, String> {
560 let logical_size =
561 usize::try_from(fork.logical_size).map_err(|_| "fork is too large".to_string())?;
562 if logical_size == 0 {
563 return Ok(Vec::new());
564 }
565
566 let mut out = Vec::with_capacity(logical_size);
567 for extent in &fork.extents {
568 if extent.block_count == 0 || out.len() >= logical_size {
569 break;
570 }
571 read_hfsplus_extent(reader, volume.block_size, extent, logical_size, &mut out)?;
572 }
573
574 let mut start_block = fork.extents.iter().map(|extent| extent.block_count).sum();
575 while out.len() < logical_size {
576 let extents = extents.ok_or_else(|| "missing HFS+ extents B-tree".to_string())?;
577 let overflow = hfsplus_overflow_extents(reader, extents, file_id, fork_type, start_block)?;
578 if overflow.is_empty() {
579 break;
580 }
581
582 for extent in overflow {
583 if extent.block_count == 0 || out.len() >= logical_size {
584 break;
585 }
586 read_hfsplus_extent(reader, volume.block_size, &extent, logical_size, &mut out)?;
587 start_block = start_block.saturating_add(extent.block_count);
588 }
589 }
590
591 if out.len() < logical_size {
592 return Err(format!(
593 "fork truncated: read {} of {} bytes",
594 out.len(),
595 logical_size
596 ));
597 }
598 out.truncate(logical_size);
599 Ok(out)
600}
601
602fn read_hfsplus_extent<R: Read + Seek>(
603 reader: &mut R,
604 block_size: u32,
605 extent: &hfsplus::volume::ExtentDescriptor,
606 logical_size: usize,
607 out: &mut Vec<u8>,
608) -> Result<(), String> {
609 let offset = u64::from(extent.start_block)
610 .checked_mul(u64::from(block_size))
611 .ok_or_else(|| "HFS+ extent offset overflow".to_string())?;
612 let byte_len = u64::from(extent.block_count)
613 .checked_mul(u64::from(block_size))
614 .ok_or_else(|| "HFS+ extent length overflow".to_string())?;
615 let remaining = logical_size.saturating_sub(out.len());
616 let mut to_read = usize::try_from(byte_len)
617 .unwrap_or(usize::MAX)
618 .min(remaining);
619 reader
620 .seek(SeekFrom::Start(offset))
621 .map_err(|e| format!("seek HFS+ extent: {e}"))?;
622
623 while to_read > 0 {
624 let chunk = to_read.min(64 * 1024);
625 let start = out.len();
626 out.resize(start + chunk, 0);
627 reader
628 .read_exact(&mut out[start..start + chunk])
629 .map_err(|e| format!("read HFS+ extent: {e}"))?;
630 to_read -= chunk;
631 }
632
633 Ok(())
634}
635
636fn hfsplus_overflow_extents<R: Read + Seek>(
637 reader: &mut R,
638 extents: &hfsplus::btree::BTreeHeaderRecord,
639 file_id: u32,
640 fork_type: u8,
641 start_block: u32,
642) -> Result<Vec<hfsplus::volume::ExtentDescriptor>, String> {
643 let records = hfsplus::btree::scan_leaves(
644 reader,
645 extents,
646 extents.first_leaf_node,
647 &|record_data| {
648 let key = hfsplus_extent_key(record_data)?;
649 Some(key == (fork_type, file_id, start_block))
650 },
651 &|record_data| {
652 let key_length = u16::from_be_bytes([record_data[0], record_data[1]]) as usize;
653 let data_start = 2 + key_length;
654 let data = record_data
655 .get(data_start..data_start + 64)
656 .ok_or_else(|| {
657 hfsplus::HfsPlusError::InvalidBTree("extent record too short".into())
658 })?;
659 let mut extents = Vec::with_capacity(8);
660 for chunk in data.chunks_exact(8) {
661 extents.push(hfsplus::volume::ExtentDescriptor {
662 start_block: u32::from_be_bytes(chunk[0..4].try_into().unwrap()),
663 block_count: u32::from_be_bytes(chunk[4..8].try_into().unwrap()),
664 });
665 }
666 Ok(extents)
667 },
668 )
669 .map_err(|e| format!("read HFS+ overflow extents: {e}"))?;
670
671 Ok(records.into_iter().flatten().collect())
672}
673
674fn hfsplus_extent_key(record_data: &[u8]) -> Option<(u8, u32, u32)> {
675 (record_data.len() >= 12).then(|| {
676 (
677 record_data[2],
678 u32::from_be_bytes(record_data[4..8].try_into().unwrap()),
679 u32::from_be_bytes(record_data[8..12].try_into().unwrap()),
680 )
681 })
682}
683
684fn path_to_vfs_path(path: &Path) -> Option<String> {
685 let mut parts = Vec::new();
686 for component in path.components() {
687 let Component::Normal(part) = component else {
688 continue;
689 };
690 let Some(cleaned) = clean_component(&part.to_string_lossy()) else {
691 continue;
692 };
693 parts.push(cleaned);
694 }
695
696 (!parts.is_empty()).then(|| parts.join("/"))
697}
698
699fn prefixed_path(volume_name: &str, rel_path: &str) -> String {
700 if volume_name.is_empty() {
701 rel_path.to_string()
702 } else {
703 format!("{volume_name}/{rel_path}")
704 }
705}
706
707fn clean_component(raw: &str) -> Option<String> {
708 let cleaned: String = raw
709 .chars()
710 .map(|ch| match ch {
711 '/' | ':' | '\\' => '_',
712 ch if ch.is_control() => '_',
713 ch => ch,
714 })
715 .collect();
716 let trimmed = cleaned.trim();
717 (!trimmed.is_empty() && trimmed != "." && trimmed != "..").then(|| trimmed.to_string())
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723
724 #[test]
725 fn decodes_hfs_mdb_volume_name_as_mac_roman() {
726 let mut filesystem = vec![0; HFS_MDB_VOLUME_NAME_OFFSET + 9];
727 filesystem[1024..1026].copy_from_slice(&HFS_SIGNATURE.to_be_bytes());
728 filesystem[HFS_MDB_VOLUME_NAME_OFFSET] = 8;
729 filesystem[HFS_MDB_VOLUME_NAME_OFFSET + 1..].copy_from_slice(b"TETRIS\xA51");
730
731 assert_eq!(
732 hfs_volume_name_from_mdb(&filesystem).as_deref(),
733 Some("TETRIS•1")
734 );
735 }
736
737 #[test]
738 fn rejects_invalid_hfs_mdb_volume_name_bounds() {
739 let mut filesystem = vec![0; HFS_MDB_VOLUME_NAME_OFFSET + 2];
740 filesystem[1024..1026].copy_from_slice(&HFS_SIGNATURE.to_be_bytes());
741
742 filesystem[HFS_MDB_VOLUME_NAME_OFFSET] = 28;
743 assert_eq!(hfs_volume_name_from_mdb(&filesystem), None);
744
745 filesystem[HFS_MDB_VOLUME_NAME_OFFSET] = 2;
746 assert_eq!(hfs_volume_name_from_mdb(&filesystem), None);
747 }
748
749 #[test]
750 fn detects_raw_hfs_volume_signature() {
751 let mut bytes = vec![0; 2048];
752 bytes[1024..1026].copy_from_slice(&HFS_SIGNATURE.to_be_bytes());
753
754 assert!(looks_like_dc42_or_hfs(&bytes));
755 }
756
757 #[test]
758 fn detects_raw_hfsplus_and_hfsx_volume_signatures() {
759 for signature in [HFS_PLUS_SIGNATURE, HFSX_SIGNATURE] {
760 let mut bytes = vec![0; 2048];
761 bytes[1024..1026].copy_from_slice(&signature.to_be_bytes());
762
763 assert!(looks_like_dc42_or_hfs(&bytes));
764 }
765 }
766
767 #[test]
768 fn detects_hfs_volume_inside_apple_partition_map() {
769 const PARTITION_START: usize = 64;
770 const PARTITION_BLOCKS: usize = 4;
771 let mut bytes = apm_fixture(PARTITION_START + PARTITION_BLOCKS + 2, 2);
772 write_apm_partition(
773 &mut bytes,
774 2,
775 PARTITION_START,
776 PARTITION_BLOCKS,
777 0,
778 PARTITION_BLOCKS,
779 APPLE_HFS_PARTITION_TYPE,
780 );
781 bytes[PARTITION_START * APM_BLOCK_SIZE + 1024..PARTITION_START * APM_BLOCK_SIZE + 1026]
782 .copy_from_slice(&HFS_SIGNATURE.to_be_bytes());
783
784 assert_eq!(
785 apple_hfs_partition_range(&bytes),
786 Some((
787 PARTITION_START * APM_BLOCK_SIZE,
788 bytes.len() - 2 * APM_BLOCK_SIZE
789 ))
790 );
791 assert!(looks_like_dc42_or_hfs(&bytes));
792 }
793
794 #[test]
795 fn detects_hfsplus_partition_inside_mixed_apple_partition_map() {
796 const PARTITION_START: usize = 32;
797 const PARTITION_BLOCKS: usize = 16;
798 const DATA_START: usize = 2;
799 const DATA_BLOCKS: usize = 8;
800 let mut bytes = apm_fixture(128, 3);
801 write_apm_partition(&mut bytes, 2, 16, 8, 0, 8, b"Apple_Free");
802 write_apm_partition(
803 &mut bytes,
804 3,
805 PARTITION_START,
806 PARTITION_BLOCKS,
807 DATA_START,
808 DATA_BLOCKS,
809 APPLE_HFSX_PARTITION_TYPE,
810 );
811 let filesystem_start = (PARTITION_START + DATA_START) * APM_BLOCK_SIZE;
812 bytes[filesystem_start + 1024..filesystem_start + 1026]
813 .copy_from_slice(&HFS_PLUS_SIGNATURE.to_be_bytes());
814
815 assert_eq!(
816 apple_hfs_partition_range(&bytes),
817 Some((
818 filesystem_start,
819 (PARTITION_START + DATA_START + DATA_BLOCKS) * APM_BLOCK_SIZE
820 ))
821 );
822 assert!(looks_like_dc42_or_hfs(&bytes));
823 }
824
825 #[test]
826 fn rejects_non_exact_apple_hfs_partition_type() {
827 const PARTITION_START: usize = 16;
828 const PARTITION_BLOCKS: usize = 8;
829 let mut bytes = apm_fixture(PARTITION_START + PARTITION_BLOCKS + 2, 2);
830 write_apm_partition(
831 &mut bytes,
832 2,
833 PARTITION_START,
834 PARTITION_BLOCKS,
835 0,
836 PARTITION_BLOCKS,
837 b"Apple_HFS_backup",
838 );
839 bytes[PARTITION_START * APM_BLOCK_SIZE + 1024..PARTITION_START * APM_BLOCK_SIZE + 1026]
840 .copy_from_slice(&HFS_SIGNATURE.to_be_bytes());
841
842 assert!(apple_hfs_partition_range(&bytes).is_none());
843 assert!(!looks_like_dc42_or_hfs(&bytes));
844 }
845
846 #[test]
847 fn accepts_exact_apple_hfs_type_with_data_after_the_c_string() {
848 const PARTITION_START: usize = 16;
849 const PARTITION_BLOCKS: usize = 8;
850 let mut bytes = apm_fixture(PARTITION_START + PARTITION_BLOCKS + 2, 2);
851 write_apm_partition(
852 &mut bytes,
853 2,
854 PARTITION_START,
855 PARTITION_BLOCKS,
856 0,
857 PARTITION_BLOCKS,
858 APPLE_HFS_PARTITION_TYPE,
859 );
860 let type_tail = 2 * APM_BLOCK_SIZE + 48 + APPLE_HFS_PARTITION_TYPE.len() + 1;
861 bytes[type_tail] = 0xA5;
862 bytes[PARTITION_START * APM_BLOCK_SIZE + 1024..PARTITION_START * APM_BLOCK_SIZE + 1026]
863 .copy_from_slice(&HFS_SIGNATURE.to_be_bytes());
864
865 assert!(apple_hfs_partition_range(&bytes).is_some());
866 }
867
868 #[test]
869 fn apm_partition_types_are_exact_case_terminated_c_strings() {
870 let mut exact = [0u8; 32];
871 exact[..APPLE_HFS_PARTITION_TYPE.len()].copy_from_slice(APPLE_HFS_PARTITION_TYPE);
872 assert!(fixed_apm_field_equals(&exact, APPLE_HFS_PARTITION_TYPE));
873
874 let mut mixed_case = exact;
875 mixed_case[0] = b'a';
876 assert!(!fixed_apm_field_equals(
877 &mixed_case,
878 APPLE_HFS_PARTITION_TYPE
879 ));
880
881 let mut unterminated = [b'X'; 32];
882 unterminated[..APPLE_HFS_PARTITION_TYPE.len()].copy_from_slice(APPLE_HFS_PARTITION_TYPE);
883 assert!(!fixed_apm_field_equals(
884 &unterminated,
885 APPLE_HFS_PARTITION_TYPE
886 ));
887 }
888
889 const APM_BLOCK_SIZE: usize = 512;
890
891 fn apm_fixture(block_count: usize, map_block_count: u32) -> Vec<u8> {
892 let mut bytes = vec![0; block_count * APM_BLOCK_SIZE];
893 bytes[0..2].copy_from_slice(&DRIVER_DESCRIPTOR_SIGNATURE.to_be_bytes());
894 bytes[2..4].copy_from_slice(&(APM_BLOCK_SIZE as u16).to_be_bytes());
895 bytes[4..8].copy_from_slice(&(block_count as u32).to_be_bytes());
896
897 let map_entry = APM_BLOCK_SIZE;
898 bytes[map_entry..map_entry + 2]
899 .copy_from_slice(&APPLE_PARTITION_MAP_SIGNATURE.to_be_bytes());
900 bytes[map_entry + 4..map_entry + 8].copy_from_slice(&map_block_count.to_be_bytes());
901 bytes[map_entry + 8..map_entry + 12].copy_from_slice(&1u32.to_be_bytes());
902 bytes[map_entry + 12..map_entry + 16].copy_from_slice(&map_block_count.to_be_bytes());
903 bytes[map_entry + 48..map_entry + 48 + 19].copy_from_slice(b"Apple_partition_map");
904 bytes
905 }
906
907 fn write_apm_partition(
908 bytes: &mut [u8],
909 map_index: usize,
910 partition_start: usize,
911 partition_blocks: usize,
912 data_start: usize,
913 data_blocks: usize,
914 partition_type: &[u8],
915 ) {
916 assert!(partition_type.len() <= 32);
917 let entry = APM_BLOCK_SIZE * map_index;
918 bytes[entry..entry + 2].copy_from_slice(&APPLE_PARTITION_MAP_SIGNATURE.to_be_bytes());
919 bytes[entry + 4..entry + 8].copy_from_slice(&2u32.to_be_bytes());
920 bytes[entry + 8..entry + 12].copy_from_slice(&(partition_start as u32).to_be_bytes());
921 bytes[entry + 12..entry + 16].copy_from_slice(&(partition_blocks as u32).to_be_bytes());
922 bytes[entry + 48..entry + 48 + partition_type.len()].copy_from_slice(partition_type);
923 bytes[entry + 80..entry + 84].copy_from_slice(&(data_start as u32).to_be_bytes());
924 bytes[entry + 84..entry + 88].copy_from_slice(&(data_blocks as u32).to_be_bytes());
925 }
926
927 #[test]
928 fn detects_dc42_wrapped_hfs_payload() {
929 let bytes = dc42_with_payload_signature(HFS_SIGNATURE);
930
931 assert!(looks_like_dc42_or_hfs(&bytes));
932 }
933
934 #[test]
935 fn extracts_hfsplus_data_fork_files() {
936 let mut builder = hfsplus::testutil::HfsPlusImageBuilder::new();
937 builder.add_file("hello.txt", b"hello hfs+", 0o100644);
938 let bytes = builder.build();
939
940 let image = extract_dc42_or_hfs(&bytes)
941 .expect("HFS+ extraction should succeed")
942 .expect("HFS+ signature should be detected");
943
944 assert_eq!(image.volume_name, "HFS+ Disk Image");
945 assert_eq!(image.dirs, vec!["HFS+ Disk Image".to_string()]);
946 let file = image
947 .files
948 .iter()
949 .find(|file| file.path == "HFS+ Disk Image/hello.txt")
950 .expect("synthetic HFS+ file should be present");
951 assert_eq!(file.data, b"hello hfs+");
952 assert!(file.rsrc.is_empty());
953 assert_eq!(file.file_type, *b"????");
954 assert_eq!(file.creator, *b"????");
955 }
956
957 #[test]
958 fn parses_hfsplus_catalog_finder_metadata() {
959 let mut record = hfsplus_catalog_file_record("Star Trek JR Demo");
960 let key_len = u16::from_be_bytes([record[0], record[1]]) as usize;
961 let record_offset = (2 + key_len + 1) & !1;
962 let finder = record_offset + HFSPLUS_FILE_USER_INFO_OFFSET;
963 record[finder..finder + 4].copy_from_slice(b"APPL");
964 record[finder + 4..finder + 8].copy_from_slice(b"MPLY");
965 record[finder + 8..finder + 10].copy_from_slice(&0x0400u16.to_be_bytes());
966
967 let metadata = hfsplus_file_finder_metadata_from_record(&record)
968 .expect("file record metadata should parse");
969
970 assert_eq!(
971 hfsplus_catalog_key(&record)
972 .expect("catalog key should parse")
973 .0,
974 42
975 );
976 assert_eq!(metadata.file_type, *b"APPL");
977 assert_eq!(metadata.creator, *b"MPLY");
978 assert_eq!(metadata.finder_flags, 0x0400);
979 }
980
981 #[test]
982 fn empty_hfsplus_catalog_finder_codes_fall_back_to_unknown() {
983 let record = hfsplus_catalog_file_record("Untyped");
984
985 let metadata = hfsplus_file_finder_metadata_from_record(&record)
986 .expect("file record metadata should parse");
987
988 assert_eq!(metadata.file_type, *b"????");
989 assert_eq!(metadata.creator, *b"????");
990 assert_eq!(metadata.finder_flags, 0);
991 }
992
993 #[test]
994 fn reads_hfsplus_resource_fork_inline_extents() {
995 const BLOCK_SIZE: usize = 512;
996 let mut bytes = vec![0u8; BLOCK_SIZE * 4];
997 bytes[BLOCK_SIZE * 2..BLOCK_SIZE * 2 + 4].copy_from_slice(b"rsrc");
998 let fork = hfsplus::volume::ForkData {
999 logical_size: 4,
1000 clump_size: 0,
1001 total_blocks: 1,
1002 extents: {
1003 let mut extents = [hfsplus::volume::ExtentDescriptor::default(); 8];
1004 extents[0] = hfsplus::volume::ExtentDescriptor {
1005 start_block: 2,
1006 block_count: 1,
1007 };
1008 extents
1009 },
1010 };
1011 let volume = hfsplus::volume::VolumeHeader {
1012 signature: HFS_PLUS_SIGNATURE,
1013 version: 4,
1014 attributes: 0,
1015 last_mounted_version: 0,
1016 journal_info_block: 0,
1017 create_date: 0,
1018 modify_date: 0,
1019 backup_date: 0,
1020 checked_date: 0,
1021 file_count: 0,
1022 folder_count: 0,
1023 block_size: BLOCK_SIZE as u32,
1024 total_blocks: 4,
1025 free_blocks: 0,
1026 next_allocation: 0,
1027 rsrc_clump_size: 0,
1028 data_clump_size: 0,
1029 next_catalog_id: 0,
1030 write_count: 0,
1031 encoding_bitmap: 0,
1032 finder_info: [0; 8],
1033 allocation_file: hfsplus::volume::ForkData::default(),
1034 extents_file: hfsplus::volume::ForkData::default(),
1035 catalog_file: hfsplus::volume::ForkData::default(),
1036 attributes_file: hfsplus::volume::ForkData::default(),
1037 startup_file: hfsplus::volume::ForkData::default(),
1038 is_hfsx: false,
1039 };
1040 let mut reader = std::io::Cursor::new(bytes);
1041
1042 let out = read_hfsplus_fork(&mut reader, &volume, None, &fork, 42, HFSPLUS_FORK_RESOURCE)
1043 .expect("inline resource fork should read");
1044
1045 assert_eq!(out, b"rsrc");
1046 }
1047
1048 #[test]
1049 fn rejects_dc42_like_data_without_filesystem_signature() {
1050 let bytes = dc42_with_payload_signature(0);
1051
1052 assert!(!looks_like_dc42_or_hfs(&bytes));
1053 }
1054
1055 fn dc42_with_payload_signature(signature: u16) -> Vec<u8> {
1056 const HEADER_LEN: usize = 84;
1057 const DATA_LEN: usize = 2048;
1058
1059 let mut bytes = vec![0; HEADER_LEN + DATA_LEN];
1060 bytes[0] = 4;
1061 bytes[1..5].copy_from_slice(b"Test");
1062 bytes[64..68].copy_from_slice(&(DATA_LEN as u32).to_be_bytes());
1063 bytes[82..84].copy_from_slice(&[0x01, 0x00]);
1064 bytes[HEADER_LEN + 1024..HEADER_LEN + 1026].copy_from_slice(&signature.to_be_bytes());
1065 bytes
1066 }
1067
1068 fn hfsplus_catalog_file_record(name: &str) -> Vec<u8> {
1069 let name_utf16: Vec<u16> = name.encode_utf16().collect();
1070 let key_len = 6 + name_utf16.len() * 2;
1071 let record_offset = (2 + key_len + 1) & !1;
1072 let mut record = vec![0u8; record_offset + 88];
1073 record[0..2].copy_from_slice(&(key_len as u16).to_be_bytes());
1074 record[2..6].copy_from_slice(&42u32.to_be_bytes());
1075 record[6..8].copy_from_slice(&(name_utf16.len() as u16).to_be_bytes());
1076 for (idx, ch) in name_utf16.iter().enumerate() {
1077 let start = 8 + idx * 2;
1078 record[start..start + 2].copy_from_slice(&ch.to_be_bytes());
1079 }
1080 record[record_offset..record_offset + 2]
1081 .copy_from_slice(&HFSPLUS_CATALOG_FILE_RECORD.to_be_bytes());
1082 record
1083 }
1084}