Skip to main content

systemless/
disk_image.rs

1//! Read-only extraction helpers for classic Mac disk images.
2//!
3//! The runtime VFS is not a mounted HFS volume; it is a set of data-fork,
4//! resource-fork, and Finder metadata maps. These helpers turn DC42/raw
5//! HFS/HFS+ images into entries that can be seeded into that existing VFS.
6
7use 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            // hfs-reader exposes type/creator but not fdFlags yet.
121            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    // The HFS master directory block stores its authoritative volume name as
194    // a Str27 Pascal string. Decode those bytes directly because hfs-reader's
195    // host-path representation has already replaced non-ASCII characters.
196    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
205/// Locate the first supported HFS-family partition in an Apple Partition Map image.
206/// The driver descriptor record supplies the physical block size; each
207/// partition-map entry then identifies the partition start, length, data extent,
208/// and type.
209/// Inside Macintosh: Devices (1994), pp. 3-13–3-15, 3-25–3-27.
210fn 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    // HFS+ stores the volume name in catalog thread records rather than the
332    // volume header. Keep the same deterministic VFS mount shape as nameless
333    // disk-image payloads instead of guessing from optional catalog metadata.
334    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}