Skip to main content

fs_mac/
hfsplus.rs

1use byteorder::{BigEndian, ByteOrder};
2#[cfg(feature = "rust173-polyfill")]
3use disk_core::rust173::IntIsMultipleOf as _;
4use std::cmp::Ordering;
5use std::collections::{HashMap, HashSet};
6use std::io::{Read, Seek, SeekFrom, Write};
7
8use crate::hfs_common::{
9    self, bitmap_clear_bit_be, bitmap_collect_clear_runs_be, bitmap_set_bit_be, bitmap_test_bit_be,
10    btree_detach_freed_node_and_refresh, btree_free_node, btree_grow_blocks, btree_map_chain_tail,
11    btree_refresh_index_keys, btree_remove_record, btree_retire_empty_leaf, BTreeHeader,
12    BTreeKeyFormat,
13};
14use disk_core::entry::{EntryType, FileEntry};
15use disk_core::filesystem::CompactResult;
16use disk_core::filesystem::{
17    CreateDirectoryOptions, CreateFileOptions, EditableFilesystem, Filesystem, FilesystemError,
18};
19
20const HFS_PLUS_SIGNATURE: u16 = 0x482B;
21const HFSX_SIGNATURE: u16 = 0x4858;
22
23/// HFS+ reserved CNIDs (Inside Macintosh: Files / TN1150).
24const HFSPLUS_EXTENTS_FILE_ID: u32 = 3;
25const HFSPLUS_CATALOG_FILE_ID: u32 = 4;
26const HFSPLUS_ALLOCATION_FILE_ID: u32 = 6;
27const HFSPLUS_ATTRIBUTES_FILE_ID: u32 = 8;
28
29/// Which special-file B-tree a grow-on-full operation targets. Each is
30/// backed by a `ForkData` in the volume header and an in-memory tree buffer
31/// (`catalog_data` / `extents_overflow_data` / `attributes_data`).
32/// See `grow_btree_fork` and `docs/todo_hfsplus_fork_growth.md` §4b.
33#[derive(Clone, Copy, PartialEq, Eq, Debug)]
34enum BTreeFork {
35    Catalog,
36    Extents,
37    Attributes,
38}
39
40impl BTreeFork {
41    /// Reserved CNID of the special file backing this B-tree — the key under
42    /// which its own overflow extents are recorded in the extents-overflow
43    /// B-tree (Phase B).
44    fn file_id(self) -> u32 {
45        match self {
46            BTreeFork::Catalog => HFSPLUS_CATALOG_FILE_ID,
47            BTreeFork::Extents => HFSPLUS_EXTENTS_FILE_ID,
48            BTreeFork::Attributes => HFSPLUS_ATTRIBUTES_FILE_ID,
49        }
50    }
51
52    fn label(self) -> &'static str {
53        match self {
54            BTreeFork::Catalog => "catalog",
55            BTreeFork::Extents => "extents-overflow",
56            BTreeFork::Attributes => "attributes",
57        }
58    }
59}
60
61/// Volume attribute bit (`vh.attributes`) set when the volume carries a
62/// journal. We refuse to enter edit mode on these volumes until journal
63/// replay is implemented — see Step 4 of `docs/hfsplus_enhancements.md`.
64pub(crate) const HFSPLUS_VOLUME_JOURNALED_BIT: u32 = 0x2000;
65
66/// `kHFSVolumeUnmountedBit` — set in `vh.attributes` to mark a cleanly-
67/// unmounted volume. Mac OS refuses to mount volumes without this bit
68/// (it triggers a "needs scavenging" path).
69pub(crate) const HFSPLUS_VOLUME_UNMOUNTED_BIT: u32 = 0x100;
70
71/// `kHFSBootVolumeInconsistentBit` — set by Mac OS while a volume is
72/// mounted; cleared on clean unmount. Must be cleared on sync so the
73/// volume isn't flagged for repair on next mount.
74pub(crate) const HFSPLUS_BOOT_VOLUME_INCONSISTENT_BIT: u32 = 0x800;
75
76/// `kHFSVolumeInconsistentBit` — newer companion to the boot-inconsistent
77/// bit; Mac OS sets it when the volume is in a transient inconsistent
78/// state (mid-mount, mid-write). Must be cleared on sync so Disk Utility
79/// doesn't refuse to mount the volume.
80pub(crate) const HFSPLUS_VOLUME_INCONSISTENT_BIT: u32 = 0x4000;
81
82/// HFS+ B-tree `keyCompareType` byte values (BTHeaderRec offset 37 in
83/// node 0). Plain HFS+ uses case-folding NFD compare; HFSX with the
84/// case-sensitive attribute uses binary compare.
85const KEY_COMPARE_CASE_FOLDING: u8 = 0xCF;
86const KEY_COMPARE_BINARY: u8 = 0xBC;
87
88/// Fork-type byte used in extents-overflow keys.
89const HFSPLUS_FORK_DATA: u8 = 0x00;
90#[allow(dead_code)]
91const HFSPLUS_FORK_RESOURCE: u8 = 0xFF;
92
93/// Attributes B-tree record types (per Apple TN1150 / `hfs_format.h`).
94pub(crate) const HFSPLUS_ATTR_INLINE_DATA: u32 = 0x10;
95#[allow(dead_code)] // matched by Step 13 (xattr writes); kept for completeness
96const HFSPLUS_ATTR_FORK_DATA: u32 = 0x20;
97#[allow(dead_code)]
98const HFSPLUS_ATTR_EXTENTS: u32 = 0x30;
99
100/// HFS+ extent descriptor: start_block (u32) + block_count (u32).
101#[derive(Debug, Clone, Copy)]
102pub(crate) struct ExtentDescriptor {
103    pub(crate) start_block: u32,
104    pub(crate) block_count: u32,
105}
106
107impl ExtentDescriptor {
108    fn parse(data: &[u8]) -> Self {
109        ExtentDescriptor {
110            start_block: BigEndian::read_u32(&data[0..4]),
111            block_count: BigEndian::read_u32(&data[4..8]),
112        }
113    }
114
115    fn is_empty(&self) -> bool {
116        self.block_count == 0
117    }
118
119    fn serialize(&self, out: &mut [u8]) {
120        BigEndian::write_u32(&mut out[0..4], self.start_block);
121        BigEndian::write_u32(&mut out[4..8], self.block_count);
122    }
123}
124
125/// HFS+ fork data (80 bytes).
126#[derive(Debug, Clone)]
127pub(crate) struct ForkData {
128    pub(crate) logical_size: u64,
129    // Preserved for VH round-trip; HFS+ on-disk fork data has a clump_size
130    // slot at bytes [8..12]. We never tune it, but parsing/emitting the
131    // named field keeps the serializer symmetric with the parser.
132    #[allow(dead_code)]
133    pub(crate) clump_size: u32,
134    pub(crate) total_blocks: u32,
135    pub(crate) extents: [ExtentDescriptor; 8],
136}
137
138impl ForkData {
139    fn parse(data: &[u8]) -> Self {
140        let mut extents = [ExtentDescriptor {
141            start_block: 0,
142            block_count: 0,
143        }; 8];
144        for i in 0..8 {
145            extents[i] = ExtentDescriptor::parse(&data[16 + i * 8..24 + i * 8]);
146        }
147        ForkData {
148            logical_size: BigEndian::read_u64(&data[0..8]),
149            clump_size: BigEndian::read_u32(&data[8..12]),
150            total_blocks: BigEndian::read_u32(&data[12..16]),
151            extents,
152        }
153    }
154
155    fn serialize(&self, out: &mut [u8]) {
156        BigEndian::write_u64(&mut out[0..8], self.logical_size);
157        BigEndian::write_u32(&mut out[8..12], self.clump_size);
158        BigEndian::write_u32(&mut out[12..16], self.total_blocks);
159        for i in 0..8 {
160            self.extents[i].serialize(&mut out[16 + i * 8..24 + i * 8]);
161        }
162    }
163
164    pub(crate) fn empty() -> Self {
165        ForkData {
166            logical_size: 0,
167            clump_size: 0,
168            total_blocks: 0,
169            extents: [ExtentDescriptor {
170                start_block: 0,
171                block_count: 0,
172            }; 8],
173        }
174    }
175}
176
177/// One extended-attribute record decoded from the attributes B-tree.
178///
179/// The HFS+ attributes file stores xattrs keyed by `(fileID, startBlock, name)`;
180/// inline records carry their value byte-for-byte, fork records carry a fork
181/// header pointing at a separate xattr fork (read on demand), and extents
182/// records carry continuation extents for forks larger than 8 inline extents.
183#[derive(Debug, Clone)]
184#[allow(dead_code)] // accessor fields are read in upcoming Step 13/14 callers
185pub(crate) struct XattrRecord {
186    pub name: String,
187    /// Starting block of the value within an attribute fork. `0` for inline
188    /// records and the first segment of fork-style records; nonzero for
189    /// `Extents` continuation records.
190    pub start_block: u32,
191    pub kind: XattrKind,
192}
193
194#[derive(Debug, Clone)]
195#[allow(dead_code)] // Fork/Extents variants are surfaced once xattr writes ship
196pub(crate) enum XattrKind {
197    /// Value stored directly in the B-tree record (the common case for
198    /// `com.apple.FinderInfo` and most `com.apple.*` keys).
199    Inline(Vec<u8>),
200    /// Value stored in its own fork; the inline 8 extents in `ForkData`
201    /// cover the value (or anchor a chain of `Extents` records).
202    Fork(ForkData),
203    /// Continuation extents for a fork-style xattr that needed more than
204    /// 8 extents.
205    Extents(Vec<ExtentDescriptor>),
206}
207
208/// HFS+ Volume Header (512 bytes at partition_offset + 1024).
209#[derive(Debug, Clone)]
210#[allow(dead_code)]
211struct HfsPlusVolumeHeader {
212    signature: u16,
213    version: u16,
214    attributes: u32,
215    last_mounted_version: u32,
216    journal_info_block: u32,
217    create_date: u32,
218    modify_date: u32,
219    backup_date: u32,
220    checked_date: u32,
221    file_count: u32,
222    folder_count: u32,
223    block_size: u32,
224    total_blocks: u32,
225    free_blocks: u32,
226    next_allocation: u32,
227    rsrc_clump_size: u32,
228    data_clump_size: u32,
229    next_catalog_id: u32,
230    write_count: u32,
231    encodings_bitmap: u64,
232    finder_info: [u32; 8],
233    allocation_file: ForkData,
234    extents_file: ForkData,
235    catalog_file: ForkData,
236    attributes_file: ForkData,
237    startup_file: ForkData,
238}
239
240impl HfsPlusVolumeHeader {
241    fn parse(data: &[u8]) -> Result<Self, FilesystemError> {
242        if data.len() < 512 {
243            return Err(FilesystemError::Parse("volume header too short".into()));
244        }
245        let sig = BigEndian::read_u16(&data[0..2]);
246        if sig != HFS_PLUS_SIGNATURE && sig != HFSX_SIGNATURE {
247            return Err(FilesystemError::Parse(format!(
248                "bad HFS+ volume header signature: 0x{sig:04X}"
249            )));
250        }
251
252        let mut finder_info = [0u32; 8];
253        for i in 0..8 {
254            finder_info[i] = BigEndian::read_u32(&data[80 + i * 4..84 + i * 4]);
255        }
256
257        Ok(HfsPlusVolumeHeader {
258            signature: sig,
259            version: BigEndian::read_u16(&data[2..4]),
260            attributes: BigEndian::read_u32(&data[4..8]),
261            last_mounted_version: BigEndian::read_u32(&data[8..12]),
262            journal_info_block: BigEndian::read_u32(&data[12..16]),
263            create_date: BigEndian::read_u32(&data[16..20]),
264            modify_date: BigEndian::read_u32(&data[20..24]),
265            backup_date: BigEndian::read_u32(&data[24..28]),
266            checked_date: BigEndian::read_u32(&data[28..32]),
267            file_count: BigEndian::read_u32(&data[32..36]),
268            folder_count: BigEndian::read_u32(&data[36..40]),
269            block_size: BigEndian::read_u32(&data[40..44]),
270            total_blocks: BigEndian::read_u32(&data[44..48]),
271            free_blocks: BigEndian::read_u32(&data[48..52]),
272            next_allocation: BigEndian::read_u32(&data[52..56]),
273            rsrc_clump_size: BigEndian::read_u32(&data[56..60]),
274            data_clump_size: BigEndian::read_u32(&data[60..64]),
275            next_catalog_id: BigEndian::read_u32(&data[64..68]),
276            write_count: BigEndian::read_u32(&data[68..72]),
277            encodings_bitmap: BigEndian::read_u64(&data[72..80]),
278            finder_info,
279            allocation_file: ForkData::parse(&data[112..192]),
280            extents_file: ForkData::parse(&data[192..272]),
281            catalog_file: ForkData::parse(&data[272..352]),
282            attributes_file: ForkData::parse(&data[352..432]),
283            startup_file: ForkData::parse(&data[432..512]),
284        })
285    }
286
287    fn serialize(&self) -> [u8; 512] {
288        let mut out = [0u8; 512];
289        BigEndian::write_u16(&mut out[0..2], self.signature);
290        BigEndian::write_u16(&mut out[2..4], self.version);
291        BigEndian::write_u32(&mut out[4..8], self.attributes);
292        BigEndian::write_u32(&mut out[8..12], self.last_mounted_version);
293        BigEndian::write_u32(&mut out[12..16], self.journal_info_block);
294        BigEndian::write_u32(&mut out[16..20], self.create_date);
295        BigEndian::write_u32(&mut out[20..24], self.modify_date);
296        BigEndian::write_u32(&mut out[24..28], self.backup_date);
297        BigEndian::write_u32(&mut out[28..32], self.checked_date);
298        BigEndian::write_u32(&mut out[32..36], self.file_count);
299        BigEndian::write_u32(&mut out[36..40], self.folder_count);
300        BigEndian::write_u32(&mut out[40..44], self.block_size);
301        BigEndian::write_u32(&mut out[44..48], self.total_blocks);
302        BigEndian::write_u32(&mut out[48..52], self.free_blocks);
303        BigEndian::write_u32(&mut out[52..56], self.next_allocation);
304        BigEndian::write_u32(&mut out[56..60], self.rsrc_clump_size);
305        BigEndian::write_u32(&mut out[60..64], self.data_clump_size);
306        BigEndian::write_u32(&mut out[64..68], self.next_catalog_id);
307        BigEndian::write_u32(&mut out[68..72], self.write_count);
308        BigEndian::write_u64(&mut out[72..80], self.encodings_bitmap);
309        for i in 0..8 {
310            BigEndian::write_u32(&mut out[80 + i * 4..84 + i * 4], self.finder_info[i]);
311        }
312        self.allocation_file.serialize(&mut out[112..192]);
313        self.extents_file.serialize(&mut out[192..272]);
314        self.catalog_file.serialize(&mut out[272..352]);
315        self.attributes_file.serialize(&mut out[352..432]);
316        self.startup_file.serialize(&mut out[432..512]);
317        out
318    }
319
320    fn is_hfsx(&self) -> bool {
321        self.signature == HFSX_SIGNATURE
322    }
323}
324
325/// B-tree node descriptor (14 bytes).
326#[derive(Debug)]
327struct BTreeNodeDescriptor {
328    next: u32,
329    #[allow(dead_code)]
330    prev: u32,
331    kind: i8,
332    #[allow(dead_code)]
333    height: u8,
334    num_records: u16,
335}
336
337impl BTreeNodeDescriptor {
338    fn parse(data: &[u8]) -> Self {
339        BTreeNodeDescriptor {
340            next: BigEndian::read_u32(&data[0..4]),
341            prev: BigEndian::read_u32(&data[4..8]),
342            kind: data[8] as i8,
343            height: data[9],
344            num_records: BigEndian::read_u16(&data[10..12]),
345        }
346    }
347}
348
349/// B-tree header record (after the node descriptor in node 0).
350#[derive(Debug, Clone)]
351#[allow(dead_code)]
352struct BTreeHeaderRecord {
353    depth: u16,
354    root_node: u32,
355    leaf_records: u32,
356    first_leaf_node: u32,
357    last_leaf_node: u32,
358    node_size: u16,
359    max_key_len: u16,
360    total_nodes: u32,
361    free_nodes: u32,
362    /// `keyCompareType` byte (BTHeaderRec offset 37): `0xCF` =
363    /// case-folding NFD, `0xBC` = binary (HFSX case-sensitive). Older /
364    /// hand-built B-trees may leave this zero — callers should treat any
365    /// value other than `0xBC` as case-folding to stay compatible with
366    /// non-HFSX volumes.
367    key_compare_type: u8,
368}
369
370impl BTreeHeaderRecord {
371    fn parse(data: &[u8]) -> Self {
372        // The keyCompareType byte sits 7 bytes past the freeNodes field,
373        // i.e. at offset 37 of the BTHeaderRec. The 106-byte slice handed
374        // to us is large enough to cover it; older callers that only had
375        // 30 bytes get treated as case-folding.
376        let key_compare_type = if data.len() >= 38 { data[37] } else { 0 };
377        BTreeHeaderRecord {
378            depth: BigEndian::read_u16(&data[0..2]),
379            root_node: BigEndian::read_u32(&data[2..6]),
380            leaf_records: BigEndian::read_u32(&data[6..10]),
381            first_leaf_node: BigEndian::read_u32(&data[10..14]),
382            last_leaf_node: BigEndian::read_u32(&data[14..18]),
383            node_size: BigEndian::read_u16(&data[18..20]),
384            max_key_len: BigEndian::read_u16(&data[20..22]),
385            total_nodes: BigEndian::read_u32(&data[22..26]),
386            free_nodes: BigEndian::read_u32(&data[26..30]),
387            key_compare_type,
388        }
389    }
390}
391
392/// Catalog record types.
393const CATALOG_FOLDER: i16 = 1;
394const CATALOG_FILE: i16 = 2;
395
396/// Validate a name for a new file or directory on an HFS+/HFSX volume.
397fn validate_hfsplus_create_name(name: &str) -> Result<(), FilesystemError> {
398    if name.is_empty() {
399        return Err(FilesystemError::InvalidData(
400            "filename is empty — pick a non-blank name".into(),
401        ));
402    }
403    if name.contains(':') {
404        return Err(FilesystemError::InvalidData(
405            "filename contains ':', which classic Mac OS uses as a path separator — \
406             rename the file (try '-' or '_' instead)"
407                .into(),
408        ));
409    }
410    let utf16_len = fs_mac_unicode::hfs_unicode::decompose_str(name).len();
411    if utf16_len > 255 {
412        return Err(FilesystemError::InvalidData(format!(
413            "filename is too long ({utf16_len} UTF-16 units); HFS+ allows up to 255 — \
414             shorten the name (note: some emoji and rare characters count as 2 units)"
415        )));
416    }
417    Ok(())
418}
419#[allow(dead_code)]
420const CATALOG_FOLDER_THREAD: i16 = 3;
421#[allow(dead_code)]
422const CATALOG_FILE_THREAD: i16 = 4;
423
424/// `HFSPlusBSDInfo` — the POSIX block every catalog file and folder record
425/// carries at record offset 32.
426///
427/// This is how OS X does Unix permissions on HFS+: `ownerID` / `groupID` /
428/// `fileMode` are the real uid, gid and mode (type bits included), and the
429/// kernel honours them whenever the volume is mounted with ownership
430/// enabled. A volume that has never been touched by OS X — one formatted by
431/// Classic Mac OS, or by a tool that leaves the block zeroed — carries
432/// `fileMode == 0`, which means "no POSIX info recorded" rather than
433/// "mode 0000". [`Self::is_set`] is that distinction; surfacing a zero mode
434/// as `Some(0)` would render every file as `----------`.
435#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
436pub(crate) struct HfsPlusBsdInfo {
437    pub(crate) owner_id: u32,
438    pub(crate) group_id: u32,
439    pub(crate) file_mode: u16,
440}
441
442impl HfsPlusBsdInfo {
443    /// Record-body offset of the `HFSPlusBSDInfo` block.
444    pub(crate) const OFFSET: usize = 32;
445
446    /// Parse from a catalog record body. Short records read as unset.
447    fn parse(rec: &[u8]) -> Self {
448        if rec.len() < Self::OFFSET + 16 {
449            return Self::default();
450        }
451        Self {
452            owner_id: BigEndian::read_u32(&rec[Self::OFFSET..Self::OFFSET + 4]),
453            group_id: BigEndian::read_u32(&rec[Self::OFFSET + 4..Self::OFFSET + 8]),
454            // adminFlags @+8 and ownerFlags @+9 sit between; fileMode is
455            // a u16 at +10 and `special` a u32 at +12.
456            file_mode: BigEndian::read_u16(&rec[Self::OFFSET + 10..Self::OFFSET + 12]),
457        }
458    }
459
460    /// Whether this record actually carries POSIX info.
461    fn is_set(&self) -> bool {
462        self.file_mode != 0
463    }
464
465    /// Stamp `mode`, `uid` and `gid` onto a `FileEntry`, but only when the
466    /// record has POSIX info to report.
467    fn apply_to(&self, fe: &mut FileEntry) {
468        if !self.is_set() {
469            return;
470        }
471        fe.mode = Some(self.file_mode as u32);
472        fe.uid = Some(self.owner_id);
473        fe.gid = Some(self.group_id);
474    }
475}
476
477/// A parsed HFS+ catalog entry.
478#[derive(Debug, Clone)]
479#[allow(clippy::large_enum_variant)] // catalog entries are short-lived per-record buffers; boxing adds heap churn
480enum CatalogEntry {
481    Folder {
482        folder_id: u32,
483        name: String,
484        /// Mac-epoch seconds — the "modification date" the browser and
485        /// `tar_export` surface.
486        content_mod_date: u32,
487        bsd: HfsPlusBsdInfo,
488    },
489    File {
490        file_id: u32,
491        name: String,
492        data_size: u64,
493        data_fork: ForkData,
494        rsrc_size: u64,
495        rsrc_fork: ForkData,
496        /// Raw 4-byte Mac OSType type/creator, straight off disk (no lossy
497        /// display decode) so high-bit codes survive round-trips.
498        type_code: [u8; 4],
499        creator_code: [u8; 4],
500        /// Finder flags (userInfo.fdFlags) — bit 0x8000 is `kIsAlias`.
501        finder_flags: u16,
502        /// HFS+ file hardlink target inode number (`bsdInfo.special` u32).
503        /// `Some` only when the FInfo type/creator are `hlnk`/`hfs+`. The
504        /// inode itself lives at `iNode<N>` inside the volume's
505        /// `HFS+ Private Data` directory.
506        link_inode_num: Option<u32>,
507        /// HFS+ directory hardlink target inode number. `Some` only when
508        /// the FInfo type/creator are `fdrp`/`MACS`. The directory inode
509        /// lives at `dir_<N>` inside the volume's
510        /// `.HFS+ Private Directory Data\r` directory and replaces the
511        /// stub when surfaced through `list_directory`.
512        dir_link_inode_num: Option<u32>,
513        /// Mac-epoch seconds — the "modification date" the browser and
514        /// `tar_export` surface.
515        content_mod_date: u32,
516        bsd: HfsPlusBsdInfo,
517    },
518}
519
520/// Magic 4-byte type code on file hardlink stubs (`fdType`).
521const HFSPLUS_HARDLINK_FILE_TYPE: &[u8; 4] = b"hlnk";
522/// Magic 4-byte creator code on file hardlink stubs (`fdCreator`).
523const HFSPLUS_HARDLINK_FILE_CREATOR: &[u8; 4] = b"hfs+";
524/// Magic 4-byte type code on directory hardlink stubs (10.5+).
525const HFSPLUS_HARDLINK_DIR_TYPE: &[u8; 4] = b"fdrp";
526/// Magic 4-byte creator code on directory hardlink stubs.
527const HFSPLUS_HARDLINK_DIR_CREATOR: &[u8; 4] = b"MACS";
528
529/// Free-function form of `HfsPlusFilesystem::catalog_compare`, callable
530/// from sibling modules (e.g. `hfsplus_fsck`) that only have the raw
531/// catalog bytes and no `<R>`-typed filesystem instance.
532pub(crate) fn catalog_compare_keys(a: &[u8], b: &[u8], case_sensitive: bool) -> Ordering {
533    if a.len() < 8 || b.len() < 8 {
534        return a.len().cmp(&b.len());
535    }
536    let parent_a = BigEndian::read_u32(&a[2..6]);
537    let parent_b = BigEndian::read_u32(&b[2..6]);
538    let name_len_a = BigEndian::read_u16(&a[6..8]) as usize;
539    let name_len_b = BigEndian::read_u16(&b[6..8]) as usize;
540    let name_a: Vec<u16> = a[8..8 + name_len_a.min((a.len() - 8) / 2) * 2]
541        .chunks_exact(2)
542        .map(BigEndian::read_u16)
543        .collect();
544    let name_b: Vec<u16> = b[8..8 + name_len_b.min((b.len() - 8) / 2) * 2]
545        .chunks_exact(2)
546        .map(BigEndian::read_u16)
547        .collect();
548    hfs_common::compare_hfsplus_keys(parent_a, &name_a, parent_b, &name_b, case_sensitive)
549}
550
551/// Name of the hidden directory at the volume root that stores HFS+ file
552/// hardlink inodes (TN1150). Four NUL bytes followed by ASCII text — the
553/// nulls keep the directory invisible to clients that ignore zero-byte
554/// filename units.
555fn hfsplus_private_dir_name() -> String {
556    let mut s = String::with_capacity(4 + 17);
557    for _ in 0..4 {
558        s.push('\u{0}');
559    }
560    s.push_str("HFS+ Private Data");
561    s
562}
563
564/// Name of the hidden directory at the volume root that stores directory
565/// hardlink inodes (10.5+). The trailing carriage return keeps the
566/// directory hidden from clients that disallow `\r` in filenames.
567fn hfsplus_dir_private_dir_name() -> String {
568    let mut s = String::with_capacity(28 + 1);
569    s.push_str(".HFS+ Private Directory Data");
570    s.push('\u{D}');
571    s
572}
573
574/// HFS+ filesystem implementation.
575pub struct HfsPlusFilesystem<R: Read + Seek> {
576    reader: R,
577    partition_offset: u64,
578    /// Partition length when the caller knows it; the alternate volume header
579    /// sits 1024 bytes before the partition end, not before total_blocks * bs.
580    partition_size: Option<u64>,
581    vh: HfsPlusVolumeHeader,
582    /// Cached catalog B-tree file data.
583    catalog_data: Vec<u8>,
584    /// B-tree header for the catalog file.
585    catalog_header: BTreeHeaderRecord,
586    /// Volume label (from catalog root folder thread).
587    label: String,
588    /// Cached allocation bitmap (loaded on first write operation).
589    bitmap: Option<Vec<u8>>,
590    /// Cached extents-overflow B-tree file data (None until first fragmented
591    /// fork forces it to load; stays None on volumes with no overflow file).
592    extents_overflow_data: Option<Vec<u8>>,
593    /// Cached attributes B-tree file data. `None` until Phase 5 (xattr
594    /// write path) populates it; included in the snapshot now so
595    /// rollback semantics are forward-compatible.
596    attributes_data: Option<Vec<u8>>,
597    /// Set of CNIDs that own at least one record in the attributes B-tree
598    /// (extended attributes). Populated only by `prepare_for_edit` — `None`
599    /// for read-only opens. Until the xattr write path lands (Phase 5 of
600    /// `docs/hfsplus_enhancements.md`), `delete_entry` refuses any CNID in
601    /// this set so we don't silently leave dangling attribute records.
602    xattr_cnids: Option<HashSet<u32>>,
603    /// File hardlink resolution map (iNodeNum -> real inode CNID). Populated
604    /// lazily the first time a `list_directory` / `read_file` call needs to
605    /// follow a hardlink stub. `None` until consulted; `Some(map)` after the
606    /// first lookup, with `map` empty on volumes with no hardlinks. Cleared
607    /// by snapshot rollback so writes that move the private dir don't leave
608    /// a stale map behind.
609    hardlink_inode_map: Option<HashMap<u32, u32>>,
610    /// Directory hardlink resolution map (dir-inode iNodeNum -> directory
611    /// CNID). Same lazy/snapshot semantics as `hardlink_inode_map`.
612    dir_hardlink_inode_map: Option<HashMap<u32, u32>>,
613    /// Boot blocks staged via `set_boot_blocks` and flushed by
614    /// `do_sync_metadata`. Used by the clone path (Step 21) to carry the
615    /// source's first 1024 bytes onto the freshly-built target verbatim.
616    /// Not part of the snapshot — clone stages once at the end and only
617    /// the final `sync_metadata` writes to disk.
618    pending_boot_blocks: Option<Box<[u8; 1024]>>,
619}
620
621/// Snapshot of every byte/value an HFS+ mutation can touch — taken at the
622/// start of each `EditableFilesystem` method so a mid-flight failure can
623/// rewind to byte-identical pre-call state instead of leaving the catalog
624/// half-applied.
625///
626/// Forward-compatible with Phase 5 (attributes B-tree write side) — the
627/// `attributes_data` slot is captured today even though it's always `None`
628/// until xattr writes ship.
629#[derive(Debug, Clone)]
630pub struct HfsPlusSnapshot {
631    vh: HfsPlusVolumeHeader,
632    catalog_data: Vec<u8>,
633    catalog_header: BTreeHeaderRecord,
634    bitmap: Option<Vec<u8>>,
635    extents_overflow_data: Option<Vec<u8>>,
636    attributes_data: Option<Vec<u8>>,
637    xattr_cnids: Option<HashSet<u32>>,
638    hardlink_inode_map: Option<HashMap<u32, u32>>,
639    dir_hardlink_inode_map: Option<HashMap<u32, u32>>,
640}
641
642impl<R: Read + Seek> HfsPlusFilesystem<R> {
643    pub fn open(reader: R, partition_offset: u64) -> Result<Self, FilesystemError> {
644        Self::open_sized(reader, partition_offset, None)
645    }
646
647    /// [`open`](Self::open) with the partition length, so the alternate volume
648    /// header is written where Apple's tools look for it.
649    pub fn open_sized(
650        mut reader: R,
651        partition_offset: u64,
652        partition_size: Option<u64>,
653    ) -> Result<Self, FilesystemError> {
654        // Read volume header at offset + 1024
655        reader.seek(SeekFrom::Start(partition_offset + 1024))?;
656        let mut vh_buf = [0u8; 512];
657        reader.read_exact(&mut vh_buf)?;
658        let vh = HfsPlusVolumeHeader::parse(&vh_buf)?;
659
660        if vh.block_size == 0 || !vh.block_size.is_power_of_two() {
661            return Err(FilesystemError::Parse(format!(
662                "invalid block size: {}",
663                vh.block_size
664            )));
665        }
666
667        // Sanity bound: the catalog and extents-overflow forks must fit
668        // within the volume. A corrupt logical_size that says "50 GiB" on a
669        // 58 GiB volume would otherwise drive `Vec::with_capacity` into swap.
670        let volume_bytes = vh.total_blocks as u64 * vh.block_size as u64;
671        if vh.catalog_file.logical_size > volume_bytes {
672            return Err(FilesystemError::Parse(format!(
673                "catalog file logical_size {} exceeds volume size {}",
674                vh.catalog_file.logical_size, volume_bytes
675            )));
676        }
677        if vh.extents_file.logical_size > volume_bytes {
678            return Err(FilesystemError::Parse(format!(
679                "extents-overflow file logical_size {} exceeds volume size {}",
680                vh.extents_file.logical_size, volume_bytes
681            )));
682        }
683
684        log::debug!(
685            "[HFS+ open @ {partition_offset}] vh ok: block_size={}, total_blocks={} ({} bytes), \
686             catalog={} bytes, extents_file={} bytes, alloc_file={} bytes",
687            vh.block_size,
688            vh.total_blocks,
689            volume_bytes,
690            vh.catalog_file.logical_size,
691            vh.extents_file.logical_size,
692            vh.allocation_file.logical_size,
693        );
694
695        // Eagerly load the extents-overflow B-tree (its own 8 inline extents
696        // are authoritative — the overflow file can't have overflow records),
697        // then read the catalog with overflow support. Volumes with hundreds
698        // of thousands of files can have catalog forks that exceed 8 inline
699        // extents; reading inline-only would silently truncate and lead to
700        // corrupt walks (and, with no cycle detection, hangs).
701        log::debug!("[HFS+ open] reading extents-overflow file...");
702        let extents_overflow_data = if vh.extents_file.logical_size > 0 {
703            Some(read_fork(
704                &mut reader,
705                partition_offset,
706                vh.block_size,
707                &vh.extents_file,
708            )?)
709        } else {
710            None
711        };
712        log::debug!(
713            "[HFS+ open] extents-overflow loaded: {} bytes",
714            extents_overflow_data.as_ref().map(|d| d.len()).unwrap_or(0)
715        );
716
717        log::debug!("[HFS+ open] reading catalog file...");
718        let catalog_data = read_fork_with_overflow(
719            &mut reader,
720            partition_offset,
721            vh.block_size,
722            &vh.catalog_file,
723            HFSPLUS_CATALOG_FILE_ID,
724            HFSPLUS_FORK_DATA,
725            extents_overflow_data.as_deref(),
726        )?;
727        log::debug!("[HFS+ open] catalog loaded: {} bytes", catalog_data.len());
728
729        // Parse B-tree header from node 0
730        if catalog_data.len() < 14 + 106 {
731            return Err(FilesystemError::Parse(
732                "catalog file too small for B-tree header".into(),
733            ));
734        }
735        let catalog_header = BTreeHeaderRecord::parse(&catalog_data[14..14 + 106]);
736        log::debug!(
737            "[HFS+ open] catalog btree: depth={}, root_node={}, first_leaf={}, last_leaf={}, \
738             node_size={}, total_nodes={}, free_nodes={}",
739            catalog_header.depth,
740            catalog_header.root_node,
741            catalog_header.first_leaf_node,
742            catalog_header.last_leaf_node,
743            catalog_header.node_size,
744            catalog_header.total_nodes,
745            catalog_header.free_nodes,
746        );
747
748        // Try to find the volume label from the root folder thread.
749        log::debug!("[HFS+ open] scanning catalog for volume label...");
750        let label = find_volume_label(&catalog_data, &catalog_header);
751        log::debug!("[HFS+ open] volume label: {:?}", label);
752
753        Ok(HfsPlusFilesystem {
754            reader,
755            partition_offset,
756            partition_size,
757            vh,
758            catalog_data,
759            catalog_header,
760            label,
761            bitmap: None,
762            extents_overflow_data,
763            attributes_data: None,
764            xattr_cnids: None,
765            hardlink_inode_map: None,
766            dir_hardlink_inode_map: None,
767            pending_boot_blocks: None,
768        })
769    }
770
771    // ---- Accessors for `hfsplus_clone` (Step 10 of
772    //      docs/hfsplus_enhancements.md). The clone module captures source
773    //      metadata without taking a reference to the private VH/ForkData
774    //      types — these helpers expose primitives + raw fork bytes.
775
776    #[allow(dead_code)] // used in later HFS+ clone steps
777    pub(crate) fn partition_offset(&self) -> u64 {
778        self.partition_offset
779    }
780
781    pub fn block_size(&self) -> u32 {
782        self.vh.block_size
783    }
784
785    pub(crate) fn total_blocks(&self) -> u32 {
786        self.vh.total_blocks
787    }
788
789    pub(crate) fn signature(&self) -> u16 {
790        self.vh.signature
791    }
792
793    pub(crate) fn vh_attributes(&self) -> u32 {
794        self.vh.attributes
795    }
796
797    /// True when the volume header's journaled bit is set.
798    pub fn is_journaled(&self) -> bool {
799        self.vh.attributes & HFSPLUS_VOLUME_JOURNALED_BIT != 0
800    }
801
802    /// Locate, parse, and summarize the journal without applying any state.
803    /// Returns `Ok(None)` when the volume is not journaled. The summaries walk
804    /// `start -> end`; the walk stops early (and `partial` is set) on the first
805    /// corrupt or short transaction, mirroring how macOS replay behaves.
806    ///
807    /// See `docs/hfsplus_enhancements.md` Phase 9, Step 24.
808    /// Locate and parse the JIB + journal header. `Ok(None)` when the volume
809    /// is not journaled. Shared by `read_journal` and `journal_detail`.
810    fn locate_journal(
811        &mut self,
812    ) -> Result<
813        Option<(
814            crate::hfsplus_journal::JournalInfoBlock,
815            crate::hfsplus_journal::JournalHeader,
816        )>,
817        FilesystemError,
818    > {
819        use crate::hfsplus_journal::{JournalHeader, JournalInfoBlock};
820
821        if !self.is_journaled() {
822            return Ok(None);
823        }
824        let to_fs = |e: crate::hfsplus_journal::JournalError| {
825            FilesystemError::InvalidData(format!("journal: {e}"))
826        };
827
828        // JIB lives at allocation block `journal_info_block`.
829        let jib_byte =
830            self.partition_offset + self.vh.journal_info_block as u64 * self.vh.block_size as u64;
831        self.reader.seek(SeekFrom::Start(jib_byte))?;
832        let mut jib_buf = vec![0u8; 512];
833        self.reader.read_exact(&mut jib_buf)?;
834        let jib = JournalInfoBlock::parse(&jib_buf).map_err(to_fs)?;
835
836        // The journal header's fixed fields live in the first 512 bytes.
837        self.reader
838            .seek(SeekFrom::Start(self.partition_offset + jib.offset))?;
839        let mut jh_buf = vec![0u8; 512];
840        self.reader.read_exact(&mut jh_buf)?;
841        let header = JournalHeader::parse(&jh_buf).map_err(to_fs)?;
842        Ok(Some((jib, header)))
843    }
844
845    /// Byte ranges (relative to the partition) of the volume's metadata
846    /// structures, for classifying which subsystem a journaled block targets.
847    fn metadata_regions(&self) -> Vec<crate::hfsplus_journal::MetadataRegion> {
848        use crate::hfsplus_journal::MetadataRegion;
849        let bs = self.vh.block_size as u64;
850        let mut regions = vec![MetadataRegion {
851            name: "volume header",
852            start: 1024,
853            end: 1536,
854        }];
855        let fork = |name: &'static str, fork: &ForkData| -> Option<MetadataRegion> {
856            let first = fork.extents.iter().find(|e| !e.is_empty())?;
857            let start = first.start_block as u64 * bs;
858            let bytes = fork.total_blocks as u64 * bs;
859            Some(MetadataRegion {
860                name,
861                start,
862                end: start + bytes,
863            })
864        };
865        for (name, f) in [
866            ("allocation bitmap", &self.vh.allocation_file),
867            ("extents btree", &self.vh.extents_file),
868            ("catalog btree", &self.vh.catalog_file),
869            ("attributes btree", &self.vh.attributes_file),
870            ("startup file", &self.vh.startup_file),
871        ] {
872            if let Some(r) = fork(name, f) {
873                regions.push(r);
874            }
875        }
876        regions
877    }
878
879    /// Decode the journal into per-transaction, per-block detail for the GUI
880    /// history viewer (Step 29). Block previews are capped; classification is
881    /// against [`metadata_regions`]. `Ok(None)` when not journaled.
882    pub fn journal_detail(
883        &mut self,
884    ) -> Result<Option<crate::hfsplus_journal::JournalDetail>, FilesystemError> {
885        let Some((jib, header)) = self.locate_journal()? else {
886            return Ok(None);
887        };
888        let regions = self.metadata_regions();
889        let detail = crate::hfsplus_journal::read_journal_detail(
890            &mut self.reader,
891            self.partition_offset,
892            jib,
893            header,
894            regions,
895        );
896        Ok(Some(detail))
897    }
898
899    pub fn read_journal(
900        &mut self,
901    ) -> Result<Option<crate::hfsplus_journal::JournalState>, FilesystemError> {
902        use crate::hfsplus_journal::JournalWalker;
903
904        let Some((jib, header)) = self.locate_journal()? else {
905            return Ok(None);
906        };
907
908        let mut transactions = Vec::new();
909        let mut partial = false;
910        let mut checksum_mismatch = None;
911        let mut sequence_jumps = Vec::new();
912        {
913            let mut walker =
914                JournalWalker::new(&mut self.reader, self.partition_offset, &jib, &header);
915            let mut prev_seq: Option<u32> = None;
916            while let Some(item) = walker.next() {
917                match item {
918                    Ok(view) => {
919                        let seq = view.sequence_num;
920                        if let Some(prev) = prev_seq {
921                            // macOS expects each transaction's sequence to be
922                            // prev or prev+1; anything else is a jump.
923                            if seq != prev && seq != prev.wrapping_add(1) {
924                                sequence_jumps.push((prev, seq));
925                            }
926                        }
927                        prev_seq = Some(seq);
928                        transactions.push(view.summary());
929                    }
930                    Err(crate::hfsplus_journal::JournalError::CorruptTransaction {
931                        sequence_num,
932                    }) => {
933                        checksum_mismatch = Some(sequence_num);
934                        partial = true;
935                        break;
936                    }
937                    Err(_) => {
938                        partial = true;
939                        break;
940                    }
941                }
942            }
943        }
944
945        Ok(Some(crate::hfsplus_journal::JournalState {
946            info: jib,
947            header,
948            transactions,
949            partial,
950            checksum_mismatch,
951            sequence_jumps,
952        }))
953    }
954
955    pub(crate) fn vh_create_date(&self) -> u32 {
956        self.vh.create_date
957    }
958
959    pub(crate) fn vh_modify_date(&self) -> u32 {
960        self.vh.modify_date
961    }
962
963    pub(crate) fn vh_backup_date(&self) -> u32 {
964        self.vh.backup_date
965    }
966
967    pub(crate) fn vh_checked_date(&self) -> u32 {
968        self.vh.checked_date
969    }
970
971    pub(crate) fn vh_file_count(&self) -> u32 {
972        self.vh.file_count
973    }
974
975    pub(crate) fn vh_folder_count(&self) -> u32 {
976        self.vh.folder_count
977    }
978
979    pub(crate) fn vh_finder_info(&self) -> [u32; 8] {
980        self.vh.finder_info
981    }
982
983    pub(crate) fn vh_free_blocks(&self) -> u32 {
984        self.vh.free_blocks
985    }
986
987    #[allow(dead_code)] // used in later HFS+ clone steps
988    pub(crate) fn vh_next_catalog_id(&self) -> u32 {
989        self.vh.next_catalog_id
990    }
991
992    /// Read and return the allocation-file bitmap. Routes through the
993    /// extents-overflow-aware fork reader, so volumes whose allocation file
994    /// spills past 8 inline extents work correctly.
995    pub(crate) fn read_allocation_bitmap_for_fsck(&mut self) -> Result<Vec<u8>, FilesystemError> {
996        self.read_allocation_bitmap()
997    }
998
999    /// `total_blocks` field of the source's catalog file fork. Used by the
1000    /// streamed defrag planner to size the target's catalog B-tree (with
1001    /// a small safety margin) so it has enough free nodes to host every
1002    /// inserted record without running out of B-tree node slots.
1003    pub(crate) fn vh_catalog_total_blocks(&self) -> u32 {
1004        self.vh.catalog_file.total_blocks
1005    }
1006
1007    /// `total_blocks` field of the source's extents-overflow file fork.
1008    /// Most defrag targets pack files contiguously and need little or no
1009    /// overflow capacity, but matching source size is a safe ceiling.
1010    pub(crate) fn vh_extents_total_blocks(&self) -> u32 {
1011        self.vh.extents_file.total_blocks
1012    }
1013
1014    /// `total_blocks` field of the source's attributes file fork. Zero on
1015    /// volumes without xattrs.
1016    pub(crate) fn vh_attributes_total_blocks(&self) -> u32 {
1017        self.vh.attributes_file.total_blocks
1018    }
1019
1020    pub(crate) fn label(&self) -> &str {
1021        &self.label
1022    }
1023
1024    pub(crate) fn catalog_data(&self) -> &[u8] {
1025        &self.catalog_data
1026    }
1027
1028    pub(crate) fn catalog_node_size(&self) -> usize {
1029        self.catalog_header.node_size as usize
1030    }
1031
1032    pub(crate) fn catalog_first_leaf(&self) -> u32 {
1033        self.catalog_header.first_leaf_node
1034    }
1035
1036    /// The extents-overflow B-tree as read from disk, for fsck.
1037    pub(crate) fn extents_overflow_data(&self) -> Option<&[u8]> {
1038        self.extents_overflow_data.as_deref()
1039    }
1040
1041    /// Partition length when the opener knew it; where the alternate header lives.
1042    pub(crate) fn partition_size(&self) -> Option<u64> {
1043        self.partition_size
1044    }
1045
1046    /// The attributes B-tree, loaded on first use, for fsck.
1047    pub(crate) fn attributes_data_for_fsck(&mut self) -> Result<Option<&[u8]>, FilesystemError> {
1048        if self.vh.attributes_file.logical_size == 0 {
1049            return Ok(None);
1050        }
1051        self.ensure_attributes_loaded()?;
1052        Ok(self.attributes_data.as_deref())
1053    }
1054
1055    pub(crate) fn case_sensitive_catalog(&self) -> bool {
1056        self.case_sensitive()
1057    }
1058
1059    /// Read the first 1024 bytes of the partition (HFS+ "boot blocks" —
1060    /// always zero on macOS-formatted volumes but copied verbatim by clone).
1061    pub(crate) fn read_boot_blocks(&mut self) -> Result<[u8; 1024], FilesystemError> {
1062        self.reader.seek(SeekFrom::Start(self.partition_offset))?;
1063        let mut buf = [0u8; 1024];
1064        self.reader.read_exact(&mut buf)?;
1065        Ok(buf)
1066    }
1067
1068    /// Open a streaming `Read` over a user fork without buffering its bytes.
1069    /// Used by the clone pipeline (Step 21+) so a 38 GiB volume's data
1070    /// forks don't have to materialise in RAM. The returned reader borrows
1071    /// `self`'s underlying I/O for its lifetime — drop it before issuing
1072    /// any other read against this filesystem.
1073    pub(crate) fn fork_stream_reader(
1074        &mut self,
1075        file_id: u32,
1076        fork_type: u8,
1077        fork_record: &[u8; 80],
1078    ) -> Result<ForkStreamReader<'_, R>, FilesystemError> {
1079        let fork = ForkData::parse(fork_record);
1080        let logical_size = fork.logical_size;
1081
1082        // Resolve every extent (inline + overflow) into device-offset / length
1083        // pairs once at construction. The extents-overflow B-tree was already
1084        // loaded eagerly during `open`; consulting it again here is O(records).
1085        let block_size = self.vh.block_size as u64;
1086        let mut extents: Vec<(u64, u64)> = Vec::new();
1087        let mut covered: u64 = 0;
1088        for ext in fork.extents.iter() {
1089            if ext.block_count == 0 {
1090                continue;
1091            }
1092            let off = self.partition_offset + ext.start_block as u64 * block_size;
1093            let len = ext.block_count as u64 * block_size;
1094            extents.push((off, len));
1095            covered += len;
1096        }
1097        if covered < logical_size {
1098            let inline_blocks: u32 = fork.extents.iter().map(|e| e.block_count).sum();
1099            let overflow = match self.extents_overflow_data.as_deref() {
1100                Some(data) => {
1101                    collect_hfsplus_overflow_extents(data, file_id, fork_type, inline_blocks)
1102                }
1103                None => {
1104                    return Err(FilesystemError::InvalidData(format!(
1105                        "file {file_id} fork {fork_type:#x}: {} bytes requested but only \
1106                         {} bytes in inline extents and no extents-overflow B-tree available",
1107                        logical_size, covered
1108                    )));
1109                }
1110            };
1111            for ext in overflow {
1112                if ext.block_count == 0 {
1113                    continue;
1114                }
1115                let off = self.partition_offset + ext.start_block as u64 * block_size;
1116                let len = ext.block_count as u64 * block_size;
1117                extents.push((off, len));
1118                covered += len;
1119                if covered >= logical_size {
1120                    break;
1121                }
1122            }
1123            if covered < logical_size {
1124                return Err(FilesystemError::InvalidData(format!(
1125                    "file {file_id} fork {fork_type:#x}: extents (inline + overflow) \
1126                     cover {covered} of {logical_size} bytes"
1127                )));
1128            }
1129        }
1130
1131        Ok(ForkStreamReader {
1132            reader: &mut self.reader,
1133            extents,
1134            logical_size,
1135            pos: 0,
1136            cur_extent: 0,
1137            cur_extent_consumed: 0,
1138        })
1139    }
1140
1141    /// Capture the full mutable state for snapshot/rollback. Cheap on the
1142    /// scale of a single mutation: catalog + bitmap clone is O(volume
1143    /// metadata size), not O(volume size).
1144    pub fn snapshot(&self) -> HfsPlusSnapshot {
1145        HfsPlusSnapshot {
1146            vh: self.vh.clone(),
1147            catalog_data: self.catalog_data.clone(),
1148            catalog_header: self.catalog_header.clone(),
1149            bitmap: self.bitmap.clone(),
1150            extents_overflow_data: self.extents_overflow_data.clone(),
1151            attributes_data: self.attributes_data.clone(),
1152            xattr_cnids: self.xattr_cnids.clone(),
1153            hardlink_inode_map: self.hardlink_inode_map.clone(),
1154            dir_hardlink_inode_map: self.dir_hardlink_inode_map.clone(),
1155        }
1156    }
1157
1158    /// Restore previously captured state. Used by the snapshot guard
1159    /// around every `EditableFilesystem` mutation when the body returns
1160    /// `Err`.
1161    pub fn restore_snapshot(&mut self, snap: HfsPlusSnapshot) {
1162        self.vh = snap.vh;
1163        self.catalog_data = snap.catalog_data;
1164        self.catalog_header = snap.catalog_header;
1165        self.bitmap = snap.bitmap;
1166        self.extents_overflow_data = snap.extents_overflow_data;
1167        self.attributes_data = snap.attributes_data;
1168        self.xattr_cnids = snap.xattr_cnids;
1169        self.hardlink_inode_map = snap.hardlink_inode_map;
1170        self.dir_hardlink_inode_map = snap.dir_hardlink_inode_map;
1171    }
1172
1173    /// Sanity-check the volume for edit-mode compatibility and pre-load the
1174    /// attributes-B-tree CNID set. Called by `open_editable_filesystem` —
1175    /// not by read-only opens, so the cost is paid only when we're about
1176    /// to mutate.
1177    ///
1178    /// Journaled volumes are accepted only when the journal is *clean* (empty,
1179    /// `start == end`) — the cleanly-unmounted case. A clean journal has no
1180    /// pending state, so editing the catalog in place and leaving the journal
1181    /// empty keeps the volume valid for macOS (it replays nothing on mount).
1182    /// A *dirty* journal carries metadata the catalog hasn't absorbed yet;
1183    /// editing on top of that stale catalog would lose the journaled changes,
1184    /// so we still refuse it here. In-place transactional writes that journal
1185    /// our own mutations and recover dirty volumes are the remaining half of
1186    /// Step 27 (`docs/hfsplus_enhancements.md`) and are not yet wired in.
1187    ///
1188    /// xattr-bearing volumes succeed but later `delete_entry` calls against any
1189    /// of the cached CNIDs are refused at the source until Phase 5 lands.
1190    pub fn prepare_for_edit(&mut self) -> Result<(), FilesystemError> {
1191        if self.vh.attributes & HFSPLUS_VOLUME_JOURNALED_BIT != 0 {
1192            match self.locate_journal() {
1193                Ok(Some((_jib, header))) if !header.is_empty() => {
1194                    return Err(FilesystemError::Unsupported(
1195                        "dirty journaled HFS+ volume — mount and cleanly unmount it in macOS \
1196                         to flush the journal, then retry (or open read-only)"
1197                            .into(),
1198                    ));
1199                }
1200                Ok(_) => {
1201                    // Clean journal (or, defensively, none) — safe to edit in
1202                    // place; the journal stays empty and valid.
1203                }
1204                Err(e) => {
1205                    return Err(FilesystemError::Unsupported(format!(
1206                        "journaled HFS+ volume with an unreadable journal ({e}) — \
1207                         open read-only"
1208                    )));
1209                }
1210            }
1211        }
1212
1213        // No attributes file → nothing to scan, leave the set as `None`
1214        // (delete_entry treats it as "no xattr-bearing CNIDs known").
1215        if self.vh.attributes_file.logical_size == 0 {
1216            self.xattr_cnids = Some(HashSet::new());
1217            return Ok(());
1218        }
1219
1220        // Load the attributes B-tree fork (with overflow extents if needed)
1221        // and walk its leaves to harvest fileIDs from each record key.
1222        let (attr_data, attr_header) = self
1223            .ensure_attributes_loaded()?
1224            .expect("attributes_file.logical_size > 0 must produce a header (checked just above)");
1225        let node_size = attr_header.node_size as usize;
1226        let mut cnids: HashSet<u32> = HashSet::new();
1227        hfs_common::walk_leaf_records::<(), _>(
1228            attr_data,
1229            attr_header.first_leaf_node,
1230            node_size,
1231            |_node_idx, _rec_idx, _abs_off, rec_bytes| {
1232                if rec_bytes.len() < 2 {
1233                    return None;
1234                }
1235                let key_len = BigEndian::read_u16(&rec_bytes[0..2]) as usize;
1236                // key body must hold at least pad(2) + fileID(4)
1237                if key_len < 6 || 2 + 6 > rec_bytes.len() {
1238                    return None;
1239                }
1240                let file_id = BigEndian::read_u32(&rec_bytes[4..8]);
1241                cnids.insert(file_id);
1242                None
1243            },
1244        );
1245        log::debug!(
1246            "[HFS+ open_editable] attributes B-tree carries records for {} CNIDs",
1247            cnids.len()
1248        );
1249        self.xattr_cnids = Some(cnids);
1250        Ok(())
1251    }
1252
1253    /// Lazy-load the attributes B-tree file into `self.attributes_data`. Returns
1254    /// `Ok(None)` if the volume has no attributes file (`logical_size == 0`),
1255    /// otherwise `Ok(Some((&[u8], header)))`.
1256    ///
1257    /// Subsequent calls reuse the cached buffer, so xattr lookups across many
1258    /// CNIDs cost one fork read total.
1259    fn ensure_attributes_loaded(
1260        &mut self,
1261    ) -> Result<Option<(&[u8], BTreeHeaderRecord)>, FilesystemError> {
1262        if self.vh.attributes_file.logical_size == 0 {
1263            return Ok(None);
1264        }
1265        if self.attributes_data.is_none() {
1266            let data = read_fork_with_overflow(
1267                &mut self.reader,
1268                self.partition_offset,
1269                self.vh.block_size,
1270                &self.vh.attributes_file,
1271                HFSPLUS_ATTRIBUTES_FILE_ID,
1272                HFSPLUS_FORK_DATA,
1273                self.extents_overflow_data.as_deref(),
1274            )?;
1275            if data.len() < 14 + 106 {
1276                return Err(FilesystemError::Parse(
1277                    "attributes file too small for B-tree header".into(),
1278                ));
1279            }
1280            self.attributes_data = Some(data);
1281        }
1282        let data = self.attributes_data.as_deref().expect("just populated");
1283        let header = BTreeHeaderRecord::parse(&data[14..14 + 106]);
1284        Ok(Some((data, header)))
1285    }
1286
1287    /// Return every extended-attribute record attached to `cnid`, decoded into
1288    /// [`XattrRecord`]. Returns an empty vec if the volume has no attributes
1289    /// file or `cnid` owns no xattrs.
1290    ///
1291    /// Inline payloads are decoded eagerly (the common case for
1292    /// `com.apple.FinderInfo` and the rest of the `com.apple.*` namespace).
1293    /// Fork/extents records are returned with their on-disk metadata; the
1294    /// actual fork bytes are read on demand by Step 13's xattr write/read
1295    /// path, not here.
1296    ///
1297    /// HFS+ attribute key layout (after the 2-byte keyLength prefix):
1298    ///   pad (2) | fileID (4) | startBlock (4) | nameLen (2) | name (UTF-16BE)
1299    /// Records are 2-byte aligned; data follows the key at the next even
1300    /// offset and starts with a u32 record type (`HFSPLUS_ATTR_*`).
1301    #[allow(dead_code)] // first user lands in Step 13/14 (xattr writes + clone replay)
1302    pub(crate) fn list_xattrs(&mut self, cnid: u32) -> Result<Vec<XattrRecord>, FilesystemError> {
1303        let Some((data, header)) = self.ensure_attributes_loaded()? else {
1304            return Ok(Vec::new());
1305        };
1306        let node_size = header.node_size as usize;
1307        let first_leaf = header.first_leaf_node;
1308        let mut out: Vec<XattrRecord> = Vec::new();
1309        hfs_common::walk_leaf_records::<(), _>(
1310            data,
1311            first_leaf,
1312            node_size,
1313            |_node_idx, _rec_idx, _abs_off, rec_bytes| {
1314                if rec_bytes.len() < 2 {
1315                    return None;
1316                }
1317                let key_len = BigEndian::read_u16(&rec_bytes[0..2]) as usize;
1318                // key body must hold pad(2) + fileID(4) + startBlock(4) + nameLen(2)
1319                if key_len < 12 || 2 + key_len > rec_bytes.len() {
1320                    return None;
1321                }
1322                let file_id = BigEndian::read_u32(&rec_bytes[4..8]);
1323                if file_id != cnid {
1324                    return None;
1325                }
1326                let start_block = BigEndian::read_u32(&rec_bytes[8..12]);
1327                let name_len = BigEndian::read_u16(&rec_bytes[12..14]) as usize;
1328                let name_bytes_end = 14 + name_len * 2;
1329                if name_bytes_end > 2 + key_len {
1330                    return None;
1331                }
1332                let name = decode_utf16be(&rec_bytes[14..name_bytes_end]);
1333
1334                // Record data starts at next even offset after the key (the
1335                // key body is always an even number of bytes — pad+id+sb+nl
1336                // is 12 plus 2*name_len — so data immediately follows).
1337                let data_off = 2 + key_len;
1338                if data_off + 4 > rec_bytes.len() {
1339                    return None;
1340                }
1341                let record_type = BigEndian::read_u32(&rec_bytes[data_off..data_off + 4]);
1342                let kind = match record_type {
1343                    HFSPLUS_ATTR_INLINE_DATA => {
1344                        // recordType(4) + reserved[2](8) + attrSize(4) + attrData[attrSize]
1345                        let header_end = data_off + 4 + 8 + 4;
1346                        if header_end > rec_bytes.len() {
1347                            return None;
1348                        }
1349                        let attr_size =
1350                            BigEndian::read_u32(&rec_bytes[header_end - 4..header_end]) as usize;
1351                        if header_end + attr_size > rec_bytes.len() {
1352                            return None;
1353                        }
1354                        XattrKind::Inline(rec_bytes[header_end..header_end + attr_size].to_vec())
1355                    }
1356                    HFSPLUS_ATTR_FORK_DATA => {
1357                        // recordType(4) + reserved(4) + HFSPlusForkData(80)
1358                        let fork_off = data_off + 4 + 4;
1359                        if fork_off + 80 > rec_bytes.len() {
1360                            return None;
1361                        }
1362                        XattrKind::Fork(ForkData::parse(&rec_bytes[fork_off..fork_off + 80]))
1363                    }
1364                    HFSPLUS_ATTR_EXTENTS => {
1365                        // recordType(4) + reserved(4) + 8 × ExtentDescriptor(8) = 64
1366                        let ext_off = data_off + 4 + 4;
1367                        if ext_off + 64 > rec_bytes.len() {
1368                            return None;
1369                        }
1370                        let mut extents = Vec::with_capacity(8);
1371                        for i in 0..8 {
1372                            let e = ExtentDescriptor::parse(
1373                                &rec_bytes[ext_off + i * 8..ext_off + (i + 1) * 8],
1374                            );
1375                            extents.push(e);
1376                        }
1377                        XattrKind::Extents(extents)
1378                    }
1379                    _ => {
1380                        log::warn!(
1381                            "[HFS+ list_xattrs] unknown attribute record type 0x{:x} \
1382                             on cnid={} name={:?}; skipping",
1383                            record_type,
1384                            cnid,
1385                            name
1386                        );
1387                        return None;
1388                    }
1389                };
1390                out.push(XattrRecord {
1391                    name,
1392                    start_block,
1393                    kind,
1394                });
1395                None
1396            },
1397        );
1398        Ok(out)
1399    }
1400
1401    /// List all children of a given parent CNID.
1402    ///
1403    /// HFS+ catalog records are ordered by (parent_id, name), so all children
1404    /// of a given parent occupy a contiguous run in the leaf chain. We stop
1405    /// the walk as soon as we encounter a record with parent_id > target —
1406    /// otherwise listing the root of a 500k-file volume would scan every
1407    /// leaf node in the catalog. The `visited` set guards against cycles
1408    /// from corrupt `next` pointers.
1409    fn list_children(&self, parent_cnid: u32) -> Result<Vec<CatalogEntry>, FilesystemError> {
1410        let node_size = self.catalog_header.node_size as usize;
1411        if node_size == 0 {
1412            return Ok(vec![]);
1413        }
1414
1415        let mut results = Vec::new();
1416        // Descend the catalog B-tree to find the leaf that would contain the
1417        // smallest key for this parent_cnid (i.e. (parent_cnid, "")). Walking
1418        // forward from there skips the leaves that hold smaller parent_cnids
1419        // — for high-CNID parents on a 24k-node catalog, that's the difference
1420        // between O(node_count) per call and O(depth + matches).
1421        let header = hfs_common::BTreeHeader::read(&self.catalog_data);
1422        let search_key = Self::build_catalog_key(parent_cnid, "");
1423        let cs = self.case_sensitive();
1424        let cmp = |a: &[u8], b: &[u8]| Self::catalog_compare(a, b, cs);
1425        let (start_leaf, _chain) =
1426            hfs_common::btree_find_insert_leaf(&self.catalog_data, &header, &search_key, &cmp);
1427        let mut node_idx = if start_leaf != 0 {
1428            start_leaf
1429        } else {
1430            self.catalog_header.first_leaf_node
1431        };
1432        let mut visited: std::collections::HashSet<u32> = std::collections::HashSet::new();
1433        let mut seen_target = false;
1434
1435        'outer: while node_idx != 0 {
1436            if !visited.insert(node_idx) {
1437                break;
1438            }
1439            let offset = node_idx as usize * node_size;
1440            if offset + node_size > self.catalog_data.len() {
1441                break;
1442            }
1443            let node = &self.catalog_data[offset..offset + node_size];
1444
1445            let desc = BTreeNodeDescriptor::parse(node);
1446            // kind -1 = leaf node
1447            if desc.kind != -1 {
1448                node_idx = desc.next;
1449                continue;
1450            }
1451
1452            for i in 0..desc.num_records as usize {
1453                // Record offsets are stored at end of node, growing backward
1454                let offset_pos = node_size - 2 * (i + 1);
1455                if offset_pos + 2 > node.len() {
1456                    break;
1457                }
1458                let rec_offset = BigEndian::read_u16(&node[offset_pos..offset_pos + 2]) as usize;
1459                if rec_offset + 6 > node.len() {
1460                    continue;
1461                }
1462
1463                // Parse catalog key
1464                let key_len = BigEndian::read_u16(&node[rec_offset..rec_offset + 2]) as usize;
1465                if key_len < 6 || rec_offset + 2 + key_len > node.len() {
1466                    continue;
1467                }
1468                let key_data = &node[rec_offset + 2..rec_offset + 2 + key_len];
1469
1470                // Key: parent_id (4) + name_length (2) + name (UTF-16BE)
1471                let key_parent_id = BigEndian::read_u32(&key_data[0..4]);
1472                if key_parent_id == parent_cnid {
1473                    seen_target = true;
1474                } else if key_parent_id > parent_cnid {
1475                    // Catalog is sorted by (parent_id, name). Once we pass
1476                    // the target, no further records will match. If we
1477                    // already collected matches we're done; if not, the
1478                    // target may still appear in a later leaf only if we
1479                    // somehow started past it (shouldn't happen). Either
1480                    // way, stop scanning.
1481                    break 'outer;
1482                } else {
1483                    // key_parent_id < parent_cnid: not yet at the target.
1484                    if seen_target {
1485                        // (defensive) records out of order — bail.
1486                        break 'outer;
1487                    }
1488                    continue;
1489                }
1490
1491                let name_length = BigEndian::read_u16(&key_data[4..6]) as usize;
1492                let name = if name_length > 0 && 6 + name_length * 2 <= key_data.len() {
1493                    decode_utf16be(&key_data[6..6 + name_length * 2])
1494                } else {
1495                    String::new()
1496                };
1497
1498                // Record data follows key (aligned to even offset from node start)
1499                let mut rec_data_start = rec_offset + 2 + key_len;
1500                if !rec_data_start.is_multiple_of(2) {
1501                    rec_data_start += 1;
1502                }
1503                if rec_data_start + 2 > node.len() {
1504                    continue;
1505                }
1506
1507                let record_type = BigEndian::read_i16(&node[rec_data_start..rec_data_start + 2]);
1508                let rec = &node[rec_data_start..];
1509
1510                match record_type {
1511                    CATALOG_FOLDER => {
1512                        if rec.len() < 88 {
1513                            continue;
1514                        }
1515                        let folder_id = BigEndian::read_u32(&rec[8..12]);
1516                        // Content modification date lives at record offset
1517                        // 20 (Mac-epoch u32); the write path uses the same
1518                        // offset (see set_catalog_dates around line 4413).
1519                        let content_mod_date = BigEndian::read_u32(&rec[20..24]);
1520                        results.push(CatalogEntry::Folder {
1521                            folder_id,
1522                            name,
1523                            content_mod_date,
1524                            bsd: HfsPlusBsdInfo::parse(rec),
1525                        });
1526                    }
1527                    CATALOG_FILE => {
1528                        if rec.len() < 248 {
1529                            continue;
1530                        }
1531                        let file_id = BigEndian::read_u32(&rec[8..12]);
1532                        // FileInfo at offset 48: fdType(4) + fdCreator(4) + fdFlags(2)
1533                        let type_code: [u8; 4] = rec[48..52].try_into().unwrap();
1534                        let creator_code: [u8; 4] = rec[52..56].try_into().unwrap();
1535                        let finder_flags = BigEndian::read_u16(&rec[56..58]);
1536                        // BSD info at offset 32 (16 bytes); the `special` u32
1537                        // at byte 12 of that block (offset 44 of the record)
1538                        // is `iNodeNum` on hardlink stubs and the link
1539                        // count on inodes — we only consume the former.
1540                        let link_inode_num = if &rec[48..52] == HFSPLUS_HARDLINK_FILE_TYPE
1541                            && &rec[52..56] == HFSPLUS_HARDLINK_FILE_CREATOR
1542                        {
1543                            Some(BigEndian::read_u32(&rec[44..48]))
1544                        } else {
1545                            None
1546                        };
1547                        let dir_link_inode_num = if &rec[48..52] == HFSPLUS_HARDLINK_DIR_TYPE
1548                            && &rec[52..56] == HFSPLUS_HARDLINK_DIR_CREATOR
1549                        {
1550                            Some(BigEndian::read_u32(&rec[44..48]))
1551                        } else {
1552                            None
1553                        };
1554                        // Data fork at offset 88 (80 bytes)
1555                        let data_fork = ForkData::parse(&rec[88..168]);
1556                        // Resource fork at offset 168 (80 bytes)
1557                        let rsrc_fork = ForkData::parse(&rec[168..248]);
1558                        // HFS+ file record content-mod-date at offset 20
1559                        // (Mac-epoch u32) — same slot the folder record uses.
1560                        let content_mod_date = BigEndian::read_u32(&rec[20..24]);
1561                        results.push(CatalogEntry::File {
1562                            file_id,
1563                            name,
1564                            data_size: data_fork.logical_size,
1565                            data_fork,
1566                            rsrc_size: rsrc_fork.logical_size,
1567                            rsrc_fork,
1568                            type_code,
1569                            creator_code,
1570                            finder_flags,
1571                            link_inode_num,
1572                            dir_link_inode_num,
1573                            content_mod_date,
1574                            bsd: HfsPlusBsdInfo::parse(rec),
1575                        });
1576                    }
1577                    _ => {}
1578                }
1579            }
1580
1581            node_idx = desc.next;
1582        }
1583
1584        Ok(results)
1585    }
1586
1587    /// Lazily build (and cache) the iNodeNum -> inode-CNID map by scanning
1588    /// the children of the volume's `HFS+ Private Data` directory. Returns
1589    /// `Ok(())` whether or not the directory exists; volumes without
1590    /// hardlinks end up with an empty map and the lookup short-circuits.
1591    fn ensure_hardlink_inode_map(&mut self) -> Result<(), FilesystemError> {
1592        if self.hardlink_inode_map.is_some() {
1593            return Ok(());
1594        }
1595        let mut map: HashMap<u32, u32> = HashMap::new();
1596        let private_name = hfsplus_private_dir_name();
1597        let private_cnid = self.list_children(2)?.into_iter().find_map(|c| match c {
1598            CatalogEntry::Folder {
1599                folder_id, name, ..
1600            } if name == private_name => Some(folder_id),
1601            _ => None,
1602        });
1603        if let Some(cnid) = private_cnid {
1604            for child in self.list_children(cnid)? {
1605                if let CatalogEntry::File { file_id, name, .. } = child {
1606                    if let Some(rest) = name.strip_prefix("iNode") {
1607                        if let Ok(inode_num) = rest.parse::<u32>() {
1608                            map.insert(inode_num, file_id);
1609                        }
1610                    }
1611                }
1612            }
1613        }
1614        self.hardlink_inode_map = Some(map);
1615        Ok(())
1616    }
1617
1618    /// Resolve a file hardlink stub's iNodeNum to its inode CNID. Returns
1619    /// `None` when the volume has no `HFS+ Private Data` directory or the
1620    /// inode is missing — callers fall back to treating the entry as an
1621    /// ordinary file.
1622    fn resolve_hardlink_inode(&mut self, inode_num: u32) -> Result<Option<u32>, FilesystemError> {
1623        self.ensure_hardlink_inode_map()?;
1624        Ok(self
1625            .hardlink_inode_map
1626            .as_ref()
1627            .and_then(|m| m.get(&inode_num).copied()))
1628    }
1629
1630    /// CNID of the volume's `HFS+ Private Data` directory, if present.
1631    /// Lookup is O(root.children); cheap enough that we don't cache.
1632    fn find_private_dir_cnid(&self) -> Result<Option<u32>, FilesystemError> {
1633        let private_name = hfsplus_private_dir_name();
1634        Ok(self.list_children(2)?.into_iter().find_map(|c| match c {
1635            CatalogEntry::Folder {
1636                folder_id, name, ..
1637            } if name == private_name => Some(folder_id),
1638            _ => None,
1639        }))
1640    }
1641
1642    /// CNID of the volume's `.HFS+ Private Directory Data\r` directory, if
1643    /// present. Same cost profile as `find_private_dir_cnid`.
1644    fn find_dir_private_dir_cnid(&self) -> Result<Option<u32>, FilesystemError> {
1645        let dir_private_name = hfsplus_dir_private_dir_name();
1646        Ok(self.list_children(2)?.into_iter().find_map(|c| match c {
1647            CatalogEntry::Folder {
1648                folder_id, name, ..
1649            } if name == dir_private_name => Some(folder_id),
1650            _ => None,
1651        }))
1652    }
1653
1654    /// Lazily build (and cache) the dir-hardlink iNodeNum -> directory-inode
1655    /// CNID map. Mirrors `ensure_hardlink_inode_map` but scans children of
1656    /// the directory-hardlink private dir for `dir_<N>` folders.
1657    fn ensure_dir_hardlink_inode_map(&mut self) -> Result<(), FilesystemError> {
1658        if self.dir_hardlink_inode_map.is_some() {
1659            return Ok(());
1660        }
1661        let mut map: HashMap<u32, u32> = HashMap::new();
1662        if let Some(cnid) = self.find_dir_private_dir_cnid()? {
1663            for child in self.list_children(cnid)? {
1664                if let CatalogEntry::Folder {
1665                    folder_id, name, ..
1666                } = child
1667                {
1668                    if let Some(rest) = name.strip_prefix("dir_") {
1669                        if let Ok(inode_num) = rest.parse::<u32>() {
1670                            map.insert(inode_num, folder_id);
1671                        }
1672                    }
1673                }
1674            }
1675        }
1676        self.dir_hardlink_inode_map = Some(map);
1677        Ok(())
1678    }
1679
1680    /// Resolve a dir-hardlink stub's iNodeNum to the dir-inode CNID.
1681    fn resolve_dir_hardlink_inode(
1682        &mut self,
1683        inode_num: u32,
1684    ) -> Result<Option<u32>, FilesystemError> {
1685        self.ensure_dir_hardlink_inode_map()?;
1686        Ok(self
1687            .dir_hardlink_inode_map
1688            .as_ref()
1689            .and_then(|m| m.get(&inode_num).copied()))
1690    }
1691
1692    /// If the catalog record at `(parent_cnid, name)` is a file hardlink stub
1693    /// (`fdType='hlnk' fdCreator='hfs+'`), return its target iNodeNum from
1694    /// `bsdInfo.special`. `Ok(None)` for ordinary files / folders / missing
1695    /// records — callers treat that as "not a hardlink".
1696    fn read_file_record_link_inode_num(
1697        &self,
1698        parent_cnid: u32,
1699        name: &str,
1700    ) -> Result<Option<u32>, FilesystemError> {
1701        let Some((_, _, abs_off)) = self.find_catalog_record(parent_cnid, name) else {
1702            return Ok(None);
1703        };
1704        if abs_off + 2 > self.catalog_data.len() {
1705            return Ok(None);
1706        }
1707        let key_len = BigEndian::read_u16(&self.catalog_data[abs_off..abs_off + 2]) as usize;
1708        let mut body_start = abs_off + 2 + key_len;
1709        if !body_start.is_multiple_of(2) {
1710            body_start += 1;
1711        }
1712        if body_start + 56 > self.catalog_data.len() {
1713            return Ok(None);
1714        }
1715        let record_type = BigEndian::read_i16(&self.catalog_data[body_start..body_start + 2]);
1716        if record_type != CATALOG_FILE {
1717            return Ok(None);
1718        }
1719        if &self.catalog_data[body_start + 48..body_start + 52] != HFSPLUS_HARDLINK_FILE_TYPE
1720            || &self.catalog_data[body_start + 52..body_start + 56] != HFSPLUS_HARDLINK_FILE_CREATOR
1721        {
1722            return Ok(None);
1723        }
1724        Ok(Some(BigEndian::read_u32(
1725            &self.catalog_data[body_start + 44..body_start + 48],
1726        )))
1727    }
1728
1729    /// Find a file record by its file_id (CNID) in the catalog B-tree.
1730    /// Returns (data_fork, resource_fork).
1731    fn find_file_by_id(&self, file_id: u32) -> Option<(ForkData, ForkData)> {
1732        let node_size = self.catalog_header.node_size as usize;
1733        let first_leaf = self.catalog_header.first_leaf_node;
1734
1735        hfs_common::walk_leaf_records(
1736            &self.catalog_data,
1737            first_leaf,
1738            node_size,
1739            |_node_idx, _rec_idx, _abs_off, rec| {
1740                if rec.len() < 8 {
1741                    return None;
1742                }
1743                let key_len = BigEndian::read_u16(&rec[0..2]) as usize;
1744                if key_len < 6 || 2 + key_len > rec.len() {
1745                    return None;
1746                }
1747                let mut data_rel = 2 + key_len;
1748                if !data_rel.is_multiple_of(2) {
1749                    data_rel += 1;
1750                }
1751                if data_rel + 2 > rec.len() {
1752                    return None;
1753                }
1754                let record_type = BigEndian::read_i16(&rec[data_rel..data_rel + 2]);
1755                if record_type != CATALOG_FILE {
1756                    return None;
1757                }
1758                let body = &rec[data_rel..];
1759                if body.len() < 248 {
1760                    return None;
1761                }
1762                let rec_file_id = BigEndian::read_u32(&body[8..12]);
1763                if rec_file_id != file_id {
1764                    return None;
1765                }
1766                Some((
1767                    ForkData::parse(&body[88..168]),
1768                    ForkData::parse(&body[168..248]),
1769                ))
1770            },
1771        )
1772    }
1773
1774    /// Read the allocation bitmap and return it.
1775    fn read_allocation_bitmap(&mut self) -> Result<Vec<u8>, FilesystemError> {
1776        // Allocation files on large volumes can run beyond 8 inline extents;
1777        // route through the overflow-aware reader.
1778        read_fork_with_overflow(
1779            &mut self.reader,
1780            self.partition_offset,
1781            self.vh.block_size,
1782            &self.vh.allocation_file,
1783            HFSPLUS_ALLOCATION_FILE_ID,
1784            HFSPLUS_FORK_DATA,
1785            self.extents_overflow_data.as_deref(),
1786        )
1787    }
1788
1789    /// Read a user fork (data or resource) belonging to `file_id`, consulting
1790    /// the extents-overflow B-tree when the inline 8 extents are insufficient.
1791    fn read_user_fork(
1792        &mut self,
1793        file_id: u32,
1794        fork_type: u8,
1795        fork: &ForkData,
1796    ) -> Result<Vec<u8>, FilesystemError> {
1797        read_fork_with_overflow(
1798            &mut self.reader,
1799            self.partition_offset,
1800            self.vh.block_size,
1801            fork,
1802            file_id,
1803            fork_type,
1804            self.extents_overflow_data.as_deref(),
1805        )
1806    }
1807
1808    /// Ensure the allocation bitmap is cached in memory.
1809    fn ensure_bitmap(&mut self) -> Result<(), FilesystemError> {
1810        if self.bitmap.is_none() {
1811            self.bitmap = Some(self.read_allocation_bitmap()?);
1812        }
1813        Ok(())
1814    }
1815
1816    /// Build HFS+ catalog key bytes: key_len(2) + parent_id(4) + name_length(2) + name(UTF-16BE NFD).
1817    pub(crate) fn build_catalog_key(parent_cnid: u32, name: &str) -> Vec<u8> {
1818        let utf16: Vec<u16> = fs_mac_unicode::hfs_unicode::decompose_str(name);
1819        let key_len = 4 + 2 + utf16.len() * 2;
1820        let mut key = Vec::with_capacity(2 + key_len);
1821        let mut buf = [0u8; 2];
1822        BigEndian::write_u16(&mut buf, key_len as u16);
1823        key.extend_from_slice(&buf);
1824        let mut buf4 = [0u8; 4];
1825        BigEndian::write_u32(&mut buf4, parent_cnid);
1826        key.extend_from_slice(&buf4);
1827        BigEndian::write_u16(&mut buf, utf16.len() as u16);
1828        key.extend_from_slice(&buf);
1829        for &ch in &utf16 {
1830            BigEndian::write_u16(&mut buf, ch);
1831            key.extend_from_slice(&buf);
1832        }
1833        key
1834    }
1835
1836    /// Compare function for catalog B-tree records (compares key portion only).
1837    /// `true` on HFSX volumes whose catalog B-tree advertises binary (case-
1838    /// sensitive) key compare. Plain HFS+ and HFSX-with-case-folding both
1839    /// return `false`. Driven by the catalog header `keyCompareType` byte
1840    /// rather than the volume signature alone — Apple's spec explicitly
1841    /// allows an `H+` filesystem to declare itself HFSX-shaped.
1842    pub(crate) fn case_sensitive(&self) -> bool {
1843        self.catalog_header.key_compare_type == KEY_COMPARE_BINARY
1844    }
1845
1846    fn catalog_compare(a: &[u8], b: &[u8], case_sensitive: bool) -> Ordering {
1847        // Both records start with: key_len(2) + parent_id(4) + name_len(2) + name(UTF-16BE)
1848        if a.len() < 8 || b.len() < 8 {
1849            return a.len().cmp(&b.len());
1850        }
1851        let parent_a = BigEndian::read_u32(&a[2..6]);
1852        let parent_b = BigEndian::read_u32(&b[2..6]);
1853        let name_len_a = BigEndian::read_u16(&a[6..8]) as usize;
1854        let name_len_b = BigEndian::read_u16(&b[6..8]) as usize;
1855        let name_a: Vec<u16> = a[8..8 + name_len_a.min((a.len() - 8) / 2) * 2]
1856            .chunks_exact(2)
1857            .map(BigEndian::read_u16)
1858            .collect();
1859        let name_b: Vec<u16> = b[8..8 + name_len_b.min((b.len() - 8) / 2) * 2]
1860            .chunks_exact(2)
1861            .map(BigEndian::read_u16)
1862            .collect();
1863        hfs_common::compare_hfsplus_keys(parent_a, &name_a, parent_b, &name_b, case_sensitive)
1864    }
1865
1866    /// Find a catalog record by (parent_cnid, name).
1867    /// Returns Some((node_idx, rec_idx, absolute_offset_in_catalog_data)) if found.
1868    /// Scans leaf nodes linearly (correct for all catalog sizes we encounter).
1869    fn find_catalog_record(&self, parent_cnid: u32, name: &str) -> Option<(u32, usize, usize)> {
1870        let search_key = Self::build_catalog_key(parent_cnid, name);
1871        let node_size = self.catalog_header.node_size as usize;
1872        let first_leaf = self.catalog_header.first_leaf_node;
1873        let cs = self.case_sensitive();
1874
1875        hfs_common::walk_leaf_records(
1876            &self.catalog_data,
1877            first_leaf,
1878            node_size,
1879            |node_idx, rec_idx, abs_off, rec| {
1880                if rec.len() < 8 {
1881                    return None;
1882                }
1883                let key_len = BigEndian::read_u16(&rec[0..2]) as usize;
1884                let key_portion = &rec[..2 + key_len.min(rec.len() - 2)];
1885                if Self::catalog_compare(key_portion, &search_key, cs) == Ordering::Equal {
1886                    Some((node_idx, rec_idx, abs_off))
1887                } else {
1888                    None
1889                }
1890            },
1891        )
1892    }
1893
1894    /// Find a thread record by CNID (thread key: parent_id=cnid, name="").
1895    fn find_catalog_record_by_cnid(&self, cnid: u32) -> Option<(u32, usize, usize)> {
1896        self.find_catalog_record(cnid, "")
1897    }
1898
1899    /// Update parent folder valence (child count) by delta.
1900    fn update_parent_valence(
1901        &mut self,
1902        parent_cnid: u32,
1903        delta: i32,
1904    ) -> Result<(), FilesystemError> {
1905        // Find parent's thread record to get its actual parent + name
1906        let (node_idx, _rec_idx, _offset) = self
1907            .find_catalog_record_by_cnid(parent_cnid)
1908            .ok_or_else(|| {
1909                FilesystemError::NotFound(format!("thread record for CNID {parent_cnid} not found"))
1910            })?;
1911
1912        // Now scan for the actual folder record (not thread) with this CNID.
1913        let node_size = self.catalog_header.node_size as usize;
1914        let first_leaf = self.catalog_header.first_leaf_node;
1915        let val_offset = hfs_common::walk_leaf_records(
1916            &self.catalog_data,
1917            first_leaf,
1918            node_size,
1919            |_node_idx, _rec_idx, abs_off, rec| {
1920                if rec.len() < 8 {
1921                    return None;
1922                }
1923                let key_len = BigEndian::read_u16(&rec[0..2]) as usize;
1924                let mut data_off = abs_off + 2 + key_len;
1925                if !data_off.is_multiple_of(2) {
1926                    data_off += 1;
1927                }
1928                let abs_end = abs_off + rec.len();
1929                if data_off + 12 > abs_end {
1930                    return None;
1931                }
1932                let record_type = BigEndian::read_i16(&self.catalog_data[data_off..data_off + 2]);
1933                if record_type != CATALOG_FOLDER {
1934                    return None;
1935                }
1936                let folder_id =
1937                    BigEndian::read_u32(&self.catalog_data[data_off + 8..data_off + 12]);
1938                if folder_id == parent_cnid {
1939                    Some(data_off + 4)
1940                } else {
1941                    None
1942                }
1943            },
1944        );
1945        let val_offset = val_offset.ok_or_else(|| {
1946            FilesystemError::NotFound(format!(
1947                "folder record for parent CNID {parent_cnid} not found in catalog"
1948            ))
1949        })?;
1950        let old_val = BigEndian::read_u32(&self.catalog_data[val_offset..val_offset + 4]);
1951        let new_val = (old_val as i64 + delta as i64).max(0) as u32;
1952        BigEndian::write_u32(&mut self.catalog_data[val_offset..val_offset + 4], new_val);
1953        let _ = node_idx; // suppress warning
1954        Ok(())
1955    }
1956
1957    /// Insert a catalog record into the B-tree, handling splits and growth.
1958    ///
1959    /// Delegates to the shared `btree_insert_full`, which — for a non-sequential
1960    /// (per-`put`) insert into a full leaf — first tries a B*-style rotation into
1961    /// an adjacent sibling before splitting, lifting random-insert leaf occupancy
1962    /// from the ~69% a plain split leaves to ~88% (docs/hfsplus_btree_growth_plan.md
1963    /// §4d). `BTreeKeyFormat::HFSPLUS_CATALOG` drives the 2-byte / variable-length
1964    /// index keys. This is the same path classic HFS's `insert_catalog_record`
1965    /// uses; the previous hand-rolled split dance here packed less densely because
1966    /// it never rotated.
1967    fn insert_catalog_record(&mut self, key_record: &[u8]) -> Result<(), FilesystemError>
1968    where
1969        R: std::io::Write,
1970    {
1971        let cs = self.case_sensitive();
1972        // Insert; if the catalog runs out of free B-tree nodes, grow the fork
1973        // and retry once (docs/todo_hfsplus_fork_growth.md §4b). `btree_insert_full`
1974        // returns a *clean* DiskFull (no partial split) when it can't cover the
1975        // worst-case cascade, so the retry can't duplicate the record. A second
1976        // DiskFull means the volume itself is full — propagate it.
1977        let mut grown = false;
1978        loop {
1979            let cmp = |a: &[u8], b: &[u8]| Self::catalog_compare(a, b, cs);
1980            match hfs_common::btree_insert_full(
1981                &mut self.catalog_data,
1982                key_record,
1983                &BTreeKeyFormat::HFSPLUS_CATALOG,
1984                &cmp,
1985            ) {
1986                Ok(()) => break,
1987                Err(FilesystemError::DiskFull(_)) if !grown => {
1988                    self.grow_btree_fork(BTreeFork::Catalog)?;
1989                    grown = true;
1990                }
1991                Err(e) => return Err(e),
1992            }
1993        }
1994        self.catalog_header = BTreeHeaderRecord::parse(&self.catalog_data[14..14 + 106]);
1995        Ok(())
1996    }
1997
1998    /// Remove a catalog record by (node_idx, rec_idx).
1999    fn remove_catalog_record(
2000        &mut self,
2001        node_idx: u32,
2002        rec_idx: usize,
2003    ) -> Result<(), FilesystemError> {
2004        let node_size = self.catalog_header.node_size as usize;
2005        let offset = node_idx as usize * node_size;
2006        let cs = self.case_sensitive();
2007        let cmp = |a: &[u8], b: &[u8]| Self::catalog_compare(a, b, cs);
2008        btree_remove_record(
2009            &mut self.catalog_data[offset..offset + node_size],
2010            node_size,
2011            rec_idx,
2012        );
2013
2014        // Check if leaf is now empty
2015        let num = BigEndian::read_u16(&self.catalog_data[offset + 10..offset + 12]);
2016        if num > 0 && rec_idx == 0 {
2017            // The leaf's first key changed: Disk First Aid wants the separators
2018            // above it to say so exactly (E_IKey).
2019            btree_refresh_index_keys(
2020                &mut self.catalog_data,
2021                node_size,
2022                node_idx,
2023                &BTreeKeyFormat::HFSPLUS_CATALOG,
2024                &cmp,
2025            )?;
2026        }
2027        if num == 0 {
2028            // Free the node and update prev/next links
2029            let prev = BigEndian::read_u32(&self.catalog_data[offset + 4..offset + 8]);
2030            let next = BigEndian::read_u32(&self.catalog_data[offset..offset + 4]);
2031            if prev != 0 {
2032                let prev_off = prev as usize * node_size;
2033                BigEndian::write_u32(&mut self.catalog_data[prev_off..prev_off + 4], next);
2034            }
2035            if next != 0 {
2036                let next_off = next as usize * node_size;
2037                BigEndian::write_u32(&mut self.catalog_data[next_off + 4..next_off + 8], prev);
2038            }
2039            btree_free_node(&mut self.catalog_data, node_size, node_idx);
2040
2041            let mut h = BTreeHeader::read(&self.catalog_data);
2042            h.free_nodes += 1;
2043            if h.first_leaf_node == node_idx {
2044                h.first_leaf_node = next;
2045            }
2046            if h.last_leaf_node == node_idx {
2047                h.last_leaf_node = prev;
2048            }
2049            h.write(&mut self.catalog_data);
2050            // The parent still pointed at the freed leaf; a later insert landed
2051            // there and the next split zeroed it.
2052            btree_detach_freed_node_and_refresh(
2053                &mut self.catalog_data,
2054                node_size,
2055                node_idx,
2056                &BTreeKeyFormat::HFSPLUS_CATALOG,
2057                &cmp,
2058            )?;
2059        }
2060
2061        // Update header leaf_records
2062        let mut h = BTreeHeader::read(&self.catalog_data);
2063        h.leaf_records = h.leaf_records.saturating_sub(1);
2064        h.write(&mut self.catalog_data);
2065        self.catalog_header = BTreeHeaderRecord::parse(&self.catalog_data[14..14 + 106]);
2066        Ok(())
2067    }
2068}
2069
2070// --- Extents-overflow B-tree helpers (read+write side) ---
2071
2072impl<R: Read + Seek> HfsPlusFilesystem<R> {
2073    /// Compare two extents-overflow records by key. Per TN1150 the canonical
2074    /// order is fileID, then forkType, then startBlock — even though the on-
2075    /// disk byte layout puts forkType first.
2076    ///
2077    /// Both arguments are full record bytes (or trimmed index records); the
2078    /// 2-byte key_len prefix lives at offset 0..2 and the key body at 2..12.
2079    pub(crate) fn extents_compare(a: &[u8], b: &[u8]) -> Ordering {
2080        if a.len() < 12 || b.len() < 12 {
2081            return a.len().cmp(&b.len());
2082        }
2083        let a_file = BigEndian::read_u32(&a[4..8]);
2084        let b_file = BigEndian::read_u32(&b[4..8]);
2085        a_file.cmp(&b_file).then(a[2].cmp(&b[2])).then_with(|| {
2086            let a_sb = BigEndian::read_u32(&a[8..12]);
2087            let b_sb = BigEndian::read_u32(&b[8..12]);
2088            a_sb.cmp(&b_sb)
2089        })
2090    }
2091
2092    /// Build a full extents-overflow record as it lives on disk:
2093    /// `[key_len_u16=10, forkType, pad=0, fileID(BE), startBlock(BE),
2094    ///   8 × HFSPlusExtentDescriptor]` = 76 bytes total.
2095    ///
2096    /// `chunk` may hold fewer than 8 entries; remaining slots are written as
2097    /// empty descriptors (start=0, count=0) — same convention as `ForkData`.
2098    fn build_extents_overflow_record(
2099        file_id: u32,
2100        fork_type: u8,
2101        file_rel_start: u32,
2102        chunk: &[ExtentDescriptor],
2103    ) -> Vec<u8> {
2104        let mut rec = vec![0u8; 2 + 10 + 64];
2105        BigEndian::write_u16(&mut rec[0..2], 10); // key_len
2106        rec[2] = fork_type;
2107        rec[3] = 0; // pad
2108        BigEndian::write_u32(&mut rec[4..8], file_id);
2109        BigEndian::write_u32(&mut rec[8..12], file_rel_start);
2110        for (i, ext) in chunk.iter().take(8).enumerate() {
2111            ext.serialize(&mut rec[12 + i * 8..12 + i * 8 + 8]);
2112        }
2113        rec
2114    }
2115}
2116
2117impl<R: Read + Write + Seek> HfsPlusFilesystem<R> {
2118    /// Insert a record into the extents-overflow B-tree, splitting the leaf and
2119    /// growing the root when the target leaf is full — mirroring
2120    /// `insert_catalog_record` / `insert_xattr_record`.
2121    ///
2122    /// The extents-overflow tree uses 2-byte ("big") keys with *fixed*-length
2123    /// index separators (`kBTBigKeysMask` set, `kBTVariableIndexKeysMask` clear),
2124    /// padded to the 10-byte `maxKeyLength`. `BTreeKeyFormat::HFSPLUS_EXTENTS`
2125    /// drives that; before P1's key-format descriptor the shared growth helpers
2126    /// forced the classic 1-byte/0x25 shape, which is why this path was pinned to
2127    /// a single leaf level (docs/hfsplus_btree_growth_plan.md §4a/P3).
2128    fn insert_extents_overflow_record(&mut self, key_record: &[u8]) -> Result<(), FilesystemError> {
2129        {
2130            let data = self.extents_overflow_data.as_ref().ok_or_else(|| {
2131                FilesystemError::Unsupported(
2132                    "volume has no extents-overflow B-tree — fragmented files cannot be created"
2133                        .into(),
2134                )
2135            })?;
2136            if BTreeHeader::read(data).node_size == 0 {
2137                return Err(FilesystemError::InvalidData(
2138                    "extents-overflow B-tree has zero node_size".into(),
2139                ));
2140            }
2141        }
2142        // Insert; grow the extents-overflow fork and retry once on a clean
2143        // node-exhaustion DiskFull (see `insert_catalog_record`).
2144        let mut grown = false;
2145        loop {
2146            let data = self.extents_overflow_data.as_mut().unwrap();
2147            match hfs_common::btree_insert_full(
2148                data,
2149                key_record,
2150                &BTreeKeyFormat::HFSPLUS_EXTENTS,
2151                &Self::extents_compare,
2152            ) {
2153                Ok(()) => return Ok(()),
2154                Err(FilesystemError::DiskFull(_)) if !grown => {
2155                    self.grow_btree_fork(BTreeFork::Extents)?;
2156                    grown = true;
2157                }
2158                Err(e) => return Err(e),
2159            }
2160        }
2161    }
2162
2163    /// Remove every overflow record belonging to `(file_id, fork_type)`.
2164    /// Used by `delete_entry_inner` to clean up overflow records before the
2165    /// catalog row is removed.
2166    fn remove_extents_overflow_records_for(
2167        &mut self,
2168        file_id: u32,
2169        fork_type: u8,
2170    ) -> Result<(), FilesystemError> {
2171        let Some(ref data) = self.extents_overflow_data else {
2172            return Ok(());
2173        };
2174        let header = BTreeHeader::read(data);
2175        let node_size = header.node_size as usize;
2176        if node_size == 0 {
2177            return Ok(());
2178        }
2179
2180        // Collect (node_idx, rec_idx) pairs first — mutating during a walk
2181        // would invalidate offsets.
2182        let mut victims: Vec<(u32, usize)> = Vec::new();
2183        hfs_common::walk_leaf_records::<(), _>(
2184            data,
2185            header.first_leaf_node,
2186            node_size,
2187            |node_idx, rec_idx, _abs_off, rec| {
2188                if rec.len() < 12 {
2189                    return None;
2190                }
2191                let key_len = BigEndian::read_u16(&rec[0..2]) as usize;
2192                if key_len < 10 {
2193                    return None;
2194                }
2195                let rec_fork = rec[2];
2196                let rec_file = BigEndian::read_u32(&rec[4..8]);
2197                if rec_fork == fork_type && rec_file == file_id {
2198                    victims.push((node_idx, rec_idx));
2199                }
2200                None
2201            },
2202        );
2203        if victims.is_empty() {
2204            return Ok(());
2205        }
2206
2207        // Remove highest rec_idx first per node so earlier indices stay
2208        // stable.
2209        let data = self.extents_overflow_data.as_mut().unwrap();
2210        victims.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
2211        let mut removed: u32 = 0;
2212        for &(node_idx, rec_idx) in &victims {
2213            let off = node_idx as usize * node_size;
2214            btree_remove_record(&mut data[off..off + node_size], node_size, rec_idx);
2215            removed += 1;
2216        }
2217        let mut h = BTreeHeader::read(data);
2218        h.leaf_records = h.leaf_records.saturating_sub(removed);
2219        h.write(data);
2220        // An emptied leaf leaves the tree; one whose first key changed needs
2221        // the separators above it redone.
2222        let mut nodes: Vec<u32> = victims.iter().map(|v| v.0).collect();
2223        nodes.dedup();
2224        for node_idx in nodes {
2225            let off = node_idx as usize * node_size;
2226            let kf = BTreeKeyFormat::HFSPLUS_EXTENTS;
2227            if BigEndian::read_u16(&data[off + 10..off + 12]) == 0 {
2228                btree_retire_empty_leaf(data, node_size, node_idx, &kf, &Self::extents_compare)?;
2229            } else if victims.iter().any(|&(n, r)| n == node_idx && r == 0) {
2230                btree_refresh_index_keys(data, node_size, node_idx, &kf, &Self::extents_compare)?;
2231            }
2232        }
2233        Ok(())
2234    }
2235}
2236
2237// --- Attributes B-tree helpers (write side) ---
2238
2239impl<R: Read + Seek> HfsPlusFilesystem<R> {
2240    /// Compare two attribute records by key. Per TN1150 the canonical order is
2241    /// fileID, then attrName as UTF-16BE codepoints, then startBlock — even
2242    /// though the on-disk byte layout puts startBlock before the name.
2243    ///
2244    /// Both arguments are full record bytes (or trimmed index records); the
2245    /// 2-byte key_len prefix lives at offset 0..2 and the key body at 2..2+keyLen.
2246    /// Key body layout: pad(2) + fileID(4) + startBlock(4) + nameLen(2) + name(UTF-16BE).
2247    pub(crate) fn attr_compare(a: &[u8], b: &[u8]) -> Ordering {
2248        if a.len() < 14 || b.len() < 14 {
2249            return a.len().cmp(&b.len());
2250        }
2251        let af = BigEndian::read_u32(&a[4..8]);
2252        let bf = BigEndian::read_u32(&b[4..8]);
2253        if af != bf {
2254            return af.cmp(&bf);
2255        }
2256        let an_len = BigEndian::read_u16(&a[12..14]) as usize;
2257        let bn_len = BigEndian::read_u16(&b[12..14]) as usize;
2258        let a_name_end = (14 + an_len * 2).min(a.len());
2259        let b_name_end = (14 + bn_len * 2).min(b.len());
2260        // UTF-16BE codepoint comparison via raw byte compare: high byte first
2261        // makes byte-lexicographic ordering equal to u16-codepoint ordering.
2262        let cmp = a[14..a_name_end].cmp(&b[14..b_name_end]);
2263        if cmp != Ordering::Equal {
2264            return cmp;
2265        }
2266        let asb = BigEndian::read_u32(&a[8..12]);
2267        let bsb = BigEndian::read_u32(&b[8..12]);
2268        asb.cmp(&bsb)
2269    }
2270
2271    /// Build a complete inline attribute record:
2272    /// `[key_len_u16, pad=0, fileID, startBlock=0, nameLen, name(UTF-16BE),
2273    ///   recordType=HFSPLUS_ATTR_INLINE_DATA, reserved[2]=0, attrSize, attrData]`.
2274    ///
2275    /// Caller is expected to have already NFD-normalized `name` if needed; we
2276    /// re-normalize defensively so on-disk keys stay canonical.
2277    pub(crate) fn build_inline_attr_record(cnid: u32, name: &str, value: &[u8]) -> Vec<u8> {
2278        let utf16: Vec<u16> = fs_mac_unicode::hfs_unicode::decompose_str(name);
2279        let key_body_len = 12 + utf16.len() * 2;
2280        let data_section_len = 4 + 8 + 4 + value.len();
2281        let mut rec = vec![0u8; 2 + key_body_len + data_section_len];
2282        BigEndian::write_u16(&mut rec[0..2], key_body_len as u16);
2283        // pad already zero at [2..4]
2284        BigEndian::write_u32(&mut rec[4..8], cnid);
2285        BigEndian::write_u32(&mut rec[8..12], 0); // startBlock = 0 for inline
2286        BigEndian::write_u16(&mut rec[12..14], utf16.len() as u16);
2287        for (i, ch) in utf16.iter().enumerate() {
2288            BigEndian::write_u16(&mut rec[14 + i * 2..16 + i * 2], *ch);
2289        }
2290        let data_off = 2 + key_body_len;
2291        BigEndian::write_u32(&mut rec[data_off..data_off + 4], HFSPLUS_ATTR_INLINE_DATA);
2292        // reserved[2] (8 bytes) at [data_off+4..data_off+12] already zero
2293        BigEndian::write_u32(&mut rec[data_off + 12..data_off + 16], value.len() as u32);
2294        rec[data_off + 16..data_off + 16 + value.len()].copy_from_slice(value);
2295        rec
2296    }
2297}
2298
2299impl<R: Read + Write + Seek> HfsPlusFilesystem<R> {
2300    /// Insert a record into the attributes B-tree. Splits the leaf and grows
2301    /// the root if the target leaf is full, mirroring `insert_catalog_record`.
2302    fn insert_xattr_record(&mut self, key_record: &[u8]) -> Result<(), FilesystemError> {
2303        if self.vh.attributes_file.logical_size == 0 {
2304            return Err(FilesystemError::Unsupported(
2305                "volume has no attributes B-tree — extended attribute writes \
2306                 require an existing attributes file"
2307                    .into(),
2308            ));
2309        }
2310        self.ensure_attributes_loaded()?;
2311        let data = self
2312            .attributes_data
2313            .as_mut()
2314            .expect("ensure_attributes_loaded populated the buffer");
2315        let header = BTreeHeader::read(data);
2316        let node_size = header.node_size as usize;
2317        if node_size == 0 {
2318            return Err(FilesystemError::InvalidData(
2319                "attributes B-tree has zero node_size".into(),
2320            ));
2321        }
2322        // Reject records that can't fit in any leaf node — node has 14 bytes
2323        // of descriptor + a 2-byte free-space slot in the offset table.
2324        if key_record.len() + 2 > node_size.saturating_sub(14 + 2) {
2325            return Err(FilesystemError::XattrTooLarge(format!(
2326                "xattr record of {} bytes exceeds attributes node capacity ({} bytes); \
2327                 fork-style attribute writes are not yet implemented",
2328                key_record.len(),
2329                node_size,
2330            )));
2331        }
2332        // The attributes B-tree, like the catalog, uses 2-byte big keys and
2333        // variable-length index separators, so it threads HFSPLUS_ATTRIBUTES
2334        // through the shared insert (which also rotates before splitting).
2335        // Insert; grow the attributes fork and retry once on a clean
2336        // node-exhaustion DiskFull (see `insert_catalog_record`).
2337        let mut grown = false;
2338        loop {
2339            let data = self
2340                .attributes_data
2341                .as_mut()
2342                .expect("ensure_attributes_loaded populated the buffer");
2343            match hfs_common::btree_insert_full(
2344                data,
2345                key_record,
2346                &BTreeKeyFormat::HFSPLUS_ATTRIBUTES,
2347                &Self::attr_compare,
2348            ) {
2349                Ok(()) => return Ok(()),
2350                Err(FilesystemError::DiskFull(_)) if !grown => {
2351                    self.grow_btree_fork(BTreeFork::Attributes)?;
2352                    grown = true;
2353                }
2354                Err(e) => return Err(e),
2355            }
2356        }
2357    }
2358
2359    /// Remove every attribute record matching `(cnid, name)`. Returns the
2360    /// number of records removed (0 on a no-attributes-file volume or when no
2361    /// records match). Used by both `remove_xattr` (caller-driven) and
2362    /// `delete_entry` (cleanup, with `name == None` to drop all xattrs on the
2363    /// CNID).
2364    fn remove_xattr_records(
2365        &mut self,
2366        cnid: u32,
2367        name: Option<&str>,
2368    ) -> Result<u32, FilesystemError> {
2369        if self.vh.attributes_file.logical_size == 0 {
2370            return Ok(0);
2371        }
2372        self.ensure_attributes_loaded()?;
2373        let data = self
2374            .attributes_data
2375            .as_mut()
2376            .expect("ensure_attributes_loaded populated the buffer");
2377        let header = BTreeHeader::read(data);
2378        let node_size = header.node_size as usize;
2379        if node_size == 0 {
2380            return Ok(0);
2381        }
2382        let target_utf16: Option<Vec<u16>> = name.map(fs_mac_unicode::hfs_unicode::decompose_str);
2383
2384        let mut victims: Vec<(u32, usize)> = Vec::new();
2385        hfs_common::walk_leaf_records::<(), _>(
2386            data,
2387            header.first_leaf_node,
2388            node_size,
2389            |node_idx, rec_idx, _abs_off, rec| {
2390                if rec.len() < 14 {
2391                    return None;
2392                }
2393                let key_len = BigEndian::read_u16(&rec[0..2]) as usize;
2394                if key_len < 12 || 2 + key_len > rec.len() {
2395                    return None;
2396                }
2397                let file_id = BigEndian::read_u32(&rec[4..8]);
2398                if file_id != cnid {
2399                    return None;
2400                }
2401                if let Some(target) = target_utf16.as_ref() {
2402                    let nl = BigEndian::read_u16(&rec[12..14]) as usize;
2403                    if nl != target.len() {
2404                        return None;
2405                    }
2406                    if 14 + nl * 2 > 2 + key_len {
2407                        return None;
2408                    }
2409                    for (i, ch) in target.iter().enumerate() {
2410                        let off = 14 + i * 2;
2411                        if BigEndian::read_u16(&rec[off..off + 2]) != *ch {
2412                            return None;
2413                        }
2414                    }
2415                }
2416                victims.push((node_idx, rec_idx));
2417                None
2418            },
2419        );
2420        if victims.is_empty() {
2421            return Ok(0);
2422        }
2423        // Highest (node, rec) first so earlier indices stay stable.
2424        victims.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
2425        let mut removed: u32 = 0;
2426        for (node_idx, rec_idx) in &victims {
2427            let off = *node_idx as usize * node_size;
2428            btree_remove_record(&mut data[off..off + node_size], node_size, *rec_idx);
2429            removed += 1;
2430        }
2431        let mut h = BTreeHeader::read(data);
2432        h.leaf_records = h.leaf_records.saturating_sub(removed);
2433        h.write(data);
2434        // An emptied leaf leaves the tree; one whose first key changed needs
2435        // the separators above it redone.
2436        let mut nodes: Vec<u32> = victims.iter().map(|v| v.0).collect();
2437        nodes.dedup();
2438        for node_idx in nodes {
2439            let off = node_idx as usize * node_size;
2440            let kf = BTreeKeyFormat::HFSPLUS_ATTRIBUTES;
2441            if BigEndian::read_u16(&data[off + 10..off + 12]) == 0 {
2442                btree_retire_empty_leaf(data, node_size, node_idx, &kf, &Self::attr_compare)?;
2443            } else if victims.iter().any(|&(n, r)| n == node_idx && r == 0) {
2444                btree_refresh_index_keys(data, node_size, node_idx, &kf, &Self::attr_compare)?;
2445            }
2446        }
2447        Ok(removed)
2448    }
2449}
2450
2451// --- Write helpers (require R: Read + Write + Seek) ---
2452
2453impl<R: Read + Write + Seek> HfsPlusFilesystem<R> {
2454    /// Write data to an allocation block.
2455    fn write_block(&mut self, block: u32, data: &[u8]) -> Result<(), FilesystemError> {
2456        let offset = self.partition_offset + block as u64 * self.vh.block_size as u64;
2457        self.reader.seek(SeekFrom::Start(offset))?;
2458        self.reader.write_all(data)?;
2459        Ok(())
2460    }
2461
2462    /// Write the volume header to both primary (offset+1024) and backup (offset+total_size-1024).
2463    fn write_volume_header(&mut self) -> Result<(), FilesystemError> {
2464        let vh_bytes = self.vh.serialize();
2465        // Primary
2466        self.reader
2467            .seek(SeekFrom::Start(self.partition_offset + 1024))?;
2468        self.reader.write_all(&vh_bytes)?;
2469        // Backup: 1024 bytes before the partition end when the length is
2470        // known, else before the end of the last allocation block.
2471        let volume_end = self
2472            .partition_size
2473            .filter(|&s| s >= 2048)
2474            .unwrap_or(self.vh.total_blocks as u64 * self.vh.block_size as u64);
2475        if volume_end > 1024 {
2476            self.reader
2477                .seek(SeekFrom::Start(self.partition_offset + volume_end - 1024))?;
2478            self.reader.write_all(&vh_bytes)?;
2479        }
2480        Ok(())
2481    }
2482
2483    /// Write the extents-overflow B-tree data back to disk through the
2484    /// extents_file fork extents. No-op on volumes without an overflow file
2485    /// or when the cached buffer hasn't been touched.
2486    fn write_extents_overflow(&mut self) -> Result<(), FilesystemError> {
2487        if self.vh.extents_file.logical_size == 0 {
2488            return Ok(());
2489        }
2490        let Some(ref data) = self.extents_overflow_data else {
2491            return Ok(());
2492        };
2493        write_fork_data(
2494            &mut self.reader,
2495            self.partition_offset,
2496            self.vh.block_size,
2497            &self.vh.extents_file,
2498            data,
2499        )
2500    }
2501
2502    /// Write the attributes B-tree data back to disk through the
2503    /// attributes_file fork extents. No-op on volumes without an attributes
2504    /// file or when the cached buffer hasn't been touched.
2505    fn write_attributes(&mut self) -> Result<(), FilesystemError> {
2506        if self.vh.attributes_file.logical_size == 0 {
2507            return Ok(());
2508        }
2509        if self.attributes_data.is_none() {
2510            return Ok(());
2511        }
2512        write_fork_data_with_overflow(
2513            &mut self.reader,
2514            self.partition_offset,
2515            self.vh.block_size,
2516            &self.vh.attributes_file,
2517            HFSPLUS_ATTRIBUTES_FILE_ID,
2518            0,
2519            self.extents_overflow_data.as_deref(),
2520            self.attributes_data.as_deref().unwrap(),
2521        )
2522    }
2523
2524    /// Write the catalog B-tree data back to disk through the catalog_file
2525    /// fork extents — including any overflow extents past the 8 inline slots
2526    /// that §4b growth may have added.
2527    fn write_catalog(&mut self) -> Result<(), FilesystemError> {
2528        write_fork_data_with_overflow(
2529            &mut self.reader,
2530            self.partition_offset,
2531            self.vh.block_size,
2532            &self.vh.catalog_file,
2533            HFSPLUS_CATALOG_FILE_ID,
2534            0,
2535            self.extents_overflow_data.as_deref(),
2536            &self.catalog_data,
2537        )
2538    }
2539
2540    /// Write the allocation bitmap back to disk.
2541    fn write_allocation_bitmap(&mut self) -> Result<(), FilesystemError> {
2542        if let Some(ref bm) = self.bitmap {
2543            write_fork_data(
2544                &mut self.reader,
2545                self.partition_offset,
2546                self.vh.block_size,
2547                &self.vh.allocation_file,
2548                bm,
2549            )?;
2550        }
2551        Ok(())
2552    }
2553
2554    /// Allocate `blocks_needed` blocks from the allocation bitmap, possibly
2555    /// across multiple non-contiguous extents.
2556    ///
2557    /// Strategy: greedy largest-run-first — collect every clear run, sort by
2558    /// length descending, take a prefix of each run until the request is
2559    /// satisfied. This avoids `DiskFull` on volumes that have plenty of free
2560    /// space but no contiguous run large enough for the whole request.
2561    ///
2562    /// On success the bitmap is updated, `vh.free_blocks` is decremented,
2563    /// and `vh.next_allocation` is set to the byte just past the last
2564    /// extent so the next allocation starts looking forward instead of
2565    /// re-scanning the same area.
2566    fn allocate_extents(
2567        &mut self,
2568        blocks_needed: u32,
2569    ) -> Result<Vec<ExtentDescriptor>, FilesystemError> {
2570        if blocks_needed == 0 {
2571            return Ok(Vec::new());
2572        }
2573        self.ensure_bitmap()?;
2574        let total_blocks = self.vh.total_blocks;
2575        let bitmap = self.bitmap.as_mut().unwrap();
2576
2577        // Quick total-free check before any scan: if free_blocks < requested
2578        // we cannot possibly satisfy the request, regardless of layout.
2579        if self.vh.free_blocks < blocks_needed {
2580            return Err(FilesystemError::DiskFull(format!(
2581                "{} blocks free, {} requested",
2582                self.vh.free_blocks, blocks_needed
2583            )));
2584        }
2585
2586        let mut runs = bitmap_collect_clear_runs_be(bitmap, total_blocks);
2587        // Largest run first, ties broken by lower start (deterministic).
2588        runs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
2589
2590        let mut extents: Vec<ExtentDescriptor> = Vec::new();
2591        let mut remaining = blocks_needed;
2592        let mut last_end = 0u32;
2593        for (start, len) in runs {
2594            if remaining == 0 {
2595                break;
2596            }
2597            let take = len.min(remaining);
2598            extents.push(ExtentDescriptor {
2599                start_block: start,
2600                block_count: take,
2601            });
2602            for i in 0..take {
2603                bitmap_set_bit_be(bitmap, start + i);
2604            }
2605            remaining -= take;
2606            last_end = start + take;
2607        }
2608
2609        if remaining > 0 {
2610            // Roll back any partial allocation before reporting failure so
2611            // the bitmap stays consistent for the snapshot guard.
2612            for ext in &extents {
2613                for i in 0..ext.block_count {
2614                    bitmap_clear_bit_be(bitmap, ext.start_block + i);
2615                }
2616            }
2617            return Err(FilesystemError::DiskFull(format!(
2618                "needed {blocks_needed} blocks, short by {remaining} after gathering all free runs"
2619            )));
2620        }
2621
2622        self.vh.free_blocks -= blocks_needed;
2623        self.vh.next_allocation = last_end;
2624        Ok(extents)
2625    }
2626
2627    /// Free `count` blocks starting at `start`.
2628    fn free_blocks(&mut self, start: u32, count: u32) {
2629        self.ensure_bitmap().ok();
2630        if let Some(ref mut bitmap) = self.bitmap {
2631            for i in 0..count {
2632                bitmap_clear_bit_be(bitmap, start + i);
2633            }
2634        }
2635        self.vh.free_blocks += count;
2636    }
2637
2638    /// Free all blocks referenced by a fork (inline extents only).
2639    fn free_fork_blocks(&mut self, fork: &ForkData) {
2640        for ext in &fork.extents {
2641            if ext.is_empty() {
2642                break;
2643            }
2644            self.free_blocks(ext.start_block, ext.block_count);
2645        }
2646    }
2647
2648    /// Free every block referenced by a fork's *overflow* extents (the
2649    /// extras living in the extents-overflow B-tree, beyond the 8 inline
2650    /// `ForkData.extents`). The record removal is a separate call —
2651    /// `remove_extents_overflow_records_for` — so callers can sequence
2652    /// "free blocks" and "remove records" independently.
2653    fn free_fork_overflow_extents(&mut self, file_id: u32, fork_type: u8, fork: &ForkData) {
2654        let Some(ref ext_data) = self.extents_overflow_data else {
2655            return;
2656        };
2657        let inline_blocks: u32 = fork.extents.iter().map(|e| e.block_count).sum();
2658        let overflow =
2659            collect_hfsplus_overflow_extents(ext_data, file_id, fork_type, inline_blocks);
2660        for ext in overflow {
2661            if ext.block_count == 0 {
2662                continue;
2663            }
2664            self.free_blocks(ext.start_block, ext.block_count);
2665        }
2666    }
2667
2668    // --- B-tree fork grow-on-full (docs/todo_hfsplus_fork_growth.md §4b) ---
2669
2670    /// Mutable handle to the in-memory tree buffer for `kind`. Errors if the
2671    /// volume has no such B-tree (e.g. growing the extents-overflow tree on a
2672    /// volume that never built one).
2673    fn tree_buf_mut(&mut self, kind: BTreeFork) -> Result<&mut Vec<u8>, FilesystemError> {
2674        match kind {
2675            BTreeFork::Catalog => Ok(&mut self.catalog_data),
2676            BTreeFork::Extents => self.extents_overflow_data.as_mut().ok_or_else(|| {
2677                FilesystemError::Unsupported("volume has no extents-overflow B-tree".into())
2678            }),
2679            BTreeFork::Attributes => self.attributes_data.as_mut().ok_or_else(|| {
2680                FilesystemError::Unsupported("volume has no attributes B-tree".into())
2681            }),
2682        }
2683    }
2684
2685    /// The volume-header `ForkData` backing `kind`.
2686    fn fork_mut(&mut self, kind: BTreeFork) -> &mut ForkData {
2687        match kind {
2688            BTreeFork::Catalog => &mut self.vh.catalog_file,
2689            BTreeFork::Extents => &mut self.vh.extents_file,
2690            BTreeFork::Attributes => &mut self.vh.attributes_file,
2691        }
2692    }
2693
2694    /// First volume block immediately *after* the fork's last inline extent —
2695    /// the candidate start for a contiguous tail growth. Returns 0 (never a
2696    /// valid data block) when the fork has no extents. Overflow extents are
2697    /// intentionally not consulted: if the true tail lies in an overflow
2698    /// extent, `allocate_contiguous_after` simply finds the candidate block
2699    /// busy and the caller falls back to the general allocator — correct, just
2700    /// not optimally contiguous.
2701    fn fork_last_inline_block(&self, kind: BTreeFork) -> u32 {
2702        let fork = match kind {
2703            BTreeFork::Catalog => &self.vh.catalog_file,
2704            BTreeFork::Extents => &self.vh.extents_file,
2705            BTreeFork::Attributes => &self.vh.attributes_file,
2706        };
2707        let mut last = 0u32;
2708        for e in &fork.extents {
2709            if e.block_count == 0 {
2710                break;
2711            }
2712            last = e.start_block + e.block_count;
2713        }
2714        last
2715    }
2716
2717    /// Try to claim `blocks` free volume blocks *immediately following*
2718    /// `after_block`, so the fork's last extent simply gets longer (extent
2719    /// count unchanged — the fragmentation-free common case). Returns the new
2720    /// extent on success, or `None` if `after_block` is 0, the run would run
2721    /// past the volume, or any block in it is already allocated. Updates the
2722    /// bitmap, `free_blocks`, and `next_allocation` exactly like
2723    /// `allocate_extents`.
2724    fn allocate_contiguous_after(
2725        &mut self,
2726        after_block: u32,
2727        blocks: u32,
2728    ) -> Option<ExtentDescriptor> {
2729        if blocks == 0 || after_block == 0 {
2730            return None;
2731        }
2732        self.ensure_bitmap().ok()?;
2733        if after_block.checked_add(blocks)? > self.vh.total_blocks {
2734            return None;
2735        }
2736        if self.vh.free_blocks < blocks {
2737            return None;
2738        }
2739        let bitmap = self.bitmap.as_mut()?;
2740        for i in 0..blocks {
2741            if bitmap_test_bit_be(bitmap, after_block + i) {
2742                return None; // tail is not free — caller falls back
2743            }
2744        }
2745        for i in 0..blocks {
2746            bitmap_set_bit_be(bitmap, after_block + i);
2747        }
2748        self.vh.free_blocks -= blocks;
2749        self.vh.next_allocation = (after_block + blocks).max(self.vh.next_allocation);
2750        Some(ExtentDescriptor {
2751            start_block: after_block,
2752            block_count: blocks,
2753        })
2754    }
2755
2756    /// Attach freshly-allocated `new_extents` to the fork backing `kind`,
2757    /// merging the first one into the fork's last inline extent when they are
2758    /// physically contiguous (so a contiguous tail grow keeps the extent count
2759    /// unchanged), dropping the rest into free inline slots, and spilling any
2760    /// remainder past the 8 inline slots into the extents-overflow B-tree
2761    /// (Phase B). Bumps the fork's `total_blocks` / `logical_size`.
2762    fn attach_extents_to_fork(
2763        &mut self,
2764        kind: BTreeFork,
2765        new_extents: &[ExtentDescriptor],
2766    ) -> Result<(), FilesystemError> {
2767        let block_size = self.vh.block_size as u64;
2768        let fork = self.fork_mut(kind);
2769        let mut added_blocks = 0u32;
2770        let mut overflow: Vec<ExtentDescriptor> = Vec::new();
2771        for ext in new_extents {
2772            if ext.block_count == 0 {
2773                continue;
2774            }
2775            added_blocks += ext.block_count;
2776            // Index of the last non-empty inline slot, if any.
2777            let last_idx = (0..8)
2778                .take_while(|&i| fork.extents[i].block_count != 0)
2779                .last();
2780            let mut placed = false;
2781            if let Some(i) = last_idx {
2782                let le = fork.extents[i];
2783                if le.start_block + le.block_count == ext.start_block {
2784                    fork.extents[i].block_count += ext.block_count;
2785                    placed = true;
2786                }
2787            }
2788            if !placed {
2789                if let Some(i) = (0..8).find(|&i| fork.extents[i].block_count == 0) {
2790                    fork.extents[i] = *ext;
2791                    placed = true;
2792                }
2793            }
2794            if !placed {
2795                overflow.push(*ext);
2796            }
2797        }
2798        fork.total_blocks += added_blocks;
2799        fork.logical_size += added_blocks as u64 * block_size;
2800        if !overflow.is_empty() {
2801            self.spill_fork_extents_to_overflow(kind, &overflow)?;
2802        }
2803        Ok(())
2804    }
2805
2806    /// Phase B: record fork `extents` that overflowed the 8 inline slots into
2807    /// the extents-overflow B-tree, keyed by the special file's reserved CNID
2808    /// (fork type 0). These append after the fork's existing inline + overflow
2809    /// blocks, so their file-relative start is `inline_blocks +
2810    /// existing_overflow_blocks`. The write side (`write_fork_data_with_overflow`)
2811    /// and read side (`read_fork_with_overflow`) both consult these records.
2812    fn spill_fork_extents_to_overflow(
2813        &mut self,
2814        kind: BTreeFork,
2815        extents: &[ExtentDescriptor],
2816    ) -> Result<(), FilesystemError> {
2817        // The extents-overflow file cannot store its *own* overflow extents
2818        // (it would have to contain a record describing where it lives) — HFS+
2819        // limits it to 8 inline extents. In practice it stays tiny, so this is
2820        // a hard error if it ever happens, and the recursion guard that keeps
2821        // a catalog/attributes spill (which grows the extents fork) from
2822        // re-entering here for the extents fork itself.
2823        if kind == BTreeFork::Extents {
2824            return Err(FilesystemError::DiskFull(
2825                "extents-overflow B-tree fork exceeded its 8 inline extents".into(),
2826            ));
2827        }
2828        let file_id = kind.file_id();
2829        let fork = match kind {
2830            BTreeFork::Catalog => &self.vh.catalog_file,
2831            BTreeFork::Attributes => &self.vh.attributes_file,
2832            BTreeFork::Extents => unreachable!(),
2833        };
2834        let inline_blocks: u32 = fork.extents.iter().map(|e| e.block_count).sum();
2835        let existing_overflow: u32 = match self.extents_overflow_data.as_deref() {
2836            Some(ed) => collect_hfsplus_overflow_extents(ed, file_id, 0, inline_blocks)
2837                .iter()
2838                .map(|e| e.block_count)
2839                .sum(),
2840            None => 0,
2841        };
2842        let mut file_rel = inline_blocks + existing_overflow;
2843        // HFS+ extents-overflow records hold up to 8 extent descriptors each.
2844        for chunk in extents.chunks(8) {
2845            let rec = Self::build_extents_overflow_record(file_id, 0, file_rel, chunk);
2846            self.insert_extents_overflow_record(&rec)?;
2847            file_rel += chunk.iter().map(|e| e.block_count).sum::<u32>();
2848        }
2849        Ok(())
2850    }
2851
2852    /// Grow the B-tree fork backing `kind` by at least one clump of nodes so a
2853    /// subsequent `btree_insert_full` has free nodes to allocate. Allocates
2854    /// volume blocks (preferring a contiguous tail extension), attaches them to
2855    /// the fork, extends the in-memory tree buffer, and publishes the new nodes
2856    /// to the tree header (`total_nodes` / `free_nodes`). The node-allocation
2857    /// bitmap bits for the new nodes already read as 0 (free) inside the header
2858    /// node's capacity; past that, a map node is appended to the bitmap chain
2859    /// (§4b Phase C).
2860    fn grow_btree_fork(&mut self, kind: BTreeFork) -> Result<(), FilesystemError> {
2861        let block_size = self.vh.block_size;
2862
2863        let (node_size, total_nodes) = {
2864            let buf = self.tree_buf_mut(kind)?;
2865            let h = BTreeHeader::read(buf);
2866            (h.node_size as usize, h.total_nodes)
2867        };
2868        if node_size == 0 || node_size < 256 {
2869            return Err(FilesystemError::InvalidData(format!(
2870                "{} B-tree has invalid node_size {node_size}",
2871                kind.label()
2872            )));
2873        }
2874
2875        // Grow by at least 8 nodes, rounded up to the fork's clump, to amortise
2876        // the work and keep per-`put` workloads from re-entering grow on every
2877        // insert (which would fragment the fork and defeat the contiguous-tail
2878        // strategy).
2879        let clump_nodes = (self.vh.data_clump_size as usize)
2880            .div_ceil(node_size)
2881            .max(1) as u32;
2882        let grow_nodes = clump_nodes.max(8);
2883
2884        // The header node's bitmap addresses (node_size - 256) * 8 node bits;
2885        // each map node (BTNodeKind=2) chained off the header adds
2886        // map_node_bitmap_bytes*8 more. When a grow would push the node count
2887        // past the current addressable range, we must append one map node so
2888        // the new node indices have bitmap bits (§4b Phase C). A single map
2889        // node covers far more nodes than one grow adds, so at most one new map
2890        // node is ever needed per grow.
2891        let header_cap = ((node_size - 256) * 8) as u32;
2892        let per_map = (hfs_common::map_node_bitmap_bytes(node_size) * 8) as u32;
2893        let existing_map = {
2894            let buf = self.tree_buf_mut(kind)?;
2895            (hfs_common::btree_bitmap_segments(buf, node_size).len() as u32).saturating_sub(1)
2896        };
2897        let current_capacity = header_cap + existing_map * per_map;
2898        let need_map = total_nodes + grow_nodes > current_capacity;
2899        if need_map && per_map == 0 {
2900            return Err(FilesystemError::DiskFull(format!(
2901                "{} B-tree node bitmap full and node size too small for map nodes",
2902                kind.label()
2903            )));
2904        }
2905        let add_slots = grow_nodes + u32::from(need_map);
2906        let grow_blocks = btree_grow_blocks(total_nodes, add_slots, node_size, block_size);
2907        // Allocate volume blocks, preferring a contiguous tail extension so the
2908        // fork's last extent just gets longer.
2909        if grow_blocks > 0 {
2910            let last_block = self.fork_last_inline_block(kind);
2911            let new_extents = match self.allocate_contiguous_after(last_block, grow_blocks) {
2912                Some(e) => vec![e],
2913                None => self.allocate_extents(grow_blocks)?,
2914            };
2915            self.attach_extents_to_fork(kind, &new_extents)?;
2916        }
2917
2918        // Extend the in-memory tree buffer and publish the new nodes. The added
2919        // bytes are zero, so the new node-bitmap bits read as free and
2920        // `btree_alloc_node` will hand them out.
2921        {
2922            let buf = self.tree_buf_mut(kind)?;
2923            buf.resize(buf.len() + add_slots as usize * node_size, 0);
2924
2925            if need_map {
2926                // The new map node takes the first new slot; the free data
2927                // nodes follow it. Its own index is still inside the *existing*
2928                // bitmap capacity (one grow adds far fewer nodes than the
2929                // header/last-map-node segment can address), so we can mark it
2930                // used after linking it into the chain.
2931                let map_idx = total_nodes;
2932                let tail = btree_map_chain_tail(buf, node_size); // 0 => link via header node 0
2933                hfs_common::init_map_node(buf, node_size, map_idx, tail, 0);
2934                let linker_off = tail as usize * node_size; // node 0 is the header
2935                BigEndian::write_u32(&mut buf[linker_off..linker_off + 4], map_idx);
2936                hfs_common::btree_bitmap_set(buf, node_size, map_idx);
2937            }
2938
2939            let mut h = BTreeHeader::read(buf);
2940            h.total_nodes += add_slots;
2941            h.free_nodes += grow_nodes; // the map node is allocated, not free
2942            h.write(buf);
2943        }
2944        self.vh.write_count = self.vh.write_count.wrapping_add(1);
2945        if kind == BTreeFork::Catalog {
2946            self.catalog_header = BTreeHeaderRecord::parse(&self.catalog_data[14..14 + 106]);
2947        }
2948        Ok(())
2949    }
2950
2951    /// Write file data to allocated blocks. Returns the ForkData describing
2952    /// the allocation.
2953    ///
2954    /// Allocates via `allocate_extents`. The first 8 extents land in the
2955    /// inline `ForkData.extents` slots; the remainder is grouped into
2956    /// 8-extent chunks and inserted into the extents-overflow B-tree as
2957    /// records keyed by `(forkType, fileID, file-relative startBlock)`.
2958    ///
2959    /// `file_id` and `fork_type` are required so overflow records carry
2960    /// the correct key. Pass `HFSPLUS_FORK_DATA` for the data fork and
2961    /// `HFSPLUS_FORK_RESOURCE` for the resource fork.
2962    fn write_data_to_blocks(
2963        &mut self,
2964        data: &mut dyn std::io::Read,
2965        data_len: u64,
2966        file_id: u32,
2967        fork_type: u8,
2968    ) -> Result<ForkData, FilesystemError> {
2969        if data_len == 0 {
2970            return Ok(ForkData::empty());
2971        }
2972        let block_size = self.vh.block_size as u64;
2973        let blocks_needed = data_len.div_ceil(block_size) as u32;
2974        let extents = self.allocate_extents(blocks_needed)?;
2975
2976        // Stream the data block-by-block, walking the extent vec in order.
2977        let mut buf = vec![0u8; block_size as usize];
2978        let mut remaining = data_len;
2979        'outer: for ext in &extents {
2980            for i in 0..ext.block_count {
2981                if remaining == 0 {
2982                    break 'outer;
2983                }
2984                let to_read = remaining.min(block_size) as usize;
2985                data.read_exact(&mut buf[..to_read]).map_err(|e| {
2986                    FilesystemError::Io(std::io::Error::new(
2987                        e.kind(),
2988                        format!("reading file data: {e}"),
2989                    ))
2990                })?;
2991                if to_read < block_size as usize {
2992                    buf[to_read..].fill(0);
2993                }
2994                self.write_block(ext.start_block + i, &buf)?;
2995                remaining -= to_read as u64;
2996            }
2997        }
2998
2999        // Build the inline ForkData from the first 8 extents.
3000        let mut fork = ForkData::empty();
3001        fork.logical_size = data_len;
3002        fork.total_blocks = blocks_needed;
3003        for (slot, ext) in fork.extents.iter_mut().zip(extents.iter().take(8)) {
3004            *slot = *ext;
3005        }
3006
3007        // For each 8-extent chunk past the inline capacity, build and
3008        // insert one overflow record. The key's startBlock is the file-
3009        // relative block index where the chunk's first extent begins.
3010        if extents.len() > 8 {
3011            let inline_blocks: u32 = extents.iter().take(8).map(|e| e.block_count).sum();
3012            let mut file_rel_start = inline_blocks;
3013            for chunk in extents[8..].chunks(8) {
3014                let rec =
3015                    Self::build_extents_overflow_record(file_id, fork_type, file_rel_start, chunk);
3016                self.insert_extents_overflow_record(&rec)?;
3017                file_rel_start += chunk.iter().map(|e| e.block_count).sum::<u32>();
3018            }
3019        }
3020
3021        Ok(fork)
3022    }
3023
3024    /// Build a complete HFS+ file catalog record (248 bytes).
3025    /// `mtime_secs = Some(secs)` stamps that Unix time into all four date
3026    /// fields (a cross-fs copy carrying the source mtime through); `None`
3027    /// stamps `now`. The after-create `set_dates` Commander path still
3028    /// overrides all four.
3029    fn build_file_record_with_dates(
3030        file_id: u32,
3031        data_fork: &ForkData,
3032        rsrc_fork: &ForkData,
3033        type_code: &[u8; 4],
3034        creator_code: &[u8; 4],
3035        mtime_secs: Option<u64>,
3036        finder_flags: u16,
3037    ) -> [u8; 248] {
3038        let mut rec = [0u8; 248];
3039        let now = match mtime_secs {
3040            Some(s) => disk_core::times::unix_to_mac_epoch(s),
3041            None => hfs_common::hfs_now(),
3042        };
3043        BigEndian::write_i16(&mut rec[0..2], CATALOG_FILE);
3044        BigEndian::write_u32(&mut rec[8..12], file_id);
3045        BigEndian::write_u32(&mut rec[12..16], now); // createDate
3046        BigEndian::write_u32(&mut rec[16..20], now); // contentModDate
3047        BigEndian::write_u32(&mut rec[20..24], now); // attributeModDate
3048        BigEndian::write_u32(&mut rec[24..28], now); // accessDate
3049                                                     // FileInfo (userInfo): fdType at offset 48, fdCreator at offset 52
3050        rec[48..52].copy_from_slice(type_code);
3051        rec[52..56].copy_from_slice(creator_code);
3052        BigEndian::write_u16(&mut rec[56..58], finder_flags); // fdFlags
3053                                                              // dataFork at offset 88
3054        data_fork.serialize(&mut rec[88..168]);
3055        // resourceFork at offset 168
3056        rsrc_fork.serialize(&mut rec[168..248]);
3057        rec
3058    }
3059
3060    /// Stamp the per-record date fields. `frec_start` is the absolute byte
3061    /// offset of the catalog record's data section (i.e. just past the
3062    /// `recordType` field at offset 0 of the record body). At the standard
3063    /// HFS+ file/folder record offsets:
3064    ///   contentModDate @ +16, attributeModDate @ +20, accessDate @ +24.
3065    ///
3066    /// Pass `update_content = true` when the operation changed file content
3067    /// (fork rewrite). Attribute-only changes (type/creator, finder flags,
3068    /// blessed-folder) leave contentModDate alone.
3069    fn stamp_record_dates(&mut self, frec_start: usize, update_content: bool) {
3070        let now = hfs_common::hfs_now();
3071        if update_content {
3072            BigEndian::write_u32(
3073                &mut self.catalog_data[frec_start + 16..frec_start + 20],
3074                now,
3075            );
3076        }
3077        BigEndian::write_u32(
3078            &mut self.catalog_data[frec_start + 20..frec_start + 24],
3079            now,
3080        );
3081        BigEndian::write_u32(
3082            &mut self.catalog_data[frec_start + 24..frec_start + 28],
3083            now,
3084        );
3085    }
3086
3087    /// Build a complete HFS+ folder catalog record (88 bytes).
3088    /// `mtime_secs = Some(secs)` stamps that Unix time into the four date
3089    /// fields (a cross-fs copy carrying the source mtime through); `None`
3090    /// stamps `now`. The after-create Commander `set_dates` path still
3091    /// overrides.
3092    fn build_folder_record_with_dates(folder_id: u32, mtime_secs: Option<u64>) -> [u8; 88] {
3093        let mut rec = [0u8; 88];
3094        let now = match mtime_secs {
3095            Some(s) => disk_core::times::unix_to_mac_epoch(s),
3096            None => hfs_common::hfs_now(),
3097        };
3098        BigEndian::write_i16(&mut rec[0..2], CATALOG_FOLDER);
3099        // valence = 0 (offset 4)
3100        BigEndian::write_u32(&mut rec[8..12], folder_id);
3101        BigEndian::write_u32(&mut rec[12..16], now);
3102        BigEndian::write_u32(&mut rec[16..20], now);
3103        BigEndian::write_u32(&mut rec[20..24], now);
3104        BigEndian::write_u32(&mut rec[24..28], now);
3105        rec
3106    }
3107
3108    /// Build a thread record. Thread key: (cnid, ""). Thread data: type(2) + reserved(2) + parentID(4) + name.
3109    fn build_thread_record(record_type: i16, parent_cnid: u32, name: &str) -> (Vec<u8>, Vec<u8>) {
3110        // Thread key: parent_id = cnid, name = empty
3111        // Actually, thread records are keyed by (cnid, "")
3112        // We need to build key + record separately
3113
3114        // Key
3115        let key = Self::build_catalog_key(parent_cnid, "");
3116        // But wait — for thread records, the key uses the CNID as parent_id and empty name.
3117        // The parent_cnid here is actually the target CNID, not the actual parent.
3118        // Let me re-read: Thread key: key_len(2) + parent_id(4, =CNID) + name_len(2, =0)
3119
3120        // Record data: type(2) + reserved(2) + parentID(4) + name_len(2) + name(UTF-16BE)
3121        let utf16: Vec<u16> = fs_mac_unicode::hfs_unicode::decompose_str(name);
3122        let mut rec = Vec::with_capacity(10 + utf16.len() * 2);
3123        let mut buf2 = [0u8; 2];
3124        let mut buf4 = [0u8; 4];
3125        BigEndian::write_i16(&mut buf2, record_type);
3126        rec.extend_from_slice(&buf2); // type
3127        rec.extend_from_slice(&[0, 0]); // reserved
3128        BigEndian::write_u32(&mut buf4, parent_cnid);
3129        rec.extend_from_slice(&buf4); // parentID (the actual parent of the entry)
3130        BigEndian::write_u16(&mut buf2, utf16.len() as u16);
3131        rec.extend_from_slice(&buf2); // name_len
3132        for &ch in &utf16 {
3133            BigEndian::write_u16(&mut buf2, ch);
3134            rec.extend_from_slice(&buf2);
3135        }
3136        (key, rec)
3137    }
3138
3139    /// Sync metadata: write catalog + allocation bitmap + volume header + flush.
3140    fn do_sync_metadata(&mut self) -> Result<(), FilesystemError> {
3141        self.vh.modify_date = hfs_common::hfs_now();
3142        // Stamp clean-unmount state into the volume header. Without this,
3143        // a VH read from a mounted/dirty volume (bit 8 clear, bits 11/14
3144        // set) gets persisted as-is and Disk Utility refuses to mount the
3145        // result. Mirrors the unconditional `drAtrbUmounted` set in
3146        // classic HFS `write_mdb`. See ~/Downloads/HFSTests/ corruption
3147        // case (2026-05): rusty-backup edit on /dev/rdisk preserved
3148        // attributes 0x80004000 instead of writing 0x80000100.
3149        self.vh.attributes |= HFSPLUS_VOLUME_UNMOUNTED_BIT;
3150        self.vh.attributes &= !HFSPLUS_BOOT_VOLUME_INCONSISTENT_BIT;
3151        self.vh.attributes &= !HFSPLUS_VOLUME_INCONSISTENT_BIT;
3152        self.write_catalog()?;
3153        self.write_extents_overflow()?;
3154        self.write_attributes()?;
3155        self.write_allocation_bitmap()?;
3156        self.write_volume_header()?;
3157        if let Some(ref boot) = self.pending_boot_blocks {
3158            self.reader.seek(SeekFrom::Start(self.partition_offset))?;
3159            self.reader.write_all(boot.as_ref())?;
3160        }
3161        self.reader.flush()?;
3162        Ok(())
3163    }
3164}
3165
3166impl<R: Read + Seek + Send> Filesystem for HfsPlusFilesystem<R> {
3167    fn root(&mut self) -> Result<FileEntry, FilesystemError> {
3168        Ok(FileEntry {
3169            name: "/".into(),
3170            path: "/".into(),
3171            entry_type: EntryType::Directory,
3172            size: 0,
3173            location: 2, // HFS+ root directory CNID
3174            modified: None,
3175            modified_unix: None,
3176            type_code: None,
3177            creator_code: None,
3178            symlink_target: None,
3179            special_type: None,
3180            mode: None,
3181            uid: None,
3182            gid: None,
3183            resource_fork_size: None,
3184            aux_type: None,
3185            link_target_cnid: None,
3186            amiga_protection: None,
3187            amiga_comment: None,
3188            amiga_date: None,
3189            dos_attributes: None,
3190            finder_flags: None,
3191            prodos_file_type: None,
3192            mac_dates: None,
3193            owner_label: None,
3194            native_attrs: None,
3195            qnx2_attrs: None,
3196        })
3197    }
3198
3199    fn list_directory(&mut self, entry: &FileEntry) -> Result<Vec<FileEntry>, FilesystemError> {
3200        // For directory hardlinks, `entry.location` points at the stub
3201        // catalog row; the actual children live under the `dir_<N>` inode
3202        // referenced by `link_target_cnid`. Following the link here keeps
3203        // recursive walks (browse view, fsck, capture) seeing the real
3204        // contents instead of the empty stub.
3205        let parent_cnid = entry
3206            .link_target_cnid
3207            .map(|c| c as u32)
3208            .unwrap_or(entry.location as u32);
3209        let children = self.list_children(parent_cnid)?;
3210
3211        let mut entries = Vec::new();
3212        for child in children {
3213            match child {
3214                CatalogEntry::Folder {
3215                    folder_id,
3216                    name,
3217                    content_mod_date,
3218                    bsd,
3219                } => {
3220                    let path = if entry.path == "/" {
3221                        format!("/{name}")
3222                    } else {
3223                        format!("{}/{name}", entry.path)
3224                    };
3225                    let mut fe = FileEntry::new_directory(name, path, folder_id as u64);
3226                    bsd.apply_to(&mut fe);
3227                    fe.modified = crate::hfs_common::format_mac_date(content_mod_date);
3228                    fe.modified_unix = crate::hfs_common::mac_date_to_unix(content_mod_date);
3229                    entries.push(fe);
3230                }
3231                CatalogEntry::File {
3232                    file_id,
3233                    name,
3234                    data_size,
3235                    data_fork,
3236                    rsrc_size,
3237                    rsrc_fork,
3238                    type_code,
3239                    creator_code,
3240                    finder_flags,
3241                    link_inode_num,
3242                    dir_link_inode_num,
3243                    content_mod_date,
3244                    bsd,
3245                } => {
3246                    let path = if entry.path == "/" {
3247                        format!("/{name}")
3248                    } else {
3249                        format!("{}/{name}", entry.path)
3250                    };
3251                    // Directory hardlink: surface as a Directory entry that
3252                    // points at the dir-inode under .HFS+ Private Directory
3253                    // Data. list_directory called on it follows the link
3254                    // (see the parent_cnid resolution at the top of this fn).
3255                    if let Some(num) = dir_link_inode_num {
3256                        let mut fe = FileEntry::new_directory(name, path, file_id as u64);
3257                        fe.type_code = Some(type_code);
3258                        fe.creator_code = Some(creator_code);
3259                        bsd.apply_to(&mut fe);
3260                        if let Some(target_cnid) = self.resolve_dir_hardlink_inode(num)? {
3261                            fe.link_target_cnid = Some(target_cnid as u64);
3262                        }
3263                        let _ = (data_fork, rsrc_fork, finder_flags);
3264                        entries.push(fe);
3265                        continue;
3266                    }
3267                    // For file hardlinks (`fdType='hlnk'`/`fdCreator='hfs+'`),
3268                    // resolve the inode CNID and surface the inode's data /
3269                    // resource fork sizes — the link's own forks are empty.
3270                    let mut display_size = data_size;
3271                    let mut display_rsrc = rsrc_size;
3272                    let resolved_target = match link_inode_num {
3273                        Some(num) => self.resolve_hardlink_inode(num)?,
3274                        None => None,
3275                    };
3276                    if let Some(target_cnid) = resolved_target {
3277                        if let Some((d, r)) = self.find_file_by_id(target_cnid) {
3278                            display_size = d.logical_size;
3279                            display_rsrc = r.logical_size;
3280                        }
3281                    }
3282                    let mut fe = FileEntry::new_file(name, path, display_size, file_id as u64);
3283                    fe.type_code = Some(type_code);
3284                    fe.creator_code = Some(creator_code);
3285                    fe.finder_flags = Some(finder_flags);
3286                    bsd.apply_to(&mut fe);
3287                    fe.modified = crate::hfs_common::format_mac_date(content_mod_date);
3288                    fe.modified_unix = crate::hfs_common::mac_date_to_unix(content_mod_date);
3289                    if display_rsrc > 0 {
3290                        fe.resource_fork_size = Some(display_rsrc);
3291                    }
3292                    if let Some(target) = resolved_target {
3293                        fe.link_target_cnid = Some(target as u64);
3294                    }
3295                    // NOTE: symlink/alias *target resolution* used to happen
3296                    // here (read the data fork for `slnk`/`rhap` files; read
3297                    // the resource fork for entries with the IS_ALIAS finder
3298                    // flag). On a slow or forward-only source — zstd
3299                    // streaming backups, NAS-backed images — those reads can
3300                    // each take minutes because the source has to decompress
3301                    // gigabytes to reach the fork's extent. With Mac OS X
3302                    // root directories typically holding a handful of
3303                    // symlinks (etc → private/etc, var → private/var, etc.),
3304                    // listing the root would stall for tens of minutes.
3305                    //
3306                    // The target isn't load-bearing for navigation: the GUI
3307                    // file preview already reads the data fork on demand
3308                    // when the user selects the entry, and an `slnk`/`rhap`
3309                    // data fork is just the UTF-8 target path — it renders
3310                    // fine as a text preview. The Finder-alias path
3311                    // (`mac_alias::resolve_alias_target`) was inline-used
3312                    // by the directory listing only; nothing else depends
3313                    // on the alias having a resolved target. So we drop the
3314                    // eager resolution and let the preview path do the read
3315                    // when (and only when) the user actually clicks.
3316                    let _ = (data_fork, rsrc_fork);
3317                    entries.push(fe);
3318                }
3319            }
3320        }
3321
3322        entries.sort_by_key(|a| a.name.to_lowercase());
3323        Ok(entries)
3324    }
3325
3326    fn read_file(
3327        &mut self,
3328        entry: &FileEntry,
3329        max_bytes: usize,
3330    ) -> Result<Vec<u8>, FilesystemError> {
3331        // Hardlink stubs carry empty forks; follow the indirection to the
3332        // inode under `HFS+ Private Data` and read its data fork instead.
3333        let file_id = entry
3334            .link_target_cnid
3335            .map(|c| c as u32)
3336            .unwrap_or(entry.location as u32);
3337        let (data_fork, _rsrc_fork) = self.find_file_by_id(file_id).ok_or_else(|| {
3338            FilesystemError::NotFound(format!("file id {file_id} not found in catalog"))
3339        })?;
3340
3341        let mut data = self.read_user_fork(file_id, HFSPLUS_FORK_DATA, &data_fork)?;
3342        data.truncate(max_bytes);
3343        Ok(data)
3344    }
3345
3346    fn write_file_to(
3347        &mut self,
3348        entry: &FileEntry,
3349        writer: &mut dyn std::io::Write,
3350    ) -> Result<u64, FilesystemError> {
3351        if entry.is_directory() {
3352            return Err(FilesystemError::NotADirectory(entry.path.clone()));
3353        }
3354        // Hardlink stubs: read forks from the inode CNID, not the stub.
3355        let file_id = entry
3356            .link_target_cnid
3357            .map(|c| c as u32)
3358            .unwrap_or(entry.location as u32);
3359        let (data_fork, _rsrc_fork) = self.find_file_by_id(file_id).ok_or_else(|| {
3360            FilesystemError::NotFound(format!("file id {file_id} not found in catalog"))
3361        })?;
3362        write_fork_to(
3363            &mut self.reader,
3364            self.partition_offset,
3365            self.vh.block_size,
3366            &data_fork,
3367            writer,
3368        )
3369    }
3370
3371    fn volume_label(&self) -> Option<&str> {
3372        if self.label.is_empty() {
3373            None
3374        } else {
3375            Some(&self.label)
3376        }
3377    }
3378
3379    fn fs_type(&self) -> &str {
3380        if self.vh.is_hfsx() {
3381            "HFSX"
3382        } else {
3383            "HFS+"
3384        }
3385    }
3386
3387    fn validate_name(&self, name: &str) -> Result<(), FilesystemError> {
3388        validate_hfsplus_create_name(name)
3389    }
3390
3391    fn uses_colon_paths(&self) -> bool {
3392        true
3393    }
3394
3395    fn total_size(&self) -> u64 {
3396        self.vh.total_blocks as u64 * self.vh.block_size as u64
3397    }
3398
3399    fn used_size(&self) -> u64 {
3400        (self.vh.total_blocks - self.vh.free_blocks) as u64 * self.vh.block_size as u64
3401    }
3402
3403    /// HFS+ allocates fork storage in fixed-size allocation blocks
3404    /// (`blockSize`), so a fork's real on-disk footprint is its length
3405    /// rounded up to this unit. Exposing it lets the copy preflight and
3406    /// `du` model destination consumption exactly instead of falling back
3407    /// to a 512-byte floor.
3408    fn allocation_unit(&self) -> Option<u64> {
3409        if self.vh.block_size == 0 {
3410            None
3411        } else {
3412            Some(self.vh.block_size as u64)
3413        }
3414    }
3415
3416    fn last_data_byte(&mut self) -> Result<u64, FilesystemError> {
3417        let bitmap = self.read_allocation_bitmap()?;
3418        // The alternate header's blocks are always allocated; skip them, and only them (R-078).
3419        let search_up_to = alt_vh_blocks(self.vh.total_blocks, self.vh.block_size).start;
3420        let last = find_last_set_bit(&bitmap, search_up_to);
3421        // The trim point must leave room for the alternate VH at the new end:
3422        // the resizer counts those trailing blocks as used (two at 512 bytes).
3423        let trailing: u64 = if self.vh.block_size <= 512 { 2 } else { 1 };
3424        match last {
3425            Some(block) => {
3426                let byte = (block as u64 + 1 + trailing) * self.vh.block_size as u64;
3427                Ok(byte.min(self.total_size()))
3428            }
3429            None => Ok(self.total_size()),
3430        }
3431    }
3432
3433    fn defragmented_minimum_size(&mut self) -> Result<u64, FilesystemError> {
3434        // After a packed clone the volume holds:
3435        //   - boot blocks + primary VH (in allocation block 0)
3436        //   - all currently-allocated user data + B-tree files + bitmap
3437        //   - the alternate VH at the last 1024 bytes of the volume
3438        //
3439        // We can't access the `SourceCatalogSnapshot` here (it's built
3440        // later by the clone pipeline), so we can't run the precise
3441        // record-byte tally that [`hfsplus_defrag::plan_defrag_layout`]
3442        // uses. Instead, over-estimate the target catalog as
3443        // `2× source_catalog_file.logical_size`, matching the packing
3444        // overhead multiplier the planner applies. Then add an extra ×2
3445        // safety pad: the planner's exact estimate could in theory exceed
3446        // 2× source catalog if a volume has been heavily deleted (lots of
3447        // empty space inside leaves that the precise estimator wouldn't
3448        // count), but it can't exceed 4×. This puts a hard ceiling on the
3449        // partition envelope without the catalog-growth invariant the
3450        // planner enforces precisely on the clone path.
3451        //
3452        // We also add 1024 bytes to host the new alt-VH at the end of the
3453        // smaller image and round up to a whole block.
3454        let block_size = self.vh.block_size as u64;
3455        let used_blocks = self.vh.total_blocks.saturating_sub(self.vh.free_blocks) as u64;
3456        let used_bytes = used_blocks.saturating_mul(block_size);
3457        // Catalog-growth pad: 3× source catalog logical size. Combined
3458        // with the catalog blocks already counted in `used_blocks` (the
3459        // source's allocated catalog) that's 4× source total — the safe
3460        // over-estimate for the planner's exact-record-bytes × 2 result.
3461        let catalog_pad = self.vh.catalog_file.logical_size.saturating_mul(3);
3462        let total = used_bytes.saturating_add(catalog_pad).saturating_add(1024);
3463        Ok(total.div_ceil(block_size) * block_size)
3464    }
3465
3466    fn write_resource_fork_to(
3467        &mut self,
3468        entry: &FileEntry,
3469        writer: &mut dyn std::io::Write,
3470    ) -> Result<u64, FilesystemError> {
3471        let file_id = entry.location as u32;
3472        let (_data_fork, rsrc_fork) = self.find_file_by_id(file_id).ok_or_else(|| {
3473            FilesystemError::NotFound(format!("file id {file_id} not found in catalog"))
3474        })?;
3475        if rsrc_fork.logical_size == 0 {
3476            return Ok(0);
3477        }
3478        let data = read_fork(
3479            &mut self.reader,
3480            self.partition_offset,
3481            self.vh.block_size,
3482            &rsrc_fork,
3483        )?;
3484        writer.write_all(&data)?;
3485        Ok(data.len() as u64)
3486    }
3487
3488    fn resource_fork_size(&mut self, entry: &FileEntry) -> u64 {
3489        let file_id = entry.location as u32;
3490        self.find_file_by_id(file_id)
3491            .map(|(_d, r)| r.logical_size)
3492            .unwrap_or(0)
3493    }
3494
3495    fn fragmentation_stats(
3496        &mut self,
3497    ) -> Option<Result<disk_core::filesystem::FragmentationStats, FilesystemError>> {
3498        Some(Ok(hfsplus_fragmentation_stats(
3499            self.catalog_data(),
3500            self.catalog_first_leaf(),
3501            self.catalog_node_size(),
3502        )))
3503    }
3504
3505    fn blessed_system_folder(&mut self) -> Option<(u64, String)> {
3506        // finderInfo[0] = Classic Mac OS System Folder CNID
3507        let cnid = self.vh.finder_info[0];
3508        if cnid == 0 {
3509            // Try finderInfo[5] = Mac OS X boot directory
3510            let cnid_x = self.vh.finder_info[5];
3511            if cnid_x == 0 {
3512                return None;
3513            }
3514            return self.lookup_folder_name(cnid_x);
3515        }
3516        self.lookup_folder_name(cnid)
3517    }
3518
3519    fn fsck(&mut self) -> Option<Result<disk_core::fsck::FsckResult, FilesystemError>> {
3520        Some(crate::hfsplus_fsck::check(self))
3521    }
3522
3523    fn journal_detail(&mut self) -> Result<Option<Box<dyn std::any::Any + Send>>, FilesystemError> {
3524        // Boxed so `dyn Filesystem` callers can downcast to `JournalDetail`; concrete calls hit the inherent method.
3525        Ok(HfsPlusFilesystem::journal_detail(self)?
3526            .map(|d| Box::new(d) as Box<dyn std::any::Any + Send>))
3527    }
3528}
3529
3530impl<R: Read + Seek + Send> HfsPlusFilesystem<R> {
3531    fn lookup_folder_name(&self, cnid: u32) -> Option<(u64, String)> {
3532        // Find the thread record for this CNID to get its name
3533        if let Some((_node, _rec, offset)) = self.find_catalog_record_by_cnid(cnid) {
3534            let node_size = self.catalog_header.node_size as usize;
3535            let key_len = BigEndian::read_u16(&self.catalog_data[offset..offset + 2]) as usize;
3536            let mut rec_data_start = offset + 2 + key_len;
3537            if !rec_data_start.is_multiple_of(2) {
3538                rec_data_start += 1;
3539            }
3540            // Thread record: type(2) + reserved(2) + parentID(4) + name_len(2) + name(UTF-16BE)
3541            if rec_data_start + 10 <= self.catalog_data.len() {
3542                let name_len = BigEndian::read_u16(
3543                    &self.catalog_data[rec_data_start + 8..rec_data_start + 10],
3544                ) as usize;
3545                let name_end = rec_data_start + 10 + name_len * 2;
3546                if name_end <= self.catalog_data.len() {
3547                    let name = decode_utf16be(&self.catalog_data[rec_data_start + 10..name_end]);
3548                    return Some((cnid as u64, name));
3549                }
3550            }
3551            let _ = node_size;
3552        }
3553        // CNID set but can't resolve name
3554        Some((cnid as u64, format!("CNID {}", cnid)))
3555    }
3556}
3557
3558/// Stream a fork's data through its extent descriptors to a writer.
3559fn write_fork_to<R: Read + Seek>(
3560    reader: &mut R,
3561    partition_offset: u64,
3562    block_size: u32,
3563    fork: &ForkData,
3564    writer: &mut dyn std::io::Write,
3565) -> Result<u64, FilesystemError> {
3566    let size = fork.logical_size;
3567    let mut written: u64 = 0;
3568    let mut buf = vec![0u8; 64 * 1024];
3569    for ext in &fork.extents {
3570        if ext.is_empty() || written >= size {
3571            break;
3572        }
3573        let offset = partition_offset + ext.start_block as u64 * block_size as u64;
3574        let extent_len = ext.block_count as u64 * block_size as u64;
3575        let to_emit = extent_len.min(size - written);
3576        reader.seek(SeekFrom::Start(offset))?;
3577        let mut left = to_emit;
3578        while left > 0 {
3579            let n = (buf.len() as u64).min(left) as usize;
3580            reader.read_exact(&mut buf[..n])?;
3581            writer.write_all(&buf[..n])?;
3582            left -= n as u64;
3583        }
3584        written += to_emit;
3585    }
3586    Ok(written)
3587}
3588
3589/// Read a fork's data through its extent descriptors.
3590fn read_fork<R: Read + Seek>(
3591    reader: &mut R,
3592    partition_offset: u64,
3593    block_size: u32,
3594    fork: &ForkData,
3595) -> Result<Vec<u8>, FilesystemError> {
3596    let size = fork.logical_size as usize;
3597    let mut data = Vec::with_capacity(size);
3598
3599    let extent_count = fork.extents.iter().filter(|e| !e.is_empty()).count();
3600    let t_total = std::time::Instant::now();
3601    log::debug!(
3602        "[HFS+ read_fork] start: logical_size={} bytes, inline_extents={}",
3603        size,
3604        extent_count
3605    );
3606
3607    for (idx, ext) in fork.extents.iter().enumerate() {
3608        if ext.is_empty() {
3609            break;
3610        }
3611        let offset = partition_offset + ext.start_block as u64 * block_size as u64;
3612        let len = ext.block_count as u64 * block_size as u64;
3613        let read_len = len.min((size - data.len()) as u64) as usize;
3614        let t_ext = std::time::Instant::now();
3615        reader.seek(SeekFrom::Start(offset))?;
3616        let mut buf = vec![0u8; read_len];
3617        reader.read_exact(&mut buf)?;
3618        let elapsed_ms = t_ext.elapsed().as_secs_f64() * 1000.0;
3619        let mb = read_len as f64 / (1024.0 * 1024.0);
3620        let mbps = if elapsed_ms > 0.0 {
3621            mb / (elapsed_ms / 1000.0)
3622        } else {
3623            f64::INFINITY
3624        };
3625        log::debug!(
3626            "[HFS+ read_fork] ext[{}]: offset={} bytes={} time={:.1}ms throughput={:.2} MB/s",
3627            idx,
3628            offset,
3629            read_len,
3630            elapsed_ms,
3631            mbps
3632        );
3633        data.extend_from_slice(&buf);
3634        if data.len() >= size {
3635            break;
3636        }
3637    }
3638
3639    data.truncate(size);
3640    let total_ms = t_total.elapsed().as_secs_f64() * 1000.0;
3641    let total_mb = size as f64 / (1024.0 * 1024.0);
3642    let total_mbps = if total_ms > 0.0 {
3643        total_mb / (total_ms / 1000.0)
3644    } else {
3645        f64::INFINITY
3646    };
3647    log::debug!(
3648        "[HFS+ read_fork] done: {} bytes in {:.1}ms ({:.2} MB/s)",
3649        size,
3650        total_ms,
3651        total_mbps
3652    );
3653    Ok(data)
3654}
3655
3656/// Walk the extents-overflow B-tree leaf chain and collect every extent
3657/// belonging to (`file_id`, `fork_type`) whose `startBlock` key is at or
3658/// past `min_start_block`.
3659///
3660/// The HFS+ extents-overflow record is:
3661/// ```text
3662/// HFSPlusExtentKey { keyLength: u16 = 10, forkType: u8, pad: u8,
3663///                    fileID: u32, startBlock: u32 }
3664/// HFSPlusExtentRecord { extents: [HFSPlusExtentDescriptor; 8] }
3665/// ```
3666/// Each `HFSPlusExtentDescriptor` is `{ startBlock: u32, blockCount: u32 }`.
3667fn collect_hfsplus_overflow_extents(
3668    extents_data: &[u8],
3669    file_id: u32,
3670    fork_type: u8,
3671    min_start_block: u32,
3672) -> Vec<ExtentDescriptor> {
3673    use crate::hfs_common::walk_leaf_records;
3674
3675    if extents_data.len() < 14 + 30 {
3676        return Vec::new();
3677    }
3678    let header = BTreeHeaderRecord::parse(&extents_data[14..14 + 30.min(extents_data.len() - 14)]);
3679    let node_size = header.node_size as usize;
3680    if node_size == 0 {
3681        return Vec::new();
3682    }
3683
3684    let mut out: Vec<(u32, ExtentDescriptor)> = Vec::new();
3685    walk_leaf_records::<(), _>(
3686        extents_data,
3687        header.first_leaf_node,
3688        node_size,
3689        |_node, _rec_idx, _abs_off, rec| {
3690            if rec.len() < 12 {
3691                return None;
3692            }
3693            let key_len = BigEndian::read_u16(&rec[0..2]) as usize;
3694            // HFS+ extent key: forkType(1) pad(1) fileID(4) startBlock(4) = 10 bytes.
3695            if key_len < 10 || 2 + key_len > rec.len() {
3696                return None;
3697            }
3698            let rec_fork = rec[2];
3699            let rec_file = BigEndian::read_u32(&rec[4..8]);
3700            let rec_start_block = BigEndian::read_u32(&rec[8..12]);
3701            if rec_fork != fork_type || rec_file != file_id {
3702                return None;
3703            }
3704            if rec_start_block < min_start_block {
3705                return None;
3706            }
3707            // Record body is 8 × 8-byte HFSPlusExtentDescriptor immediately
3708            // after the key; HFS+ keys are 2-byte aligned by construction.
3709            let data_off = 2 + key_len;
3710            if data_off + 64 > rec.len() {
3711                return None;
3712            }
3713            for j in 0..8 {
3714                let ext = ExtentDescriptor::parse(&rec[data_off + j * 8..data_off + j * 8 + 8]);
3715                if ext.block_count > 0 {
3716                    out.push((rec_start_block, ext));
3717                }
3718            }
3719            None
3720        },
3721    );
3722
3723    out.sort_by_key(|(sb, _)| *sb);
3724    out.into_iter().map(|(_, e)| e).collect()
3725}
3726
3727/// Like `read_fork`, but consults the extents-overflow B-tree when the inline
3728/// 8 extents don't cover `fork.logical_size`.
3729///
3730/// `extents_overflow_data` is the bytes of `vh.extents_file` (loaded once via
3731/// `read_fork`; the extents-overflow file itself can't have overflow records
3732/// — its 8 inline extents are authoritative). Pass `None` if the volume has
3733/// no extents-overflow file or you've already verified the fork fits inline.
3734fn read_fork_with_overflow<R: Read + Seek>(
3735    reader: &mut R,
3736    partition_offset: u64,
3737    block_size: u32,
3738    fork: &ForkData,
3739    file_id: u32,
3740    fork_type: u8,
3741    extents_overflow_data: Option<&[u8]>,
3742) -> Result<Vec<u8>, FilesystemError> {
3743    let mut data = read_fork(reader, partition_offset, block_size, fork)?;
3744    let target = fork.logical_size as usize;
3745    if data.len() >= target {
3746        return Ok(data);
3747    }
3748    let Some(ext_data) = extents_overflow_data else {
3749        return Err(FilesystemError::InvalidData(format!(
3750            "file {file_id} fork {fork_type:#x}: {} bytes requested but only \
3751             {} bytes in inline extents and no extents-overflow B-tree available",
3752            fork.logical_size,
3753            data.len()
3754        )));
3755    };
3756    let inline_blocks: u32 = fork.extents.iter().map(|e| e.block_count).sum();
3757    let overflow = collect_hfsplus_overflow_extents(ext_data, file_id, fork_type, inline_blocks);
3758    for ext in overflow {
3759        if data.len() >= target {
3760            break;
3761        }
3762        if ext.block_count == 0 {
3763            continue;
3764        }
3765        let offset = partition_offset + ext.start_block as u64 * block_size as u64;
3766        let extent_len = ext.block_count as u64 * block_size as u64;
3767        let to_read = extent_len.min((target - data.len()) as u64) as usize;
3768        reader.seek(SeekFrom::Start(offset))?;
3769        let mut buf = vec![0u8; to_read];
3770        reader.read_exact(&mut buf)?;
3771        data.extend_from_slice(&buf);
3772    }
3773    if data.len() < target {
3774        return Err(FilesystemError::InvalidData(format!(
3775            "file {file_id} fork {fork_type:#x}: extents (inline + overflow) \
3776             cover {} of {} bytes",
3777            data.len(),
3778            fork.logical_size
3779        )));
3780    }
3781    Ok(data)
3782}
3783
3784/// Translate a byte offset within a fork (file space) to a byte offset on
3785/// the underlying device, using the fork's inline 8 extents. Returns
3786/// `Some((device_offset, contiguous_bytes_remaining))` for the extent that
3787/// contains `fork_offset`, or `None` if `fork_offset` falls past the inline
3788/// extents (caller would need to consult the extents-overflow B-tree).
3789fn translate_fork_offset(
3790    fork: &ForkData,
3791    block_size: u32,
3792    partition_offset: u64,
3793    fork_offset: u64,
3794) -> Option<(u64, u64)> {
3795    let bs = block_size as u64;
3796    let mut cursor: u64 = 0;
3797    for ext in &fork.extents {
3798        if ext.block_count == 0 {
3799            continue;
3800        }
3801        let len = ext.block_count as u64 * bs;
3802        if fork_offset < cursor + len {
3803            let local = fork_offset - cursor;
3804            let dev = partition_offset + ext.start_block as u64 * bs + local;
3805            return Some((dev, len - local));
3806        }
3807        cursor += len;
3808    }
3809    None
3810}
3811
3812/// Read exactly one B-tree node from a fork, by node index.
3813fn read_node_from_fork<R: Read + Seek>(
3814    reader: &mut R,
3815    partition_offset: u64,
3816    block_size: u32,
3817    fork: &ForkData,
3818    node_idx: u32,
3819    node_size: usize,
3820) -> Option<Vec<u8>> {
3821    let fork_offset = (node_idx as u64).checked_mul(node_size as u64)?;
3822    let (dev_off, contig) = translate_fork_offset(fork, block_size, partition_offset, fork_offset)?;
3823    if (contig as usize) < node_size {
3824        // Node straddles an extent boundary — should never happen for HFS+
3825        // since node_size divides block_size. Bail rather than do a multi-
3826        // extent read for the cheap probe path.
3827        return None;
3828    }
3829    reader.seek(SeekFrom::Start(dev_off)).ok()?;
3830    let mut buf = vec![0u8; node_size];
3831    reader.read_exact(&mut buf).ok()?;
3832    Some(buf)
3833}
3834
3835/// Probe an HFS+ volume header at `partition_offset` and return the volume
3836/// label without loading the full catalog.
3837///
3838/// The volume name lives in the *key* of the root folder record at
3839/// `(parent=1, name=<vol_name>)`. Catalog records are sorted primarily by
3840/// parent CNID (ascending u32), and `parent=1` is smaller than every other
3841/// parent in the catalog — so the root folder record is guaranteed to be
3842/// record 0 of the first leaf node. We don't need to look at the root
3843/// thread record at all (it sorts after every `parent=1` and `parent=2`
3844/// non-thread record, and on a volume with many top-level entries it can
3845/// spill into a later leaf — which is what was causing this probe to come
3846/// back empty for real volumes).
3847///
3848/// Reads node 0 (the B-tree header) and the first leaf node of the catalog
3849/// directly via the catalog fork's inline extents — typically two small
3850/// reads. Returns None if the partition doesn't hold an HFS+/HFSX volume.
3851pub fn probe_hfsplus_volume_label<R: Read + Seek>(
3852    reader: &mut R,
3853    partition_offset: u64,
3854) -> Option<String> {
3855    // APM "Apple_HFS" partitions can be pure classic HFS, native HFS+, or
3856    // **HFS-wrapped HFS+** (the legacy hybrid where a classic HFS MDB at
3857    // partition_offset+1024 carries `drEmbedSigWord=0x482B` and points at
3858    // the embedded HFS+ region somewhere inside the partition). The wrapper
3859    // case is the failure mode we hit on real volumes — `resolve_apple_hfs`
3860    // returns the embedded HFS+ offset for all three cases.
3861    let (fs_type, hfsplus_offset) = resolve_apple_hfs(reader, partition_offset);
3862    if fs_type != "hfsplus" {
3863        return None;
3864    }
3865    reader.seek(SeekFrom::Start(hfsplus_offset + 1024)).ok()?;
3866    let mut vh_buf = [0u8; 512];
3867    reader.read_exact(&mut vh_buf).ok()?;
3868    let vh = HfsPlusVolumeHeader::parse(&vh_buf).ok()?;
3869    if vh.block_size == 0 {
3870        return None;
3871    }
3872
3873    // Read node 0 to learn the catalog's actual node_size and first_leaf_node.
3874    // We don't yet know node_size, so probe with the volume's allocation block
3875    // size (HFS+ catalog node sizes are typically 4 KiB or 8 KiB and never
3876    // exceed the allocation block size on commonly-encountered volumes).
3877    let probe_size = (vh.block_size as usize).clamp(512, 32 * 1024);
3878    let node0 = read_node_from_fork(
3879        reader,
3880        hfsplus_offset,
3881        vh.block_size,
3882        &vh.catalog_file,
3883        0,
3884        probe_size,
3885    )?;
3886    if node0.len() < 14 + 30 {
3887        return None;
3888    }
3889    let header = BTreeHeaderRecord::parse(&node0[14..14 + 30]);
3890    let node_size = header.node_size as usize;
3891    if node_size == 0 || node_size > 32 * 1024 || header.first_leaf_node == 0 {
3892        return None;
3893    }
3894
3895    // Read the first leaf node. Record 0 is the root folder record whose key
3896    // is (parent=1, nameLength, name in UTF-16BE).
3897    let leaf = read_node_from_fork(
3898        reader,
3899        hfsplus_offset,
3900        vh.block_size,
3901        &vh.catalog_file,
3902        header.first_leaf_node,
3903        node_size,
3904    )?;
3905    if leaf.len() < node_size || leaf[8] as i8 != -1 {
3906        return None;
3907    }
3908    let num_records = BigEndian::read_u16(&leaf[10..12]) as usize;
3909    if num_records == 0 {
3910        return None;
3911    }
3912    let (rec_start, rec_end) = crate::hfs_common::btree_record_range(&leaf, node_size, 0);
3913    if rec_start >= rec_end || rec_end > node_size {
3914        return None;
3915    }
3916    let rec = &leaf[rec_start..rec_end];
3917    if rec.len() < 8 {
3918        return None;
3919    }
3920    let key_len = BigEndian::read_u16(&rec[0..2]) as usize;
3921    if key_len < 6 || 2 + key_len > rec.len() {
3922        return None;
3923    }
3924    let parent_id = BigEndian::read_u32(&rec[2..6]);
3925    if parent_id != 1 {
3926        return None;
3927    }
3928    let name_length = BigEndian::read_u16(&rec[6..8]) as usize;
3929    if name_length == 0 {
3930        return None;
3931    }
3932    let body_start = 8;
3933    let body_end = body_start + name_length * 2;
3934    if body_end > rec.len() || body_end > 2 + key_len {
3935        return None;
3936    }
3937    let label = decode_utf16be(&rec[body_start..body_end]);
3938    if label.is_empty() {
3939        None
3940    } else {
3941        Some(label)
3942    }
3943}
3944
3945/// Write data through a fork's extent descriptors.
3946fn write_fork_data<R: Write + Seek>(
3947    writer: &mut R,
3948    partition_offset: u64,
3949    block_size: u32,
3950    fork: &ForkData,
3951    data: &[u8],
3952) -> Result<(), FilesystemError> {
3953    let mut written = 0usize;
3954    for ext in &fork.extents {
3955        if ext.is_empty() || written >= data.len() {
3956            break;
3957        }
3958        let offset = partition_offset + ext.start_block as u64 * block_size as u64;
3959        let extent_len = ext.block_count as u64 * block_size as u64;
3960        let to_write = extent_len.min((data.len() - written) as u64) as usize;
3961        writer.seek(SeekFrom::Start(offset))?;
3962        writer.write_all(&data[written..written + to_write])?;
3963        written += to_write;
3964    }
3965    Ok(())
3966}
3967
3968/// Write a special-file B-tree fork that may have grown past its 8 inline
3969/// extents (§4b Phase B). Writes the inline extents first, then continues
3970/// into the fork's overflow extents pulled from the extents-overflow B-tree
3971/// — the write-side mirror of [`read_fork_with_overflow`]. Without this, a
3972/// grown catalog/attributes fork would be silently truncated on disk to its
3973/// first 8 extents even though it looks complete in memory.
3974#[allow(clippy::too_many_arguments)]
3975fn write_fork_data_with_overflow<R: Write + Seek>(
3976    writer: &mut R,
3977    partition_offset: u64,
3978    block_size: u32,
3979    fork: &ForkData,
3980    file_id: u32,
3981    fork_type: u8,
3982    extents_overflow_data: Option<&[u8]>,
3983    data: &[u8],
3984) -> Result<(), FilesystemError> {
3985    let mut written = 0usize;
3986    let write_extent = |writer: &mut R, ext: &ExtentDescriptor, written: &mut usize| {
3987        if ext.block_count == 0 || *written >= data.len() {
3988            return Ok(false);
3989        }
3990        let offset = partition_offset + ext.start_block as u64 * block_size as u64;
3991        let extent_len = ext.block_count as u64 * block_size as u64;
3992        let to_write = extent_len.min((data.len() - *written) as u64) as usize;
3993        writer.seek(SeekFrom::Start(offset))?;
3994        writer.write_all(&data[*written..*written + to_write])?;
3995        *written += to_write;
3996        Ok::<bool, FilesystemError>(true)
3997    };
3998
3999    for ext in &fork.extents {
4000        if ext.is_empty() {
4001            break;
4002        }
4003        if !write_extent(writer, ext, &mut written)? {
4004            return Ok(());
4005        }
4006    }
4007    if written >= data.len() {
4008        return Ok(());
4009    }
4010    // Inline extents didn't cover the whole fork — continue into overflow.
4011    let Some(ext_data) = extents_overflow_data else {
4012        return Err(FilesystemError::InvalidData(format!(
4013            "special file {file_id} fork {fork_type:#x}: {} bytes to write but only \
4014             inline extents and no extents-overflow B-tree",
4015            data.len()
4016        )));
4017    };
4018    let inline_blocks: u32 = fork.extents.iter().map(|e| e.block_count).sum();
4019    for ext in collect_hfsplus_overflow_extents(ext_data, file_id, fork_type, inline_blocks) {
4020        if !write_extent(writer, &ext, &mut written)? {
4021            break;
4022        }
4023    }
4024    if written < data.len() {
4025        return Err(FilesystemError::InvalidData(format!(
4026            "special file {file_id} fork {fork_type:#x}: extents cover {written} of {} bytes",
4027            data.len()
4028        )));
4029    }
4030    Ok(())
4031}
4032
4033/// Decode a UTF-16BE byte slice to a String.
4034fn decode_utf16be(data: &[u8]) -> String {
4035    let chars: Vec<u16> = data.chunks_exact(2).map(BigEndian::read_u16).collect();
4036    String::from_utf16_lossy(&chars)
4037}
4038
4039/// Find the volume label from the catalog B-tree (root folder thread record).
4040/// Count data-fork extents per file by walking catalog leaves. Cheap —
4041/// all catalog data already lives in `catalog_data` (loaded at FS open),
4042/// so this is a pure in-memory scan. Each file record's data fork holds 8
4043/// inline extent descriptors at offset 88+16; we tally non-zero ones and
4044/// flag forks that overflow into the extents-overflow B-tree as having
4045/// >1 extent (the most common cause of real-world fragmentation).
4046fn hfsplus_fragmentation_stats(
4047    catalog_data: &[u8],
4048    first_leaf: u32,
4049    node_size: usize,
4050) -> disk_core::filesystem::FragmentationStats {
4051    use crate::hfs_common::walk_leaf_records;
4052    use byteorder::{BigEndian, ByteOrder};
4053
4054    let mut stats = disk_core::filesystem::FragmentationStats::default();
4055    if node_size == 0 || catalog_data.is_empty() {
4056        return stats;
4057    }
4058    walk_leaf_records::<(), _>(
4059        catalog_data,
4060        first_leaf,
4061        node_size,
4062        |_node_idx, _rec_idx, _abs_off, rec| {
4063            // HFS+ catalog leaf record: u16 keyLength at offset 0, then the
4064            // key payload (parentID + HFSUniStr255), then the record body
4065            // (padded to even offset). The body's first 2 bytes carry the
4066            // record type — 0x0002 = file, 0x0001 = folder, threads = 3/4.
4067            if rec.len() < 4 {
4068                return None;
4069            }
4070            let key_len = BigEndian::read_u16(&rec[0..2]) as usize;
4071            let mut body_start = 2 + key_len;
4072            if !body_start.is_multiple_of(2) {
4073                body_start += 1;
4074            }
4075            if body_start + 88 + 80 > rec.len() {
4076                return None;
4077            }
4078            let body = &rec[body_start..];
4079            let rec_type = BigEndian::read_u16(&body[0..2]);
4080            if rec_type != 0x0002 {
4081                return None;
4082            }
4083            let data_fork = &body[88..88 + 80];
4084            let total_blocks = BigEndian::read_u32(&data_fork[12..16]);
4085            if total_blocks == 0 {
4086                return None;
4087            }
4088            let mut inline_extents: u32 = 0;
4089            let mut inline_blocks: u32 = 0;
4090            for i in 0..8 {
4091                let off = 16 + i * 8;
4092                let count = BigEndian::read_u32(&data_fork[off + 4..off + 8]);
4093                if count > 0 {
4094                    inline_extents += 1;
4095                    inline_blocks = inline_blocks.saturating_add(count);
4096                }
4097            }
4098            stats.files_with_data += 1;
4099            // Multiple inline extents OR overflow into the extents B-tree
4100            // both count as fragmented for UI purposes.
4101            if inline_extents > 1 || total_blocks > inline_blocks {
4102                stats.fragmented_files += 1;
4103            }
4104            None
4105        },
4106    );
4107    stats
4108}
4109
4110fn find_volume_label(catalog_data: &[u8], header: &BTreeHeaderRecord) -> String {
4111    use crate::hfs_common::walk_leaf_records;
4112
4113    let node_size = header.node_size as usize;
4114    if node_size == 0 {
4115        return String::new();
4116    }
4117
4118    walk_leaf_records::<String, _>(
4119        catalog_data,
4120        header.first_leaf_node,
4121        node_size,
4122        |_node_idx, _rec_idx, _abs_off, rec| {
4123            if rec.len() < 8 {
4124                return None;
4125            }
4126            let key_len = BigEndian::read_u16(&rec[0..2]) as usize;
4127            if key_len < 6 || 2 + key_len > rec.len() {
4128                return None;
4129            }
4130            // Catalog key: parent_id(4) + name_length(2) + name(UTF-16BE)
4131            let parent_id = BigEndian::read_u32(&rec[2..6]);
4132            if parent_id != 2 {
4133                return None;
4134            }
4135            let name_length = BigEndian::read_u16(&rec[6..8]) as usize;
4136            if name_length != 0 {
4137                return None;
4138            }
4139
4140            // Record data follows the key, 2-byte aligned. HFS+ keys are
4141            // already 2-byte sized so no padding is needed in the standard
4142            // case, but tolerate odd offsets defensively.
4143            let mut data_start = 2 + key_len;
4144            if !data_start.is_multiple_of(2) {
4145                data_start += 1;
4146            }
4147            if data_start + 10 > rec.len() {
4148                return None;
4149            }
4150            let record_type = BigEndian::read_i16(&rec[data_start..data_start + 2]);
4151            // Folder thread = 3
4152            if record_type != 3 {
4153                return None;
4154            }
4155            let thread_name_len =
4156                BigEndian::read_u16(&rec[data_start + 8..data_start + 10]) as usize;
4157            let body_start = data_start + 10;
4158            let body_end = body_start + thread_name_len * 2;
4159            if body_end > rec.len() {
4160                return None;
4161            }
4162            Some(decode_utf16be(&rec[body_start..body_end]))
4163        },
4164    )
4165    .unwrap_or_default()
4166}
4167
4168/// Find the index of the last set bit in a bitmap (MSB-first).
4169///
4170/// MSB-first means bit position 7 of byte N (mask 0x80) corresponds to
4171/// global index `N*8`, and bit position 0 (mask 0x01) to global index
4172/// `N*8 + 7`. The "last" set bit is the one with the highest global index.
4173fn find_last_set_bit(bitmap: &[u8], max_bits: u32) -> Option<u32> {
4174    let max = max_bits as usize;
4175    let scan_bytes = max.div_ceil(8).min(bitmap.len());
4176    for byte_idx in (0..scan_bytes).rev() {
4177        let byte = bitmap[byte_idx];
4178        if byte == 0 {
4179            continue;
4180        }
4181        // Within the byte, the highest global index belongs to the lowest
4182        // bit position (LSB). Iterate bit positions 0..8 ascending and
4183        // return the first set one in range.
4184        for bit_pos in 0..8u32 {
4185            let global = (byte_idx as u32) * 8 + (7 - bit_pos);
4186            if global >= max_bits {
4187                continue;
4188            }
4189            if (byte >> bit_pos) & 1 == 1 {
4190                return Some(global);
4191            }
4192        }
4193    }
4194    None
4195}
4196
4197// --- EditableFilesystem implementation ---
4198
4199impl<R: Read + Write + Seek + Send> EditableFilesystem for HfsPlusFilesystem<R> {
4200    fn as_filesystem(&self) -> &dyn disk_core::filesystem::Filesystem {
4201        self
4202    }
4203    fn as_filesystem_mut(&mut self) -> &mut dyn disk_core::filesystem::Filesystem {
4204        self
4205    }
4206    fn create_file(
4207        &mut self,
4208        parent: &FileEntry,
4209        name: &str,
4210        data: &mut dyn std::io::Read,
4211        data_len: u64,
4212        options: &CreateFileOptions,
4213    ) -> Result<FileEntry, FilesystemError> {
4214        let snap = self.snapshot();
4215        let result = self.create_file_inner(parent, name, data, data_len, options);
4216        if result.is_err() {
4217            self.restore_snapshot(snap);
4218        }
4219        result
4220    }
4221
4222    fn create_directory(
4223        &mut self,
4224        parent: &FileEntry,
4225        name: &str,
4226        options: &CreateDirectoryOptions,
4227    ) -> Result<FileEntry, FilesystemError> {
4228        let snap = self.snapshot();
4229        let result = self.create_directory_inner(parent, name, options);
4230        if result.is_err() {
4231            self.restore_snapshot(snap);
4232        }
4233        result
4234    }
4235
4236    fn delete_entry(
4237        &mut self,
4238        parent: &FileEntry,
4239        entry: &FileEntry,
4240    ) -> Result<(), FilesystemError> {
4241        let snap = self.snapshot();
4242        let result = self.delete_entry_inner(parent, entry);
4243        if result.is_err() {
4244            self.restore_snapshot(snap);
4245        }
4246        result
4247    }
4248
4249    fn rename(
4250        &mut self,
4251        parent: &FileEntry,
4252        entry: &FileEntry,
4253        new_name: &str,
4254    ) -> Result<(), FilesystemError> {
4255        if new_name == entry.name {
4256            return Ok(());
4257        }
4258        validate_hfsplus_create_name(new_name)?;
4259        let snap = self.snapshot();
4260        let result = self.rename_inner(parent, entry, new_name);
4261        if result.is_err() {
4262            self.restore_snapshot(snap);
4263        }
4264        result
4265    }
4266
4267    fn set_type_creator(
4268        &mut self,
4269        entry: &FileEntry,
4270        type_code: &str,
4271        creator_code: &str,
4272    ) -> Result<(), FilesystemError> {
4273        let snap = self.snapshot();
4274        let result = self.set_type_creator_inner(entry, type_code, creator_code);
4275        if result.is_err() {
4276            self.restore_snapshot(snap);
4277        }
4278        result
4279    }
4280
4281    fn write_resource_fork(
4282        &mut self,
4283        entry: &FileEntry,
4284        data: &mut dyn std::io::Read,
4285        len: u64,
4286    ) -> Result<(), FilesystemError> {
4287        let snap = self.snapshot();
4288        let result = self.write_resource_fork_inner(entry, data, len);
4289        if result.is_err() {
4290            self.restore_snapshot(snap);
4291        }
4292        result
4293    }
4294
4295    fn set_permissions(&mut self, entry: &FileEntry, mode: u32) -> Result<(), FilesystemError> {
4296        let frec = self.record_body_offset(entry.location as u32)?;
4297        let base = Self::bsd_type_bits(&self.catalog_data, frec, entry.is_directory());
4298        let new = disk_core::unix_common::inode::with_permission_bits(base, mode) as u16;
4299        let at = frec + HfsPlusBsdInfo::OFFSET + 10;
4300        BigEndian::write_u16(&mut self.catalog_data[at..at + 2], new);
4301        Ok(())
4302    }
4303
4304    fn set_owner(&mut self, entry: &FileEntry, uid: u32, gid: u32) -> Result<(), FilesystemError> {
4305        // HFSPlusBSDInfo carries 32-bit ownerID / groupID.
4306        let frec = self.record_body_offset(entry.location as u32)?;
4307        let at = frec + HfsPlusBsdInfo::OFFSET;
4308        BigEndian::write_u32(&mut self.catalog_data[at..at + 4], uid);
4309        BigEndian::write_u32(&mut self.catalog_data[at + 4..at + 8], gid);
4310        // An ownership change on a record with no POSIX info yet would be
4311        // invisible to OS X, which reads ownership only where `fileMode`
4312        // says the block is populated. Give it the type bits so the file
4313        // gets a real mode instead of a half-filled one.
4314        let mode_at = frec + HfsPlusBsdInfo::OFFSET + 10;
4315        if BigEndian::read_u16(&self.catalog_data[mode_at..mode_at + 2]) == 0 {
4316            let default_mode = if entry.is_directory() {
4317                0o040_755
4318            } else {
4319                0o100_644
4320            };
4321            BigEndian::write_u16(
4322                &mut self.catalog_data[mode_at..mode_at + 2],
4323                default_mode as u16,
4324            );
4325        }
4326        Ok(())
4327    }
4328
4329    fn sync_metadata(&mut self) -> Result<(), FilesystemError> {
4330        self.do_sync_metadata()
4331    }
4332
4333    fn free_space(&mut self) -> Result<u64, FilesystemError> {
4334        Ok(self.vh.free_blocks as u64 * self.vh.block_size as u64)
4335    }
4336
4337    fn set_blessed_folder(&mut self, entry: &FileEntry) -> Result<(), FilesystemError> {
4338        let snap = self.snapshot();
4339        let result = self.set_blessed_folder_inner(entry);
4340        if result.is_err() {
4341            self.restore_snapshot(snap);
4342        }
4343        result
4344    }
4345
4346    fn write_boot_blocks(&mut self, blocks: &[u8; 1024]) -> Result<(), FilesystemError> {
4347        self.set_boot_blocks(blocks);
4348        Ok(())
4349    }
4350}
4351
4352impl<R: Read + Write + Seek + Send> HfsPlusFilesystem<R> {
4353    /// Set (or replace) an inline extended attribute on `cnid`.
4354    ///
4355    /// Any existing record keyed by `(cnid, name)` is removed first so the
4356    /// final state holds exactly one record per `(cnid, name)` pair. Records
4357    /// whose payload would not fit in a single B-tree leaf node are rejected
4358    /// with `XattrTooLarge` — fork-style storage is not yet wired up.
4359    ///
4360    /// Wrapped in the snapshot guard so partial failures (e.g. a leaf split
4361    /// running out of free B-tree nodes) leave the in-memory state byte-equal
4362    /// to the pre-call state.
4363    pub fn set_xattr(
4364        &mut self,
4365        cnid: u32,
4366        name: &str,
4367        value: &[u8],
4368    ) -> Result<(), FilesystemError> {
4369        let snap = self.snapshot();
4370        let result = (|| -> Result<(), FilesystemError> {
4371            self.remove_xattr_records(cnid, Some(name))?;
4372            let rec = Self::build_inline_attr_record(cnid, name, value);
4373            self.insert_xattr_record(&rec)?;
4374            if let Some(set) = self.xattr_cnids.as_mut() {
4375                set.insert(cnid);
4376            }
4377            Ok(())
4378        })();
4379        if result.is_err() {
4380            self.restore_snapshot(snap);
4381        }
4382        result
4383    }
4384
4385    /// Remove a single extended attribute by name. Returns `NotFound` if no
4386    /// matching record exists. Wrapped in the snapshot guard.
4387    pub fn remove_xattr(&mut self, cnid: u32, name: &str) -> Result<(), FilesystemError> {
4388        let snap = self.snapshot();
4389        let result = (|| -> Result<(), FilesystemError> {
4390            let removed = self.remove_xattr_records(cnid, Some(name))?;
4391            if removed == 0 {
4392                return Err(FilesystemError::NotFound(format!(
4393                    "extended attribute '{name}' on cnid {cnid}"
4394                )));
4395            }
4396            // Drop the cnid from the cached set if no records remain.
4397            if self.list_xattrs(cnid)?.is_empty() {
4398                if let Some(set) = self.xattr_cnids.as_mut() {
4399                    set.remove(&cnid);
4400                }
4401            }
4402            Ok(())
4403        })();
4404        if result.is_err() {
4405            self.restore_snapshot(snap);
4406        }
4407        result
4408    }
4409}
4410
4411/// Captured per-record metadata, replayed by [`HfsPlusFilesystem::set_record_metadata`]
4412/// onto a freshly-created target catalog record. Field offsets are
4413/// identical for HFS+ file (248-byte) and folder (88-byte) records, so the
4414/// same struct serves both shapes.
4415///
4416/// Used by the clone pipeline (Step 21 of `docs/hfsplus_enhancements.md`)
4417/// to stamp the source's dates / Finder metadata / BSD info / text
4418/// encoding onto each cloned entry after `create_file` /
4419/// `create_directory` has assigned it a new CNID.
4420#[derive(Debug, Clone, Default)]
4421pub(crate) struct RecordMetadata {
4422    pub create_date: u32,
4423    pub content_mod_date: u32,
4424    pub attribute_mod_date: u32,
4425    pub access_date: u32,
4426    pub backup_date: u32,
4427    pub bsd_info: [u8; 16],
4428    pub finder_info: [u8; 16],
4429    pub extended_finder_info: [u8; 16],
4430    pub text_encoding: u32,
4431}
4432
4433impl<R: Read + Write + Seek + Send> HfsPlusFilesystem<R> {
4434    /// Replay captured per-record metadata onto the catalog record for
4435    /// `cnid`. Patches dates, BSD info, FInfo / FXInfo (or DInfo / DXInfo
4436    /// for folders — same offsets), and textEncoding. The locked-bit and
4437    /// flags-word fields are intentionally untouched here so callers can
4438    /// stamp them via dedicated paths (none today; the source's `flags` is
4439    /// already mirrored by `create_file` / `create_directory` for the
4440    /// fields the clone cares about).
4441    ///
4442    /// Mostly used by the clone replay path; tests cover the round-trip
4443    /// in `clone_round_trip_*`.
4444    /// Byte offset of catalog record `cnid`'s **body** (past the key) inside
4445    /// `catalog_data`, resolved through the record's thread entry.
4446    ///
4447    /// Shared by every in-place record patch — the metadata replay below and
4448    /// the POSIX-permission setters — so the thread-lookup / key-skip /
4449    /// even-alignment dance lives in one place.
4450    pub(crate) fn record_body_offset(&mut self, cnid: u32) -> Result<usize, FilesystemError> {
4451        let (_, _, t_offset) = self.find_catalog_record_by_cnid(cnid).ok_or_else(|| {
4452            FilesystemError::NotFound(format!("thread record for CNID {cnid} not found"))
4453        })?;
4454        let key_len = BigEndian::read_u16(&self.catalog_data[t_offset..t_offset + 2]) as usize;
4455        let mut rec_data_start = t_offset + 2 + key_len;
4456        if !rec_data_start.is_multiple_of(2) {
4457            rec_data_start += 1;
4458        }
4459        let thread_parent =
4460            BigEndian::read_u32(&self.catalog_data[rec_data_start + 4..rec_data_start + 8]);
4461        let thread_name_len =
4462            BigEndian::read_u16(&self.catalog_data[rec_data_start + 8..rec_data_start + 10])
4463                as usize;
4464        let thread_name = decode_utf16be(
4465            &self.catalog_data[rec_data_start + 10..rec_data_start + 10 + thread_name_len * 2],
4466        );
4467
4468        let (_, _, f_offset) = self
4469            .find_catalog_record(thread_parent, &thread_name)
4470            .ok_or_else(|| {
4471                FilesystemError::NotFound(format!(
4472                    "catalog record for CNID {cnid} ('{thread_name}') not found"
4473                ))
4474            })?;
4475        let fkey_len = BigEndian::read_u16(&self.catalog_data[f_offset..f_offset + 2]) as usize;
4476        let mut frec = f_offset + 2 + fkey_len;
4477        if !frec.is_multiple_of(2) {
4478            frec += 1;
4479        }
4480        if frec + 84 > self.catalog_data.len() {
4481            return Err(FilesystemError::InvalidData(format!(
4482                "catalog record for CNID {cnid} truncated"
4483            )));
4484        }
4485        Ok(frec)
4486    }
4487
4488    /// The `S_IFMT` bits to build a new `fileMode` on, for a record at
4489    /// `frec`.
4490    ///
4491    /// Normally the record's existing `fileMode` supplies them. A volume
4492    /// that has never carried POSIX info has `fileMode == 0` and therefore
4493    /// no type bits — writing bare permission bits there would leave a
4494    /// typeless mode that OS X reads as neither file nor directory, so the
4495    /// type comes from the catalog record's own kind instead.
4496    fn bsd_type_bits(catalog: &[u8], frec: usize, is_dir: bool) -> u32 {
4497        let at = frec + HfsPlusBsdInfo::OFFSET + 10;
4498        let cur = BigEndian::read_u16(&catalog[at..at + 2]) as u32;
4499        if cur & 0o170_000 != 0 {
4500            cur
4501        } else if is_dir {
4502            0o040_000
4503        } else {
4504            0o100_000
4505        }
4506    }
4507
4508    pub(crate) fn set_record_metadata(
4509        &mut self,
4510        cnid: u32,
4511        meta: &RecordMetadata,
4512    ) -> Result<(), FilesystemError> {
4513        let frec = self.record_body_offset(cnid)?;
4514
4515        BigEndian::write_u32(
4516            &mut self.catalog_data[frec + 12..frec + 16],
4517            meta.create_date,
4518        );
4519        BigEndian::write_u32(
4520            &mut self.catalog_data[frec + 16..frec + 20],
4521            meta.content_mod_date,
4522        );
4523        BigEndian::write_u32(
4524            &mut self.catalog_data[frec + 20..frec + 24],
4525            meta.attribute_mod_date,
4526        );
4527        BigEndian::write_u32(
4528            &mut self.catalog_data[frec + 24..frec + 28],
4529            meta.access_date,
4530        );
4531        BigEndian::write_u32(
4532            &mut self.catalog_data[frec + 28..frec + 32],
4533            meta.backup_date,
4534        );
4535        self.catalog_data[frec + 32..frec + 48].copy_from_slice(&meta.bsd_info);
4536        self.catalog_data[frec + 48..frec + 64].copy_from_slice(&meta.finder_info);
4537        self.catalog_data[frec + 64..frec + 80].copy_from_slice(&meta.extended_finder_info);
4538        BigEndian::write_u32(
4539            &mut self.catalog_data[frec + 80..frec + 84],
4540            meta.text_encoding,
4541        );
4542        Ok(())
4543    }
4544
4545    /// Set the volume's create / modify / backup / checked dates. The
4546    /// `modify_date` is overwritten by `do_sync_metadata` with `hfs_now()`
4547    /// at sync time, so callers that want the source's modifyDate to
4548    /// survive should call this *after* the final mutation (i.e. as the
4549    /// last step before sync) — but for the clone pipeline the modifyDate
4550    /// is intentionally bumped to "now" anyway, so the input value here is
4551    /// a fallback used only if sync doesn't run.
4552    pub(crate) fn set_volume_dates(
4553        &mut self,
4554        create_date: u32,
4555        modify_date: u32,
4556        backup_date: u32,
4557        checked_date: u32,
4558    ) {
4559        self.vh.create_date = create_date;
4560        self.vh.modify_date = modify_date;
4561        self.vh.backup_date = backup_date;
4562        self.vh.checked_date = checked_date;
4563    }
4564
4565    /// Replace the volume header's 8 × u32 Finder Info slots verbatim. The
4566    /// caller is responsible for any CNID remap (slots 0/3/5 etc. may hold
4567    /// CNIDs that point at the now-renumbered blessed folder / OS folder).
4568    pub(crate) fn set_volume_finder_info(&mut self, finder_info: [u32; 8]) {
4569        self.vh.finder_info = finder_info;
4570    }
4571
4572    /// Stage the source volume's first 1024 bytes (boot blocks) for write
4573    /// at the next `sync_metadata`. Macs only ever care about the boot
4574    /// blocks on the System volume, but the area is part of the volume's
4575    /// signature, so the clone copies it verbatim for round-trip parity.
4576    pub(crate) fn set_boot_blocks(&mut self, blocks: &[u8; 1024]) {
4577        self.pending_boot_blocks = Some(Box::new(*blocks));
4578    }
4579}
4580
4581impl<R: Read + Write + Seek + Send> HfsPlusFilesystem<R> {
4582    /// Locate the `\0\0\0\0HFS+ Private Data` directory at root, creating
4583    /// it if missing. Returns its `FileEntry`. The directory's existence is
4584    /// the prerequisite for any hardlink-related write — both
4585    /// [`Self::create_hardlink_inode`] and [`Self::create_hardlink`] call
4586    /// this internally so the replay path doesn't need to.
4587    pub fn ensure_private_dir(&mut self) -> Result<FileEntry, FilesystemError> {
4588        let private_name = hfsplus_private_dir_name();
4589        let root = self.root()?;
4590        for entry in self.list_directory(&root)? {
4591            if entry.is_directory() && entry.name == private_name {
4592                return Ok(entry);
4593            }
4594        }
4595        self.create_directory(&root, &private_name, &CreateDirectoryOptions::default())
4596    }
4597
4598    /// Locate the `.HFS+ Private Directory Data\r` directory at root,
4599    /// creating it if missing. Companion to [`Self::ensure_private_dir`]
4600    /// for directory hardlinks.
4601    pub fn ensure_dir_private_dir(&mut self) -> Result<FileEntry, FilesystemError> {
4602        let dir_private_name = hfsplus_dir_private_dir_name();
4603        let root = self.root()?;
4604        for entry in self.list_directory(&root)? {
4605            if entry.is_directory() && entry.name == dir_private_name {
4606                return Ok(entry);
4607            }
4608        }
4609        self.create_directory(&root, &dir_private_name, &CreateDirectoryOptions::default())
4610    }
4611
4612    /// Create a hardlink inode (`iNode<inode_num>`) under the volume's
4613    /// private directory. The inode's data and resource forks hold the
4614    /// shared payload; its `bsdInfo.special` (linkCount) starts at zero
4615    /// and is bumped per-link by [`Self::create_hardlink`]. The caller is
4616    /// responsible for calling `sync_metadata` once all replay edits are
4617    /// staged.
4618    ///
4619    /// Returns an `AlreadyExists` error if `iNode<inode_num>` already lives
4620    /// in the private dir, so callers don't accidentally double-emit.
4621    pub fn create_hardlink_inode(
4622        &mut self,
4623        inode_num: u32,
4624        data: &mut dyn std::io::Read,
4625        data_len: u64,
4626        options: &CreateFileOptions,
4627    ) -> Result<FileEntry, FilesystemError> {
4628        let snap = self.snapshot();
4629        let result = (|| -> Result<FileEntry, FilesystemError> {
4630            let private = self.ensure_private_dir()?;
4631            let inode_name = format!("iNode{inode_num}");
4632            self.create_file(&private, &inode_name, data, data_len, options)
4633        })();
4634        if result.is_err() {
4635            self.restore_snapshot(snap);
4636        }
4637        result
4638    }
4639
4640    /// Create a file hardlink stub at `(parent, name)` referencing the
4641    /// inode `iNode<inode_num>` under the private dir. The stub carries
4642    /// empty forks and FInfo `('hlnk', 'hfs+')` with
4643    /// `bsdInfo.special = inode_num`. The matching inode's linkCount
4644    /// (`bsdInfo.special`) is bumped by 1.
4645    ///
4646    /// Returns the stub's `FileEntry` with `link_target_cnid` set to the
4647    /// inode's CNID — callers that want the inode bytes can read through
4648    /// it directly via [`Self::read_file`] / [`Self::write_file_to`].
4649    pub fn create_hardlink(
4650        &mut self,
4651        parent: &FileEntry,
4652        name: &str,
4653        inode_num: u32,
4654    ) -> Result<FileEntry, FilesystemError> {
4655        let snap = self.snapshot();
4656        let result = (|| -> Result<FileEntry, FilesystemError> {
4657            let private_cnid = self.find_private_dir_cnid()?.ok_or_else(|| {
4658                FilesystemError::NotFound(
4659                    "HFS+ Private Data directory missing — call create_hardlink_inode first".into(),
4660                )
4661            })?;
4662            // Locate the inode and confirm it exists; bump its linkCount.
4663            let inode_name = format!("iNode{inode_num}");
4664            let inode_cnid = self
4665                .list_children(private_cnid)?
4666                .into_iter()
4667                .find_map(|c| match c {
4668                    CatalogEntry::File {
4669                        file_id, name: n, ..
4670                    } if n == inode_name => Some(file_id),
4671                    _ => None,
4672                })
4673                .ok_or_else(|| {
4674                    FilesystemError::NotFound(format!(
4675                        "hardlink inode '{inode_name}' missing — create it before linking"
4676                    ))
4677                })?;
4678
4679            // Create the stub as an empty file with type/creator overrides.
4680            let mut empty: &[u8] = &[];
4681            let stub = self.create_file(
4682                parent,
4683                name,
4684                &mut empty,
4685                0,
4686                &CreateFileOptions {
4687                    os_type: Some(*b"hlnk"),
4688                    os_creator: Some(*b"hfs+"),
4689                    ..Default::default()
4690                },
4691            )?;
4692
4693            // Patch bsdInfo.special on the stub to hold the iNodeNum.
4694            let parent_cnid = parent.location as u32;
4695            let (_, _, abs) = self.find_catalog_record(parent_cnid, name).ok_or_else(|| {
4696                FilesystemError::NotFound(format!(
4697                    "freshly-created stub '{name}' missing from catalog"
4698                ))
4699            })?;
4700            let key_len = BigEndian::read_u16(&self.catalog_data[abs..abs + 2]) as usize;
4701            let mut body = abs + 2 + key_len;
4702            if !body.is_multiple_of(2) {
4703                body += 1;
4704            }
4705            BigEndian::write_u32(&mut self.catalog_data[body + 44..body + 48], inode_num);
4706
4707            // Bump linkCount on the inode (saturates if it overflows).
4708            self.adjust_inode_link_count(private_cnid, inode_num, 1)?;
4709
4710            // Drop the cached inode_num -> CNID map so the next hardlink
4711            // resolution sees the new inode without picking up stale state.
4712            self.hardlink_inode_map = None;
4713
4714            // Surface the inode CNID on the returned entry so callers can
4715            // inspect it without re-walking the catalog.
4716            let mut fe = stub;
4717            fe.link_target_cnid = Some(inode_cnid as u64);
4718            Ok(fe)
4719        })();
4720        if result.is_err() {
4721            self.restore_snapshot(snap);
4722        }
4723        result
4724    }
4725
4726    /// Create a directory-hardlink inode (`dir_<inode_num>`) under the
4727    /// volume's directory-private directory. The inode is a regular folder
4728    /// — callers populate its contents like any other directory. Its
4729    /// `bsdInfo.special` (linkCount) starts at zero and is bumped per stub
4730    /// by [`Self::create_dir_hardlink`].
4731    pub fn create_dir_hardlink_inode(
4732        &mut self,
4733        inode_num: u32,
4734        options: &CreateDirectoryOptions,
4735    ) -> Result<FileEntry, FilesystemError> {
4736        let snap = self.snapshot();
4737        let result = (|| -> Result<FileEntry, FilesystemError> {
4738            let private = self.ensure_dir_private_dir()?;
4739            let inode_name = format!("dir_{inode_num}");
4740            self.create_directory(&private, &inode_name, options)
4741        })();
4742        if result.is_err() {
4743            self.restore_snapshot(snap);
4744        }
4745        result
4746    }
4747
4748    /// Create a directory-hardlink stub at `(parent, name)` referencing
4749    /// `dir_<inode_num>` under the directory-private directory. The stub
4750    /// is a FILE record (not a folder) with FInfo `('fdrp', 'MACS')` and
4751    /// `bsdInfo.special = inode_num`; `list_directory` surfaces it as a
4752    /// `Directory` entry that follows the link automatically. The matching
4753    /// inode's linkCount (`bsdInfo.special`) is bumped by 1.
4754    pub fn create_dir_hardlink(
4755        &mut self,
4756        parent: &FileEntry,
4757        name: &str,
4758        inode_num: u32,
4759    ) -> Result<FileEntry, FilesystemError> {
4760        let snap = self.snapshot();
4761        let result = (|| -> Result<FileEntry, FilesystemError> {
4762            let dir_private_cnid = self.find_dir_private_dir_cnid()?.ok_or_else(|| {
4763                FilesystemError::NotFound(
4764                    ".HFS+ Private Directory Data missing — call create_dir_hardlink_inode first"
4765                        .into(),
4766                )
4767            })?;
4768            let inode_name = format!("dir_{inode_num}");
4769            let inode_cnid = self
4770                .list_children(dir_private_cnid)?
4771                .into_iter()
4772                .find_map(|c| match c {
4773                    CatalogEntry::Folder {
4774                        folder_id, name: n, ..
4775                    } if n == inode_name => Some(folder_id),
4776                    _ => None,
4777                })
4778                .ok_or_else(|| {
4779                    FilesystemError::NotFound(format!(
4780                        "directory inode '{inode_name}' missing — create it before linking"
4781                    ))
4782                })?;
4783
4784            let mut empty: &[u8] = &[];
4785            let stub = self.create_file(
4786                parent,
4787                name,
4788                &mut empty,
4789                0,
4790                &CreateFileOptions {
4791                    os_type: Some(*b"fdrp"),
4792                    os_creator: Some(*b"MACS"),
4793                    ..Default::default()
4794                },
4795            )?;
4796
4797            // Patch bsdInfo.special on the stub to hold the iNodeNum.
4798            let parent_cnid = parent.location as u32;
4799            let (_, _, abs) = self.find_catalog_record(parent_cnid, name).ok_or_else(|| {
4800                FilesystemError::NotFound(format!(
4801                    "freshly-created stub '{name}' missing from catalog"
4802                ))
4803            })?;
4804            let key_len = BigEndian::read_u16(&self.catalog_data[abs..abs + 2]) as usize;
4805            let mut body = abs + 2 + key_len;
4806            if !body.is_multiple_of(2) {
4807                body += 1;
4808            }
4809            BigEndian::write_u32(&mut self.catalog_data[body + 44..body + 48], inode_num);
4810
4811            // Bump linkCount on the dir-inode's folder record (lives at
4812            // body+44..48 of the folder record too — DInfo doesn't overlap).
4813            self.adjust_dir_inode_link_count(dir_private_cnid, inode_num, 1)?;
4814
4815            // Drop the cached map so the next lookup sees the new inode.
4816            self.dir_hardlink_inode_map = None;
4817
4818            // Surface the inode CNID on the returned entry; flip its type
4819            // to Directory so callers treat it as one.
4820            let mut fe = stub;
4821            fe.entry_type = disk_core::entry::EntryType::Directory;
4822            fe.size = 0;
4823            fe.link_target_cnid = Some(inode_cnid as u64);
4824            Ok(fe)
4825        })();
4826        if result.is_err() {
4827            self.restore_snapshot(snap);
4828        }
4829        result
4830    }
4831
4832    /// Adjust the directory-inode `linkCount` (`bsdInfo.special` u32 — at
4833    /// folder-record body offset 32+12 = 44, same as file records) by
4834    /// `delta`. Mirrors `adjust_inode_link_count` for file inodes.
4835    fn adjust_dir_inode_link_count(
4836        &mut self,
4837        dir_private_cnid: u32,
4838        inode_num: u32,
4839        delta: i64,
4840    ) -> Result<u32, FilesystemError> {
4841        let inode_name = format!("dir_{inode_num}");
4842        let (_, _, abs_off) = self
4843            .find_catalog_record(dir_private_cnid, &inode_name)
4844            .ok_or_else(|| {
4845                FilesystemError::InvalidData(format!(
4846                    "directory inode '{inode_name}' missing from .HFS+ Private Directory Data"
4847                ))
4848            })?;
4849        let key_len = BigEndian::read_u16(&self.catalog_data[abs_off..abs_off + 2]) as usize;
4850        let mut body_start = abs_off + 2 + key_len;
4851        if !body_start.is_multiple_of(2) {
4852            body_start += 1;
4853        }
4854        let cur = BigEndian::read_u32(&self.catalog_data[body_start + 44..body_start + 48]) as i64;
4855        let new = (cur + delta).max(0) as u32;
4856        BigEndian::write_u32(
4857            &mut self.catalog_data[body_start + 44..body_start + 48],
4858            new,
4859        );
4860        Ok(new)
4861    }
4862
4863    fn create_file_inner(
4864        &mut self,
4865        parent: &FileEntry,
4866        name: &str,
4867        data: &mut dyn std::io::Read,
4868        data_len: u64,
4869        options: &CreateFileOptions,
4870    ) -> Result<FileEntry, FilesystemError> {
4871        let parent_cnid = parent.location as u32;
4872
4873        validate_hfsplus_create_name(name)?;
4874
4875        // Check for duplicates
4876        if self.find_catalog_record(parent_cnid, name).is_some() {
4877            return Err(FilesystemError::AlreadyExists(name.into()));
4878        }
4879
4880        // Assign CNID
4881        let file_id = self.vh.next_catalog_id;
4882        self.vh.next_catalog_id += 1;
4883
4884        // Determine type/creator: prefer caller-supplied, fill any missing or
4885        // blank half from the extension dictionary so a partial FInfo from an
4886        // imported AppleDouble isn't thrown away.
4887        let (type_code, creator_code) = hfs_common::resolve_create_type_creator(
4888            name,
4889            options.os_type,
4890            options.os_creator,
4891            options.type_code.as_deref(),
4892            options.creator_code.as_deref(),
4893        );
4894
4895        // Allocate blocks and write data
4896        let data_fork = self.write_data_to_blocks(data, data_len, file_id, HFSPLUS_FORK_DATA)?;
4897
4898        // Handle resource fork
4899        let rsrc_fork = if let Some(ref rsrc_src) = options.resource_fork {
4900            match rsrc_src {
4901                disk_core::filesystem::ResourceForkSource::Data(rsrc_data) => {
4902                    let mut cursor = std::io::Cursor::new(rsrc_data);
4903                    self.write_data_to_blocks(
4904                        &mut cursor,
4905                        rsrc_data.len() as u64,
4906                        file_id,
4907                        HFSPLUS_FORK_RESOURCE,
4908                    )?
4909                }
4910                disk_core::filesystem::ResourceForkSource::File(path) => {
4911                    let mut f = std::fs::File::open(path)?;
4912                    let len = f.metadata()?.len();
4913                    self.write_data_to_blocks(&mut f, len, file_id, HFSPLUS_FORK_RESOURCE)?
4914                }
4915            }
4916        } else {
4917            ForkData::empty()
4918        };
4919
4920        // Build file record. A cross-fs copy passes the source mtime through
4921        // options.unix_times; a genuinely-new file leaves it None and takes
4922        // `now`. The after-create Commander `set_dates` path still overrides.
4923        let mtime = options.unix_times.map(|t| t.mtime_or_now());
4924        let file_rec = Self::build_file_record_with_dates(
4925            file_id,
4926            &data_fork,
4927            &rsrc_fork,
4928            &type_code,
4929            &creator_code,
4930            mtime,
4931            options.finder_flags.unwrap_or(0),
4932        );
4933
4934        // Build key + record for catalog insertion
4935        let key = Self::build_catalog_key(parent_cnid, name);
4936        let mut key_record = key.clone();
4937        // Pad to even boundary if needed
4938        if key_record.len() % 2 != 0 {
4939            key_record.push(0);
4940        }
4941        key_record.extend_from_slice(&file_rec);
4942
4943        // Insert file record
4944        self.insert_catalog_record(&key_record)?;
4945
4946        // Build and insert thread record
4947        let (thread_key, thread_data) =
4948            Self::build_thread_record(CATALOG_FILE_THREAD, parent_cnid, name);
4949        let mut thread_record = thread_key;
4950        if thread_record.len() % 2 != 0 {
4951            thread_record.push(0);
4952        }
4953        thread_record.extend_from_slice(&thread_data);
4954        // Thread key is (file_id, ""), but build_thread_record used parent_cnid...
4955        // Actually, thread records are keyed by (file_id, "")
4956        let actual_thread_key = Self::build_catalog_key(file_id, "");
4957        let mut actual_thread_record = actual_thread_key;
4958        if actual_thread_record.len() % 2 != 0 {
4959            actual_thread_record.push(0);
4960        }
4961        actual_thread_record.extend_from_slice(&thread_data);
4962        self.insert_catalog_record(&actual_thread_record)?;
4963
4964        // Update parent valence
4965        self.update_parent_valence(parent_cnid, 1)?;
4966
4967        // Update VH counts
4968        self.vh.file_count += 1;
4969
4970        let path = if parent.path == "/" {
4971            format!("/{name}")
4972        } else {
4973            format!("{}/{name}", parent.path)
4974        };
4975        let mut fe = FileEntry::new_file(name.to_string(), path, data_len, file_id as u64);
4976        if type_code != [0; 4] {
4977            fe.type_code = Some(type_code);
4978            fe.creator_code = Some(creator_code);
4979        }
4980        if rsrc_fork.logical_size > 0 {
4981            fe.resource_fork_size = Some(rsrc_fork.logical_size);
4982        }
4983        let stamped = mtime.unwrap_or_else(disk_core::times::now);
4984        fe.modified_unix = Some(stamped);
4985        Ok(fe)
4986    }
4987
4988    fn create_directory_inner(
4989        &mut self,
4990        parent: &FileEntry,
4991        name: &str,
4992        options: &CreateDirectoryOptions,
4993    ) -> Result<FileEntry, FilesystemError> {
4994        let parent_cnid = parent.location as u32;
4995
4996        validate_hfsplus_create_name(name)?;
4997
4998        // Diagnostic: refuse to create a second `\0\0\0\0HFS+ Private Data`
4999        // or `.HFS+ Private Directory Data\r` directory at the volume root.
5000        // The hardlink subsystem is the only legitimate creator of these
5001        // magic-named directories (via `ensure_private_dir`), and it walks
5002        // `list_directory(root)` to detect an existing one before falling
5003        // back to create. If `find_catalog_record` later misses it (e.g.
5004        // due to a key-compare quirk), the resulting duplicate is the bug
5005        // observed in HFSTest6 (2026-05). Belt-and-suspenders: in addition
5006        // to the find_catalog_record check below, scan root by CNID-aware
5007        // lookup so neither a name-fold mismatch nor a misplaced record
5008        // slips through. See docs/hfs_write_tweaks.md.
5009        if parent_cnid == 2
5010            && (name == hfsplus_private_dir_name() || name == hfsplus_dir_private_dir_name())
5011        {
5012            let already_present = self
5013                .find_private_dir_cnid()?
5014                .filter(|_| name == hfsplus_private_dir_name())
5015                .is_some()
5016                || self
5017                    .find_dir_private_dir_cnid()?
5018                    .filter(|_| name == hfsplus_dir_private_dir_name())
5019                    .is_some();
5020            if already_present {
5021                let bt = std::backtrace::Backtrace::force_capture();
5022                log::error!(
5023                    "[HFS+] refused create_directory for magic name (already \
5024                     present). parent_cnid={}, leading NULs={}\nbacktrace:\n{bt}",
5025                    parent_cnid,
5026                    name.chars().take_while(|c| *c == '\0').count()
5027                );
5028                return Err(FilesystemError::AlreadyExists(name.into()));
5029            }
5030        }
5031
5032        // Check duplicates
5033        if self.find_catalog_record(parent_cnid, name).is_some() {
5034            return Err(FilesystemError::AlreadyExists(name.into()));
5035        }
5036
5037        // Assign CNID
5038        let folder_id = self.vh.next_catalog_id;
5039        self.vh.next_catalog_id += 1;
5040
5041        // Build folder record
5042        // See build_file_record_with_dates for the mtime semantics.
5043        let mtime = options.unix_times.map(|t| t.mtime_or_now());
5044        let folder_rec = Self::build_folder_record_with_dates(folder_id, mtime);
5045
5046        // Build key + record
5047        let key = Self::build_catalog_key(parent_cnid, name);
5048        let mut key_record = key.clone();
5049        if key_record.len() % 2 != 0 {
5050            key_record.push(0);
5051        }
5052        key_record.extend_from_slice(&folder_rec);
5053
5054        self.insert_catalog_record(&key_record)?;
5055
5056        // Thread record (type 3 = folder thread)
5057        let (_thread_key, thread_data) =
5058            Self::build_thread_record(CATALOG_FOLDER_THREAD, parent_cnid, name);
5059        let actual_thread_key = Self::build_catalog_key(folder_id, "");
5060        let mut actual_thread_record = actual_thread_key;
5061        if actual_thread_record.len() % 2 != 0 {
5062            actual_thread_record.push(0);
5063        }
5064        actual_thread_record.extend_from_slice(&thread_data);
5065        self.insert_catalog_record(&actual_thread_record)?;
5066
5067        // Update parent valence
5068        self.update_parent_valence(parent_cnid, 1)?;
5069
5070        // Update VH
5071        self.vh.folder_count += 1;
5072
5073        let path = if parent.path == "/" {
5074            format!("/{name}")
5075        } else {
5076            format!("{}/{name}", parent.path)
5077        };
5078        let mut fe = FileEntry::new_directory(name.to_string(), path, folder_id as u64);
5079        let stamped = mtime.unwrap_or_else(disk_core::times::now);
5080        fe.modified_unix = Some(stamped);
5081        Ok(fe)
5082    }
5083
5084    fn delete_entry_inner(
5085        &mut self,
5086        parent: &FileEntry,
5087        entry: &FileEntry,
5088    ) -> Result<(), FilesystemError> {
5089        let parent_cnid = parent.location as u32;
5090        let cnid = entry.location as u32;
5091
5092        // Drop every attribute record attached to this CNID so we don't leave
5093        // dangling rows in the attributes B-tree pointing at a freed CNID.
5094        // No-op on volumes without an attributes file. Errors here trip the
5095        // snapshot rollback in `delete_entry`.
5096        let xattrs_removed = self.remove_xattr_records(cnid, None)?;
5097        if xattrs_removed > 0 {
5098            if let Some(set) = self.xattr_cnids.as_mut() {
5099                set.remove(&cnid);
5100            }
5101        }
5102
5103        // Check directory is empty
5104        if entry.is_directory() {
5105            let children = self.list_children(cnid)?;
5106            if !children.is_empty() {
5107                return Err(FilesystemError::InvalidData(
5108                    "cannot delete non-empty directory".into(),
5109                ));
5110            }
5111        }
5112
5113        // File hardlink stub? If so, decrement the inode's linkCount before
5114        // we touch the stub's catalog row. Last reference also frees the
5115        // inode's forks and removes its iNode<N> entry from the private
5116        // directory.
5117        let link_inode_num = if entry.is_directory() {
5118            None
5119        } else {
5120            self.read_file_record_link_inode_num(parent_cnid, &entry.name)?
5121        };
5122        if let Some(inode_num) = link_inode_num {
5123            self.delete_hardlink_inode_ref(inode_num)?;
5124        }
5125
5126        // Find and remove the entry record
5127        let (node_idx, rec_idx, _offset) = self
5128            .find_catalog_record(parent_cnid, &entry.name)
5129            .ok_or_else(|| {
5130                FilesystemError::NotFound(format!("entry '{}' not found in catalog", entry.name))
5131            })?;
5132
5133        // If it's a file, free its data and resource fork blocks (including
5134        // any overflow extents) and remove its overflow records. Hardlink
5135        // stubs have empty forks (the data lives on the inode we already
5136        // adjusted above), so this is a no-op for them.
5137        if !entry.is_directory() {
5138            if let Some((data_fork, rsrc_fork)) = self.find_file_by_id(cnid) {
5139                self.free_fork_overflow_extents(cnid, HFSPLUS_FORK_DATA, &data_fork);
5140                self.free_fork_overflow_extents(cnid, HFSPLUS_FORK_RESOURCE, &rsrc_fork);
5141                self.remove_extents_overflow_records_for(cnid, HFSPLUS_FORK_DATA)?;
5142                self.remove_extents_overflow_records_for(cnid, HFSPLUS_FORK_RESOURCE)?;
5143                self.free_fork_blocks(&data_fork);
5144                self.free_fork_blocks(&rsrc_fork);
5145            }
5146        }
5147
5148        self.remove_catalog_record(node_idx, rec_idx)?;
5149
5150        // Find and remove the thread record
5151        if let Some((t_node, t_rec, _)) = self.find_catalog_record_by_cnid(cnid) {
5152            self.remove_catalog_record(t_node, t_rec)?;
5153        }
5154
5155        // Update parent valence
5156        self.update_parent_valence(parent_cnid, -1)?;
5157
5158        // Update VH counts
5159        if entry.is_directory() {
5160            self.vh.folder_count = self.vh.folder_count.saturating_sub(1);
5161        } else {
5162            self.vh.file_count = self.vh.file_count.saturating_sub(1);
5163        }
5164
5165        Ok(())
5166    }
5167
5168    fn rename_inner(
5169        &mut self,
5170        parent: &FileEntry,
5171        entry: &FileEntry,
5172        new_name: &str,
5173    ) -> Result<(), FilesystemError> {
5174        let parent_cnid = parent.location as u32;
5175        let cnid = entry.location as u32;
5176
5177        // Locate the entry's catalog record by its current (parent, name) key.
5178        let (node_idx, rec_idx, _abs) = self
5179            .find_catalog_record(parent_cnid, &entry.name)
5180            .ok_or_else(|| {
5181                FilesystemError::NotFound(format!("entry '{}' not found in catalog", entry.name))
5182            })?;
5183
5184        // Reject a collision only with a DIFFERENT record. On a case-insensitive
5185        // volume a case-only rename folds back to this same record (allowed).
5186        if let Some((n2, r2, _)) = self.find_catalog_record(parent_cnid, new_name) {
5187            if (n2, r2) != (node_idx, rec_idx) {
5188                return Err(FilesystemError::AlreadyExists(new_name.to_string()));
5189            }
5190        }
5191
5192        // Extract the record's data body to re-emit it verbatim under the new
5193        // key (CNID, forks, Finder info, BSD info all preserved).
5194        let node_size = self.catalog_node_size();
5195        let node_off = node_idx as usize * node_size;
5196        let node = &self.catalog_data[node_off..node_off + node_size];
5197        let (rec_start, rec_end) = crate::hfs_common::btree_record_range(node, node_size, rec_idx);
5198        let key_len = BigEndian::read_u16(&node[rec_start..rec_start + 2]) as usize;
5199        let mut data_rel = 2 + key_len;
5200        if !data_rel.is_multiple_of(2) {
5201            data_rel += 1;
5202        }
5203        let data_bytes = node[rec_start + data_rel..rec_end].to_vec();
5204
5205        let mut new_record = Self::build_catalog_key(parent_cnid, new_name);
5206        if new_record.len() % 2 != 0 {
5207            new_record.push(0);
5208        }
5209        new_record.extend_from_slice(&data_bytes);
5210
5211        self.remove_catalog_record(node_idx, rec_idx)?;
5212        self.insert_catalog_record(&new_record)?;
5213
5214        // The thread record's name is a variable-length UTF-16 string, so its
5215        // footprint changes with the new name — rebuild it (remove + reinsert)
5216        // rather than patching in place. Folders always have a thread; files
5217        // generally do.
5218        if let Some((t_node, t_rec, _)) = self.find_catalog_record_by_cnid(cnid) {
5219            self.remove_catalog_record(t_node, t_rec)?;
5220            let thread_type = if entry.is_directory() {
5221                CATALOG_FOLDER_THREAD
5222            } else {
5223                CATALOG_FILE_THREAD
5224            };
5225            let (_k, thread_data) = Self::build_thread_record(thread_type, parent_cnid, new_name);
5226            let mut thread_record = Self::build_catalog_key(cnid, "");
5227            if thread_record.len() % 2 != 0 {
5228                thread_record.push(0);
5229            }
5230            thread_record.extend_from_slice(&thread_data);
5231            self.insert_catalog_record(&thread_record)?;
5232        }
5233        // A same-parent rename leaves parent valence and VH counts unchanged.
5234        Ok(())
5235    }
5236
5237    /// Adjust the inode-record `linkCount` (`bsdInfo.special`, body offset
5238    /// 44..48) by `delta` and return the post-update count. Locates the
5239    /// record by `(private_dir_cnid, "iNode<N>")` so the in-memory edit
5240    /// hits the live catalog leaf. Touches `attributeModDate` so users can
5241    /// see when the inode was last referenced.
5242    fn adjust_inode_link_count(
5243        &mut self,
5244        private_cnid: u32,
5245        inode_num: u32,
5246        delta: i64,
5247    ) -> Result<u32, FilesystemError> {
5248        let inode_name = format!("iNode{inode_num}");
5249        let (_, _, abs_off) = self
5250            .find_catalog_record(private_cnid, &inode_name)
5251            .ok_or_else(|| {
5252                FilesystemError::InvalidData(format!(
5253                    "hardlink inode '{inode_name}' missing from HFS+ Private Data"
5254                ))
5255            })?;
5256        let key_len = BigEndian::read_u16(&self.catalog_data[abs_off..abs_off + 2]) as usize;
5257        let mut body_start = abs_off + 2 + key_len;
5258        if !body_start.is_multiple_of(2) {
5259            body_start += 1;
5260        }
5261        let cur = BigEndian::read_u32(&self.catalog_data[body_start + 44..body_start + 48]) as i64;
5262        let new = (cur + delta).max(0) as u32;
5263        BigEndian::write_u32(
5264            &mut self.catalog_data[body_start + 44..body_start + 48],
5265            new,
5266        );
5267        self.stamp_record_dates(body_start, false);
5268        Ok(new)
5269    }
5270
5271    /// Decrement `linkCount` on the inode named `iNode<inode_num>` under the
5272    /// volume's `HFS+ Private Data` dir. When the count drops to zero, free
5273    /// the inode's forks, drop its xattrs, and remove the inode catalog row
5274    /// plus thread plus map entry. Bumps `vh.file_count` and the private
5275    /// dir's valence accordingly.
5276    fn delete_hardlink_inode_ref(&mut self, inode_num: u32) -> Result<(), FilesystemError> {
5277        let private_cnid = self.find_private_dir_cnid()?.ok_or_else(|| {
5278            FilesystemError::InvalidData(
5279                "hardlink stub on volume with no HFS+ Private Data directory".into(),
5280            )
5281        })?;
5282        let inode_cnid = self.resolve_hardlink_inode(inode_num)?.ok_or_else(|| {
5283            FilesystemError::InvalidData(format!(
5284                "hardlink target iNode{inode_num} missing from HFS+ Private Data"
5285            ))
5286        })?;
5287
5288        let new_count = self.adjust_inode_link_count(private_cnid, inode_num, -1)?;
5289        if new_count > 0 {
5290            return Ok(());
5291        }
5292
5293        // Last reference — free the inode's forks and drop its rows.
5294        if let Some((data_fork, rsrc_fork)) = self.find_file_by_id(inode_cnid) {
5295            self.free_fork_overflow_extents(inode_cnid, HFSPLUS_FORK_DATA, &data_fork);
5296            self.free_fork_overflow_extents(inode_cnid, HFSPLUS_FORK_RESOURCE, &rsrc_fork);
5297            self.remove_extents_overflow_records_for(inode_cnid, HFSPLUS_FORK_DATA)?;
5298            self.remove_extents_overflow_records_for(inode_cnid, HFSPLUS_FORK_RESOURCE)?;
5299            self.free_fork_blocks(&data_fork);
5300            self.free_fork_blocks(&rsrc_fork);
5301        }
5302
5303        let xattrs_removed = self.remove_xattr_records(inode_cnid, None)?;
5304        if xattrs_removed > 0 {
5305            if let Some(set) = self.xattr_cnids.as_mut() {
5306                set.remove(&inode_cnid);
5307            }
5308        }
5309
5310        let inode_name = format!("iNode{inode_num}");
5311        if let Some((n, r, _)) = self.find_catalog_record(private_cnid, &inode_name) {
5312            self.remove_catalog_record(n, r)?;
5313        }
5314        if let Some((tn, tr, _)) = self.find_catalog_record_by_cnid(inode_cnid) {
5315            self.remove_catalog_record(tn, tr)?;
5316        }
5317        self.update_parent_valence(private_cnid, -1)?;
5318        self.vh.file_count = self.vh.file_count.saturating_sub(1);
5319
5320        if let Some(map) = self.hardlink_inode_map.as_mut() {
5321            map.remove(&inode_num);
5322        }
5323        Ok(())
5324    }
5325
5326    fn set_type_creator_inner(
5327        &mut self,
5328        entry: &FileEntry,
5329        type_code: &str,
5330        creator_code: &str,
5331    ) -> Result<(), FilesystemError> {
5332        let cnid = entry.location as u32;
5333
5334        // Find the thread to get parent + name
5335        let (_t_node, _t_rec, t_offset) =
5336            self.find_catalog_record_by_cnid(cnid).ok_or_else(|| {
5337                FilesystemError::NotFound(format!("thread for CNID {cnid} not found"))
5338            })?;
5339
5340        // Read parent_cnid and name from thread data
5341        let node_size = self.catalog_header.node_size as usize;
5342        let key_len = BigEndian::read_u16(&self.catalog_data[t_offset..t_offset + 2]) as usize;
5343        let mut rec_data_start = t_offset + 2 + key_len;
5344        if !rec_data_start.is_multiple_of(2) {
5345            rec_data_start += 1;
5346        }
5347        let thread_parent =
5348            BigEndian::read_u32(&self.catalog_data[rec_data_start + 4..rec_data_start + 8]);
5349        let thread_name_len =
5350            BigEndian::read_u16(&self.catalog_data[rec_data_start + 8..rec_data_start + 10])
5351                as usize;
5352        let thread_name = decode_utf16be(
5353            &self.catalog_data[rec_data_start + 10..rec_data_start + 10 + thread_name_len * 2],
5354        );
5355
5356        // Find the actual file record
5357        let (f_node, f_rec, f_offset) = self
5358            .find_catalog_record(thread_parent, &thread_name)
5359            .ok_or_else(|| {
5360                FilesystemError::NotFound(format!("file record for '{}' not found", thread_name))
5361            })?;
5362
5363        // Compute the record data offset
5364        let fkey_len = BigEndian::read_u16(&self.catalog_data[f_offset..f_offset + 2]) as usize;
5365        let mut frec_start = f_offset + 2 + fkey_len;
5366        if !frec_start.is_multiple_of(2) {
5367            frec_start += 1;
5368        }
5369
5370        // Write type and creator at offsets 48-52 and 52-56
5371        let tc = hfs_common::encode_fourcc(type_code);
5372        let cc = hfs_common::encode_fourcc(creator_code);
5373        self.catalog_data[frec_start + 48..frec_start + 52].copy_from_slice(&tc);
5374        self.catalog_data[frec_start + 52..frec_start + 56].copy_from_slice(&cc);
5375
5376        // Type/creator is an attribute-only change.
5377        self.stamp_record_dates(frec_start, false);
5378
5379        let _ = (f_node, f_rec, node_size); // suppress warnings
5380        Ok(())
5381    }
5382
5383    fn write_resource_fork_inner(
5384        &mut self,
5385        entry: &FileEntry,
5386        data: &mut dyn std::io::Read,
5387        len: u64,
5388    ) -> Result<(), FilesystemError> {
5389        let cnid = entry.location as u32;
5390
5391        // Free existing resource fork blocks (inline + any overflow extents).
5392        if let Some((_data_fork, rsrc_fork)) = self.find_file_by_id(cnid) {
5393            self.free_fork_overflow_extents(cnid, HFSPLUS_FORK_RESOURCE, &rsrc_fork);
5394            self.remove_extents_overflow_records_for(cnid, HFSPLUS_FORK_RESOURCE)?;
5395            self.free_fork_blocks(&rsrc_fork);
5396        }
5397
5398        // Allocate and write new resource fork data
5399        let new_rsrc = self.write_data_to_blocks(data, len, cnid, HFSPLUS_FORK_RESOURCE)?;
5400
5401        // Find the file record and update the resource fork
5402        let (t_node, _t_rec, t_offset) =
5403            self.find_catalog_record_by_cnid(cnid).ok_or_else(|| {
5404                FilesystemError::NotFound(format!("thread for CNID {cnid} not found"))
5405            })?;
5406
5407        let key_len = BigEndian::read_u16(&self.catalog_data[t_offset..t_offset + 2]) as usize;
5408        let mut rec_data_start = t_offset + 2 + key_len;
5409        if !rec_data_start.is_multiple_of(2) {
5410            rec_data_start += 1;
5411        }
5412        let thread_parent =
5413            BigEndian::read_u32(&self.catalog_data[rec_data_start + 4..rec_data_start + 8]);
5414        let thread_name_len =
5415            BigEndian::read_u16(&self.catalog_data[rec_data_start + 8..rec_data_start + 10])
5416                as usize;
5417        let thread_name = decode_utf16be(
5418            &self.catalog_data[rec_data_start + 10..rec_data_start + 10 + thread_name_len * 2],
5419        );
5420
5421        let (_f_node, _f_rec, f_offset) = self
5422            .find_catalog_record(thread_parent, &thread_name)
5423            .ok_or_else(|| {
5424                FilesystemError::NotFound(format!("file record for '{}' not found", thread_name))
5425            })?;
5426
5427        let fkey_len = BigEndian::read_u16(&self.catalog_data[f_offset..f_offset + 2]) as usize;
5428        let mut frec_start = f_offset + 2 + fkey_len;
5429        if !frec_start.is_multiple_of(2) {
5430            frec_start += 1;
5431        }
5432
5433        // Write resource fork data (at offset 168 in file record)
5434        let mut rsrc_bytes = [0u8; 80];
5435        new_rsrc.serialize(&mut rsrc_bytes);
5436        self.catalog_data[frec_start + 168..frec_start + 248].copy_from_slice(&rsrc_bytes);
5437
5438        // Fork rewrite is a content change, so contentModDate also moves.
5439        self.stamp_record_dates(frec_start, true);
5440
5441        let _ = t_node;
5442        Ok(())
5443    }
5444
5445    fn set_blessed_folder_inner(&mut self, entry: &FileEntry) -> Result<(), FilesystemError> {
5446        if !entry.is_directory() {
5447            return Err(FilesystemError::InvalidData(
5448                "can only bless a directory".into(),
5449            ));
5450        }
5451        self.vh.finder_info[0] = entry.location as u32;
5452        Ok(())
5453    }
5454}
5455
5456// --- Compact reader ---
5457
5458/// Compact reader for HFS+: layout-preserving image with zeros for unallocated blocks.
5459///
5460/// Outputs `total_blocks * block_size` bytes. Allocated blocks are read from the
5461/// source; unallocated blocks are emitted as zeros. This preserves the original
5462/// block layout so that `block_N` is always at byte offset `N * block_size`,
5463/// enabling correct filesystem browsing and reliable restore.
5464/// Streaming reader over a single user fork. Built by
5465/// [`HfsPlusFilesystem::fork_stream_reader`] with all inline + overflow
5466/// extents resolved up front; `read` walks the extent list, seeking the
5467/// underlying source reader as it crosses extent boundaries. Bounded
5468/// strictly by the fork's `logical_size` — the trailing partial block is
5469/// truncated at the byte level rather than the block level.
5470pub struct ForkStreamReader<'a, R: Read + Seek> {
5471    reader: &'a mut R,
5472    /// `(device_offset, byte_length)` for each extent, in fork order.
5473    extents: Vec<(u64, u64)>,
5474    /// Total bytes the reader is allowed to emit (`fork.logical_size`).
5475    logical_size: u64,
5476    /// Bytes already produced.
5477    pos: u64,
5478    cur_extent: usize,
5479    /// Bytes already consumed from the current extent.
5480    cur_extent_consumed: u64,
5481}
5482
5483impl<'a, R: Read + Seek> Read for ForkStreamReader<'a, R> {
5484    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
5485        if self.pos >= self.logical_size || buf.is_empty() {
5486            return Ok(0);
5487        }
5488        // Skip past extents whose bytes are fully consumed.
5489        while self.cur_extent < self.extents.len() {
5490            let (_, len) = self.extents[self.cur_extent];
5491            if self.cur_extent_consumed < len {
5492                break;
5493            }
5494            self.cur_extent += 1;
5495            self.cur_extent_consumed = 0;
5496        }
5497        if self.cur_extent >= self.extents.len() {
5498            return Ok(0);
5499        }
5500        let (off, len) = self.extents[self.cur_extent];
5501        let remaining_in_extent = len - self.cur_extent_consumed;
5502        let remaining_logical = self.logical_size - self.pos;
5503        let max_chunk = remaining_in_extent.min(remaining_logical);
5504        let to_read = (buf.len() as u64).min(max_chunk) as usize;
5505        self.reader
5506            .seek(SeekFrom::Start(off + self.cur_extent_consumed))?;
5507        let n = self.reader.read(&mut buf[..to_read])?;
5508        if n == 0 {
5509            return Ok(0);
5510        }
5511        self.cur_extent_consumed += n as u64;
5512        self.pos += n as u64;
5513        Ok(n)
5514    }
5515}
5516
5517pub struct CompactHfsPlusReader<R: Read + Seek> {
5518    reader: R,
5519    partition_offset: u64,
5520    block_size: u32,
5521    total_blocks: u32,
5522    bitmap: Vec<u8>,
5523    /// Current allocation block being output.
5524    current_block: u32,
5525    /// Byte position within the current block.
5526    block_pos: u64,
5527    #[allow(dead_code)]
5528    original_size: u64,
5529}
5530
5531impl<R: Read + Seek> CompactHfsPlusReader<R> {
5532    pub fn new(
5533        mut reader: R,
5534        partition_offset: u64,
5535    ) -> Result<(Self, CompactResult), FilesystemError> {
5536        // Read volume header
5537        log::debug!(
5538            "[HFS+ compact] seeking to VH at offset {}",
5539            partition_offset + 1024
5540        );
5541        reader.seek(SeekFrom::Start(partition_offset + 1024))?;
5542        let mut vh_buf = [0u8; 512];
5543        reader.read_exact(&mut vh_buf)?;
5544        let vh = HfsPlusVolumeHeader::parse(&vh_buf)?;
5545        log::debug!(
5546            "[HFS+ compact] VH ok: block_size={}, total_blocks={}, alloc_file extents[0]=(start={}, count={})",
5547            vh.block_size,
5548            vh.total_blocks,
5549            vh.allocation_file.extents[0].start_block,
5550            vh.allocation_file.extents[0].block_count,
5551        );
5552
5553        // Load extents-overflow first so the allocation file can use it if
5554        // it's fragmented past 8 inline extents.
5555        let extents_overflow_data = if vh.extents_file.logical_size > 0 {
5556            Some(read_fork(
5557                &mut reader,
5558                partition_offset,
5559                vh.block_size,
5560                &vh.extents_file,
5561            )?)
5562        } else {
5563            None
5564        };
5565
5566        // Read allocation file
5567        let alloc_data = read_fork_with_overflow(
5568            &mut reader,
5569            partition_offset,
5570            vh.block_size,
5571            &vh.allocation_file,
5572            HFSPLUS_ALLOCATION_FILE_ID,
5573            HFSPLUS_FORK_DATA,
5574            extents_overflow_data.as_deref(),
5575        )
5576        .map_err(|e| {
5577            log::debug!("[HFS+ compact] read_fork(alloc_file) failed: {e}");
5578            e
5579        })?;
5580        log::debug!(
5581            "[HFS+ compact] allocation bitmap read: {} bytes",
5582            alloc_data.len()
5583        );
5584
5585        // Count allocated blocks
5586        let mut allocated = 0u32;
5587        for bit in 0..vh.total_blocks {
5588            let byte_idx = bit as usize / 8;
5589            let bit_idx = 7 - (bit % 8);
5590            if byte_idx < alloc_data.len() && (alloc_data[byte_idx] >> bit_idx) & 1 == 1 {
5591                allocated += 1;
5592            }
5593        }
5594        log::debug!(
5595            "[HFS+ compact] allocated={} / {} total blocks ({} free)",
5596            allocated,
5597            vh.total_blocks,
5598            vh.total_blocks - allocated,
5599        );
5600
5601        let original_size = vh.total_blocks as u64 * vh.block_size as u64;
5602        // Layout-preserving: output size equals the original partition size.
5603        // Unallocated blocks are zeroed, so they compress extremely well.
5604        let compacted_size = original_size;
5605        // data_size: only allocated blocks require disk reads.
5606        let data_size = allocated as u64 * vh.block_size as u64;
5607        log::debug!(
5608            "[HFS+ compact] compacted_size={} data_size={} original_size={} (layout-preserving; free blocks -> zeros)",
5609            compacted_size, data_size, original_size
5610        );
5611
5612        let result = CompactResult {
5613            original_size,
5614            compacted_size,
5615            data_size,
5616            clusters_used: allocated,
5617        };
5618
5619        Ok((
5620            CompactHfsPlusReader {
5621                reader,
5622                partition_offset,
5623                block_size: vh.block_size,
5624                total_blocks: vh.total_blocks,
5625                bitmap: alloc_data,
5626                current_block: 0,
5627                block_pos: 0,
5628                original_size,
5629            },
5630            result,
5631        ))
5632    }
5633
5634    fn is_block_allocated(&self, block: u32) -> bool {
5635        let byte_idx = block as usize / 8;
5636        let bit_idx = 7 - (block % 8);
5637        byte_idx < self.bitmap.len() && (self.bitmap[byte_idx] >> bit_idx) & 1 == 1
5638    }
5639}
5640
5641impl<R: Read + Seek> Read for CompactHfsPlusReader<R> {
5642    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
5643        if self.current_block >= self.total_blocks {
5644            return Ok(0);
5645        }
5646
5647        let block_size = self.block_size as u64;
5648
5649        // Find the longest run starting at the current block whose
5650        // allocation state matches the current block's, then service as
5651        // much of that run as fits in `buf`. Coalescing turns a 24 GiB
5652        // partition's ~6M per-4-KiB-block syscalls into far fewer reads
5653        // (typically one per file extent), critical on slow source media
5654        // where each seek + read costs milliseconds.
5655        let allocated = self.is_block_allocated(self.current_block);
5656        let mut run_end = self.current_block + 1;
5657        while run_end < self.total_blocks && self.is_block_allocated(run_end) == allocated {
5658            run_end += 1;
5659        }
5660
5661        let bytes_in_run = (run_end - self.current_block) as u64 * block_size - self.block_pos;
5662        let to_read = (bytes_in_run as usize).min(buf.len());
5663
5664        let n = if allocated {
5665            let offset =
5666                self.partition_offset + self.current_block as u64 * block_size + self.block_pos;
5667            self.reader.seek(SeekFrom::Start(offset))?;
5668            self.reader.read(&mut buf[..to_read])?
5669        } else {
5670            // Unallocated run — emit zeros so free space compresses to nothing.
5671            buf[..to_read].fill(0);
5672            to_read
5673        };
5674
5675        // Advance cursor by however many bytes we actually produced.
5676        let mut remaining_in_n = n as u64;
5677        // First, finish the partial block we may have been mid-way through.
5678        if self.block_pos > 0 {
5679            let want = block_size - self.block_pos;
5680            let take = remaining_in_n.min(want);
5681            self.block_pos += take;
5682            remaining_in_n -= take;
5683            if self.block_pos >= block_size {
5684                self.block_pos = 0;
5685                self.current_block += 1;
5686            }
5687        }
5688        // Then jump whole blocks.
5689        let whole = (remaining_in_n / block_size) as u32;
5690        self.current_block += whole;
5691        remaining_in_n -= whole as u64 * block_size;
5692        // Finally the trailing partial block.
5693        if remaining_in_n > 0 {
5694            self.block_pos = remaining_in_n;
5695        }
5696
5697        Ok(n)
5698    }
5699}
5700
5701// --- Blank-volume builder ---
5702
5703/// Build a freshly-formatted, mountable HFS+ (or HFSX) image as a raw byte
5704/// buffer.
5705///
5706/// Layout (sized for `block_size` ≤ `node_size`; node_size is fixed at
5707/// 4096 to match Apple's default):
5708///
5709/// - Block 0: VH region (1024 boot bytes + primary VH at byte 1024)
5710/// - Bitmap blocks: enough to hold `total_blocks / 8` bytes
5711/// - Extents-overflow B-tree: 4 nodes (header + empty leaf + 2 free)
5712/// - Catalog B-tree: 4 nodes (header + leaf with root folder + thread)
5713/// - Free user-data area
5714/// - Last 1024 bytes: alt VH (mirror of primary)
5715///
5716/// The catalog leaf carries two records: the root folder thread keyed by
5717/// `(parent=2, name="")` (data: `recordType=3`, `parentID=1`, nodeName =
5718/// `name`) and the root folder record keyed by `(parent=1, name=name)`
5719/// (data: `recordType=1`, `folderID=2`, valence=0, dates set to now).
5720///
5721/// `case_sensitive=true` produces an HFSX volume (signature `H+`/`HX`,
5722/// catalog header `keyCompareType = kHFSBinaryCompare`); `false` produces
5723/// plain HFS+ with case-folding compare. The journaled bit is never set —
5724/// edit mode refuses journaled volumes (Step 4) so a freshly built blank
5725/// must opt out.
5726///
5727/// Panics if `size_bytes` is too small to hold the reserved region or if
5728/// `block_size` isn't a power of 2 in `[512, 4096]`.
5729pub fn create_blank_hfsplus(
5730    size_bytes: u64,
5731    block_size: u32,
5732    name: &str,
5733    case_sensitive: bool,
5734) -> Vec<u8> {
5735    create_blank_hfsplus_sized(size_bytes, block_size, name, case_sensitive, 0, 0)
5736}
5737
5738/// Volume-scaled default catalog B-tree size, in bytes, for a fresh HFS+
5739/// volume. Mirrors classic HFS's `default_btree_sizes`: ~0.5% of the volume so
5740/// a blank holds thousands of records before exhausting its node budget (HFS+
5741/// has no grow-on-full path — docs/hfsplus_btree_growth_plan.md §4c). The
5742/// `build_blank_hfsplus_front` caller clamps the result to whole nodes in
5743/// `[4, header-bitmap capacity]`.
5744pub fn default_hfsplus_catalog_bytes(volume_bytes: u64) -> u64 {
5745    volume_bytes / 200
5746}
5747
5748/// Variant of [`create_blank_hfsplus`] that lets the caller request minimum
5749/// catalog and extents-overflow B-tree sizes (in bytes) — e.g. a clone target
5750/// that must hold every record from a fragmented source, or a test that needs a
5751/// deep catalog without a multi-GiB volume. Each minimum is rounded up to whole
5752/// nodes and raised to the 4-node floor; the catalog also never drops below the
5753/// volume-scaled [`default_hfsplus_catalog_bytes`].
5754pub fn create_blank_hfsplus_sized(
5755    size_bytes: u64,
5756    block_size: u32,
5757    name: &str,
5758    case_sensitive: bool,
5759    min_catalog_bytes: u32,
5760    min_extents_bytes: u32,
5761) -> Vec<u8> {
5762    let (front, vh_bytes, image_size) = build_blank_hfsplus_front(
5763        size_bytes,
5764        block_size,
5765        name,
5766        case_sensitive,
5767        min_catalog_bytes,
5768        min_extents_bytes,
5769    );
5770    let mut img = vec![0u8; image_size];
5771    img[..front.len()].copy_from_slice(&front);
5772    let alt = image_size - 1024;
5773    img[alt..alt + 512].copy_from_slice(&vh_bytes);
5774    img
5775}
5776
5777/// Build only the front (boot/VH/bitmap/B-trees) region and the 512-byte
5778/// VH bytes. Returns `(front_bytes, vh_bytes, total_image_size)`. Used by
5779/// both [`create_blank_hfsplus`] (full Vec) and
5780/// [`write_blank_hfsplus_into`] (sparse-file streaming).
5781fn build_blank_hfsplus_front(
5782    size_bytes: u64,
5783    block_size: u32,
5784    name: &str,
5785    case_sensitive: bool,
5786    min_catalog_bytes: u32,
5787    min_extents_bytes: u32,
5788) -> (Vec<u8>, [u8; 512], usize) {
5789    assert!(
5790        block_size.is_power_of_two() && (512..=4096).contains(&block_size),
5791        "block_size must be a power of 2 in [512, 4096], got {block_size}"
5792    );
5793    let bs = block_size as u64;
5794    let total_blocks = (size_bytes / bs) as u32;
5795    assert!(total_blocks >= 64, "image too small ({size_bytes} bytes)");
5796
5797    // node_size pinned at 4096 — Apple's default and what every existing
5798    // helper in this file assumes (compute_record_offsets, etc.).
5799    let node_size: usize = 4096;
5800    let nodes_per_block: u32 = (node_size as u32 / block_size).max(1);
5801    let blocks_per_node: u32 = (block_size / node_size as u32).max(1);
5802    let _ = nodes_per_block;
5803
5804    // Size the catalog B-tree from the volume (docs/hfsplus_btree_growth_plan.md
5805    // §4c): HFS+ has no grow-on-full path, so a blank that reserved only the
5806    // legacy 4 nodes exhausted after ~24 files. Scale it to ~0.5% of the volume
5807    // (mirroring classic HFS's `default_btree_sizes`) so a fresh volume holds
5808    // thousands of records, honouring an explicit `min_catalog_bytes` floor from
5809    // sized/clone callers. The extents-overflow tree keeps the 4-node default
5810    // unless a caller asks for more (its own scaling is P3 work).
5811    //
5812    // `to_nodes` rounds a byte budget up to whole nodes and clamps to
5813    // [4, header-bitmap capacity]. The blank's node-allocation bitmap lives
5814    // entirely in the header node's record 2 (bytes 248..node_size-8 — see
5815    // `write_blank_btree_header_node`), which addresses `(node_size - 256) * 8`
5816    // nodes without dedicated map nodes; cap there. Volumes whose 0.5% catalog
5817    // would exceed this (~123 MiB at 4096, i.e. hundreds of GiB of volume) are
5818    // far larger than this tool targets, and the defrag/clone path — which does
5819    // build map nodes — covers them. (The §4b live-grow path appends map nodes
5820    // incrementally past this cap; see `grow_btree_fork`.)
5821    let max_btree_nodes: u32 = ((node_size - 256) * 8) as u32;
5822    let to_nodes = |bytes: u64| -> u32 {
5823        bytes
5824            .div_ceil(node_size as u64)
5825            .clamp(4, max_btree_nodes as u64) as u32
5826    };
5827    let catalog_node_count =
5828        to_nodes(default_hfsplus_catalog_bytes(size_bytes).max(min_catalog_bytes as u64));
5829    let extents_node_count = to_nodes(min_extents_bytes as u64);
5830
5831    let catalog_btree_blocks: u32 = catalog_node_count * blocks_per_node;
5832    let extents_btree_blocks: u32 = extents_node_count * blocks_per_node;
5833
5834    let bitmap_bytes = total_blocks.div_ceil(8) as u64;
5835    let bitmap_blocks = bitmap_bytes.div_ceil(bs) as u32;
5836
5837    let bitmap_start: u32 = 1;
5838    let extents_start: u32 = bitmap_start + bitmap_blocks;
5839    let catalog_start: u32 = extents_start + extents_btree_blocks;
5840    let reserved_blocks: u32 = catalog_start + catalog_btree_blocks;
5841    assert!(
5842        reserved_blocks + 1 < total_blocks,
5843        "image too small for reserved region: need {} blocks, have {total_blocks}",
5844        reserved_blocks + 1
5845    );
5846
5847    let image_size = total_blocks as usize * block_size as usize;
5848    // Build only the front (boot/VH/bitmap/B-trees) and the 512-byte alt VH
5849    // separately, so multi-GB blanks don't have to materialise a full image
5850    // in RAM. The middle of the volume is free space — sparse-file zeroes
5851    // are good enough.
5852    let front_size = reserved_blocks as usize * block_size as usize;
5853    let mut front = vec![0u8; front_size];
5854
5855    // The alternate volume header occupies the last 1024 bytes of the volume.
5856    // Apple always marks the trailing allocation block(s) it touches as used
5857    // (one block for block_size >= 1024, two for 512-byte blocks) and excludes
5858    // them from the free-block count. Without this, fsck_hfs reports
5859    // "Invalid volume free block count" / bitmap under-allocation.
5860    let alt_vh_start_block: u32 = ((image_size as u64 - 1024) / bs) as u32;
5861    let trailing_blocks: u32 = total_blocks - alt_vh_start_block;
5862
5863    // --- Allocation bitmap ---
5864    {
5865        let bitmap_off = bitmap_start as usize * block_size as usize;
5866        let bitmap = &mut front[bitmap_off..bitmap_off + (bitmap_bytes as usize).max(1)];
5867        let mut mark = |blk: u32| {
5868            // MSB-first big-endian: bit position (7 - blk%8) of byte (blk/8).
5869            let byte_idx = (blk / 8) as usize;
5870            let bit_pos = 7 - (blk % 8);
5871            bitmap[byte_idx] |= 1 << bit_pos;
5872        };
5873        for blk in 0..reserved_blocks {
5874            mark(blk);
5875        }
5876        for blk in alt_vh_start_block..total_blocks {
5877            mark(blk);
5878        }
5879    }
5880
5881    // --- Extents-overflow B-tree (empty: header node only) ---
5882    // An empty tree is depth-0 / root-0 with no leaf node, exactly as Apple's
5883    // newfs_hfs lays it out (see `write_blank_btree_header_node`). The first
5884    // fragmented-file insert bootstraps a root leaf via `btree_insert_full`.
5885    {
5886        let off = extents_start as usize * block_size as usize;
5887        write_blank_btree_header_node(
5888            &mut front[off..off + node_size],
5889            node_size,
5890            /* leaf_records= */ 0,
5891            /* total_nodes= */ extents_node_count,
5892            /* free_nodes= */ extents_node_count - 1, // only the header node is used
5893            /* max_key_len= */ 10,
5894            /* key_compare_type= */ 0, // unused for extents
5895        );
5896    }
5897
5898    // --- Catalog B-tree (header + leaf with root + thread) ---
5899    let name_utf16: Vec<u16> = fs_mac_unicode::hfs_unicode::decompose_str(name);
5900    {
5901        let off = catalog_start as usize * block_size as usize;
5902        let key_compare = if case_sensitive {
5903            KEY_COMPARE_BINARY
5904        } else {
5905            KEY_COMPARE_CASE_FOLDING
5906        };
5907        write_blank_btree_header_node(
5908            &mut front[off..off + node_size],
5909            node_size,
5910            /* leaf_records= */ 2,
5911            catalog_node_count,
5912            catalog_node_count - 2,
5913            /* max_key_len= */ 516, // HFS+ catalog max key
5914            key_compare,
5915        );
5916        let leaf_off = off + node_size;
5917        write_root_catalog_leaf(&mut front[leaf_off..leaf_off + node_size], &name_utf16);
5918    }
5919
5920    // --- Volume header ---
5921    let signature = if case_sensitive {
5922        HFSX_SIGNATURE
5923    } else {
5924        HFS_PLUS_SIGNATURE
5925    };
5926    let now = hfs_common::hfs_now();
5927    let vh = HfsPlusVolumeHeader {
5928        signature,
5929        version: if case_sensitive { 5 } else { 4 },
5930        attributes: HFSPLUS_VOLUME_UNMOUNTED_BIT,
5931        last_mounted_version: 0,
5932        journal_info_block: 0,
5933        create_date: now,
5934        modify_date: now,
5935        backup_date: 0,
5936        checked_date: now,
5937        file_count: 0,
5938        // Per Apple TN1150, folder_count EXCLUDES the root directory.
5939        // Matches what macOS / Disk Utility / fsck_hfs expect on a fresh
5940        // volume; mismatching this causes false-positive
5941        // FolderCountMismatch reports when fsck'ing volumes created
5942        // elsewhere and edited here.
5943        folder_count: 0,
5944        block_size,
5945        total_blocks,
5946        free_blocks: total_blocks - reserved_blocks - trailing_blocks,
5947        next_allocation: reserved_blocks,
5948        rsrc_clump_size: block_size,
5949        data_clump_size: block_size,
5950        next_catalog_id: 16,
5951        write_count: 0,
5952        encodings_bitmap: 1, // bit 0 = MacRoman, conventional default
5953        finder_info: [0u32; 8],
5954        allocation_file: ForkData {
5955            logical_size: bitmap_blocks as u64 * bs,
5956            clump_size: 0,
5957            total_blocks: bitmap_blocks,
5958            extents: extent_array(bitmap_start, bitmap_blocks),
5959        },
5960        extents_file: ForkData {
5961            logical_size: extents_btree_blocks as u64 * bs,
5962            clump_size: 0,
5963            total_blocks: extents_btree_blocks,
5964            extents: extent_array(extents_start, extents_btree_blocks),
5965        },
5966        catalog_file: ForkData {
5967            logical_size: catalog_btree_blocks as u64 * bs,
5968            clump_size: 0,
5969            total_blocks: catalog_btree_blocks,
5970            extents: extent_array(catalog_start, catalog_btree_blocks),
5971        },
5972        attributes_file: ForkData::empty(),
5973        startup_file: ForkData::empty(),
5974    };
5975    let vh_bytes = vh.serialize();
5976
5977    // Primary VH at offset 1024 (always inside the front region).
5978    front[1024..1536].copy_from_slice(&vh_bytes);
5979
5980    (front, vh_bytes, image_size)
5981}
5982
5983/// Streaming variant of [`create_blank_hfsplus`]. Writes the metadata
5984/// regions (boot, VH, bitmap, B-trees, alt-VH) into `target` at `at_offset`
5985/// and leaves the free middle untouched. Caller is responsible for sizing
5986/// the underlying file (e.g. via `File::set_len`) so reads of the free area
5987/// return zeros.
5988///
5989/// Used by the defrag-clone backup path and the Mac CD-ROM builder so a
5990/// multi-hundred-MB blank target doesn't need that much contiguous RAM. Only
5991/// the front region (~2 MiB worst case) plus the 512-byte alt VH is held in
5992/// memory. `size_bytes` should be a whole multiple of `block_size`, otherwise
5993/// the alt VH lands past the volume's last allocation block.
5994pub fn write_blank_hfsplus_into<W: Write + Seek>(
5995    target: &mut W,
5996    at_offset: u64,
5997    size_bytes: u64,
5998    block_size: u32,
5999    name: &str,
6000    case_sensitive: bool,
6001) -> std::io::Result<()> {
6002    let (front, vh_bytes, _image_size) =
6003        build_blank_hfsplus_front(size_bytes, block_size, name, case_sensitive, 0, 0);
6004    target.seek(SeekFrom::Start(at_offset))?;
6005    target.write_all(&front)?;
6006    target.seek(SeekFrom::Start(at_offset + size_bytes - 1024))?;
6007    target.write_all(&vh_bytes)?;
6008    target.write_all(&[0u8; 512])?;
6009    Ok(())
6010}
6011
6012/// Helper for `create_blank_hfsplus`: build a single-extent inline array.
6013fn extent_array(start: u32, count: u32) -> [ExtentDescriptor; 8] {
6014    let mut e = [ExtentDescriptor {
6015        start_block: 0,
6016        block_count: 0,
6017    }; 8];
6018    e[0] = ExtentDescriptor {
6019        start_block: start,
6020        block_count: count,
6021    };
6022    e
6023}
6024
6025/// Stamp a B-tree header node into `node` (must be exactly `node_size`
6026/// bytes). Lays out three records — header(106), user-data(128), node
6027/// bitmap (rest) — and marks node 0 (header) and node 1 (leaf) as
6028/// allocated in the node bitmap.
6029pub(crate) fn write_blank_btree_header_node(
6030    node: &mut [u8],
6031    node_size: usize,
6032    leaf_records: u32,
6033    total_nodes: u32,
6034    free_nodes: u32,
6035    max_key_len: u16,
6036    key_compare_type: u8,
6037) {
6038    node.fill(0);
6039    node[8] = 1; // kind = header
6040    BigEndian::write_u16(&mut node[10..12], 3); // 3 records
6041
6042    // Record 0: BTHeaderRec at offset 14 (106 bytes).
6043    //
6044    // An *empty* tree (no leaf records, e.g. a fresh extents-overflow or
6045    // attributes B-tree) is represented exactly as Apple's `newfs_hfs` does:
6046    // treeDepth = 0, rootNode = 0, firstLeafNode = lastLeafNode = 0, and no
6047    // leaf node allocated — only the header node is in use. A populated blank
6048    // (the catalog, with its root folder + thread records) has a single
6049    // root *leaf* at node 1. `fsck_hfs` rejects a depth-1 tree whose root is
6050    // an empty leaf ("Invalid node structure"), which is why the empty case
6051    // must collapse to depth 0. `btree_insert_full` bootstraps the first leaf
6052    // on the initial insert into a depth-0 tree.
6053    let empty = leaf_records == 0;
6054    let (depth, root_first_last): (u16, u32) = if empty { (0, 0) } else { (1, 1) };
6055    let hr = 14usize;
6056    BigEndian::write_u16(&mut node[hr..hr + 2], depth);
6057    BigEndian::write_u32(&mut node[hr + 2..hr + 6], root_first_last); // root
6058    BigEndian::write_u32(&mut node[hr + 6..hr + 10], leaf_records);
6059    BigEndian::write_u32(&mut node[hr + 10..hr + 14], root_first_last); // first leaf
6060    BigEndian::write_u32(&mut node[hr + 14..hr + 18], root_first_last); // last leaf
6061    BigEndian::write_u16(&mut node[hr + 18..hr + 20], node_size as u16);
6062    BigEndian::write_u16(&mut node[hr + 20..hr + 22], max_key_len);
6063    BigEndian::write_u32(&mut node[hr + 22..hr + 26], total_nodes);
6064    BigEndian::write_u32(&mut node[hr + 26..hr + 30], free_nodes);
6065    // BTHeaderRec offset 36 = btreeType (0=control); offset 37 = keyCompareType
6066    node[hr + 36] = 0; // kBTHFSTreeType
6067    node[hr + 37] = key_compare_type;
6068    // Offset 38 = attributes: Apple sets kBTBigKeysMask on every HFS+ tree and
6069    // kBTVariableIndexKeysMask on the catalog and attributes trees (not extents).
6070    let attributes = if max_key_len == 10 {
6071        hfs_common::KBT_BIG_KEYS_MASK
6072    } else {
6073        hfs_common::KBT_BIG_KEYS_MASK | hfs_common::KBT_VARIABLE_INDEX_KEYS_MASK
6074    };
6075    BigEndian::write_u32(&mut node[hr + 38..hr + 42], attributes);
6076
6077    // Canonical TN1150 B-tree header node layout. The BTHeaderRec (record 0)
6078    // is *exactly* 106 bytes — Apple's `fsck_hfs` rejects any other length
6079    // ("Invalid BTH length"). So record 1 (the 128-byte reserved user-data
6080    // record) starts at 14 + 106 = 120, and record 2 (the node-allocation
6081    // bitmap) starts at 120 + 128 = 248, filling the node to the offset
6082    // table. The bitmap therefore addresses (node_size - 256) * 8 nodes,
6083    // which is exactly what `hfs_common::map_nodes_required` assumes — so the
6084    // header-vs-map-node boundary is consistent (see the discrepancy noted in
6085    // docs/todo_hfsplus_fork_growth.md §5, resolved by using 248 here).
6086    let user_off: u16 = 120;
6087    let bitmap_off: u16 = 248;
6088    let free_off: u16 = (node_size as u16).saturating_sub(8); // fills to offset table
6089
6090    let ot_end = node_size;
6091    BigEndian::write_u16(&mut node[ot_end - 2..ot_end], 14); // record 0
6092    BigEndian::write_u16(&mut node[ot_end - 4..ot_end - 2], user_off);
6093    BigEndian::write_u16(&mut node[ot_end - 6..ot_end - 4], bitmap_off);
6094    BigEndian::write_u16(&mut node[ot_end - 8..ot_end - 6], free_off);
6095
6096    // Mark allocated nodes in the bitmap: node 0 (header) always; node 1
6097    // (the root leaf) only for a populated tree. An empty tree's node 1 is
6098    // free and zero-filled, matching Apple.
6099    node[bitmap_off as usize] = if empty { 0b10000000 } else { 0b11000000 };
6100}
6101
6102/// Write an empty leaf node (kind=-1, height=1, 0 records). Only used by test
6103/// fixtures now — the production blank/defrag builders represent an empty tree
6104/// as depth 0 with no leaf node (the first insert bootstraps the root leaf).
6105#[cfg(test)]
6106pub(crate) fn write_empty_leaf_node(node: &mut [u8]) {
6107    node.fill(0);
6108    node[8] = 0xFF; // kind = -1
6109    node[9] = 1; // height
6110    BigEndian::write_u16(&mut node[10..12], 0); // 0 records
6111    let n = node.len();
6112    BigEndian::write_u16(&mut node[n - 2..n], 14); // free-space offset
6113}
6114
6115/// Write the catalog leaf node containing the root folder record (key =
6116/// `(parent=1, name=<volume>)`) and the root thread record (key =
6117/// `(parent=2, name="")`, data = recordType=3 + parentID=1 + name=volume).
6118fn write_root_catalog_leaf(node: &mut [u8], name_utf16: &[u16]) {
6119    node.fill(0);
6120    let n = node.len();
6121    node[8] = 0xFF; // kind = -1 leaf
6122    node[9] = 1; // height
6123    BigEndian::write_u16(&mut node[10..12], 2); // 2 records
6124
6125    let now = hfs_common::hfs_now();
6126
6127    // ---- Record 0: root folder record ----
6128    // Key: key_len(2) + parent_id(4) + name_len(2) + name(UTF-16BE)
6129    let r0_off = 14usize;
6130    let key_body = 4 + 2 + name_utf16.len() * 2;
6131    BigEndian::write_u16(&mut node[r0_off..r0_off + 2], key_body as u16);
6132    BigEndian::write_u32(&mut node[r0_off + 2..r0_off + 6], 1); // parent = 1
6133    BigEndian::write_u16(&mut node[r0_off + 6..r0_off + 8], name_utf16.len() as u16);
6134    for (i, &u) in name_utf16.iter().enumerate() {
6135        BigEndian::write_u16(&mut node[r0_off + 8 + i * 2..r0_off + 10 + i * 2], u);
6136    }
6137    let mut r0_data = r0_off + 2 + key_body;
6138    if !r0_data.is_multiple_of(2) {
6139        r0_data += 1;
6140    }
6141    BigEndian::write_i16(&mut node[r0_data..r0_data + 2], CATALOG_FOLDER);
6142    // valence = 0 at +4, folderID = 2 at +8, then dates.
6143    BigEndian::write_u32(&mut node[r0_data + 8..r0_data + 12], 2);
6144    BigEndian::write_u32(&mut node[r0_data + 12..r0_data + 16], now);
6145    BigEndian::write_u32(&mut node[r0_data + 16..r0_data + 20], now);
6146    BigEndian::write_u32(&mut node[r0_data + 20..r0_data + 24], now);
6147    BigEndian::write_u32(&mut node[r0_data + 24..r0_data + 28], now);
6148    let r0_end = r0_data + 88; // folder record is 88 bytes total
6149
6150    // ---- Record 1: root thread record ----
6151    // Key: key_len(2) + parent_id(4=2) + name_len(2=0)
6152    let r1_off = r0_end;
6153    BigEndian::write_u16(&mut node[r1_off..r1_off + 2], 6); // key_len
6154    BigEndian::write_u32(&mut node[r1_off + 2..r1_off + 6], 2); // parent = 2 (root CNID)
6155    BigEndian::write_u16(&mut node[r1_off + 6..r1_off + 8], 0); // name_len = 0
6156    let mut r1_data = r1_off + 2 + 6;
6157    if !r1_data.is_multiple_of(2) {
6158        r1_data += 1;
6159    }
6160    // Thread data: type(2)=3, reserved(2)=0, parentID(4)=1, name_len(2),
6161    // name(UTF-16BE).
6162    BigEndian::write_i16(&mut node[r1_data..r1_data + 2], 3); // folder thread
6163    BigEndian::write_u32(&mut node[r1_data + 4..r1_data + 8], 1);
6164    BigEndian::write_u16(
6165        &mut node[r1_data + 8..r1_data + 10],
6166        name_utf16.len() as u16,
6167    );
6168    for (i, &u) in name_utf16.iter().enumerate() {
6169        BigEndian::write_u16(&mut node[r1_data + 10 + i * 2..r1_data + 12 + i * 2], u);
6170    }
6171    let r1_end = r1_data + 10 + name_utf16.len() * 2;
6172
6173    // Offset table at the end of the node.
6174    BigEndian::write_u16(&mut node[n - 2..n], 14); // record 0 offset
6175    BigEndian::write_u16(&mut node[n - 4..n - 2], r1_off as u16);
6176    BigEndian::write_u16(&mut node[n - 6..n - 4], r1_end as u16); // free-space offset
6177}
6178
6179// --- Resize and validation ---
6180
6181/// Resize an HFS+ filesystem in place.
6182pub fn resize_hfsplus_in_place(
6183    device: &mut (impl Read + Write + Seek),
6184    partition_offset: u64,
6185    new_size_bytes: u64,
6186    log: &mut impl FnMut(&str),
6187) -> anyhow::Result<()> {
6188    // Read volume header
6189    device.seek(SeekFrom::Start(partition_offset + 1024))?;
6190    let mut vh_buf = [0u8; 512];
6191    device.read_exact(&mut vh_buf)?;
6192
6193    let sig = BigEndian::read_u16(&vh_buf[0..2]);
6194    if sig != HFS_PLUS_SIGNATURE && sig != HFSX_SIGNATURE {
6195        log("HFS+ resize: not an HFS+ volume, skipping");
6196        return Ok(());
6197    }
6198    let mut vh = HfsPlusVolumeHeader::parse(&vh_buf)?;
6199    let bs = vh.block_size;
6200    let old_total = vh.total_blocks;
6201    let new_total = (new_size_bytes / bs as u64) as u32;
6202    if new_total < 8 {
6203        anyhow::bail!("HFS+ resize: {new_size_bytes} bytes is too small for a volume");
6204    }
6205
6206    // The allocation file is one extent on every volume Apple or we format.
6207    let af = &vh.allocation_file;
6208    if af.extents[0].block_count == 0 || af.extents[1].block_count != 0 {
6209        anyhow::bail!("HFS+ resize: the allocation file is not a single extent; refusing");
6210    }
6211    let (mut af_start, mut af_blocks) = (af.extents[0].start_block, af.extents[0].block_count);
6212    let mut bitmap = vec![0u8; af_blocks as usize * bs as usize];
6213    device.seek(SeekFrom::Start(
6214        partition_offset + af_start as u64 * bs as u64,
6215    ))?;
6216    device.read_exact(&mut bitmap)?;
6217
6218    let old_tail = alt_vh_blocks(old_total, bs);
6219    let new_tail = alt_vh_blocks(new_total, bs);
6220    if new_total < old_total {
6221        // A count says nothing about position: the last used block must fit.
6222        let highest = find_last_set_bit(&bitmap, old_tail.start).map_or(0, |b| b + 1);
6223        if highest > new_tail.start {
6224            anyhow::bail!(
6225                "HFS+ resize: new size {} blocks ends before the last allocated block ({})",
6226                new_total,
6227                highest
6228            );
6229        }
6230    }
6231
6232    // The alternate header's blocks move with the end of the volume (R-056);
6233    // bits past the old end were padding and the grown area starts free.
6234    let cap_bits = (bitmap.len() as u64 * 8).min(u32::MAX as u64) as u32;
6235    for b in old_tail.start..old_total.min(cap_bits) {
6236        bitmap_clear_bit_be(&mut bitmap, b);
6237    }
6238    for b in old_total..new_total.min(cap_bits) {
6239        bitmap_clear_bit_be(&mut bitmap, b);
6240    }
6241
6242    // A bigger volume may need a bigger allocation file: extend it in place
6243    // when the blocks after it are free, else move it to a free run.
6244    let needed_blocks = (new_total as u64).div_ceil(8).div_ceil(bs as u64) as u32;
6245    if needed_blocks > af_blocks {
6246        let mut grown = bitmap.clone();
6247        grown.resize(needed_blocks as usize * bs as usize, 0);
6248        let usable = new_tail.start;
6249        let extend_ok = (af_start + af_blocks..af_start + needed_blocks)
6250            .all(|b| b < usable && !bitmap_test_bit_be(&grown, b));
6251        let new_start = if extend_ok {
6252            af_start
6253        } else {
6254            hfs_common::bitmap_find_clear_run_be(&grown, usable, needed_blocks).ok_or_else(
6255                || {
6256                    anyhow::anyhow!(
6257                        "HFS+ resize: no free run of {needed_blocks} blocks for the allocation file"
6258                    )
6259                },
6260            )?
6261        };
6262        if new_start != af_start {
6263            for b in af_start..af_start + af_blocks {
6264                bitmap_clear_bit_be(&mut grown, b);
6265            }
6266        }
6267        for b in new_start..new_start + needed_blocks {
6268            bitmap_set_bit_be(&mut grown, b);
6269        }
6270        log(&format!(
6271            "HFS+ resize: allocation file grows from {af_blocks} to {needed_blocks} blocks at \
6272             block {new_start}"
6273        ));
6274        bitmap = grown;
6275        af_start = new_start;
6276        af_blocks = needed_blocks;
6277        vh.allocation_file.extents[0] = ExtentDescriptor {
6278            start_block: af_start,
6279            block_count: af_blocks,
6280        };
6281        vh.allocation_file.total_blocks = af_blocks;
6282        vh.allocation_file.logical_size = af_blocks as u64 * bs as u64;
6283    }
6284    for b in new_tail.clone() {
6285        bitmap_set_bit_be(&mut bitmap, b);
6286    }
6287    let cap_bits = (bitmap.len() as u64 * 8).min(u32::MAX as u64) as u32;
6288    for b in new_total..cap_bits {
6289        bitmap_clear_bit_be(&mut bitmap, b);
6290    }
6291
6292    let used = count_used_blocks(&bitmap, new_total);
6293    vh.total_blocks = new_total;
6294    vh.free_blocks = new_total - used;
6295    if vh.next_allocation >= new_tail.start {
6296        vh.next_allocation = 0;
6297    }
6298    log(&format!(
6299        "HFS+ resize: {} -> {} blocks ({} free)",
6300        old_total, new_total, vh.free_blocks
6301    ));
6302
6303    device.seek(SeekFrom::Start(
6304        partition_offset + af_start as u64 * bs as u64,
6305    ))?;
6306    device.write_all(&bitmap)?;
6307    let out = vh.serialize();
6308    device.seek(SeekFrom::Start(partition_offset + 1024))?;
6309    device.write_all(&out)?;
6310    if new_size_bytes > 1024 {
6311        device.seek(SeekFrom::Start(partition_offset + new_size_bytes - 1024))?;
6312        device.write_all(&out)?;
6313    }
6314    device.flush()?;
6315    Ok(())
6316}
6317
6318/// Mirror the volume header 1024 bytes before the partition end when the
6319/// volume is smaller than its partition, where Mac OS looks for it (R-057).
6320/// Returns whether a copy was written.
6321pub fn mirror_alternate_volume_header(
6322    device: &mut (impl Read + Write + Seek),
6323    partition_offset: u64,
6324    partition_len: u64,
6325    log: &mut impl FnMut(&str),
6326) -> anyhow::Result<bool> {
6327    device.seek(SeekFrom::Start(partition_offset + 1024))?;
6328    let mut vh_buf = [0u8; 512];
6329    device.read_exact(&mut vh_buf)?;
6330    let sig = BigEndian::read_u16(&vh_buf[0..2]);
6331    if sig != HFS_PLUS_SIGNATURE && sig != HFSX_SIGNATURE {
6332        return Ok(false);
6333    }
6334    let block_size = BigEndian::read_u32(&vh_buf[40..44]) as u64;
6335    let total_blocks = BigEndian::read_u32(&vh_buf[44..48]) as u64;
6336    let volume_len = total_blocks * block_size;
6337    if partition_len < 2048 || volume_len >= partition_len {
6338        return Ok(false);
6339    }
6340    device.seek(SeekFrom::Start(partition_offset + partition_len - 1024))?;
6341    device.write_all(&vh_buf)?;
6342    device.flush()?;
6343    log(&format!(
6344        "HFS+ volume of {volume_len} bytes in a {partition_len}-byte partition: alternate \
6345         volume header mirrored at the partition end"
6346    ));
6347    Ok(true)
6348}
6349
6350/// The blocks that hold a volume's alternate volume header, its last 1024
6351/// bytes: one block at 1024 bytes and up, two at 512.
6352fn alt_vh_blocks(total_blocks: u32, block_size: u32) -> std::ops::Range<u32> {
6353    if total_blocks == 0 {
6354        return 0..0;
6355    }
6356    let bytes = total_blocks as u64 * block_size as u64;
6357    let start = (bytes.saturating_sub(1024) / block_size as u64) as u32;
6358    start..total_blocks
6359}
6360
6361/// Set bits among the first `total_blocks` of the allocation bitmap.
6362fn count_used_blocks(bitmap: &[u8], total_blocks: u32) -> u32 {
6363    let full = (total_blocks / 8) as usize;
6364    let mut used: u32 = bitmap[..full.min(bitmap.len())]
6365        .iter()
6366        .map(|b| b.count_ones())
6367        .sum();
6368    let keep = total_blocks % 8;
6369    if keep > 0 {
6370        if let Some(&last) = bitmap.get(full) {
6371            let mask = 0xFFu8 << (8 - keep);
6372            used += (last & mask).count_ones();
6373        }
6374    }
6375    used
6376}
6377
6378/// Validate HFS+ filesystem integrity.
6379pub fn validate_hfsplus_integrity(
6380    device: &mut (impl Read + Seek),
6381    partition_offset: u64,
6382    log: &mut impl FnMut(&str),
6383) -> anyhow::Result<()> {
6384    device.seek(SeekFrom::Start(partition_offset + 1024))?;
6385    let mut vh_buf = [0u8; 512];
6386    device.read_exact(&mut vh_buf)?;
6387
6388    let sig = BigEndian::read_u16(&vh_buf[0..2]);
6389    if sig != HFS_PLUS_SIGNATURE && sig != HFSX_SIGNATURE {
6390        log("HFS+ validate: not an HFS+ volume, skipping");
6391        return Ok(());
6392    }
6393
6394    let block_size = BigEndian::read_u32(&vh_buf[40..44]);
6395    let total_blocks = BigEndian::read_u32(&vh_buf[44..48]);
6396
6397    if !block_size.is_power_of_two() || block_size < 512 {
6398        anyhow::bail!("HFS+ validate: invalid block size {block_size}");
6399    }
6400    if total_blocks == 0 {
6401        anyhow::bail!("HFS+ validate: zero total blocks");
6402    }
6403
6404    log(&format!(
6405        "HFS+ validate: OK ({total_blocks} blocks, {block_size} block size)"
6406    ));
6407    Ok(())
6408}
6409
6410/// Resolve the actual HFS filesystem variant for an "Apple_HFS" APM partition.
6411///
6412/// Returns `(fs_type, hfsplus_offset)` where `fs_type` is `"hfs"`, `"hfsplus"`,
6413/// or `"unknown"`, and `hfsplus_offset` is the partition_offset to pass to
6414/// `HfsPlusFilesystem::open` (accounts for the embedded volume's position).
6415///
6416/// Three cases are handled:
6417/// - Native HFS+ (0x482B/0x4858 at partition_offset+1024): `hfsplus_offset == partition_offset`.
6418/// - Embedded HFS+ (HFS wrapper with 0x4244 MDB, drEmbedSigWord == 0x482B): `hfsplus_offset`
6419///   is calculated from the MDB's drAlBlSt/drAlBlkSiz/drEmbedExtent fields.
6420/// - Pure HFS (0x4244, no embedded HFS+): returns `"hfs"`.
6421pub fn resolve_apple_hfs<R: Read + Seek>(
6422    reader: &mut R,
6423    partition_offset: u64,
6424) -> (&'static str, u64) {
6425    // The HFS MDB / HFS+ volume header sits at partition_offset + 1024 (sector-aligned).
6426    // Read 512 bytes (one sector) — all required fields are within the first 512 bytes
6427    // of the MDB (largest field needed is drEmbedExtent.startBlock at MDB offset 127).
6428    // This keeps the read sector-aligned for raw character device compatibility.
6429    if reader
6430        .seek(SeekFrom::Start(partition_offset + 1024))
6431        .is_err()
6432    {
6433        return ("unknown", partition_offset);
6434    }
6435    let mut buf = [0u8; 512];
6436    if reader.read_exact(&mut buf).is_err() {
6437        return ("unknown", partition_offset);
6438    }
6439    let sig = u16::from_be_bytes([buf[0], buf[1]]);
6440    match sig {
6441        0x4244 => {
6442            // HFS MDB — check for embedded HFS+ (drEmbedSigWord at MDB offset 124)
6443            let embedded_sig = u16::from_be_bytes([buf[124], buf[125]]);
6444            if embedded_sig == 0x482B {
6445                // Embedded HFS+: calculate the embedded volume's starting offset.
6446                // drAlBlkSiz (allocation block size, bytes) at MDB offset 20 (u32 BE).
6447                // drAlBlSt (first alloc block in 512-byte sectors) at MDB offset 28 (u16 BE).
6448                // drEmbedExtent.startBlock at MDB offset 126 (u16 BE).
6449                let block_size = u32::from_be_bytes([buf[20], buf[21], buf[22], buf[23]]) as u64;
6450                let first_alloc_block = u16::from_be_bytes([buf[28], buf[29]]) as u64;
6451                let embedded_start = u16::from_be_bytes([buf[126], buf[127]]) as u64;
6452                // HFS+ volume starts at: partition_offset + drAlBlSt*512 + startBlock*drAlBlkSiz
6453                let hfsplus_offset =
6454                    partition_offset + first_alloc_block * 512 + embedded_start * block_size;
6455                ("hfsplus", hfsplus_offset)
6456            } else {
6457                ("hfs", partition_offset)
6458            }
6459        }
6460        // Native HFS+ or HFSX — volume header is directly at partition_offset+1024
6461        0x482B | 0x4858 => ("hfsplus", partition_offset),
6462        _ => ("unknown", partition_offset),
6463    }
6464}
6465
6466#[cfg(test)]
6467#[allow(clippy::identity_op)] // `1usize * block_size` keeps offset rows aligned
6468mod tests {
6469    use super::*;
6470
6471    /// A thousand files in, one more in and out, the thousand out, one in:
6472    /// the catalog must stay clean through every split, merge and retire.
6473    #[test]
6474    fn thousand_file_churn_keeps_the_catalog_clean() {
6475        use std::io::Cursor;
6476        let img = create_blank_hfsplus(64 * 1024 * 1024, 4096, "Churn", false);
6477        let mut fs = HfsPlusFilesystem::open(Cursor::new(img), 0).unwrap();
6478        fs.prepare_for_edit().unwrap();
6479        let root = fs.root().unwrap();
6480        let clean = |fs: &mut HfsPlusFilesystem<Cursor<Vec<u8>>>, stage: &str| {
6481            fs.sync_metadata().unwrap();
6482            let r = fs.fsck().expect("hfsplus has fsck").unwrap();
6483            assert!(r.errors.is_empty(), "{stage}: {:?}", r.errors);
6484        };
6485        let put = |fs: &mut HfsPlusFilesystem<Cursor<Vec<u8>>>, name: &str, data: &[u8]| {
6486            fs.create_file(
6487                &root,
6488                name,
6489                &mut Cursor::new(data),
6490                data.len() as u64,
6491                &CreateFileOptions::default(),
6492            )
6493            .unwrap_or_else(|e| panic!("creating {name}: {e}"))
6494        };
6495        let free0 = fs.vh.free_blocks;
6496        let trees0 = fs.vh.catalog_file.total_blocks + fs.vh.extents_file.total_blocks;
6497        for i in 0..1000 {
6498            put(
6499                &mut fs,
6500                &format!("f{i:04}.txt"),
6501                format!("file {i:04}").as_bytes(),
6502            );
6503        }
6504        clean(&mut fs, "after 1000 creates");
6505        let extra = put(&mut fs, "extra.bin", &[7u8; 5000]);
6506        clean(&mut fs, "after one more");
6507        fs.delete_entry(&root, &extra).unwrap();
6508        clean(&mut fs, "after deleting it");
6509        for e in fs.list_directory(&root).unwrap() {
6510            fs.delete_entry(&root, &e).unwrap();
6511        }
6512        // The B-trees keep what they grew by; every other block comes back.
6513        let trees = fs.vh.catalog_file.total_blocks + fs.vh.extents_file.total_blocks;
6514        assert_eq!(
6515            fs.vh.free_blocks + (trees - trees0),
6516            free0,
6517            "every block came back"
6518        );
6519        clean(&mut fs, "after deleting the 1000");
6520        put(&mut fs, "last.bin", &[9u8; 100]);
6521        clean(&mut fs, "after the last create");
6522        let names: Vec<String> = fs
6523            .list_directory(&root)
6524            .unwrap()
6525            .into_iter()
6526            .map(|e| e.name)
6527            .collect();
6528        assert_eq!(names, ["last.bin"]);
6529    }
6530
6531    /// Apple stamps kBTBigKeysMask on every HFS+ tree and adds
6532    /// kBTVariableIndexKeysMask on the catalog and attributes trees; so do we.
6533    #[test]
6534    fn blank_btree_headers_carry_apples_attribute_bits() {
6535        let img = create_blank_hfsplus(16 * 1024 * 1024, 4096, "Attrs", false);
6536        let vh = &img[1024..1536];
6537        let block_size = BigEndian::read_u32(&vh[0x28..0x2c]) as usize;
6538        // BTHeaderRec.attributes sits at node offset 14 + 38; a fork's first
6539        // extent start is 16 bytes into its HFSPlusForkData (extents file at
6540        // 0xC0, catalog at 0x110 in the volume header).
6541        let attrs = |fork: usize| {
6542            let start = BigEndian::read_u32(&vh[fork + 0x10..fork + 0x14]) as usize;
6543            BigEndian::read_u32(&img[start * block_size + 52..start * block_size + 56])
6544        };
6545        assert_eq!(attrs(0xC0), hfs_common::KBT_BIG_KEYS_MASK, "extents");
6546        let variable = hfs_common::KBT_BIG_KEYS_MASK | hfs_common::KBT_VARIABLE_INDEX_KEYS_MASK;
6547        assert_eq!(attrs(0x110), variable, "catalog");
6548    }
6549
6550    #[test]
6551    fn test_volume_header_parse() {
6552        let mut data = [0u8; 512];
6553        BigEndian::write_u16(&mut data[0..2], HFS_PLUS_SIGNATURE);
6554        BigEndian::write_u16(&mut data[2..4], 4); // version
6555        BigEndian::write_u32(&mut data[40..44], 4096); // block_size
6556        BigEndian::write_u32(&mut data[44..48], 100000); // total_blocks
6557        BigEndian::write_u32(&mut data[48..52], 30000); // free_blocks
6558
6559        let vh = HfsPlusVolumeHeader::parse(&data).unwrap();
6560        assert_eq!(vh.signature, HFS_PLUS_SIGNATURE);
6561        assert_eq!(vh.block_size, 4096);
6562        assert_eq!(vh.total_blocks, 100000);
6563        assert_eq!(vh.free_blocks, 30000);
6564        assert!(!vh.is_hfsx());
6565    }
6566
6567    #[test]
6568    fn test_hfsx_signature() {
6569        let mut data = [0u8; 512];
6570        BigEndian::write_u16(&mut data[0..2], HFSX_SIGNATURE);
6571        BigEndian::write_u32(&mut data[40..44], 4096);
6572        BigEndian::write_u32(&mut data[44..48], 100000);
6573        BigEndian::write_u32(&mut data[48..52], 30000);
6574
6575        let vh = HfsPlusVolumeHeader::parse(&data).unwrap();
6576        assert!(vh.is_hfsx());
6577    }
6578
6579    #[test]
6580    fn test_decode_utf16be() {
6581        // "Hello" in UTF-16BE
6582        let data = [0x00, 0x48, 0x00, 0x65, 0x00, 0x6C, 0x00, 0x6C, 0x00, 0x6F];
6583        assert_eq!(decode_utf16be(&data), "Hello");
6584    }
6585
6586    #[test]
6587    fn test_decode_utf16be_with_non_ascii() {
6588        // "Ä" (U+00C4) in UTF-16BE
6589        let data = [0x00, 0xC4];
6590        assert_eq!(decode_utf16be(&data), "Ä");
6591    }
6592
6593    #[test]
6594    fn test_extent_descriptor() {
6595        let mut data = [0u8; 8];
6596        BigEndian::write_u32(&mut data[0..4], 100);
6597        BigEndian::write_u32(&mut data[4..8], 50);
6598
6599        let ext = ExtentDescriptor::parse(&data);
6600        assert_eq!(ext.start_block, 100);
6601        assert_eq!(ext.block_count, 50);
6602        assert!(!ext.is_empty());
6603    }
6604
6605    #[test]
6606    fn test_find_last_set_bit() {
6607        let bitmap = [0b10100000, 0b00001000]; // bits 0, 2, 12
6608        assert_eq!(find_last_set_bit(&bitmap, 16), Some(12));
6609
6610        let empty = [0u8; 2];
6611        assert_eq!(find_last_set_bit(&empty, 16), None);
6612    }
6613
6614    #[test]
6615    fn test_find_last_set_bit_dense_byte() {
6616        // Regression: when the highest non-zero byte has multiple set bits,
6617        // the result must be the highest global index in that byte, not the
6618        // lowest. 0b11111100 has bits 0..=5 set (MSB-first), so the answer
6619        // is 5, not 0.
6620        assert_eq!(find_last_set_bit(&[0b11111100], 8), Some(5));
6621        assert_eq!(find_last_set_bit(&[0b11111111], 8), Some(7));
6622        assert_eq!(find_last_set_bit(&[0b00000001], 8), Some(7));
6623        assert_eq!(find_last_set_bit(&[0b10000000], 8), Some(0));
6624        // Multi-byte: byte 0 dense, byte 1 zero — answer is in byte 0.
6625        assert_eq!(find_last_set_bit(&[0b11111100, 0b00000000], 16), Some(5));
6626        // Bound clipping: 0b11111111 with max_bits=4 → only bits 0..=3 are
6627        // valid; the highest-valid one is 3.
6628        assert_eq!(find_last_set_bit(&[0b11111111], 4), Some(3));
6629    }
6630
6631    #[test]
6632    fn test_volume_header_serialize_roundtrip() {
6633        let mut data = [0u8; 512];
6634        BigEndian::write_u16(&mut data[0..2], HFS_PLUS_SIGNATURE);
6635        BigEndian::write_u16(&mut data[2..4], 4);
6636        BigEndian::write_u32(&mut data[40..44], 4096);
6637        BigEndian::write_u32(&mut data[44..48], 100);
6638        BigEndian::write_u32(&mut data[48..52], 50);
6639        BigEndian::write_u32(&mut data[64..68], 10); // next_catalog_id
6640        BigEndian::write_u32(&mut data[80..84], 42); // finder_info[0]
6641
6642        let vh = HfsPlusVolumeHeader::parse(&data).unwrap();
6643        assert_eq!(vh.next_catalog_id, 10);
6644        assert_eq!(vh.finder_info[0], 42);
6645
6646        let serialized = vh.serialize();
6647        let vh2 = HfsPlusVolumeHeader::parse(&serialized).unwrap();
6648        assert_eq!(vh2.block_size, 4096);
6649        assert_eq!(vh2.total_blocks, 100);
6650        assert_eq!(vh2.free_blocks, 50);
6651        assert_eq!(vh2.next_catalog_id, 10);
6652        assert_eq!(vh2.finder_info[0], 42);
6653    }
6654
6655    #[test]
6656    fn test_fork_data_serialize_roundtrip() {
6657        let fork = ForkData {
6658            logical_size: 12345,
6659            clump_size: 0,
6660            total_blocks: 3,
6661            extents: [
6662                ExtentDescriptor {
6663                    start_block: 10,
6664                    block_count: 3,
6665                },
6666                ExtentDescriptor {
6667                    start_block: 0,
6668                    block_count: 0,
6669                },
6670                ExtentDescriptor {
6671                    start_block: 0,
6672                    block_count: 0,
6673                },
6674                ExtentDescriptor {
6675                    start_block: 0,
6676                    block_count: 0,
6677                },
6678                ExtentDescriptor {
6679                    start_block: 0,
6680                    block_count: 0,
6681                },
6682                ExtentDescriptor {
6683                    start_block: 0,
6684                    block_count: 0,
6685                },
6686                ExtentDescriptor {
6687                    start_block: 0,
6688                    block_count: 0,
6689                },
6690                ExtentDescriptor {
6691                    start_block: 0,
6692                    block_count: 0,
6693                },
6694            ],
6695        };
6696        let mut buf = [0u8; 80];
6697        fork.serialize(&mut buf);
6698        let fork2 = ForkData::parse(&buf);
6699        assert_eq!(fork2.logical_size, 12345);
6700        assert_eq!(fork2.total_blocks, 3);
6701        assert_eq!(fork2.extents[0].start_block, 10);
6702        assert_eq!(fork2.extents[0].block_count, 3);
6703    }
6704
6705    /// Create a minimal valid in-memory HFS+ image for testing.
6706    /// Layout: 256 blocks × 4096 bytes = 1 MB
6707    /// - Block 0: unused (VH at byte 1024 within this block)
6708    /// - Block 1: allocation bitmap
6709    /// - Block 2-5: catalog B-tree (4 blocks = 16 KB = 4 nodes of 4096 bytes)
6710    ///   - Node 0: header node
6711    ///   - Node 1: leaf node with root folder record + thread
6712    ///   - Nodes 2-3: free
6713    /// - Blocks 6+: free for user data
6714    fn make_editable_hfsplus_image() -> Vec<u8> {
6715        let block_size = 4096u32;
6716        let total_blocks = 256u32;
6717        let image_size = total_blocks as usize * block_size as usize; // 1 MB
6718        let mut img = vec![0u8; image_size];
6719
6720        // Allocation bitmap: blocks 0-5 allocated (VH area + bitmap + catalog)
6721        let bitmap_block = 1u32;
6722        let catalog_start_block = 2u32;
6723        let catalog_blocks = 4u32;
6724        let alloc_blocks = 1 + 1 + catalog_blocks; // VH + bitmap + catalog = 6 blocks
6725        let bitmap_data = &mut img[bitmap_block as usize * block_size as usize
6726            ..(bitmap_block + 1) as usize * block_size as usize];
6727        // Set bits 0-5 (MSB-first): byte 0 = 0b11111100
6728        bitmap_data[0] = 0b11111100;
6729
6730        // Build catalog B-tree in blocks 2-5 (4 nodes × 4096)
6731        let node_size = 4096usize;
6732        let catalog_offset = catalog_start_block as usize * block_size as usize;
6733        let catalog_size = catalog_blocks as usize * block_size as usize;
6734
6735        // Node 0: Header node
6736        let hdr_off = catalog_offset;
6737        // Node descriptor: next=0, prev=0, kind=1(header), height=0, numRecords=3
6738        img[hdr_off + 8] = 1; // kind = header
6739        BigEndian::write_u16(&mut img[hdr_off + 10..hdr_off + 12], 3); // 3 records
6740
6741        // B-tree header record (record 0, at offset 14)
6742        let hr = hdr_off + 14;
6743        BigEndian::write_u16(&mut img[hr..hr + 2], 1); // depth = 1
6744        BigEndian::write_u32(&mut img[hr + 2..hr + 6], 1); // root_node = 1
6745        BigEndian::write_u32(&mut img[hr + 6..hr + 10], 2); // leaf_records = 2 (folder + thread)
6746        BigEndian::write_u32(&mut img[hr + 10..hr + 14], 1); // first_leaf_node = 1
6747        BigEndian::write_u32(&mut img[hr + 14..hr + 18], 1); // last_leaf_node = 1
6748        BigEndian::write_u16(&mut img[hr + 18..hr + 20], node_size as u16); // node_size
6749        BigEndian::write_u16(&mut img[hr + 20..hr + 22], 516); // max_key_len
6750        BigEndian::write_u32(&mut img[hr + 22..hr + 26], 4); // total_nodes = 4
6751        BigEndian::write_u32(&mut img[hr + 26..hr + 30], 2); // free_nodes = 2
6752
6753        // Record offsets for header node (3 records + free space offset)
6754        // Record 0: header record at offset 14
6755        // Record 1: user data record (128 bytes at offset 14+128=142)
6756        // Record 2: bitmap record (256 bytes at offset 142+128=270)
6757        // Free space offset at end
6758        let ot = hdr_off + node_size; // offset table at end of node
6759        BigEndian::write_u16(&mut img[ot - 2..ot], 14); // record 0 offset
6760        BigEndian::write_u16(&mut img[ot - 4..ot - 2], 142); // record 1 offset
6761        BigEndian::write_u16(&mut img[ot - 6..ot - 4], 270); // record 2 (bitmap)
6762        BigEndian::write_u16(&mut img[ot - 8..ot - 6], 526); // free space offset
6763
6764        // Node bitmap (record 2): mark nodes 0 and 1 as allocated
6765        img[hdr_off + 270] = 0b11000000;
6766
6767        // Node 1: Leaf node with root folder record + thread record
6768        let leaf_off = catalog_offset + node_size;
6769        // Node descriptor: next=0, prev=0, kind=-1(leaf), height=1, numRecords=2
6770        img[leaf_off + 8] = 0xFF; // kind = -1 (leaf)
6771        img[leaf_off + 9] = 1; // height = 1
6772        BigEndian::write_u16(&mut img[leaf_off + 10..leaf_off + 12], 2); // 2 records
6773
6774        // Record 0: Root folder record (CNID 2)
6775        // Key: key_len(2) + parent_id(4, =1) + name_len(2, =0)  — root folder's parent is CNID 1
6776        let r0_off = leaf_off + 14;
6777        BigEndian::write_u16(&mut img[r0_off..r0_off + 2], 6); // key_len = 6
6778        BigEndian::write_u32(&mut img[r0_off + 2..r0_off + 6], 1); // parent_id = 1 (root parent)
6779        BigEndian::write_u16(&mut img[r0_off + 6..r0_off + 8], 0); // name_len = 0
6780                                                                   // Record data starts after key (at offset 8, even-aligned)
6781        let r0_data = r0_off + 8;
6782        BigEndian::write_i16(&mut img[r0_data..r0_data + 2], CATALOG_FOLDER); // type = folder
6783        BigEndian::write_u32(&mut img[r0_data + 8..r0_data + 12], 2); // folderID = 2
6784                                                                      // Folder record is 88 bytes total
6785
6786        // Record 1: Thread record for root folder (CNID 2)
6787        // Thread key: parent_id = CNID = 2, name = empty
6788        let r1_off = r0_data + 88; // after folder record
6789        BigEndian::write_u16(&mut img[r1_off..r1_off + 2], 6); // key_len = 6
6790        BigEndian::write_u32(&mut img[r1_off + 2..r1_off + 6], 2); // parent_id = 2 (CNID)
6791        BigEndian::write_u16(&mut img[r1_off + 6..r1_off + 8], 0); // name_len = 0
6792                                                                   // Thread record data
6793        let r1_data = r1_off + 8;
6794        BigEndian::write_i16(&mut img[r1_data..r1_data + 2], 3); // type = folder thread
6795        BigEndian::write_u32(&mut img[r1_data + 4..r1_data + 8], 1); // parentID = 1
6796        BigEndian::write_u16(&mut img[r1_data + 8..r1_data + 10], 0); // name_len = 0
6797                                                                      // Thread record is variable length, but at minimum 10 bytes
6798
6799        // Record offset table for leaf node (2 records + free space)
6800        let lot = leaf_off + node_size;
6801        BigEndian::write_u16(&mut img[lot - 2..lot], 14); // record 0 offset
6802        let r1_rel = (r1_off - leaf_off) as u16;
6803        BigEndian::write_u16(&mut img[lot - 4..lot - 2], r1_rel); // record 1 offset
6804        let free_rel = (r1_data + 10 - leaf_off) as u16;
6805        BigEndian::write_u16(&mut img[lot - 6..lot - 4], free_rel); // free space offset
6806
6807        // Volume Header at byte 1024
6808        let vh = HfsPlusVolumeHeader {
6809            signature: HFS_PLUS_SIGNATURE,
6810            version: 4,
6811            attributes: 0,
6812            last_mounted_version: 0,
6813            journal_info_block: 0,
6814            create_date: hfs_common::hfs_now(),
6815            modify_date: hfs_common::hfs_now(),
6816            backup_date: 0,
6817            checked_date: 0,
6818            file_count: 0,
6819            folder_count: 0, // Apple TN1150: excludes root
6820            block_size,
6821            total_blocks,
6822            free_blocks: total_blocks - alloc_blocks,
6823            next_allocation: alloc_blocks,
6824            rsrc_clump_size: 0,
6825            data_clump_size: 0,
6826            next_catalog_id: 16, // next available CNID
6827            write_count: 0,
6828            encodings_bitmap: 0,
6829            finder_info: [0u32; 8],
6830            allocation_file: ForkData {
6831                logical_size: block_size as u64,
6832                clump_size: 0,
6833                total_blocks: 1,
6834                extents: {
6835                    let mut e = [ExtentDescriptor {
6836                        start_block: 0,
6837                        block_count: 0,
6838                    }; 8];
6839                    e[0] = ExtentDescriptor {
6840                        start_block: bitmap_block,
6841                        block_count: 1,
6842                    };
6843                    e
6844                },
6845            },
6846            extents_file: ForkData::empty(),
6847            catalog_file: ForkData {
6848                logical_size: catalog_size as u64,
6849                clump_size: 0,
6850                total_blocks: catalog_blocks,
6851                extents: {
6852                    let mut e = [ExtentDescriptor {
6853                        start_block: 0,
6854                        block_count: 0,
6855                    }; 8];
6856                    e[0] = ExtentDescriptor {
6857                        start_block: catalog_start_block,
6858                        block_count: catalog_blocks,
6859                    };
6860                    e
6861                },
6862            },
6863            attributes_file: ForkData::empty(),
6864            startup_file: ForkData::empty(),
6865        };
6866
6867        let vh_bytes = vh.serialize();
6868        img[1024..1024 + 512].copy_from_slice(&vh_bytes);
6869
6870        // Backup VH at last 1024 bytes
6871        let backup_pos = image_size - 1024;
6872        img[backup_pos..backup_pos + 512].copy_from_slice(&vh_bytes);
6873
6874        img
6875    }
6876
6877    /// Build an editable HFS+ image that also carries a 4-node empty
6878    /// extents-overflow B-tree at blocks 6..9. Used by tests that exercise
6879    /// fragmented files needing more than 8 extents (Step 7).
6880    ///
6881    /// Layout (4 KiB blocks, 256 total = 1 MiB image):
6882    /// - Block 0: VH/boot region
6883    /// - Block 1: allocation bitmap
6884    /// - Blocks 2..5: catalog B-tree (4 nodes)
6885    /// - Blocks 6..9: extents-overflow B-tree (4 nodes — header + empty leaf
6886    ///   + 2 free)
6887    /// - Blocks 10..255: free user data area
6888    fn make_editable_hfsplus_image_with_extents_btree() -> Vec<u8> {
6889        // Start from the standard editable image, then carve in an
6890        // extents-overflow B-tree.
6891        let mut img = make_editable_hfsplus_image();
6892        let block_size = 4096u32;
6893        let node_size = 4096usize;
6894        let extents_start_block = 6u32;
6895        let extents_blocks = 4u32;
6896
6897        // Mark blocks 6..9 allocated in the bitmap (byte 0 was 0b11111100 →
6898        // blocks 0..=5 allocated; flip bits 1..=3 of byte 1 to allocate
6899        // blocks 6..=9, leaving bits 4..=7 free for user data continuation).
6900        let bitmap_off = 1usize * block_size as usize;
6901        img[bitmap_off] = 0xFF; // blocks 0..=7 allocated
6902        img[bitmap_off + 1] = 0b11000000; // blocks 8..=9 allocated
6903
6904        // --- Header node (block 6, node 0) ---
6905        let hdr_off = extents_start_block as usize * block_size as usize;
6906        img[hdr_off + 8] = 1; // kind = header
6907        BigEndian::write_u16(&mut img[hdr_off + 10..hdr_off + 12], 3); // 3 records
6908
6909        // B-tree header record at offset 14
6910        let hr = hdr_off + 14;
6911        BigEndian::write_u16(&mut img[hr..hr + 2], 1); // depth = 1
6912        BigEndian::write_u32(&mut img[hr + 2..hr + 6], 1); // root_node = 1
6913        BigEndian::write_u32(&mut img[hr + 6..hr + 10], 0); // leaf_records = 0
6914        BigEndian::write_u32(&mut img[hr + 10..hr + 14], 1); // first_leaf = 1
6915        BigEndian::write_u32(&mut img[hr + 14..hr + 18], 1); // last_leaf = 1
6916        BigEndian::write_u16(&mut img[hr + 18..hr + 20], node_size as u16);
6917        BigEndian::write_u16(&mut img[hr + 20..hr + 22], 10); // max_key_len
6918        BigEndian::write_u32(&mut img[hr + 22..hr + 26], 4); // total_nodes
6919        BigEndian::write_u32(&mut img[hr + 26..hr + 30], 2); // free_nodes (2,3)
6920
6921        // Offset table at end of node 0: header(14), user(142), bitmap(270),
6922        // free(526) — same shape as the catalog header node.
6923        let ot = hdr_off + node_size;
6924        BigEndian::write_u16(&mut img[ot - 2..ot], 14);
6925        BigEndian::write_u16(&mut img[ot - 4..ot - 2], 142);
6926        BigEndian::write_u16(&mut img[ot - 6..ot - 4], 270);
6927        BigEndian::write_u16(&mut img[ot - 8..ot - 6], 526);
6928
6929        // Node bitmap (record 2): mark nodes 0 and 1 allocated.
6930        img[hdr_off + 270] = 0b11000000;
6931
6932        // --- Empty leaf node (block 7, node 1) ---
6933        let leaf_off = hdr_off + node_size;
6934        img[leaf_off + 8] = 0xFF; // kind = -1 (leaf)
6935        img[leaf_off + 9] = 1; // height = 1
6936        BigEndian::write_u16(&mut img[leaf_off + 10..leaf_off + 12], 0); // 0 records
6937
6938        // Offset table: just the free-space offset at byte 14 (start of
6939        // record area).
6940        let lot = leaf_off + node_size;
6941        BigEndian::write_u16(&mut img[lot - 2..lot], 14);
6942
6943        // Patch the volume header: point extents_file at blocks 6..9 with
6944        // a 16 KiB logical size, and decrement free_blocks by 4.
6945        // VH lives at offset 1024; offset 4..8 is `attributes`, but
6946        // extents_file fork lives at VH bytes 192..272.
6947        // Easier path: re-parse the VH, patch the fields, re-serialize.
6948        let mut vh = HfsPlusVolumeHeader::parse(&img[1024..1536]).expect("parse seed VH");
6949        vh.extents_file = ForkData {
6950            logical_size: (extents_blocks as u64) * (block_size as u64),
6951            clump_size: 0,
6952            total_blocks: extents_blocks,
6953            extents: {
6954                let mut e = [ExtentDescriptor {
6955                    start_block: 0,
6956                    block_count: 0,
6957                }; 8];
6958                e[0] = ExtentDescriptor {
6959                    start_block: extents_start_block,
6960                    block_count: extents_blocks,
6961                };
6962                e
6963            },
6964        };
6965        vh.free_blocks -= extents_blocks;
6966        vh.next_allocation = 10;
6967        let vh_bytes = vh.serialize();
6968        img[1024..1536].copy_from_slice(&vh_bytes);
6969        let alt = img.len() - 1024;
6970        img[alt..alt + 512].copy_from_slice(&vh_bytes);
6971        img
6972    }
6973
6974    /// Build an editable HFS+ image whose attributes B-tree carries one inline
6975    /// `com.apple.FinderInfo` record for `cnid` with `value` as the payload.
6976    /// Used by the Step 12 round-trip test.
6977    ///
6978    /// Layout (4 KiB blocks, 256 total = 1 MiB image):
6979    /// - Block 0: VH/boot region
6980    /// - Block 1: allocation bitmap
6981    /// - Blocks 2-5: catalog B-tree
6982    /// - Blocks 6-7: attributes B-tree (header + leaf)
6983    /// - Blocks 8..255: free
6984    fn make_editable_hfsplus_image_with_inline_xattr(cnid: u32, value: &[u8]) -> Vec<u8> {
6985        let mut img = make_editable_hfsplus_image();
6986        let block_size = 4096u32;
6987        let node_size = 4096usize;
6988        let attr_start_block = 6u32;
6989        let attr_blocks = 2u32;
6990
6991        // Mark blocks 6 and 7 allocated. Seed image had byte 0 = 0b11111100
6992        // (blocks 0..=5); set bits 6..=7 of byte 0.
6993        let bitmap_off = 1usize * block_size as usize;
6994        img[bitmap_off] = 0xFF; // blocks 0..=7 allocated
6995
6996        // --- Header node (block 6, node 0) ---
6997        let hdr_off = attr_start_block as usize * block_size as usize;
6998        img[hdr_off + 8] = 1; // kind = header
6999        BigEndian::write_u16(&mut img[hdr_off + 10..hdr_off + 12], 3);
7000
7001        let hr = hdr_off + 14;
7002        BigEndian::write_u16(&mut img[hr..hr + 2], 1); // depth = 1
7003        BigEndian::write_u32(&mut img[hr + 2..hr + 6], 1); // root_node = 1
7004        BigEndian::write_u32(&mut img[hr + 6..hr + 10], 1); // leaf_records = 1
7005        BigEndian::write_u32(&mut img[hr + 10..hr + 14], 1); // first_leaf = 1
7006        BigEndian::write_u32(&mut img[hr + 14..hr + 18], 1); // last_leaf = 1
7007        BigEndian::write_u16(&mut img[hr + 18..hr + 20], node_size as u16);
7008        BigEndian::write_u16(&mut img[hr + 20..hr + 22], 512); // max_key_len
7009        BigEndian::write_u32(&mut img[hr + 22..hr + 26], attr_blocks); // total_nodes = 2
7010
7011        // Offset table for header node (header(14) / user(142) / bitmap(270) /
7012        // free(526) — same shape as the catalog header node).
7013        let ot = hdr_off + node_size;
7014        BigEndian::write_u16(&mut img[ot - 2..ot], 14);
7015        BigEndian::write_u16(&mut img[ot - 4..ot - 2], 142);
7016        BigEndian::write_u16(&mut img[ot - 6..ot - 4], 270);
7017        BigEndian::write_u16(&mut img[ot - 8..ot - 6], 526);
7018
7019        // Node bitmap (record 2): mark nodes 0 and 1 allocated.
7020        img[hdr_off + 270] = 0b11000000;
7021
7022        // --- Leaf node (block 7, node 1) carrying one inline xattr record ---
7023        let leaf_off = hdr_off + node_size;
7024        img[leaf_off + 8] = 0xFF; // kind = -1 (leaf)
7025        img[leaf_off + 9] = 1; // height = 1
7026        BigEndian::write_u16(&mut img[leaf_off + 10..leaf_off + 12], 1); // 1 record
7027
7028        // Build the record: key + record-data.
7029        // Key body: pad(2) + fileID(4) + startBlock(4) + nameLen(2) + name UTF-16BE
7030        let name = "com.apple.FinderInfo";
7031        let name_utf16: Vec<u16> = name.encode_utf16().collect();
7032        let name_byte_len = name_utf16.len() * 2;
7033        let key_body_len = 12 + name_byte_len;
7034        // recordType(4) + reserved[2](8) + attrSize(4) + attrData(value.len())
7035        let data_section_len = 4 + 8 + 4 + value.len();
7036        let rec_total = 2 + key_body_len + data_section_len;
7037
7038        let r0 = leaf_off + 14;
7039        BigEndian::write_u16(&mut img[r0..r0 + 2], key_body_len as u16);
7040        // pad already zero
7041        BigEndian::write_u32(&mut img[r0 + 4..r0 + 8], cnid);
7042        BigEndian::write_u32(&mut img[r0 + 8..r0 + 12], 0); // startBlock = 0
7043        BigEndian::write_u16(&mut img[r0 + 12..r0 + 14], name_utf16.len() as u16);
7044        for (i, ch) in name_utf16.iter().enumerate() {
7045            BigEndian::write_u16(&mut img[r0 + 14 + i * 2..r0 + 14 + i * 2 + 2], *ch);
7046        }
7047        let data_off = r0 + 2 + key_body_len;
7048        BigEndian::write_u32(&mut img[data_off..data_off + 4], HFSPLUS_ATTR_INLINE_DATA);
7049        // reserved[2] (8 bytes) already zero
7050        BigEndian::write_u32(&mut img[data_off + 12..data_off + 16], value.len() as u32);
7051        img[data_off + 16..data_off + 16 + value.len()].copy_from_slice(value);
7052
7053        // Offset table: record 0 at 14, free-space at 14 + rec_total.
7054        let lot = leaf_off + node_size;
7055        BigEndian::write_u16(&mut img[lot - 2..lot], 14);
7056        BigEndian::write_u16(&mut img[lot - 4..lot - 2], (14 + rec_total) as u16);
7057
7058        // Patch the volume header: point attributes_file at blocks 6..7,
7059        // decrement free_blocks by 2.
7060        let mut vh = HfsPlusVolumeHeader::parse(&img[1024..1536]).expect("parse seed VH");
7061        vh.attributes_file = ForkData {
7062            logical_size: (attr_blocks as u64) * (block_size as u64),
7063            clump_size: 0,
7064            total_blocks: attr_blocks,
7065            extents: {
7066                let mut e = [ExtentDescriptor {
7067                    start_block: 0,
7068                    block_count: 0,
7069                }; 8];
7070                e[0] = ExtentDescriptor {
7071                    start_block: attr_start_block,
7072                    block_count: attr_blocks,
7073                };
7074                e
7075            },
7076        };
7077        vh.free_blocks -= attr_blocks;
7078        vh.next_allocation = 8;
7079        let vh_bytes = vh.serialize();
7080        img[1024..1536].copy_from_slice(&vh_bytes);
7081        let alt = img.len() - 1024;
7082        img[alt..alt + 512].copy_from_slice(&vh_bytes);
7083        img
7084    }
7085
7086    /// Build a minimal HFS+ image whose first leaf node holds the root folder
7087    /// record at `(parent=1, name=<volume_name>)` followed by N synthetic
7088    /// "child" records keyed by `(parent=2, name=childN)` — pushing the root
7089    /// thread record `(parent=2, name="")` into a later leaf. This mirrors
7090    /// the failure mode `probe_hfsplus_volume_label` had on real volumes
7091    /// where many top-level entries existed under the root.
7092    fn make_hfsplus_image_with_label(volume_name: &str, num_children: usize) -> Vec<u8> {
7093        let block_size = 4096u32;
7094        let total_blocks = 256u32;
7095        let image_size = total_blocks as usize * block_size as usize;
7096        let mut img = vec![0u8; image_size];
7097
7098        // Layout: block 0 (VH region), block 1 (bitmap), blocks 2..=5 (catalog, 4 nodes).
7099        let bitmap_block = 1u32;
7100        let catalog_start_block = 2u32;
7101        let catalog_blocks = 4u32;
7102        // Bitmap byte 0 = 0b11111100 (blocks 0..=5 allocated).
7103        img[bitmap_block as usize * block_size as usize] = 0b11111100;
7104
7105        let node_size = 4096usize;
7106        let catalog_offset = catalog_start_block as usize * block_size as usize;
7107
7108        // Node 0: header.
7109        let hdr = catalog_offset;
7110        img[hdr + 8] = 1; // kind = header
7111        BigEndian::write_u16(&mut img[hdr + 10..hdr + 12], 3);
7112        let hr = hdr + 14;
7113        BigEndian::write_u16(&mut img[hr..hr + 2], 1); // depth
7114        BigEndian::write_u32(&mut img[hr + 2..hr + 6], 1); // root_node = 1 (first leaf)
7115        BigEndian::write_u32(&mut img[hr + 6..hr + 10], (1 + num_children + 1) as u32);
7116        BigEndian::write_u32(&mut img[hr + 10..hr + 14], 1); // first_leaf
7117                                                             // Probe only ever reads the first leaf, so last_leaf can be anything ≥ 1.
7118        BigEndian::write_u32(&mut img[hr + 14..hr + 18], 2);
7119        BigEndian::write_u16(&mut img[hr + 18..hr + 20], node_size as u16);
7120        BigEndian::write_u16(&mut img[hr + 20..hr + 22], 516);
7121        BigEndian::write_u32(&mut img[hr + 22..hr + 26], 4);
7122        BigEndian::write_u32(&mut img[hr + 26..hr + 30], 0);
7123        // Header offset table.
7124        let ot = hdr + node_size;
7125        BigEndian::write_u16(&mut img[ot - 2..ot], 14); // header rec
7126        BigEndian::write_u16(&mut img[ot - 4..ot - 2], 142); // user data
7127        BigEndian::write_u16(&mut img[ot - 6..ot - 4], 270); // bitmap
7128        BigEndian::write_u16(&mut img[ot - 8..ot - 6], 526); // free space
7129
7130        // Node 1: first leaf. Record 0 = root folder record at
7131        // (parent=1, name=<volume_name>). The probe reads the volume name
7132        // from this record's key alone.
7133        let leaf = catalog_offset + node_size;
7134        img[leaf + 8] = 0xFF; // kind = -1 (leaf)
7135        img[leaf + 9] = 1; // height
7136        let total_recs = 1 + num_children;
7137        BigEndian::write_u16(&mut img[leaf + 10..leaf + 12], total_recs as u16);
7138
7139        // Record 0: root folder record key `(parent=1, name_len=N, name)` +
7140        // a 88-byte folder data payload.
7141        let name_units: Vec<u16> = volume_name.encode_utf16().collect();
7142        let name_bytes = name_units.len() * 2;
7143        let r0 = leaf + 14;
7144        let key_len = 6 + name_bytes; // parent(4) + name_len(2) + name
7145        BigEndian::write_u16(&mut img[r0..r0 + 2], key_len as u16);
7146        BigEndian::write_u32(&mut img[r0 + 2..r0 + 6], 1); // parent = 1
7147        BigEndian::write_u16(&mut img[r0 + 6..r0 + 8], name_units.len() as u16);
7148        for (i, u) in name_units.iter().enumerate() {
7149            BigEndian::write_u16(&mut img[r0 + 8 + i * 2..r0 + 10 + i * 2], *u);
7150        }
7151        let r0_data = r0 + 2 + key_len + ((2 + key_len) % 2); // 2-byte align
7152        BigEndian::write_i16(&mut img[r0_data..r0_data + 2], CATALOG_FOLDER);
7153        BigEndian::write_u32(&mut img[r0_data + 8..r0_data + 12], 2); // folderID
7154
7155        // Records 1..=N: synthetic child records keyed by (parent=2, name="cN")
7156        // so the root thread (parent=2, name="") gets pushed past the first leaf.
7157        // Using the smallest plausible record (we don't actually open the FS
7158        // here, just probe), so payload size doesn't matter beyond bounds.
7159        let mut rec_offsets = vec![14u16];
7160        let mut cursor = r0_data + 88;
7161        for i in 0..num_children {
7162            let child_name = format!("c{i}");
7163            let cn_units: Vec<u16> = child_name.encode_utf16().collect();
7164            let key_len = 6 + cn_units.len() * 2;
7165            let off = cursor;
7166            BigEndian::write_u16(&mut img[off..off + 2], key_len as u16);
7167            BigEndian::write_u32(&mut img[off + 2..off + 6], 2);
7168            BigEndian::write_u16(&mut img[off + 6..off + 8], cn_units.len() as u16);
7169            for (j, u) in cn_units.iter().enumerate() {
7170                BigEndian::write_u16(&mut img[off + 8 + j * 2..off + 10 + j * 2], *u);
7171            }
7172            // 2-byte aligned data offset, minimal 4-byte folder-record stub
7173            // (the probe never inspects it).
7174            let data = off + 2 + key_len + ((2 + key_len) % 2);
7175            BigEndian::write_i16(&mut img[data..data + 2], CATALOG_FOLDER);
7176            rec_offsets.push((off - leaf) as u16);
7177            cursor = data + 88;
7178        }
7179
7180        // Offset table at end of leaf, MSB-first record-offset slots.
7181        let lot = leaf + node_size;
7182        for (i, off) in rec_offsets.iter().enumerate() {
7183            BigEndian::write_u16(&mut img[lot - 2 - i * 2..lot - i * 2], *off);
7184        }
7185        BigEndian::write_u16(
7186            &mut img[lot - 2 - rec_offsets.len() * 2..lot - rec_offsets.len() * 2],
7187            (cursor - leaf) as u16,
7188        );
7189
7190        // Volume header.
7191        let vh = HfsPlusVolumeHeader {
7192            signature: HFS_PLUS_SIGNATURE,
7193            version: 4,
7194            attributes: 0,
7195            last_mounted_version: 0,
7196            journal_info_block: 0,
7197            create_date: 0,
7198            modify_date: 0,
7199            backup_date: 0,
7200            checked_date: 0,
7201            file_count: 0,
7202            folder_count: num_children as u32,
7203            block_size,
7204            total_blocks,
7205            free_blocks: total_blocks - 6,
7206            next_allocation: 6,
7207            rsrc_clump_size: 0,
7208            data_clump_size: 0,
7209            next_catalog_id: (16 + num_children) as u32,
7210            write_count: 0,
7211            encodings_bitmap: 0,
7212            finder_info: [0; 8],
7213            allocation_file: ForkData {
7214                logical_size: block_size as u64,
7215                clump_size: 0,
7216                total_blocks: 1,
7217                extents: {
7218                    let mut e = [ExtentDescriptor {
7219                        start_block: 0,
7220                        block_count: 0,
7221                    }; 8];
7222                    e[0] = ExtentDescriptor {
7223                        start_block: bitmap_block,
7224                        block_count: 1,
7225                    };
7226                    e
7227                },
7228            },
7229            extents_file: ForkData::empty(),
7230            catalog_file: ForkData {
7231                logical_size: (catalog_blocks as u64) * (block_size as u64),
7232                clump_size: 0,
7233                total_blocks: catalog_blocks,
7234                extents: {
7235                    let mut e = [ExtentDescriptor {
7236                        start_block: 0,
7237                        block_count: 0,
7238                    }; 8];
7239                    e[0] = ExtentDescriptor {
7240                        start_block: catalog_start_block,
7241                        block_count: catalog_blocks,
7242                    };
7243                    e
7244                },
7245            },
7246            attributes_file: ForkData::empty(),
7247            startup_file: ForkData::empty(),
7248        };
7249        let vh_bytes = vh.serialize();
7250        img[1024..1024 + 512].copy_from_slice(&vh_bytes);
7251        let alt = image_size - 1024;
7252        img[alt..alt + 512].copy_from_slice(&vh_bytes);
7253        img
7254    }
7255
7256    #[test]
7257    fn test_probe_hfsplus_volume_label_reads_from_root_key() {
7258        // Volume name lives in the root folder record's key, even when the
7259        // first leaf is dominated by `parent=2` child records that push the
7260        // root thread out of the first leaf. This is the regression case
7261        // that broke the probe on real volumes like Ariel-backup.
7262        let img = make_hfsplus_image_with_label("Ariel-backup", 20);
7263        let mut cursor = std::io::Cursor::new(img);
7264        let label = probe_hfsplus_volume_label(&mut cursor, 0);
7265        assert_eq!(label.as_deref(), Some("Ariel-backup"));
7266    }
7267
7268    #[test]
7269    fn test_probe_hfsplus_volume_label_short_name() {
7270        let img = make_hfsplus_image_with_label("X", 0);
7271        let mut cursor = std::io::Cursor::new(img);
7272        assert_eq!(
7273            probe_hfsplus_volume_label(&mut cursor, 0).as_deref(),
7274            Some("X")
7275        );
7276    }
7277
7278    #[test]
7279    fn test_prepare_for_edit_refuses_journaled_volume_without_valid_journal() {
7280        // Set the journaled bit but provide no valid journal (journalInfoBlock
7281        // points at zeroed boot blocks). Since we can't verify the journal is
7282        // clean, `prepare_for_edit` refuses; a plain read-only `open` still
7283        // succeeds. (Clean journaled volumes — verified empty — are accepted;
7284        // see hfsplus_fsck::tests::prepare_for_edit_allows_clean_journaled_volume.)
7285        let mut img = make_editable_hfsplus_image();
7286        let attr_off = 1024 + 4; // VH offset 4 = attributes (u32 BE)
7287        let mut attrs = BigEndian::read_u32(&img[attr_off..attr_off + 4]);
7288        attrs |= HFSPLUS_VOLUME_JOURNALED_BIT;
7289        BigEndian::write_u32(&mut img[attr_off..attr_off + 4], attrs);
7290
7291        let cursor = std::io::Cursor::new(img);
7292        let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("read-only open should succeed");
7293        let err = fs
7294            .prepare_for_edit()
7295            .expect_err("journaled volume with no valid journal must refuse edit prep");
7296        match err {
7297            FilesystemError::Unsupported(msg) => assert!(msg.contains("journal")),
7298            other => panic!("expected Unsupported(journal...), got {other:?}"),
7299        }
7300    }
7301
7302    #[test]
7303    fn test_sync_metadata_stamps_clean_unmount_attributes() {
7304        // Regression: an HFS+ volume opened from a "mounted/dirty" on-disk
7305        // state (bit 8 clear, bit 14 set) must be persisted as cleanly
7306        // unmounted (bit 8 set, bits 11/14 clear) after sync_metadata.
7307        // Without this, Disk Utility / fsck_hfs flag the volume as
7308        // inconsistent on next mount. See ~/Downloads/HFSTests/ case.
7309        let mut img = make_editable_hfsplus_image();
7310        let attr_off = 1024 + 4;
7311        let dirty = 0x80004000u32 | HFSPLUS_BOOT_VOLUME_INCONSISTENT_BIT;
7312        BigEndian::write_u32(&mut img[attr_off..attr_off + 4], dirty);
7313
7314        let cursor = std::io::Cursor::new(img);
7315        let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("open");
7316        fs.prepare_for_edit().expect("prep");
7317        fs.sync_metadata().expect("sync");
7318
7319        let img = fs.reader.into_inner();
7320        let after = BigEndian::read_u32(&img[attr_off..attr_off + 4]);
7321        assert!(
7322            after & HFSPLUS_VOLUME_UNMOUNTED_BIT != 0,
7323            "unmounted bit must be set after sync, got 0x{after:08x}"
7324        );
7325        assert!(
7326            after & HFSPLUS_VOLUME_INCONSISTENT_BIT == 0,
7327            "inconsistent bit must be cleared, got 0x{after:08x}"
7328        );
7329        assert!(
7330            after & HFSPLUS_BOOT_VOLUME_INCONSISTENT_BIT == 0,
7331            "boot-inconsistent bit must be cleared, got 0x{after:08x}"
7332        );
7333    }
7334
7335    #[test]
7336    fn test_prepare_for_edit_clean_volume_succeeds() {
7337        let img = make_editable_hfsplus_image();
7338        let cursor = std::io::Cursor::new(img);
7339        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
7340        fs.prepare_for_edit()
7341            .expect("clean volume should accept edit prep");
7342        // No xattrs on the synthetic image — the cached set is empty.
7343        assert!(fs.xattr_cnids.as_ref().unwrap().is_empty());
7344    }
7345
7346    /// Fill many leaves, delete everything, fill again: no index record may
7347    /// point at a freed leaf, or the next insert lands in a free node.
7348    #[test]
7349    fn deleting_a_leafs_first_record_refreshes_the_separators() {
7350        // fsck_hfs E_IKey: after the audit's fill-and-delete run the parent
7351        // still carried the first key the leaf had before the delete.
7352        use crate::hfs_common::{
7353            btree_record_range, btree_stale_index_keys, BTreeHeader, BTreeKeyFormat,
7354        };
7355        type Fs = HfsPlusFilesystem<std::io::Cursor<Vec<u8>>>;
7356        let img = create_blank_hfsplus_sized(64 * 1024 * 1024, 4096, "IKey", false, 16 << 20, 0);
7357        let mut fs = Fs::open(std::io::Cursor::new(img), 0).unwrap();
7358        fs.prepare_for_edit().unwrap();
7359        let root = fs.root().unwrap();
7360        let options = CreateFileOptions::default();
7361        for i in 0..1200 {
7362            let mut empty = std::io::Cursor::new(Vec::new());
7363            fs.create_file(&root, &format!("f{i:04}.txt"), &mut empty, 0, &options)
7364                .unwrap();
7365        }
7366        let node_size = fs.catalog_node_size();
7367        let header = BTreeHeader::read(fs.catalog_data());
7368        assert!(
7369            header.depth >= 2,
7370            "need an index level, depth {}",
7371            header.depth
7372        );
7373
7374        // The first record of the second leaf is a file record: deleting that
7375        // file changes the key its parent's separator must carry.
7376        let first = header.first_leaf_node as usize;
7377        let second =
7378            BigEndian::read_u32(&fs.catalog_data()[first * node_size..first * node_size + 4])
7379                as usize;
7380        let name = {
7381            let node = &fs.catalog_data()[second * node_size..(second + 1) * node_size];
7382            let (s, e) = btree_record_range(node, node_size, 0);
7383            let rec = &node[s..e];
7384            let name_len = BigEndian::read_u16(&rec[6..8]) as usize;
7385            (0..name_len)
7386                .map(|i| BigEndian::read_u16(&rec[8 + 2 * i..10 + 2 * i]) as u8 as char)
7387                .collect::<String>()
7388        };
7389        assert!(
7390            name.starts_with('f'),
7391            "expected a file record first, got {name:?}"
7392        );
7393        let victim = fs
7394            .list_directory(&root)
7395            .unwrap()
7396            .into_iter()
7397            .find(|e| e.name == name)
7398            .expect("victim listed");
7399        fs.delete_entry(&root, &victim).unwrap();
7400
7401        let cmp = |a: &[u8], b: &[u8]| Fs::catalog_compare(a, b, false);
7402        let stale = btree_stale_index_keys(
7403            fs.catalog_data(),
7404            node_size,
7405            &BTreeKeyFormat::HFSPLUS_CATALOG,
7406            &cmp,
7407        );
7408        assert!(stale.is_empty(), "separators still stale: {stale:?}");
7409        let report = fs.fsck().unwrap().unwrap();
7410        assert!(
7411            report.errors.is_empty(),
7412            "{:?}",
7413            report
7414                .errors
7415                .iter()
7416                .map(|e| &e.message)
7417                .take(4)
7418                .collect::<Vec<_>>()
7419        );
7420    }
7421
7422    #[test]
7423    fn emptied_leaves_leave_the_index_too() {
7424        use crate::hfs_common::{btree_bitmap_test, btree_record_range, BTREE_INDEX_NODE};
7425        let img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "Leaves", false);
7426        let mut fs = HfsPlusFilesystem::open(std::io::Cursor::new(img), 0).unwrap();
7427        fs.prepare_for_edit().unwrap();
7428        let root = fs.root().unwrap();
7429        let names: Vec<String> = (0..220).map(|i| format!("leaf-file-{i:04}.txt")).collect();
7430        let fill = |fs: &mut HfsPlusFilesystem<std::io::Cursor<Vec<u8>>>| {
7431            for n in &names {
7432                let mut src = std::io::Cursor::new(n.as_bytes().to_vec());
7433                fs.create_file(
7434                    &root,
7435                    n,
7436                    &mut src,
7437                    n.len() as u64,
7438                    &CreateFileOptions::default(),
7439                )
7440                .unwrap_or_else(|e| panic!("create {n}: {e:?}"));
7441            }
7442        };
7443        fill(&mut fs);
7444        assert!(
7445            fs.catalog_header.depth > 1,
7446            "the test needs a split catalog"
7447        );
7448
7449        for e in fs.list_directory(&root).unwrap() {
7450            fs.delete_entry(&root, &e).unwrap();
7451        }
7452        // No index record may point at a free node.
7453        let node_size = fs.catalog_header.node_size as usize;
7454        let data = &fs.catalog_data;
7455        for idx in 1..(data.len() / node_size) as u32 {
7456            if !btree_bitmap_test(data, node_size, idx) {
7457                continue;
7458            }
7459            let off = idx as usize * node_size;
7460            let node = &data[off..off + node_size];
7461            if node[8] as i8 != BTREE_INDEX_NODE {
7462                continue;
7463            }
7464            let n = BigEndian::read_u16(&node[10..12]) as usize;
7465            for r in 0..n {
7466                let (_, end) = btree_record_range(node, node_size, r);
7467                let child = BigEndian::read_u32(&node[end - 4..end]);
7468                assert!(
7469                    btree_bitmap_test(data, node_size, child),
7470                    "index node {idx} points at free node {child}"
7471                );
7472            }
7473        }
7474
7475        fill(&mut fs);
7476        let listed = fs.list_directory(&root).unwrap();
7477        assert_eq!(listed.len(), names.len());
7478        for e in &listed {
7479            assert_eq!(fs.read_file(e, usize::MAX).unwrap(), e.name.as_bytes());
7480        }
7481        fs.sync_metadata().unwrap();
7482        let report = fs.fsck().unwrap().unwrap();
7483        assert!(report.errors.is_empty(), "{:?}", report.errors);
7484    }
7485
7486    #[test]
7487    fn test_create_blank_hfsplus_32mib() {
7488        let img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "MyVol", false);
7489        assert_eq!(img.len(), 32 * 1024 * 1024);
7490
7491        let cursor = std::io::Cursor::new(img);
7492        let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("blank should open");
7493        assert_eq!(fs.fs_type(), "HFS+");
7494        assert_eq!(fs.label, "MyVol");
7495
7496        let root = fs.root().unwrap();
7497        let entries = fs.list_directory(&root).unwrap();
7498        assert!(entries.is_empty(), "freshly built blank must list empty");
7499
7500        // 32 MiB / 4096 = 8192 blocks. The catalog is now volume-scaled
7501        // (~0.5%, docs/hfsplus_btree_growth_plan.md §4c): 32 MiB / 200 =
7502        // 167,772 bytes -> 41 nodes @ 4096. Reserved = 1 (boot/VH) + 1 (bitmap)
7503        // + 4 (extents, default) + 41 (catalog) = 47 at the front, plus the
7504        // trailing allocation block holding the alternate volume header (Apple
7505        // reserves it; required for an fsck_hfs-clean free-block count).
7506        assert_eq!(fs.vh.total_blocks, 8192);
7507        assert_eq!(fs.vh.free_blocks, 8192 - 47 - 1);
7508        // Edit-mode prep should succeed (unmounted bit set, no journal).
7509        fs.prepare_for_edit().expect("blank must accept edit prep");
7510    }
7511
7512    #[test]
7513    fn test_create_blank_hfsx_32mib_signature() {
7514        let img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "CSVol", true);
7515        let cursor = std::io::Cursor::new(img);
7516        let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("HFSX blank should open");
7517        assert_eq!(fs.fs_type(), "HFSX");
7518        assert!(fs.vh.is_hfsx());
7519        assert_eq!(fs.label, "CSVol");
7520        let root = fs.root().unwrap();
7521        assert!(fs.list_directory(&root).unwrap().is_empty());
7522    }
7523
7524    #[test]
7525    fn test_create_blank_hfsplus_supports_create_file() {
7526        // Round-trip: build a blank, create a file inside it, sync, reopen.
7527        let img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "Work", false);
7528        let cursor = std::io::Cursor::new(img);
7529        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
7530        fs.prepare_for_edit().unwrap();
7531        let root = fs.root().unwrap();
7532        let payload = b"hello world".to_vec();
7533        let mut data = std::io::Cursor::new(payload.clone());
7534        fs.create_file(
7535            &root,
7536            "hello.txt",
7537            &mut data,
7538            payload.len() as u64,
7539            &CreateFileOptions::default(),
7540        )
7541        .expect("create_file on blank");
7542        fs.sync_metadata().unwrap();
7543
7544        let img2 = fs.reader.into_inner();
7545        let mut cursor2 = std::io::Cursor::new(img2);
7546        let mut fs2 = HfsPlusFilesystem::open(&mut cursor2, 0).unwrap();
7547        let root2 = fs2.root().unwrap();
7548        let entries = fs2.list_directory(&root2).unwrap();
7549        assert_eq!(entries.len(), 1);
7550        assert_eq!(entries[0].name, "hello.txt");
7551        let got = fs2.read_file(&entries[0], 1024).unwrap();
7552        assert_eq!(got, payload);
7553    }
7554
7555    #[test]
7556    fn test_hfsplus_rename_round_trips() {
7557        let img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "Work", false);
7558        let mut fs = HfsPlusFilesystem::open(std::io::Cursor::new(img), 0).unwrap();
7559        fs.prepare_for_edit().unwrap();
7560        let root = fs.root().unwrap();
7561
7562        let payload = b"keep my bytes across a rename".to_vec();
7563        let mut data = std::io::Cursor::new(payload.clone());
7564        let fe = fs
7565            .create_file(
7566                &root,
7567                "before.txt",
7568                &mut data,
7569                payload.len() as u64,
7570                &CreateFileOptions::default(),
7571            )
7572            .unwrap();
7573        let cnid = fe.location;
7574        fs.create_directory(&root, "olddir", &CreateDirectoryOptions::default())
7575            .unwrap();
7576
7577        // File rename to a longer name — exercises the variable-length thread
7578        // record rebuild (not an in-place patch).
7579        fs.rename(&root, &fe, "a much longer renamed file.txt")
7580            .unwrap();
7581        // Directory rename — its thread record is rebuilt too.
7582        let old_dir = fs
7583            .list_directory(&root)
7584            .unwrap()
7585            .into_iter()
7586            .find(|e| e.name == "olddir")
7587            .unwrap();
7588        fs.rename(&root, &old_dir, "newdir").unwrap();
7589
7590        // A collision with a different entry is rejected.
7591        let mut t = std::io::Cursor::new(b"x".as_slice());
7592        fs.create_file(&root, "taken.txt", &mut t, 1, &CreateFileOptions::default())
7593            .unwrap();
7594        let renamed_now = fs
7595            .list_directory(&root)
7596            .unwrap()
7597            .into_iter()
7598            .find(|e| e.name == "a much longer renamed file.txt")
7599            .unwrap();
7600        assert!(matches!(
7601            fs.rename(&root, &renamed_now, "taken.txt"),
7602            Err(FilesystemError::AlreadyExists(_))
7603        ));
7604
7605        // Persist and reopen: the renames survive a round-trip to disk.
7606        fs.sync_metadata().unwrap();
7607        let img2 = fs.reader.into_inner();
7608        let mut c2 = std::io::Cursor::new(img2);
7609        let mut fs2 = HfsPlusFilesystem::open(&mut c2, 0).unwrap();
7610        let root2 = fs2.root().unwrap();
7611        let entries = fs2.list_directory(&root2).unwrap();
7612        assert!(
7613            entries
7614                .iter()
7615                .any(|e| e.name == "a much longer renamed file.txt"),
7616            "{entries:?}"
7617        );
7618        assert!(
7619            !entries.iter().any(|e| e.name == "before.txt"),
7620            "{entries:?}"
7621        );
7622        let renamed = entries
7623            .iter()
7624            .find(|e| e.name == "a much longer renamed file.txt")
7625            .unwrap();
7626        // CNID and content preserved through the catalog re-key.
7627        assert_eq!(renamed.location, cnid);
7628        assert_eq!(fs2.read_file(renamed, 4096).unwrap(), payload);
7629        // The renamed directory is still browsable.
7630        let newdir = entries.iter().find(|e| e.name == "newdir").unwrap();
7631        assert!(fs2.list_directory(newdir).is_ok());
7632    }
7633
7634    #[test]
7635    fn test_create_file_stamps_dates() {
7636        // Newly-created files must have createDate / contentModDate /
7637        // attributeModDate / accessDate equal to sync-time hfs_now() to
7638        // within a couple seconds (test wall-clock jitter).
7639        let img = make_editable_hfsplus_image();
7640        let cursor = std::io::Cursor::new(img);
7641        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
7642        fs.prepare_for_edit().unwrap();
7643
7644        let now_before = hfs_common::hfs_now();
7645        let root = fs.root().unwrap();
7646        let mut data = std::io::Cursor::new(b"hi".as_slice());
7647        let entry = fs
7648            .create_file(
7649                &root,
7650                "dated.txt",
7651                &mut data,
7652                2,
7653                &CreateFileOptions::default(),
7654            )
7655            .unwrap();
7656        let now_after = hfs_common::hfs_now();
7657
7658        // Locate the new file's catalog record and read all four dates.
7659        let cnid = entry.location as u32;
7660        let (_n, _r, f_off) = fs.find_catalog_record(2, "dated.txt").unwrap();
7661        let fkey_len = BigEndian::read_u16(&fs.catalog_data[f_off..f_off + 2]) as usize;
7662        let mut frec = f_off + 2 + fkey_len;
7663        if !frec.is_multiple_of(2) {
7664            frec += 1;
7665        }
7666        let create_date = BigEndian::read_u32(&fs.catalog_data[frec + 12..frec + 16]);
7667        let content_mod = BigEndian::read_u32(&fs.catalog_data[frec + 16..frec + 20]);
7668        let attr_mod = BigEndian::read_u32(&fs.catalog_data[frec + 20..frec + 24]);
7669        let access = BigEndian::read_u32(&fs.catalog_data[frec + 24..frec + 28]);
7670
7671        for (label, value) in [
7672            ("createDate", create_date),
7673            ("contentModDate", content_mod),
7674            ("attributeModDate", attr_mod),
7675            ("accessDate", access),
7676        ] {
7677            assert!(
7678                value >= now_before && value <= now_after + 1,
7679                "{label} {value} not in [{now_before}, {now_after}+1]"
7680            );
7681        }
7682
7683        // Counter parity: VH file_count must have incremented by one.
7684        assert_eq!(fs.vh.file_count, 1);
7685        let _ = cnid;
7686    }
7687
7688    #[test]
7689    fn test_set_type_creator_bumps_attribute_mod_date() {
7690        // set_type_creator is an attribute-only change: attributeModDate
7691        // moves forward, but contentModDate (set at create-time) stays
7692        // pinned to the original value.
7693        let img = make_editable_hfsplus_image();
7694        let cursor = std::io::Cursor::new(img);
7695        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
7696        fs.prepare_for_edit().unwrap();
7697
7698        let root = fs.root().unwrap();
7699        let mut data = std::io::Cursor::new(b"x".as_slice());
7700        let entry = fs
7701            .create_file(&root, "a.txt", &mut data, 1, &CreateFileOptions::default())
7702            .unwrap();
7703
7704        let (_n, _r, f_off) = fs.find_catalog_record(2, "a.txt").unwrap();
7705        let fkey_len = BigEndian::read_u16(&fs.catalog_data[f_off..f_off + 2]) as usize;
7706        let mut frec = f_off + 2 + fkey_len;
7707        if !frec.is_multiple_of(2) {
7708            frec += 1;
7709        }
7710        let content_before = BigEndian::read_u32(&fs.catalog_data[frec + 16..frec + 20]);
7711
7712        // Force the timestamp to advance — hfs_now() has 1-second resolution.
7713        // Stuff the create-time content date into the past so we can assert
7714        // it stays put while attributeModDate moves to "now".
7715        BigEndian::write_u32(
7716            &mut fs.catalog_data[frec + 16..frec + 20],
7717            content_before - 60,
7718        );
7719        BigEndian::write_u32(
7720            &mut fs.catalog_data[frec + 20..frec + 24],
7721            content_before - 60,
7722        );
7723
7724        let now_before = hfs_common::hfs_now();
7725        fs.set_type_creator(&entry, "TEXT", "ttxt").unwrap();
7726        let now_after = hfs_common::hfs_now();
7727
7728        let content_after = BigEndian::read_u32(&fs.catalog_data[frec + 16..frec + 20]);
7729        let attr_after = BigEndian::read_u32(&fs.catalog_data[frec + 20..frec + 24]);
7730        assert_eq!(
7731            content_after,
7732            content_before - 60,
7733            "contentModDate must not move on attribute-only change"
7734        );
7735        assert!(
7736            attr_after >= now_before && attr_after <= now_after + 1,
7737            "attributeModDate {attr_after} not in [{now_before}, {now_after}+1]"
7738        );
7739    }
7740
7741    #[test]
7742    fn test_extents_overflow_round_trip_12_extents() {
7743        // Pre-fragment so 12 isolated 1-block runs are the only free space,
7744        // then create a 12-block file. The first 8 land in the inline
7745        // ForkData slots, the remaining 4 in one extents-overflow record.
7746        // Sync, reopen, verify byte-equal read via read_fork_with_overflow.
7747        let img = make_editable_hfsplus_image_with_extents_btree();
7748        let cursor = std::io::Cursor::new(img);
7749        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
7750        fs.prepare_for_edit().unwrap();
7751
7752        // Mark every block 0..=255 allocated, then clear 12 isolated single
7753        // blocks at evenly-spaced odd indices in the user-data region. The
7754        // 12 free blocks are at positions 11, 13, 15, ..., 33.
7755        fs.ensure_bitmap().unwrap();
7756        let bitmap = fs.bitmap.as_mut().unwrap();
7757        for byte in bitmap.iter_mut().take(32) {
7758            *byte = 0xFF;
7759        }
7760        let free_positions: Vec<u32> = (0..12).map(|i| 11 + 2 * i as u32).collect();
7761        for &blk in &free_positions {
7762            bitmap_clear_bit_be(bitmap, blk);
7763        }
7764        fs.vh.free_blocks = 12;
7765
7766        let block_size = fs.vh.block_size as usize;
7767        let payload: Vec<u8> = (0..(12 * block_size)).map(|i| (i % 251) as u8).collect();
7768        let mut reader = std::io::Cursor::new(payload.clone());
7769        let root = fs.root().unwrap();
7770        let entry = fs
7771            .create_file(
7772                &root,
7773                "frag12.bin",
7774                &mut reader,
7775                payload.len() as u64,
7776                &CreateFileOptions::default(),
7777            )
7778            .expect("12-extent allocation should succeed via overflow B-tree");
7779
7780        let cnid = entry.location as u32;
7781        let (data_fork, _rsrc) = fs.find_file_by_id(cnid).unwrap();
7782        // 8 inline extents, all 1-block.
7783        for slot in &data_fork.extents {
7784            assert_eq!(slot.block_count, 1);
7785        }
7786        assert_eq!(data_fork.total_blocks, 12);
7787
7788        // Sync to disk.
7789        fs.sync_metadata().unwrap();
7790
7791        // Reopen the underlying buffer and confirm read_fork_with_overflow
7792        // reproduces the data byte-for-byte.
7793        let img_after = fs.reader.into_inner();
7794        let mut cursor2 = std::io::Cursor::new(img_after);
7795        let mut fs2 = HfsPlusFilesystem::open(&mut cursor2, 0).unwrap();
7796        let root2 = fs2.root().unwrap();
7797        let entries = fs2.list_directory(&root2).unwrap();
7798        let reopened = entries.iter().find(|e| e.name == "frag12.bin").unwrap();
7799        let got = fs2.read_file(reopened, payload.len() + 1).unwrap();
7800        assert_eq!(got.len(), payload.len());
7801        assert_eq!(got, payload);
7802    }
7803
7804    #[test]
7805    fn test_allocate_extents_fragmented_bitmap() {
7806        // Pre-fragment the synthetic volume so 4 free blocks exist as four
7807        // 1-block runs (blocks 7, 9, 11, 13). Asking for 4 must return four
7808        // single-block extents and the data must round-trip byte-equal.
7809        let img = make_editable_hfsplus_image();
7810        let cursor = std::io::Cursor::new(img);
7811        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
7812        fs.prepare_for_edit().unwrap();
7813
7814        // Force the bitmap to load, then rewrite it. Block 0 = first byte
7815        // (bits 0..=7 = blocks 0..=7); we want blocks 0..=255 all allocated
7816        // EXCEPT 7, 9, 11, 13.
7817        fs.ensure_bitmap().unwrap();
7818        let bitmap = fs.bitmap.as_mut().unwrap();
7819        for byte in bitmap.iter_mut().take(32) {
7820            *byte = 0xFF;
7821        }
7822        // Clear blocks 7, 9, 11, 13.
7823        for blk in [7u32, 9, 11, 13] {
7824            bitmap_clear_bit_be(bitmap, blk);
7825        }
7826        fs.vh.free_blocks = 4;
7827
7828        let block_size = fs.vh.block_size as usize;
7829        let payload: Vec<u8> = (0..(4 * block_size)).map(|i| (i % 251) as u8).collect();
7830        let mut reader = std::io::Cursor::new(payload.clone());
7831        let root = fs.root().unwrap();
7832        let entry = fs
7833            .create_file(
7834                &root,
7835                "frag.bin",
7836                &mut reader,
7837                payload.len() as u64,
7838                &CreateFileOptions::default(),
7839            )
7840            .expect("fragmented allocation should succeed");
7841
7842        let cnid = entry.location as u32;
7843        let (data_fork, _rsrc) = fs.find_file_by_id(cnid).expect("file record present");
7844
7845        // 4 single-block extents in slots 0..3.
7846        for (i, expected_start) in [7u32, 9, 11, 13].iter().enumerate() {
7847            assert_eq!(data_fork.extents[i].block_count, 1, "slot {i}");
7848            assert_eq!(
7849                data_fork.extents[i].start_block, *expected_start,
7850                "slot {i}"
7851            );
7852        }
7853        for slot in &data_fork.extents[4..] {
7854            assert!(slot.is_empty(), "slot past 4 should be empty");
7855        }
7856        assert_eq!(data_fork.total_blocks, 4);
7857
7858        // Read it back and confirm byte-equal.
7859        let mut got = Vec::new();
7860        fs.write_file_to(&entry, &mut got).unwrap();
7861        assert_eq!(got, payload);
7862        assert_eq!(fs.vh.free_blocks, 0);
7863    }
7864
7865    #[test]
7866    fn test_snapshot_rollback_on_disk_full_create_file() {
7867        // The synthetic editable image has 250 free 4 KiB blocks. Asking
7868        // create_file for 251 blocks' worth of data should trip the
7869        // allocator's DiskFull and the snapshot guard must rewind every
7870        // mutation made before the failure (next_catalog_id bump, bitmap
7871        // dirty load, etc.).
7872        let img = make_editable_hfsplus_image();
7873        let cursor = std::io::Cursor::new(img);
7874        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
7875        fs.prepare_for_edit().unwrap();
7876
7877        let next_cnid_before = fs.vh.next_catalog_id;
7878        let free_blocks_before = fs.vh.free_blocks;
7879        let file_count_before = fs.vh.file_count;
7880        let catalog_before = fs.catalog_data.clone();
7881
7882        let root = fs.root().unwrap();
7883        let big_len: u64 = 251 * 4096 + 1; // exceeds the 250 free blocks
7884        let big_buf = vec![0u8; big_len as usize];
7885        let mut reader = std::io::Cursor::new(big_buf);
7886        let err = fs
7887            .create_file(
7888                &root,
7889                "too-big.bin",
7890                &mut reader,
7891                big_len,
7892                &CreateFileOptions::default(),
7893            )
7894            .expect_err("create_file should fail with DiskFull");
7895        assert!(matches!(err, FilesystemError::DiskFull(_)), "got {err:?}");
7896
7897        assert_eq!(fs.vh.next_catalog_id, next_cnid_before);
7898        assert_eq!(fs.vh.free_blocks, free_blocks_before);
7899        assert_eq!(fs.vh.file_count, file_count_before);
7900        assert_eq!(fs.catalog_data, catalog_before);
7901    }
7902
7903    #[test]
7904    fn test_set_xattr_round_trip_and_delete_cleanup() {
7905        // Step 13 round-trip: on a volume with an attributes B-tree, create a
7906        // file, attach 3 inline xattrs, sync, reopen, verify all 3 round-trip,
7907        // then delete the file and confirm every attribute record for its
7908        // CNID is gone.
7909        //
7910        // Seed image carries a sentinel record on cnid=99 so the attributes
7911        // file actually exists; the test never reads it back, just verifies
7912        // it survives unaffected when an unrelated CNID's xattrs are deleted.
7913        let sentinel_cnid = 99u32;
7914        let img = make_editable_hfsplus_image_with_inline_xattr(sentinel_cnid, &[0xAA; 8]);
7915        let cursor = std::io::Cursor::new(img);
7916        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
7917        fs.prepare_for_edit().unwrap();
7918
7919        let root = fs.root().unwrap();
7920        let mut data = std::io::Cursor::new(b"x".as_slice());
7921        let entry = fs
7922            .create_file(
7923                &root,
7924                "with-xattr.txt",
7925                &mut data,
7926                1,
7927                &CreateFileOptions::default(),
7928            )
7929            .expect("create_file");
7930        let cnid = entry.location as u32;
7931
7932        let pairs: [(&str, &[u8]); 3] = [
7933            ("com.apple.FinderInfo", &[1u8; 32]),
7934            ("com.apple.metadata:_kMDItemUserTags", b"\x00bplist00 tag"),
7935            ("user.short", b"hi"),
7936        ];
7937        for (name, value) in pairs.iter() {
7938            fs.set_xattr(cnid, name, value).expect("set_xattr");
7939        }
7940        fs.sync_metadata().unwrap();
7941
7942        // Reopen the freshly synced bytes and verify all three records survived.
7943        let img2 = fs.reader.into_inner();
7944        let mut cursor2 = std::io::Cursor::new(img2);
7945        let mut fs2 = HfsPlusFilesystem::open(&mut cursor2, 0).unwrap();
7946        let mut got = fs2.list_xattrs(cnid).expect("list_xattrs");
7947        got.sort_by(|a, b| a.name.cmp(&b.name));
7948        let mut want: Vec<(String, Vec<u8>)> = pairs
7949            .iter()
7950            .map(|(n, v)| (n.to_string(), v.to_vec()))
7951            .collect();
7952        want.sort_by(|a, b| a.0.cmp(&b.0));
7953        assert_eq!(got.len(), want.len());
7954        for (rec, (wname, wval)) in got.iter().zip(want.iter()) {
7955            assert_eq!(rec.name, *wname);
7956            match &rec.kind {
7957                XattrKind::Inline(bytes) => assert_eq!(bytes, wval),
7958                other => panic!("expected Inline xattr, got {other:?}"),
7959            }
7960        }
7961
7962        // Sentinel xattr on the unrelated CNID still present.
7963        let sentinel = fs2
7964            .list_xattrs(sentinel_cnid)
7965            .expect("sentinel list_xattrs");
7966        assert_eq!(sentinel.len(), 1, "sentinel record should survive");
7967
7968        // Delete the file: every attribute row keyed at our cnid must vanish.
7969        fs2.prepare_for_edit().unwrap();
7970        let root2 = fs2.root().unwrap();
7971        let listing = fs2.list_directory(&root2).unwrap();
7972        let target = listing
7973            .iter()
7974            .find(|e| e.name == "with-xattr.txt")
7975            .cloned()
7976            .expect("entry on reopened volume");
7977        fs2.delete_entry(&root2, &target).expect("delete");
7978        let after = fs2.list_xattrs(cnid).expect("post-delete list_xattrs");
7979        assert!(
7980            after.is_empty(),
7981            "expected 0 records after delete, got {}",
7982            after.len()
7983        );
7984        let sentinel_after = fs2
7985            .list_xattrs(sentinel_cnid)
7986            .expect("sentinel post-delete");
7987        assert_eq!(sentinel_after.len(), 1, "sentinel must survive delete");
7988    }
7989
7990    #[test]
7991    fn test_remove_xattr_not_found_and_cleanup() {
7992        // remove_xattr on a missing name returns NotFound, and removing the
7993        // last xattr drops the cnid from the cached set.
7994        let cnid = 17u32;
7995        let img = make_editable_hfsplus_image_with_inline_xattr(cnid, &[0u8; 16]);
7996        let cursor = std::io::Cursor::new(img);
7997        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
7998        fs.prepare_for_edit().unwrap();
7999        assert!(fs.xattr_cnids.as_ref().unwrap().contains(&cnid));
8000
8001        let err = fs
8002            .remove_xattr(cnid, "com.apple.does-not-exist")
8003            .expect_err("missing xattr must error");
8004        assert!(matches!(err, FilesystemError::NotFound(_)));
8005
8006        fs.remove_xattr(cnid, "com.apple.FinderInfo")
8007            .expect("remove existing xattr");
8008        assert!(
8009            !fs.xattr_cnids.as_ref().unwrap().contains(&cnid),
8010            "cnid should be dropped from cache once its last xattr is gone"
8011        );
8012    }
8013
8014    #[test]
8015    fn test_list_xattrs_round_trips_inline_finder_info() {
8016        // Synthesize a 32-byte com.apple.FinderInfo inline xattr on CNID 17,
8017        // assert list_xattrs returns it byte-for-byte and reports an empty
8018        // list for an unrelated CNID.
8019        let cnid = 17u32;
8020        let finder_info: Vec<u8> = (0..32u8).collect();
8021        let img = make_editable_hfsplus_image_with_inline_xattr(cnid, &finder_info);
8022        let cursor = std::io::Cursor::new(img);
8023        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
8024
8025        let recs = fs.list_xattrs(cnid).expect("list_xattrs cnid=17");
8026        assert_eq!(recs.len(), 1, "expected exactly one xattr record");
8027        let rec = &recs[0];
8028        assert_eq!(rec.name, "com.apple.FinderInfo");
8029        assert_eq!(rec.start_block, 0);
8030        match &rec.kind {
8031            XattrKind::Inline(bytes) => assert_eq!(bytes, &finder_info),
8032            other => panic!("expected Inline xattr, got {other:?}"),
8033        }
8034
8035        // Unrelated CNID returns empty.
8036        let none = fs.list_xattrs(99).expect("list_xattrs cnid=99");
8037        assert!(none.is_empty(), "expected no xattrs for cnid=99");
8038
8039        // Volume with no attributes file (the seed image) returns empty too.
8040        let bare = make_editable_hfsplus_image();
8041        let mut bare_fs = HfsPlusFilesystem::open(std::io::Cursor::new(bare), 0).unwrap();
8042        let empty = bare_fs.list_xattrs(cnid).expect("list_xattrs no attr file");
8043        assert!(empty.is_empty(), "no attributes file → no records");
8044    }
8045
8046    #[test]
8047    fn test_hardlink_resolution_two_links_one_inode() {
8048        // Step 15: synthesize a volume with two hardlinks pointing at one
8049        // inode under `HFS+ Private Data`. Both link rows must surface as
8050        // regular files in `list_directory`, carry `link_target_cnid` set
8051        // to the inode CNID, and read back the inode's data fork bytes.
8052        const PAYLOAD: &[u8] = b"INODE_PAYLOAD_42\n";
8053        const I_NODE_NUM: u32 = 99;
8054
8055        let mut img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "Links", false);
8056
8057        // Build the volume contents: private dir, inode, two link stubs.
8058        // Patch the link stubs' catalog records to set FInfo type='hlnk'
8059        // creator='hfs+' and bsdInfo.special = I_NODE_NUM. We do this via
8060        // direct catalog-byte edits while holding the editable handle, then
8061        // sync once at the end.
8062        let inode_cnid: u32;
8063        {
8064            let cursor = std::io::Cursor::new(&mut img);
8065            let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("open blank");
8066            fs.prepare_for_edit().expect("prepare_for_edit");
8067            let root = fs.root().expect("root");
8068
8069            let private_dir = fs
8070                .create_directory(
8071                    &root,
8072                    &hfsplus_private_dir_name(),
8073                    &CreateDirectoryOptions::default(),
8074                )
8075                .expect("create private dir");
8076
8077            let mut inode_data = std::io::Cursor::new(PAYLOAD);
8078            let inode_entry = fs
8079                .create_file(
8080                    &private_dir,
8081                    &format!("iNode{I_NODE_NUM}"),
8082                    &mut inode_data,
8083                    PAYLOAD.len() as u64,
8084                    &CreateFileOptions::default(),
8085                )
8086                .expect("create inode file");
8087            inode_cnid = inode_entry.location as u32;
8088
8089            for link_name in ["linkA", "linkB"] {
8090                let mut empty = std::io::Cursor::new(&[][..]);
8091                fs.create_file(
8092                    &root,
8093                    link_name,
8094                    &mut empty,
8095                    0,
8096                    &CreateFileOptions {
8097                        os_type: Some(*b"hlnk"),
8098                        os_creator: Some(*b"hfs+"),
8099                        ..Default::default()
8100                    },
8101                )
8102                .expect("create link stub");
8103
8104                // Patch bsdInfo.special on the freshly-created stub to point
8105                // at the inode number. body offset 32+12 = 44.
8106                let (_node, _rec, abs) = fs
8107                    .find_catalog_record(2, link_name)
8108                    .expect("link stub in catalog");
8109                let key_len = BigEndian::read_u16(&fs.catalog_data[abs..abs + 2]) as usize;
8110                let mut body_start = abs + 2 + key_len;
8111                if !body_start.is_multiple_of(2) {
8112                    body_start += 1;
8113                }
8114                BigEndian::write_u32(
8115                    &mut fs.catalog_data[body_start + 44..body_start + 48],
8116                    I_NODE_NUM,
8117                );
8118            }
8119
8120            fs.sync_metadata().expect("sync");
8121        }
8122
8123        // Re-open, walk root, verify both link stubs resolve to the inode
8124        // and read back the inode's bytes.
8125        let cursor = std::io::Cursor::new(&mut img);
8126        let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("re-open");
8127        let root = fs.root().expect("root");
8128        let entries = fs.list_directory(&root).expect("list root");
8129        let link_a = entries
8130            .iter()
8131            .find(|e| e.name == "linkA")
8132            .cloned()
8133            .expect("linkA");
8134        let link_b = entries
8135            .iter()
8136            .find(|e| e.name == "linkB")
8137            .cloned()
8138            .expect("linkB");
8139
8140        assert_eq!(
8141            link_a.link_target_cnid,
8142            Some(inode_cnid as u64),
8143            "linkA must resolve to inode CNID"
8144        );
8145        assert_eq!(
8146            link_b.link_target_cnid,
8147            Some(inode_cnid as u64),
8148            "linkB must resolve to inode CNID"
8149        );
8150        // Display size should reflect the inode's data fork, not the empty
8151        // stub fork.
8152        assert_eq!(link_a.size, PAYLOAD.len() as u64);
8153        assert_eq!(link_b.size, PAYLOAD.len() as u64);
8154
8155        let bytes_a = fs.read_file(&link_a, usize::MAX).expect("read linkA");
8156        let bytes_b = fs.read_file(&link_b, usize::MAX).expect("read linkB");
8157        assert_eq!(bytes_a, PAYLOAD);
8158        assert_eq!(bytes_b, PAYLOAD);
8159
8160        // Sanity: a file with no hardlink magic stays plain.
8161        // The private dir itself shows up at root with `link_target_cnid`
8162        // unset (it's a folder, not a file hardlink).
8163        let private = entries
8164            .iter()
8165            .find(|e| e.name == hfsplus_private_dir_name())
8166            .expect("private dir at root");
8167        assert!(private.link_target_cnid.is_none());
8168    }
8169
8170    #[test]
8171    fn test_hardlink_delete_decrements_then_frees_inode() {
8172        // Step 16: deleting a hardlink decrements `linkCount` on the inode;
8173        // deleting the last reference frees the inode, drops it from the
8174        // private directory, and reclaims its data fork blocks.
8175        const PAYLOAD: &[u8] = b"INODE_DATA_FOR_DELETE\n";
8176        const I_NODE_NUM: u32 = 7;
8177
8178        let mut img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "DelLinks", false);
8179
8180        // Capture initial free-block count after the build, then synthesize
8181        // private dir + inode (linkCount=2) + two stubs.
8182        let inode_cnid: u32;
8183        let private_cnid: u32;
8184        let payload_blocks_used: u32;
8185        let free_after_build: u32;
8186        let free_with_inode: u32;
8187        {
8188            let cursor = std::io::Cursor::new(&mut img);
8189            let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("open blank");
8190            fs.prepare_for_edit().expect("prepare_for_edit");
8191            free_after_build = fs.vh.free_blocks;
8192            let root = fs.root().expect("root");
8193            let private_dir = fs
8194                .create_directory(
8195                    &root,
8196                    &hfsplus_private_dir_name(),
8197                    &CreateDirectoryOptions::default(),
8198                )
8199                .expect("create private dir");
8200            private_cnid = private_dir.location as u32;
8201
8202            let mut inode_data = std::io::Cursor::new(PAYLOAD);
8203            let inode_entry = fs
8204                .create_file(
8205                    &private_dir,
8206                    &format!("iNode{I_NODE_NUM}"),
8207                    &mut inode_data,
8208                    PAYLOAD.len() as u64,
8209                    &CreateFileOptions::default(),
8210                )
8211                .expect("create inode file");
8212            inode_cnid = inode_entry.location as u32;
8213            free_with_inode = fs.vh.free_blocks;
8214            payload_blocks_used = free_after_build - free_with_inode;
8215            assert!(
8216                payload_blocks_used >= 1,
8217                "inode payload must consume at least one block"
8218            );
8219
8220            // Initialize bsdInfo.special on the inode record to linkCount=2.
8221            {
8222                let (_, _, abs) = fs
8223                    .find_catalog_record(private_cnid, &format!("iNode{I_NODE_NUM}"))
8224                    .expect("inode catalog record");
8225                let key_len = BigEndian::read_u16(&fs.catalog_data[abs..abs + 2]) as usize;
8226                let mut body = abs + 2 + key_len;
8227                if !body.is_multiple_of(2) {
8228                    body += 1;
8229                }
8230                BigEndian::write_u32(&mut fs.catalog_data[body + 44..body + 48], 2);
8231            }
8232
8233            for link_name in ["linkA", "linkB"] {
8234                let mut empty = std::io::Cursor::new(&[][..]);
8235                fs.create_file(
8236                    &root,
8237                    link_name,
8238                    &mut empty,
8239                    0,
8240                    &CreateFileOptions {
8241                        os_type: Some(*b"hlnk"),
8242                        os_creator: Some(*b"hfs+"),
8243                        ..Default::default()
8244                    },
8245                )
8246                .expect("create stub");
8247                let (_, _, abs) = fs.find_catalog_record(2, link_name).expect("stub");
8248                let key_len = BigEndian::read_u16(&fs.catalog_data[abs..abs + 2]) as usize;
8249                let mut body = abs + 2 + key_len;
8250                if !body.is_multiple_of(2) {
8251                    body += 1;
8252                }
8253                BigEndian::write_u32(&mut fs.catalog_data[body + 44..body + 48], I_NODE_NUM);
8254            }
8255            fs.sync_metadata().expect("sync");
8256        }
8257
8258        // Re-open editable, delete linkA. linkB and the inode must remain
8259        // readable; linkCount must drop to 1.
8260        let cursor = std::io::Cursor::new(&mut img);
8261        let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("re-open");
8262        fs.prepare_for_edit().expect("prepare_for_edit");
8263        let root = fs.root().expect("root");
8264        let entries = fs.list_directory(&root).expect("list");
8265        let link_a = entries
8266            .iter()
8267            .find(|e| e.name == "linkA")
8268            .cloned()
8269            .expect("linkA");
8270        let link_b = entries
8271            .iter()
8272            .find(|e| e.name == "linkB")
8273            .cloned()
8274            .expect("linkB");
8275        let free_before_delete = fs.vh.free_blocks;
8276
8277        fs.delete_entry(&root, &link_a).expect("delete linkA");
8278        // linkCount should now be 1; inode + linkB still alive.
8279        let cnt_after_first = {
8280            let (_, _, abs) = fs
8281                .find_catalog_record(private_cnid, &format!("iNode{I_NODE_NUM}"))
8282                .expect("inode still present");
8283            let key_len = BigEndian::read_u16(&fs.catalog_data[abs..abs + 2]) as usize;
8284            let mut body = abs + 2 + key_len;
8285            if !body.is_multiple_of(2) {
8286                body += 1;
8287            }
8288            BigEndian::read_u32(&fs.catalog_data[body + 44..body + 48])
8289        };
8290        assert_eq!(cnt_after_first, 1, "linkCount should decrement to 1");
8291        assert_eq!(
8292            fs.vh.free_blocks, free_before_delete,
8293            "no blocks freed while inode still has refs"
8294        );
8295        // linkB still resolves and reads the original payload.
8296        let bytes_b = fs.read_file(&link_b, usize::MAX).expect("read linkB");
8297        assert_eq!(bytes_b, PAYLOAD);
8298
8299        fs.delete_entry(&root, &link_b).expect("delete linkB");
8300        // Inode must be gone from the private dir, blocks reclaimed.
8301        let private_children = fs.list_children(private_cnid).expect("list private dir");
8302        assert!(
8303            private_children.is_empty(),
8304            "iNode entry should be removed once linkCount hits 0; got {} children",
8305            private_children.len()
8306        );
8307        assert!(
8308            fs.find_file_by_id(inode_cnid).is_none(),
8309            "inode catalog row should be gone"
8310        );
8311        assert_eq!(
8312            fs.vh.free_blocks,
8313            free_before_delete + payload_blocks_used,
8314            "inode payload blocks should be reclaimed"
8315        );
8316    }
8317
8318    #[test]
8319    fn test_hardlink_capture_and_replay_dedupes_inode() {
8320        // Step 17: source carries 1 inode + 3 file hardlinks. Capture
8321        // routes the inode into `files` with `is_inode = true` and the
8322        // three stubs into `hardlinks`. Replay onto a freshly built blank
8323        // via `create_hardlink_inode` + `create_hardlink` produces a
8324        // target with one inode + three stub rows (not four independent
8325        // files), all reading the same payload bytes.
8326        const PAYLOAD: &[u8] = b"shared inode contents (3 names)\n";
8327        const I_NODE_NUM: u32 = 17;
8328
8329        // Build the source.
8330        let mut source_img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "Src", false);
8331        let source_inode_cnid: u32;
8332        {
8333            let cursor = std::io::Cursor::new(&mut source_img);
8334            let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("open src blank");
8335            fs.prepare_for_edit().expect("prepare_for_edit");
8336            let mut data = std::io::Cursor::new(PAYLOAD);
8337            let inode = fs
8338                .create_hardlink_inode(
8339                    I_NODE_NUM,
8340                    &mut data,
8341                    PAYLOAD.len() as u64,
8342                    &CreateFileOptions::default(),
8343                )
8344                .expect("create source inode");
8345            source_inode_cnid = inode.location as u32;
8346            // Initialize linkCount=0 on the inode then let create_hardlink
8347            // bump it three times.
8348            let private_cnid = fs.find_private_dir_cnid().unwrap().expect("private dir");
8349            let _ = fs
8350                .adjust_inode_link_count(private_cnid, I_NODE_NUM, 0)
8351                .expect("init linkCount");
8352            let root = fs.root().expect("root");
8353            for name in ["a.txt", "b.txt", "c.txt"] {
8354                fs.create_hardlink(&root, name, I_NODE_NUM)
8355                    .expect("create stub");
8356            }
8357            fs.sync_metadata().expect("sync src");
8358        }
8359
8360        // Capture from the source.
8361        let cursor = std::io::Cursor::new(&mut source_img);
8362        let mut src_fs = HfsPlusFilesystem::open(cursor, 0).expect("re-open src");
8363        let snap =
8364            crate::hfsplus_clone::SourceCatalogSnapshot::capture(&mut src_fs).expect("capture");
8365
8366        // Snapshot expectations: exactly one inode, three hardlink stubs.
8367        let inodes: Vec<_> = snap.files.iter().filter(|f| f.is_inode).collect();
8368        assert_eq!(inodes.len(), 1, "expected exactly one inode in snapshot");
8369        let inode = inodes[0];
8370        assert_eq!(inode.cnid, source_inode_cnid);
8371        assert_eq!(inode.inode_num, Some(I_NODE_NUM));
8372        assert_eq!(inode.data_fork_size, PAYLOAD.len() as u64);
8373        // Stream the inode's data fork off the source and verify byte-equal.
8374        let inode_payload = {
8375            use std::io::Read;
8376            let mut buf = Vec::new();
8377            src_fs
8378                .fork_stream_reader(inode.cnid, 0x00, &inode.data_fork_raw)
8379                .expect("fork reader")
8380                .read_to_end(&mut buf)
8381                .expect("read fork");
8382            buf
8383        };
8384        assert_eq!(inode_payload, PAYLOAD);
8385
8386        let user_files: Vec<_> = snap.files.iter().filter(|f| !f.is_inode).collect();
8387        assert!(
8388            user_files.is_empty(),
8389            "user-tree files should be empty (the three stubs are in `hardlinks`); \
8390             got {:?}",
8391            user_files.iter().map(|f| &f.name).collect::<Vec<_>>()
8392        );
8393
8394        assert_eq!(snap.hardlinks.len(), 3, "expected 3 hardlink stubs");
8395        for h in &snap.hardlinks {
8396            assert_eq!(h.inode_num, I_NODE_NUM);
8397            assert_eq!(h.inode_source_cnid, source_inode_cnid);
8398            assert_eq!(h.parent_cnid, 2, "stubs live at root");
8399        }
8400
8401        // Replay onto a fresh blank target using the capture data.
8402        let mut target_img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "Tgt", false);
8403        let target_inode_cnid: u32;
8404        {
8405            let cursor = std::io::Cursor::new(&mut target_img);
8406            let mut tgt = HfsPlusFilesystem::open(cursor, 0).expect("open tgt blank");
8407            tgt.prepare_for_edit().expect("prepare_for_edit");
8408
8409            let mut data = std::io::Cursor::new(&inode_payload);
8410            let target_inode = tgt
8411                .create_hardlink_inode(
8412                    inode.inode_num.unwrap(),
8413                    &mut data,
8414                    inode.data_fork_size,
8415                    &CreateFileOptions::default(),
8416                )
8417                .expect("replay inode");
8418            target_inode_cnid = target_inode.location as u32;
8419
8420            let root = tgt.root().expect("tgt root");
8421            for h in &snap.hardlinks {
8422                tgt.create_hardlink(&root, &h.name, h.inode_num)
8423                    .expect("replay stub");
8424            }
8425            tgt.sync_metadata().expect("sync tgt");
8426        }
8427
8428        // Verify the target.
8429        let cursor = std::io::Cursor::new(&mut target_img);
8430        let mut tgt_fs = HfsPlusFilesystem::open(cursor, 0).expect("re-open tgt");
8431        let root = tgt_fs.root().expect("tgt root");
8432        let entries = tgt_fs.list_directory(&root).expect("list tgt root");
8433
8434        // Three stubs at root, each pointing at the same inode CNID, each
8435        // reading back the original payload.
8436        let stubs: Vec<&FileEntry> = entries
8437            .iter()
8438            .filter(|e| ["a.txt", "b.txt", "c.txt"].contains(&e.name.as_str()))
8439            .collect();
8440        assert_eq!(stubs.len(), 3);
8441        for stub in &stubs {
8442            assert_eq!(
8443                stub.link_target_cnid,
8444                Some(target_inode_cnid as u64),
8445                "stub {} must resolve to the single target inode",
8446                stub.name
8447            );
8448            assert_eq!(stub.size, PAYLOAD.len() as u64);
8449        }
8450        for stub in &stubs {
8451            let bytes = tgt_fs.read_file(stub, usize::MAX).expect("read tgt stub");
8452            assert_eq!(bytes, PAYLOAD);
8453        }
8454
8455        // Inode's linkCount on the target must be 3 (one per stub).
8456        let private_cnid = tgt_fs
8457            .find_private_dir_cnid()
8458            .unwrap()
8459            .expect("private dir");
8460        let inode_name = format!("iNode{I_NODE_NUM}");
8461        let (_, _, abs) = tgt_fs
8462            .find_catalog_record(private_cnid, &inode_name)
8463            .expect("inode in private dir");
8464        let key_len = BigEndian::read_u16(&tgt_fs.catalog_data[abs..abs + 2]) as usize;
8465        let mut body = abs + 2 + key_len;
8466        if !body.is_multiple_of(2) {
8467            body += 1;
8468        }
8469        let link_count = BigEndian::read_u32(&tgt_fs.catalog_data[body + 44..body + 48]);
8470        assert_eq!(link_count, 3, "inode linkCount must equal stub count");
8471    }
8472
8473    #[test]
8474    fn test_dir_hardlink_capture_and_replay_resolves_to_inode() {
8475        // Step 18: Time-Machine-shaped image — one directory inode under
8476        // `.HFS+ Private Directory Data\r` with two hardlink stubs at the
8477        // root. Capture must dedupe the inode (one entry in `dirs` with
8478        // `is_dir_inode = true`, two entries in `dir_hardlinks`); replay
8479        // must produce a target whose two link rows resolve to the same
8480        // directory contents byte-for-byte.
8481        const PAYLOAD: &[u8] = b"file inside dir-hardlinked folder\n";
8482        const D_INODE_NUM: u32 = 13;
8483
8484        // ---- Build the source. ----
8485        let mut source_img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "DhSrc", false);
8486        let source_inode_cnid: u32;
8487        {
8488            let cursor = std::io::Cursor::new(&mut source_img);
8489            let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("open src");
8490            fs.prepare_for_edit().expect("prepare_for_edit");
8491            let inode_dir = fs
8492                .create_dir_hardlink_inode(D_INODE_NUM, &CreateDirectoryOptions::default())
8493                .expect("create dir inode");
8494            source_inode_cnid = inode_dir.location as u32;
8495            // Put a file inside the inode dir so `list_directory` has
8496            // something to compare on the replayed side.
8497            let mut data = std::io::Cursor::new(PAYLOAD);
8498            fs.create_file(
8499                &inode_dir,
8500                "inside.txt",
8501                &mut data,
8502                PAYLOAD.len() as u64,
8503                &CreateFileOptions::default(),
8504            )
8505            .expect("create file in dir inode");
8506            // Initialize linkCount=0 then bump per stub.
8507            let dir_priv = fs
8508                .find_dir_private_dir_cnid()
8509                .unwrap()
8510                .expect("dir private");
8511            let _ = fs
8512                .adjust_dir_inode_link_count(dir_priv, D_INODE_NUM, 0)
8513                .expect("init dir linkCount");
8514            let root = fs.root().expect("root");
8515            for name in ["alpha", "beta"] {
8516                fs.create_dir_hardlink(&root, name, D_INODE_NUM)
8517                    .expect("create dir hardlink stub");
8518            }
8519            fs.sync_metadata().expect("sync src");
8520        }
8521
8522        // ---- Capture from the source. ----
8523        let cursor = std::io::Cursor::new(&mut source_img);
8524        let mut src_fs = HfsPlusFilesystem::open(cursor, 0).expect("re-open src");
8525        let snap =
8526            crate::hfsplus_clone::SourceCatalogSnapshot::capture(&mut src_fs).expect("capture");
8527
8528        // Snapshot expectations: exactly one directory inode, two stubs.
8529        let dir_inodes: Vec<_> = snap.dirs.iter().filter(|d| d.is_dir_inode).collect();
8530        assert_eq!(
8531            dir_inodes.len(),
8532            1,
8533            "expected exactly one dir inode in snapshot; got {}",
8534            dir_inodes.len()
8535        );
8536        let dir_inode = dir_inodes[0];
8537        assert_eq!(dir_inode.cnid, source_inode_cnid);
8538        assert_eq!(dir_inode.inode_num, Some(D_INODE_NUM));
8539
8540        assert_eq!(snap.dir_hardlinks.len(), 2, "expected 2 dir-hardlink stubs");
8541        for h in &snap.dir_hardlinks {
8542            assert_eq!(h.inode_num, D_INODE_NUM);
8543            assert_eq!(h.inode_dir_source_cnid, source_inode_cnid);
8544            assert_eq!(h.parent_cnid, 2, "stubs live at root");
8545        }
8546
8547        // ---- Replay onto a fresh blank target. ----
8548        let mut target_img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "DhTgt", false);
8549        let target_inode_cnid: u32;
8550        {
8551            let cursor = std::io::Cursor::new(&mut target_img);
8552            let mut tgt = HfsPlusFilesystem::open(cursor, 0).expect("open tgt");
8553            tgt.prepare_for_edit().expect("prepare_for_edit");
8554
8555            // Create the inode directory.
8556            let inode = tgt
8557                .create_dir_hardlink_inode(
8558                    dir_inode.inode_num.unwrap(),
8559                    &CreateDirectoryOptions::default(),
8560                )
8561                .expect("replay dir inode");
8562            target_inode_cnid = inode.location as u32;
8563
8564            // Replay the inode's children. (For a real clone Step 21 will
8565            // recursively walk; here it's just one file.)
8566            let mut data = std::io::Cursor::new(PAYLOAD);
8567            tgt.create_file(
8568                &inode,
8569                "inside.txt",
8570                &mut data,
8571                PAYLOAD.len() as u64,
8572                &CreateFileOptions::default(),
8573            )
8574            .expect("replay child file");
8575
8576            // Replay the stubs.
8577            let root = tgt.root().expect("tgt root");
8578            for h in &snap.dir_hardlinks {
8579                tgt.create_dir_hardlink(&root, &h.name, h.inode_num)
8580                    .expect("replay dir stub");
8581            }
8582            tgt.sync_metadata().expect("sync tgt");
8583        }
8584
8585        // ---- Verify the target. ----
8586        let cursor = std::io::Cursor::new(&mut target_img);
8587        let mut tgt_fs = HfsPlusFilesystem::open(cursor, 0).expect("re-open tgt");
8588        let root = tgt_fs.root().expect("tgt root");
8589        let entries = tgt_fs.list_directory(&root).expect("list tgt root");
8590
8591        // Both stubs surface as Directory entries pointing at the single
8592        // inode, and listing either of them returns the same children.
8593        for stub_name in ["alpha", "beta"] {
8594            let stub = entries
8595                .iter()
8596                .find(|e| e.name == stub_name)
8597                .cloned()
8598                .unwrap_or_else(|| panic!("stub {stub_name} missing from target root"));
8599            assert!(
8600                stub.is_directory(),
8601                "stub {stub_name} must surface as a directory"
8602            );
8603            assert_eq!(
8604                stub.link_target_cnid,
8605                Some(target_inode_cnid as u64),
8606                "stub {stub_name} must resolve to the single target inode"
8607            );
8608
8609            let children = tgt_fs.list_directory(&stub).expect("list stub");
8610            let inside = children
8611                .iter()
8612                .find(|c| c.name == "inside.txt")
8613                .unwrap_or_else(|| panic!("inside.txt missing under {stub_name}"));
8614            let bytes = tgt_fs
8615                .read_file(inside, usize::MAX)
8616                .expect("read inside.txt via stub");
8617            assert_eq!(bytes, PAYLOAD);
8618        }
8619
8620        // Inode's linkCount on the target must equal stub count (2).
8621        let dir_priv = tgt_fs
8622            .find_dir_private_dir_cnid()
8623            .unwrap()
8624            .expect("tgt dir private");
8625        let inode_name = format!("dir_{D_INODE_NUM}");
8626        let (_, _, abs) = tgt_fs
8627            .find_catalog_record(dir_priv, &inode_name)
8628            .expect("dir inode in private dir");
8629        let key_len = BigEndian::read_u16(&tgt_fs.catalog_data[abs..abs + 2]) as usize;
8630        let mut body = abs + 2 + key_len;
8631        if !body.is_multiple_of(2) {
8632            body += 1;
8633        }
8634        let link_count = BigEndian::read_u32(&tgt_fs.catalog_data[body + 44..body + 48]);
8635        assert_eq!(link_count, 2, "dir inode linkCount must equal stub count");
8636    }
8637
8638    #[test]
8639    fn test_hfsx_case_sensitive_allows_distinct_case_siblings() {
8640        // Step 19: HFSX volumes with `keyCompareType == 0xBC` (binary)
8641        // must treat `Foo` and `foo` as distinct names. Plain HFS+ —
8642        // including HFSX volumes built with `keyCompareType == 0xCF`
8643        // (case-folding NFD compare) — must reject the second create with
8644        // `AlreadyExists`.
8645        const PAYLOAD: &[u8] = b"hi\n";
8646
8647        // ---- HFSX, case-sensitive: both creates succeed. ----
8648        {
8649            let mut img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "HFSX", true);
8650            let cursor = std::io::Cursor::new(&mut img);
8651            let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("open hfsx");
8652            assert!(
8653                fs.case_sensitive(),
8654                "fresh HFSX blank must be case-sensitive"
8655            );
8656            fs.prepare_for_edit().expect("prepare");
8657            let root = fs.root().expect("root");
8658
8659            let mut data = std::io::Cursor::new(PAYLOAD);
8660            fs.create_file(
8661                &root,
8662                "Foo",
8663                &mut data,
8664                PAYLOAD.len() as u64,
8665                &CreateFileOptions::default(),
8666            )
8667            .expect("create Foo");
8668
8669            let mut data = std::io::Cursor::new(PAYLOAD);
8670            fs.create_file(
8671                &root,
8672                "foo",
8673                &mut data,
8674                PAYLOAD.len() as u64,
8675                &CreateFileOptions::default(),
8676            )
8677            .expect("create foo on HFSX must succeed");
8678
8679            fs.sync_metadata().expect("sync");
8680            // Both must list separately.
8681            let entries = fs.list_directory(&root).expect("list");
8682            let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
8683            assert!(names.contains(&"Foo"), "Foo missing: {names:?}");
8684            assert!(names.contains(&"foo"), "foo missing: {names:?}");
8685        }
8686
8687        // ---- HFS+, case-folding: second create rejects. ----
8688        {
8689            let mut img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "HFSPLUS", false);
8690            let cursor = std::io::Cursor::new(&mut img);
8691            let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("open hfs+");
8692            assert!(
8693                !fs.case_sensitive(),
8694                "plain HFS+ blank must be case-folding"
8695            );
8696            fs.prepare_for_edit().expect("prepare");
8697            let root = fs.root().expect("root");
8698
8699            let mut data = std::io::Cursor::new(PAYLOAD);
8700            fs.create_file(
8701                &root,
8702                "Foo",
8703                &mut data,
8704                PAYLOAD.len() as u64,
8705                &CreateFileOptions::default(),
8706            )
8707            .expect("create Foo");
8708
8709            let mut data = std::io::Cursor::new(PAYLOAD);
8710            let err = fs
8711                .create_file(
8712                    &root,
8713                    "foo",
8714                    &mut data,
8715                    PAYLOAD.len() as u64,
8716                    &CreateFileOptions::default(),
8717                )
8718                .expect_err("create foo must fail on case-folding HFS+");
8719            match err {
8720                FilesystemError::AlreadyExists(_) => {}
8721                other => panic!("expected AlreadyExists, got {other:?}"),
8722            }
8723        }
8724    }
8725
8726    #[test]
8727    fn test_clone_capture_carries_xattrs_byte_equal() {
8728        // Step 14: SourceCatalogSnapshot::capture must populate
8729        // SourceFileSnapshot::xattrs / SourceDirSnapshot::xattrs with the
8730        // inline records found in the attributes B-tree, byte-for-byte.
8731        //
8732        // Seed image already places an inline `com.apple.FinderInfo` xattr
8733        // on a sentinel CNID. We attach a second key on the same CNID via
8734        // `set_xattr`, sync, then graft a thread+file record into the
8735        // catalog so the walk associates that CNID with a real file —
8736        // capture should surface both inline values verbatim.
8737        let cnid = 99u32;
8738        let seed_value: Vec<u8> = (0..32u8).collect();
8739        let extra_value: Vec<u8> = b"\x00bplist00 demo".to_vec();
8740        let img = make_editable_hfsplus_image_with_inline_xattr(cnid, &seed_value);
8741
8742        // Open editable, attach a second xattr to the sentinel cnid, attach an
8743        // xattr to an existing directory (CNID 2 = root), and sync.
8744        let cursor = std::io::Cursor::new(img);
8745        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
8746        fs.prepare_for_edit().unwrap();
8747        fs.set_xattr(cnid, "com.apple.metadata:demo", &extra_value)
8748            .expect("set extra xattr on sentinel cnid");
8749        fs.set_xattr(2u32, "com.apple.FinderInfo", &seed_value)
8750            .expect("set xattr on root dir");
8751        // Create a real file so the snapshot walk has a file row whose CNID
8752        // equals our sentinel — we relocate the captured xattrs onto it
8753        // by overriding the new file's catalog cnid is non-trivial, so
8754        // instead we capture and just look up by cnid directly.
8755        let root = fs.root().expect("root");
8756        let mut data = std::io::Cursor::new(b"hi".as_slice());
8757        fs.create_file(
8758            &root,
8759            "carrier.txt",
8760            &mut data,
8761            2,
8762            &CreateFileOptions::default(),
8763        )
8764        .expect("create_file");
8765        fs.sync_metadata().unwrap();
8766        let img2 = fs.reader.into_inner();
8767
8768        let mut cursor2 = std::io::Cursor::new(img2);
8769        let mut fs2 = HfsPlusFilesystem::open(&mut cursor2, 0).unwrap();
8770        let snap = crate::hfsplus_clone::SourceCatalogSnapshot::capture(&mut fs2).expect("capture");
8771
8772        // The freshly-created file should snapshot with no xattrs (we never
8773        // attached any to its CNID).
8774        let carrier = snap
8775            .files
8776            .iter()
8777            .find(|f| f.name == "carrier.txt")
8778            .expect("carrier file in snapshot");
8779        assert!(
8780            carrier.xattrs.is_empty(),
8781            "fresh file should have no xattrs, got {}",
8782            carrier.xattrs.len()
8783        );
8784
8785        // The root dir snapshot must carry the xattr we attached.
8786        let root_dir = snap
8787            .dirs
8788            .iter()
8789            .find(|d| d.cnid == 2)
8790            .expect("root dir in snapshot");
8791        assert_eq!(root_dir.xattrs.len(), 1, "expected 1 xattr on root dir");
8792        assert_eq!(root_dir.xattrs[0].name, "com.apple.FinderInfo");
8793        match &root_dir.xattrs[0].kind {
8794            XattrKind::Inline(bytes) => assert_eq!(bytes, &seed_value),
8795            other => panic!("expected inline FinderInfo on root, got {other:?}"),
8796        }
8797
8798        // Attributes B-tree still carries the sentinel cnid's two records;
8799        // verify directly via list_xattrs (capture only attaches xattrs to
8800        // catalog rows it walks, and the sentinel cnid is not catalog-rooted).
8801        let mut sentinel = fs2.list_xattrs(cnid).expect("list_xattrs sentinel");
8802        sentinel.sort_by(|a, b| a.name.cmp(&b.name));
8803        assert_eq!(sentinel.len(), 2, "sentinel should carry 2 xattrs");
8804        let mut want: Vec<(String, Vec<u8>)> = vec![
8805            ("com.apple.FinderInfo".into(), seed_value.clone()),
8806            ("com.apple.metadata:demo".into(), extra_value.clone()),
8807        ];
8808        want.sort_by(|a, b| a.0.cmp(&b.0));
8809        for (rec, (wn, wv)) in sentinel.iter().zip(want.iter()) {
8810            assert_eq!(rec.name, *wn);
8811            match &rec.kind {
8812                XattrKind::Inline(bytes) => assert_eq!(bytes, wv),
8813                other => panic!("expected inline, got {other:?}"),
8814            }
8815        }
8816    }
8817
8818    #[test]
8819    fn test_hfsplus_editable_open_and_free_space() {
8820        let img = make_editable_hfsplus_image();
8821        let cursor = std::io::Cursor::new(img);
8822        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
8823        assert_eq!(fs.fs_type(), "HFS+");
8824        let free = fs.free_space().unwrap();
8825        assert!(free > 0);
8826        // 250 free blocks × 4096 = 1,024,000
8827        assert_eq!(free, 250 * 4096);
8828    }
8829
8830    #[test]
8831    fn test_hfsplus_defragmented_minimum_size() {
8832        // The synthetic image has 256 blocks of 4096 bytes total, with 6
8833        // blocks allocated near the front (VH/bitmap/catalog) and 250 free.
8834        let img = make_editable_hfsplus_image();
8835        let cursor = std::io::Cursor::new(img);
8836        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
8837
8838        let total = fs.total_size();
8839        let in_place = fs.last_data_byte().unwrap();
8840        let defrag = fs.defragmented_minimum_size().unwrap();
8841
8842        assert_eq!(total, 256 * 4096);
8843        // Last allocated block is 5 (bits 0..=5 set in bitmap byte 0), plus
8844        // one block for the alternate VH at the new end: (5+1+1) * 4096.
8845        assert_eq!(in_place, 7 * 4096);
8846        // Defrag = (used + catalog_total_blocks*3) blocks * block_size +
8847        // 1024 alt-VH, rounded up. Synthetic image's catalog_total_blocks
8848        // is 4 → delta is 12 → 6 + 12 = 18 user blocks + alt-VH = 19.
8849        assert_eq!(defrag, 19 * 4096);
8850        assert!(defrag <= total);
8851    }
8852
8853    /// R-078: with blocks of 1 KiB or more only the last block is the alternate header's.
8854    #[test]
8855    fn last_data_byte_counts_the_block_before_the_alternate_header() {
8856        let mut img = make_editable_hfsplus_image();
8857        let block_size = 4096usize;
8858        let total = 256u32;
8859        // Block 254 is the last one before the alternate header's block.
8860        let b = total - 2;
8861        img[block_size + (b / 8) as usize] |= 0x80 >> (b % 8);
8862        let mut fs = HfsPlusFilesystem::open(std::io::Cursor::new(img), 0).unwrap();
8863        assert_eq!(fs.last_data_byte().unwrap(), 256 * 4096);
8864    }
8865
8866    #[test]
8867    fn test_hfsplus_defragmented_min_smaller_on_fragmented() {
8868        // Take the synthetic image and forge an extra allocation near the END
8869        // of the bitmap. The in-place trim point must hug the tail; the
8870        // defragmented minimum must reflect *only* the allocated count, not
8871        // the position — that's the whole point of the metric.
8872        let mut img = make_editable_hfsplus_image();
8873        let block_size = 4096usize;
8874        // Mark block 200 allocated (MSB-first bitmap, byte index 25, bit 0
8875        // -> shift 7 to set the most-significant bit).
8876        let bitmap_byte = block_size + 25; // bitmap lives in block 1
8877        img[bitmap_byte] |= 0b1000_0000;
8878        // Reflect the change in the VH so the defragmented calc sees one
8879        // fewer free block.
8880        let free_off = 1024 + 48;
8881        let free = BigEndian::read_u32(&img[free_off..free_off + 4]);
8882        BigEndian::write_u32(&mut img[free_off..free_off + 4], free - 1);
8883        // Mirror the change to the alternate VH at the tail.
8884        let image_size = img.len();
8885        let alt_free_off = image_size - 1024 + 48;
8886        BigEndian::write_u32(&mut img[alt_free_off..alt_free_off + 4], free - 1);
8887
8888        let cursor = std::io::Cursor::new(img);
8889        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
8890
8891        let in_place = fs.last_data_byte().unwrap();
8892        let defrag = fs.defragmented_minimum_size().unwrap();
8893
8894        // In-place trim hugs the new tail allocation plus the alternate-VH
8895        // block: (200+1+1)*4096.
8896        assert_eq!(in_place, 202 * 4096);
8897        // Defrag: (7 used + 12 catalog-growth) blocks * 4096 + 1024 alt-VH,
8898        // rounded up = 20 blocks. Still well under in_place (201 blocks).
8899        assert_eq!(defrag, 20 * 4096);
8900        assert!(defrag < in_place);
8901    }
8902
8903    #[test]
8904    fn test_hfsplus_editable_create_file_and_read() {
8905        let img = make_editable_hfsplus_image();
8906        let cursor = std::io::Cursor::new(img);
8907        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
8908
8909        let root = fs.root().unwrap();
8910        let test_data = b"Hello, HFS+ World!";
8911        let mut data_reader = std::io::Cursor::new(test_data.as_slice());
8912
8913        let options = CreateFileOptions::default();
8914        let fe = fs
8915            .create_file(
8916                &root,
8917                "test.txt",
8918                &mut data_reader,
8919                test_data.len() as u64,
8920                &options,
8921            )
8922            .unwrap();
8923
8924        assert_eq!(fe.name, "test.txt");
8925        assert_eq!(fe.size, test_data.len() as u64);
8926
8927        // Verify we can list and find it
8928        let entries = fs.list_directory(&root).unwrap();
8929        assert!(entries.iter().any(|e| e.name == "test.txt"));
8930
8931        // Verify we can read it back
8932        let read_back = fs.read_file(&fe, 1024).unwrap();
8933        assert_eq!(&read_back, test_data);
8934    }
8935
8936    #[test]
8937    fn test_hfsplus_editable_create_directory() {
8938        let img = make_editable_hfsplus_image();
8939        let cursor = std::io::Cursor::new(img);
8940        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
8941
8942        let root = fs.root().unwrap();
8943        let options = CreateDirectoryOptions::default();
8944        let dir = fs.create_directory(&root, "TestDir", &options).unwrap();
8945
8946        assert_eq!(dir.name, "TestDir");
8947        assert!(dir.is_directory());
8948
8949        let entries = fs.list_directory(&root).unwrap();
8950        assert!(entries
8951            .iter()
8952            .any(|e| e.name == "TestDir" && e.is_directory()));
8953    }
8954
8955    #[test]
8956    fn test_hfsplus_editable_delete_file() {
8957        let img = make_editable_hfsplus_image();
8958        let cursor = std::io::Cursor::new(img);
8959        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
8960
8961        let root = fs.root().unwrap();
8962        let test_data = b"delete me";
8963        let mut data_reader = std::io::Cursor::new(test_data.as_slice());
8964        let options = CreateFileOptions::default();
8965        let fe = fs
8966            .create_file(
8967                &root,
8968                "gone.txt",
8969                &mut data_reader,
8970                test_data.len() as u64,
8971                &options,
8972            )
8973            .unwrap();
8974
8975        let free_before = fs.free_space().unwrap();
8976
8977        // Delete it
8978        fs.delete_entry(&root, &fe).unwrap();
8979
8980        // Verify it's gone
8981        let entries = fs.list_directory(&root).unwrap();
8982        assert!(!entries.iter().any(|e| e.name == "gone.txt"));
8983
8984        // Verify free space recovered
8985        let free_after = fs.free_space().unwrap();
8986        assert!(free_after > free_before);
8987    }
8988
8989    #[test]
8990    fn test_hfsplus_editable_duplicate_name_rejected() {
8991        let img = make_editable_hfsplus_image();
8992        let cursor = std::io::Cursor::new(img);
8993        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
8994
8995        let root = fs.root().unwrap();
8996        let test_data = b"first";
8997        let mut r1 = std::io::Cursor::new(test_data.as_slice());
8998        let options = CreateFileOptions::default();
8999        fs.create_file(&root, "dup.txt", &mut r1, 5, &options)
9000            .unwrap();
9001
9002        // Second creation should fail
9003        let mut r2 = std::io::Cursor::new(test_data.as_slice());
9004        let result = fs.create_file(&root, "dup.txt", &mut r2, 5, &options);
9005        assert!(matches!(result, Err(FilesystemError::AlreadyExists(_))));
9006    }
9007
9008    #[test]
9009    fn test_hfsplus_editable_blessed_folder() {
9010        let img = make_editable_hfsplus_image();
9011        let cursor = std::io::Cursor::new(img);
9012        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
9013
9014        // Initially no blessed folder
9015        assert!(fs.blessed_system_folder().is_none());
9016
9017        // Create a system folder
9018        let root = fs.root().unwrap();
9019        let options = CreateDirectoryOptions::default();
9020        let sys_dir = fs
9021            .create_directory(&root, "System Folder", &options)
9022            .unwrap();
9023
9024        // Bless it
9025        fs.set_blessed_folder(&sys_dir).unwrap();
9026
9027        // Verify it's blessed
9028        let blessed = fs.blessed_system_folder();
9029        assert!(blessed.is_some());
9030        let (cnid, name) = blessed.unwrap();
9031        assert_eq!(cnid, sys_dir.location);
9032        assert_eq!(name, "System Folder");
9033    }
9034
9035    #[test]
9036    fn test_hfsplus_editable_type_creator_auto_detect() {
9037        let img = make_editable_hfsplus_image();
9038        let cursor = std::io::Cursor::new(img);
9039        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
9040
9041        let root = fs.root().unwrap();
9042        let test_data = b"text content";
9043        let mut data_reader = std::io::Cursor::new(test_data.as_slice());
9044        let options = CreateFileOptions::default();
9045        let fe = fs
9046            .create_file(
9047                &root,
9048                "hello.txt",
9049                &mut data_reader,
9050                test_data.len() as u64,
9051                &options,
9052            )
9053            .unwrap();
9054
9055        // "txt" extension should auto-detect to TEXT/ttxt
9056        assert!(fe.type_code.is_some());
9057        assert_eq!(fe.type_code, Some(*b"TEXT"));
9058    }
9059
9060    #[test]
9061    fn test_hfsplus_catalog_variable_index_keys_grow_multilevel() {
9062        // P1 (docs/hfsplus_btree_growth_plan.md §4a): the HFS+ catalog B-tree
9063        // must grow past a single index level without corrupting. The shared
9064        // split/grow helpers used to force the classic 1-byte-length, fixed-0x25
9065        // index-key shape onto HFS+ records, so the first leaf split produced a
9066        // malformed separator (it read the high byte of the 2-byte key length as
9067        // the whole length) and descent misrouted — the "leaves must be strictly
9068        // ascending" / mis-routed-descent corruption. With
9069        // `BTreeKeyFormat::HFSPLUS_CATALOG` the separator is the child's
9070        // variable-length 2-byte key, verbatim.
9071        //
9072        // Driven at the catalog-buffer level through the exact shared
9073        // `btree_insert_full` path `insert_catalog_record` uses. node_size is
9074        // 512 (vs the production 4096) so a few thousand multi-parent records
9075        // reach depth >= 3 in a < 1 MiB buffer; the machinery is node-size
9076        // agnostic. The volume-level fsck gate lands in P2 once blank catalogs
9077        // are sized to hold the records (today a blank catalog is only 4 nodes).
9078        use crate::hfs_common::{
9079            btree_find_record, btree_insert_full, BTreeHeader, BTreeKeyFormat,
9080        };
9081        use byteorder::{BigEndian, ByteOrder};
9082        type Fs = HfsPlusFilesystem<std::io::Cursor<Vec<u8>>>;
9083
9084        let node_size = 512usize;
9085        // A single-node bitmap (512-byte node) covers ~1872 nodes, so stay under
9086        // that — no map nodes needed, matching a real blank-built tree.
9087        let total_nodes = 1800u32;
9088        let mut buf = vec![0u8; total_nodes as usize * node_size];
9089        write_blank_btree_header_node(
9090            &mut buf[0..node_size],
9091            node_size,
9092            /* leaf_records= */ 0,
9093            total_nodes,
9094            /* free_nodes= */ total_nodes - 2,
9095            /* max_key_len= */ 516,
9096            KEY_COMPARE_CASE_FOLDING,
9097        );
9098        write_empty_leaf_node(&mut buf[node_size..2 * node_size]);
9099
9100        let kf = BTreeKeyFormat::HFSPLUS_CATALOG;
9101        let cmp = |a: &[u8], b: &[u8]| Fs::catalog_compare(a, b, false);
9102
9103        // ~2500 files in shuffled key order (multiplicative hash is a bijection
9104        // mod 2^32 — unique names that land mid-leaf, not appended) sprayed
9105        // across 50 parent directories, so inserts exercise every leaf and
9106        // index split, not just tail appends.
9107        const DIRS: u32 = 50;
9108        const N: u32 = 2500;
9109        let mut inserted: Vec<(u32, String)> = Vec::with_capacity(N as usize);
9110        for i in 0..N {
9111            let hash = i.wrapping_mul(2_654_435_761);
9112            let parent = 16 + (hash % DIRS); // arbitrary distinct parent CNIDs
9113            let name = format!("f{hash:08x}");
9114            let mut rec = Fs::build_catalog_key(parent, &name);
9115            // Append filler so records have a realistic size (the compare only
9116            // reads the leading key portion; the body is irrelevant here).
9117            rec.extend_from_slice(&[0u8; 40]);
9118            btree_insert_full(&mut buf, &rec, &kf, &cmp)
9119                .unwrap_or_else(|e| panic!("insert #{i} (parent {parent}, {name}): {e}"));
9120            inserted.push((parent, name));
9121        }
9122
9123        // The tree must have grown past a single index level.
9124        let header = BTreeHeader::read(&buf);
9125        assert!(
9126            header.depth >= 3,
9127            "catalog did not reach depth >= 3 (got {}); test needs a deeper tree to \
9128             exercise index-node splits",
9129            header.depth
9130        );
9131        assert_eq!(
9132            header.leaf_records, N,
9133            "leaf_records header count drifted from inserts"
9134        );
9135
9136        // (a) Every leaf key, walked in sibling-chain order, is strictly
9137        // ascending — the direct symptom of a malformed separator was
9138        // out-of-order leaves.
9139        let mut keys: Vec<Vec<u8>> = Vec::with_capacity(N as usize);
9140        crate::hfs_common::walk_leaf_records::<(), _>(
9141            &buf,
9142            header.first_leaf_node,
9143            node_size,
9144            |_n, _r, _o, rec| {
9145                let kl = BigEndian::read_u16(&rec[0..2]) as usize;
9146                keys.push(rec[..(2 + kl).min(rec.len())].to_vec());
9147                None
9148            },
9149        );
9150        assert_eq!(
9151            keys.len(),
9152            N as usize,
9153            "leaf walk recovered {} of {N} records — chain is broken or has cycles",
9154            keys.len()
9155        );
9156        for w in keys.windows(2) {
9157            assert_eq!(
9158                cmp(&w[0], &w[1]),
9159                std::cmp::Ordering::Less,
9160                "catalog leaves are not strictly ascending — separator/descent corruption"
9161            );
9162        }
9163
9164        // (b) Every inserted record is findable by a root-to-leaf descent, i.e.
9165        // every index separator routes correctly at every level. (A nested `fn`
9166        // rather than a closure so it satisfies the `for<'a> Fn(&'a [u8]) ->
9167        // &'a [u8]` bound `btree_find_record` requires.)
9168        fn key_extract(rec: &[u8]) -> &[u8] {
9169            let kl = BigEndian::read_u16(&rec[0..2]) as usize;
9170            &rec[..(2 + kl).min(rec.len())]
9171        }
9172        for (parent, name) in &inserted {
9173            let search = Fs::build_catalog_key(*parent, name);
9174            let (found, _chain) = btree_find_record(&buf, &header, &search, &key_extract, &cmp);
9175            assert!(
9176                found.is_some(),
9177                "record (parent {parent}, {name}) not findable by descent — misrouted index"
9178            );
9179        }
9180    }
9181
9182    #[test]
9183    fn test_hfsplus_blank_sized_catalog_20k_inserts_fsck_clean() {
9184        // P2 (docs/hfsplus_btree_growth_plan.md §4c): a fresh HFS+ volume sized
9185        // to hold the records accepts >= 20k incremental catalog inserts across
9186        // many parent dirs and stays fsck-clean — the volume-level gate P1
9187        // deferred. A blank catalog used to be a fixed 4 nodes (exhausting after
9188        // ~24 files); it is now volume-scaled, and `create_blank_hfsplus_sized`
9189        // lets this test pin a large catalog into a modest 64 MiB image instead
9190        // of needing a multi-GiB volume to scale into.
9191        use crate::hfs_common::BTreeHeader;
9192        type Fs = HfsPlusFilesystem<std::io::Cursor<Vec<u8>>>;
9193
9194        // 64 MiB volume; catalog pinned to 16 MiB (~4096 nodes @ 4096) — ample
9195        // headroom for 20k file records at the byte-split's ~69% leaf occupancy.
9196        let img =
9197            create_blank_hfsplus_sized(64 * 1024 * 1024, 4096, "PutShuffle", false, 16 << 20, 0);
9198        let mut fs = Fs::open(std::io::Cursor::new(img), 0).unwrap();
9199        fs.prepare_for_edit().unwrap();
9200
9201        // Real directories so threads / valence / folder_count stay fsck-correct.
9202        const DIRS: u32 = 50;
9203        let root = fs.root().unwrap();
9204        let mut dir_ids = Vec::with_capacity(DIRS as usize);
9205        for d in 0..DIRS {
9206            let de = fs
9207                .create_directory(&root, &format!("dir{d:03}"), &Default::default())
9208                .unwrap();
9209            dir_ids.push(de.location as u32);
9210        }
9211        let base_cnid = fs.vh.next_catalog_id;
9212
9213        // 20k files in shuffled key order (multiplicative hash is a bijection
9214        // mod 2^32 — unique names landing mid-leaf, not appended), sprayed across
9215        // the directories. Insert the catalog records directly: `create_file`
9216        // snapshots the whole catalog per call for rollback, which would make a
9217        // 20k-record build O(N * catalog_size). fsck doesn't require per-file
9218        // thread records (only flags orphaned threads), so — like the classic
9219        // HFS scaling test — we insert just the file records and fix up counts.
9220        let n: u32 = 20_000;
9221        let mut per_dir = vec![0i32; DIRS as usize];
9222        let empty_fork = ForkData::empty();
9223        for i in 0..n {
9224            let hash = i.wrapping_mul(2_654_435_761);
9225            let d = (hash % DIRS) as usize;
9226            let name = format!("f{hash:08x}");
9227            let mut kr = Fs::build_catalog_key(dir_ids[d], &name);
9228            if !kr.len().is_multiple_of(2) {
9229                kr.push(0);
9230            }
9231            kr.extend_from_slice(&Fs::build_file_record_with_dates(
9232                base_cnid + i,
9233                &empty_fork,
9234                &empty_fork,
9235                &[0u8; 4],
9236                &[0u8; 4],
9237                None,
9238                0,
9239            ));
9240            fs.insert_catalog_record(&kr)
9241                .unwrap_or_else(|e| panic!("insert #{i} into dir{d}: {e}"));
9242            per_dir[d] += 1;
9243        }
9244        // Mirror the bookkeeping the create path maintains so fsck's cross-checks
9245        // (file_count, next_catalog_id, per-dir valence) pass.
9246        fs.vh.file_count += n;
9247        fs.vh.next_catalog_id = base_cnid + n;
9248        for (d, &cnt) in per_dir.iter().enumerate() {
9249            fs.update_parent_valence(dir_ids[d], cnt).unwrap();
9250        }
9251
9252        // The catalog must have grown several index levels...
9253        let h = BTreeHeader::read(fs.catalog_data());
9254        assert!(
9255            h.depth >= 3,
9256            "catalog depth {} < 3 after 20k inserts",
9257            h.depth
9258        );
9259        // ...without a premature DiskFull (every insert above succeeded).
9260
9261        // No IndexSiblingLinkBroken / order / count damage at scale.
9262        let result = fs.fsck().unwrap().unwrap();
9263        assert!(
9264            result.errors.is_empty(),
9265            "fsck found {} errors after 20k inserts: {:?}",
9266            result.errors.len(),
9267            result
9268                .errors
9269                .iter()
9270                .take(8)
9271                .map(|e| &e.message)
9272                .collect::<Vec<_>>()
9273        );
9274    }
9275
9276    #[test]
9277    fn test_hfsplus_catalog_grows_map_nodes_past_cap_phase_c() {
9278        // §4b Phase C (docs/todo_hfsplus_fork_growth.md): once a B-tree fork
9279        // grows past the header node's bitmap capacity ((node_size-256)*8 =
9280        // 30,720 nodes @ 4096), a map node (BTNodeKind=2) must be appended to
9281        // the node-allocation bitmap chain so the new node indices are
9282        // addressable. Pre-size the catalog just under the cap, then grow a few
9283        // times to cross it — exercising the map-node append without needing
9284        // the millions of records a from-scratch fill would take.
9285        use crate::hfs_common::{btree_bitmap_segments, BTreeHeader};
9286        type Fs = HfsPlusFilesystem<std::io::Cursor<Vec<u8>>>;
9287        let node_size = 4096usize;
9288        let header_cap = ((node_size - 256) * 8) as u32; // 30,720
9289
9290        // Catalog pinned to ~16 nodes below the cap; 180 MiB volume holds it.
9291        let min_catalog = (header_cap - 16) * node_size as u32;
9292        let img =
9293            create_blank_hfsplus_sized(180 * 1024 * 1024, 4096, "MapCap", false, min_catalog, 0);
9294        let mut fs = Fs::open(std::io::Cursor::new(img), 0).unwrap();
9295        fs.prepare_for_edit().unwrap();
9296
9297        let start = BTreeHeader::read(fs.catalog_data()).total_nodes;
9298        assert!(
9299            start <= header_cap && start > header_cap - 64,
9300            "blank catalog {start} not near cap"
9301        );
9302        // No map node yet — the whole bitmap lives in the header node.
9303        assert_eq!(
9304            btree_bitmap_segments(fs.catalog_data(), node_size).len(),
9305            1,
9306            "fresh near-cap catalog should have no map nodes"
9307        );
9308
9309        // Grow until we cross the header bitmap cap.
9310        let mut guard = 0;
9311        while BTreeHeader::read(fs.catalog_data()).total_nodes <= header_cap {
9312            fs.grow_btree_fork(BTreeFork::Catalog).unwrap();
9313            guard += 1;
9314            assert!(guard < 64, "too many grows to cross the cap");
9315        }
9316        let h = BTreeHeader::read(fs.catalog_data());
9317        assert!(
9318            h.total_nodes > header_cap,
9319            "did not cross the cap: {}",
9320            h.total_nodes
9321        );
9322        // A map-node segment must now exist in the bitmap chain.
9323        let segs = btree_bitmap_segments(fs.catalog_data(), node_size);
9324        assert!(
9325            segs.len() >= 2,
9326            "expected a map-node segment after crossing the cap, got {} segment(s)",
9327            segs.len()
9328        );
9329
9330        // fsck-clean with the map node in place.
9331        fs.sync_metadata().unwrap();
9332        let result = fs.fsck().unwrap().unwrap();
9333        assert!(
9334            result.errors.is_empty(),
9335            "fsck after map-node grow: {:?}",
9336            result
9337                .errors
9338                .iter()
9339                .take(8)
9340                .map(|e| &e.message)
9341                .collect::<Vec<_>>()
9342        );
9343
9344        // Reopen re-reads the grown fork and the map chain.
9345        let img = fs.reader.get_ref().clone();
9346        if let Ok(path) = std::env::var("RB_EMIT_MAPC_IMAGE") {
9347            std::fs::write(&path, &img).unwrap();
9348        }
9349        let fs2 = Fs::open(std::io::Cursor::new(img), 0).unwrap();
9350        assert_eq!(
9351            BTreeHeader::read(fs2.catalog_data()).total_nodes,
9352            h.total_nodes,
9353            "reopen lost grown nodes"
9354        );
9355        assert!(
9356            btree_bitmap_segments(fs2.catalog_data(), node_size).len() >= 2,
9357            "reopen lost the map node"
9358        );
9359    }
9360
9361    #[test]
9362    fn test_hfsplus_catalog_grow_spills_to_overflow_phase_b() {
9363        // §4b Phase B (docs/todo_hfsplus_fork_growth.md): real `create_file`
9364        // calls interleave a data-block allocation with each catalog grow, so
9365        // contiguous tail growth fails and the catalog fork fragments. Once it
9366        // exhausts its 8 inline extents, further growth spills into the
9367        // extents-overflow B-tree (keyed by the catalog's reserved CNID 4) and
9368        // is persisted by the overflow-aware write path. The volume stays
9369        // readable + fsck-clean, and a reopen re-reads the > 8-extent fork.
9370        type Fs = HfsPlusFilesystem<std::io::Cursor<Vec<u8>>>;
9371        let img = create_blank_hfsplus_sized(16 * 1024 * 1024, 4096, "SpillVol", false, 0, 0);
9372        let mut fs = Fs::open(std::io::Cursor::new(img), 0).unwrap();
9373        fs.prepare_for_edit().unwrap();
9374        let root = fs.root().unwrap();
9375        let mut dirs = Vec::new();
9376        for d in 0..20 {
9377            dirs.push(
9378                fs.create_directory(&root, &format!("d{d}"), &Default::default())
9379                    .unwrap(),
9380            );
9381        }
9382        const N: u32 = 1200;
9383        for i in 0..N {
9384            let d = &dirs[(i % 20) as usize];
9385            let mut data = std::io::Cursor::new(vec![b'x']);
9386            fs.create_file(
9387                d,
9388                &format!("f{i:05}.txt"),
9389                &mut data,
9390                1,
9391                &Default::default(),
9392            )
9393            .unwrap_or_else(|e| panic!("create_file #{i} (grow+spill should cover it): {e}"));
9394        }
9395
9396        // The catalog fork saturated its 8 inline extents and spilled the rest
9397        // into the extents-overflow B-tree.
9398        let inline: u32 = fs
9399            .vh
9400            .catalog_file
9401            .extents
9402            .iter()
9403            .map(|e| e.block_count)
9404            .sum();
9405        let inline_count = fs
9406            .vh
9407            .catalog_file
9408            .extents
9409            .iter()
9410            .filter(|e| e.block_count != 0)
9411            .count();
9412        assert_eq!(
9413            inline_count, 8,
9414            "catalog should have filled all 8 inline extents"
9415        );
9416        assert!(
9417            fs.vh.catalog_file.total_blocks > inline,
9418            "catalog total_blocks {} should exceed inline-extent blocks {inline} (overflow used)",
9419            fs.vh.catalog_file.total_blocks
9420        );
9421
9422        fs.sync_metadata().unwrap();
9423        let result = fs.fsck().unwrap().unwrap();
9424        assert!(
9425            result.errors.is_empty(),
9426            "fsck after overflow spill: {:?}",
9427            result
9428                .errors
9429                .iter()
9430                .take(8)
9431                .map(|e| &e.message)
9432                .collect::<Vec<_>>()
9433        );
9434
9435        // Reopen re-reads the > 8-extent catalog fork (inline + overflow) and
9436        // every file is still enumerable.
9437        let img = fs.reader.get_ref().clone();
9438        if let Ok(path) = std::env::var("RB_EMIT_SPILL_IMAGE") {
9439            std::fs::write(&path, &img).unwrap();
9440        }
9441        let mut fs2 = Fs::open(std::io::Cursor::new(img), 0).unwrap();
9442        let r2 = fs2.root().unwrap();
9443        let mut total = 0;
9444        for de in fs2.list_directory(&r2).unwrap() {
9445            if de.is_directory() {
9446                total += fs2.list_directory(&de).unwrap().len();
9447            }
9448        }
9449        assert_eq!(total as u32, N, "reopen lost files: {total} != {N}");
9450    }
9451
9452    /// H8: with blocks larger than nodes a grow added one block per node,
9453    /// twice (or four times) what the nodes occupy, on every grow.
9454    /// H7: the alternate volume header went to total_blocks * block_size - 1024;
9455    /// fsck_hfs and the Mac read 1024 bytes before the partition end.
9456    #[test]
9457    fn the_alternate_volume_header_follows_the_partition_end_when_known() {
9458        use disk_core::filesystem::{CreateFileOptions, EditableFilesystem, Filesystem};
9459        const VOLUME: u64 = 8 * 1024 * 1024;
9460        const PARTITION: u64 = VOLUME + 6 * 1024;
9461        let mut img = create_blank_hfsplus(VOLUME, 4096, "Slack", false);
9462        img.resize(PARTITION as usize, 0);
9463        {
9464            let mut fs =
9465                HfsPlusFilesystem::open_sized(std::io::Cursor::new(&mut img), 0, Some(PARTITION))
9466                    .unwrap();
9467            fs.prepare_for_edit().unwrap();
9468            let root = fs.root().unwrap();
9469            fs.create_file(&root, "a", &mut &b"x"[..], 1, &CreateFileOptions::default())
9470                .unwrap();
9471            fs.sync_metadata().unwrap();
9472        }
9473        let primary = &img[1024..1536];
9474        let alt = &img[PARTITION as usize - 1024..PARTITION as usize - 512];
9475        assert_eq!(
9476            &alt[0..2],
9477            b"H+",
9478            "alternate volume header missing at the partition end"
9479        );
9480        assert_eq!(primary, alt);
9481    }
9482
9483    /// H14: an imported file's Finder flags had no way into create_file.
9484    #[test]
9485    fn create_file_writes_the_finder_flags_it_is_given() {
9486        use disk_core::filesystem::{CreateFileOptions, EditableFilesystem, Filesystem};
9487        let mut img = create_blank_hfsplus(8 * 1024 * 1024, 4096, "Flags", false);
9488        let mut fs = HfsPlusFilesystem::open(std::io::Cursor::new(&mut img), 0).unwrap();
9489        fs.prepare_for_edit().unwrap();
9490        let root = fs.root().unwrap();
9491        let opts = CreateFileOptions {
9492            os_type: Some(*b"APPL"),
9493            os_creator: Some(*b"ttxt"),
9494            finder_flags: Some(0x2000),
9495            ..Default::default()
9496        };
9497        fs.create_file(&root, "app", &mut &b"x"[..], 1, &opts)
9498            .unwrap();
9499        fs.sync_metadata().unwrap();
9500        let entry = fs
9501            .list_directory(&root)
9502            .unwrap()
9503            .into_iter()
9504            .find(|e| e.name == "app")
9505            .unwrap();
9506        assert_eq!(entry.finder_flags, Some(0x2000));
9507    }
9508
9509    #[test]
9510    fn a_grow_allocates_the_blocks_the_new_nodes_occupy() {
9511        // 4 KiB nodes in 8 KiB blocks: 8 nodes are 4 blocks, not 8.
9512        assert_eq!(btree_grow_blocks(10, 8, 4096, 8192), 4);
9513        // 1 KiB nodes in 4 KiB blocks: 8 nodes are 2 blocks.
9514        assert_eq!(btree_grow_blocks(4, 8, 1024, 4096), 2);
9515        // An odd node count leaves half a block of slack that the grow uses first.
9516        assert_eq!(btree_grow_blocks(11, 8, 4096, 8192), 4);
9517        assert_eq!(btree_grow_blocks(11, 1, 4096, 8192), 0);
9518        // Nodes larger than blocks keep the old one-node-many-blocks answer.
9519        assert_eq!(btree_grow_blocks(10, 8, 4096, 512), 64);
9520        assert_eq!(btree_grow_blocks(10, 8, 4096, 4096), 8);
9521    }
9522
9523    #[test]
9524    fn test_hfsplus_catalog_grows_fork_on_full_phase_a() {
9525        // §4b Phase A (docs/todo_hfsplus_fork_growth.md): a volume whose catalog
9526        // fork is *too small* for its eventual record count grows the fork in
9527        // place through live inserts — no spurious DiskFull while the volume has
9528        // free blocks — and stays fsck-clean. Contiguous tail growth keeps the
9529        // catalog fork at a single extent. A reopen re-reads the grown fork.
9530        use crate::hfs_common::BTreeHeader;
9531        type Fs = HfsPlusFilesystem<std::io::Cursor<Vec<u8>>>;
9532
9533        // 16 MiB volume, minimal (volume-scaled, ~21-node) catalog. Inserting
9534        // far more records than that forces many grows.
9535        let img = create_blank_hfsplus_sized(16 * 1024 * 1024, 4096, "GrowVol", false, 0, 0);
9536        let mut fs = Fs::open(std::io::Cursor::new(img), 0).unwrap();
9537        fs.prepare_for_edit().unwrap();
9538
9539        let start_nodes = BTreeHeader::read(fs.catalog_data()).total_nodes;
9540
9541        const DIRS: u32 = 20;
9542        let root = fs.root().unwrap();
9543        let mut dir_ids = Vec::with_capacity(DIRS as usize);
9544        for d in 0..DIRS {
9545            let de = fs
9546                .create_directory(&root, &format!("dir{d:03}"), &Default::default())
9547                .unwrap();
9548            dir_ids.push(de.location as u32);
9549        }
9550        let base_cnid = fs.vh.next_catalog_id;
9551
9552        let n: u32 = 6_000;
9553        let mut per_dir = vec![0i32; DIRS as usize];
9554        let empty_fork = ForkData::empty();
9555        for i in 0..n {
9556            let hash = i.wrapping_mul(2_654_435_761);
9557            let d = (hash % DIRS) as usize;
9558            let name = format!("f{hash:08x}");
9559            let mut kr = Fs::build_catalog_key(dir_ids[d], &name);
9560            if !kr.len().is_multiple_of(2) {
9561                kr.push(0);
9562            }
9563            kr.extend_from_slice(&Fs::build_file_record_with_dates(
9564                base_cnid + i,
9565                &empty_fork,
9566                &empty_fork,
9567                &[0u8; 4],
9568                &[0u8; 4],
9569                None,
9570                0,
9571            ));
9572            fs.insert_catalog_record(&kr)
9573                .unwrap_or_else(|e| panic!("insert #{i} into dir{d} (grow should cover it): {e}"));
9574            per_dir[d] += 1;
9575        }
9576        fs.vh.file_count += n;
9577        fs.vh.next_catalog_id = base_cnid + n;
9578        for (d, &cnt) in per_dir.iter().enumerate() {
9579            fs.update_parent_valence(dir_ids[d], cnt).unwrap();
9580        }
9581
9582        // The fork grew (each grow adds >= 8 nodes; >= 2 grows expected here).
9583        let end_nodes = BTreeHeader::read(fs.catalog_data()).total_nodes;
9584        assert!(
9585            end_nodes >= start_nodes + 16,
9586            "catalog total_nodes only went {start_nodes} -> {end_nodes}; expected >= 2 grows"
9587        );
9588        // Contiguous tail growth keeps the catalog fork at a single extent.
9589        let cat_extents = fs
9590            .vh
9591            .catalog_file
9592            .extents
9593            .iter()
9594            .filter(|e| e.block_count != 0)
9595            .count();
9596        assert_eq!(
9597            cat_extents, 1,
9598            "contiguous tail growth should keep the catalog fork at 1 extent, got {cat_extents}"
9599        );
9600
9601        // Persist the grown fork, bitmap, and VH, then fsck the result.
9602        fs.sync_metadata().unwrap();
9603
9604        // fsck-clean after all the grows.
9605        let result = fs.fsck().unwrap().unwrap();
9606        assert!(
9607            result.errors.is_empty(),
9608            "fsck found {} errors after grow: {:?}",
9609            result.errors.len(),
9610            result
9611                .errors
9612                .iter()
9613                .take(8)
9614                .map(|e| &e.message)
9615                .collect::<Vec<_>>()
9616        );
9617
9618        // Reopen re-reads the grown fork (proves the fork extents + header
9619        // persisted) and every record is still findable.
9620        let img = fs.reader.get_ref().clone();
9621        // Optionally emit the grown image so a Mac can fsck_hfs it (the real
9622        // §4b acceptance test): set RB_EMIT_GROW_IMAGE=/path.
9623        if let Ok(path) = std::env::var("RB_EMIT_GROW_IMAGE") {
9624            std::fs::write(&path, &img).unwrap();
9625        }
9626        let mut fs2 = Fs::open(std::io::Cursor::new(img), 0).unwrap();
9627        let reopened_nodes = BTreeHeader::read(fs2.catalog_data()).total_nodes;
9628        assert_eq!(reopened_nodes, end_nodes, "reopen lost grown nodes");
9629        let r2 = fs2.root().unwrap();
9630        let dirs = fs2.list_directory(&r2).unwrap();
9631        assert_eq!(dirs.len(), DIRS as usize, "reopen lost directories");
9632    }
9633
9634    #[test]
9635    fn test_hfsplus_extents_overflow_grows_multilevel() {
9636        // P3 (docs/hfsplus_btree_growth_plan.md): the extents-overflow B-tree —
9637        // 2-byte ("big") keys with *fixed* 10-byte index separators — must split
9638        // past a single leaf level. It was pinned depth-1 because the shared
9639        // helpers forced the classic key shape; it now threads
9640        // `BTreeKeyFormat::HFSPLUS_EXTENTS`. Driven through the same
9641        // `btree_insert_full` machinery `insert_extents_overflow_record` uses.
9642        use crate::hfs_common::{
9643            btree_find_record, btree_insert_full, BTreeHeader, BTreeKeyFormat,
9644        };
9645        type Fs = HfsPlusFilesystem<std::io::Cursor<Vec<u8>>>;
9646
9647        let node_size = 512usize;
9648        let total_nodes = 1800u32;
9649        let mut buf = vec![0u8; total_nodes as usize * node_size];
9650        write_blank_btree_header_node(
9651            &mut buf[0..node_size],
9652            node_size,
9653            0,
9654            total_nodes,
9655            total_nodes - 2,
9656            /* max_key_len= */ 10,
9657            /* key_compare_type= */ 0,
9658        );
9659        write_empty_leaf_node(&mut buf[node_size..2 * node_size]);
9660
9661        let kf = BTreeKeyFormat::HFSPLUS_EXTENTS;
9662        // 600 overflow records keyed by distinct shuffled fileIDs (the multiplier
9663        // is odd, so it's a bijection mod 2^32 — distinct i give distinct keys
9664        // that land mid-leaf, not appended).
9665        const N: u32 = 600;
9666        let mut ids: Vec<u32> = Vec::with_capacity(N as usize);
9667        for i in 0..N {
9668            let file_id = i.wrapping_mul(2_654_435_761);
9669            let rec = Fs::build_extents_overflow_record(file_id, 0, 8, &[]);
9670            btree_insert_full(&mut buf, &rec, &kf, &Fs::extents_compare)
9671                .unwrap_or_else(|e| panic!("extents insert #{i} (file {file_id}): {e}"));
9672            ids.push(file_id);
9673        }
9674
9675        let header = BTreeHeader::read(&buf);
9676        assert!(
9677            header.depth >= 2,
9678            "extents-overflow tree stayed depth {} — split past a leaf level failed",
9679            header.depth
9680        );
9681        assert_eq!(header.leaf_records, N);
9682
9683        let mut recs: Vec<Vec<u8>> = Vec::with_capacity(N as usize);
9684        crate::hfs_common::walk_leaf_records::<(), _>(
9685            &buf,
9686            header.first_leaf_node,
9687            node_size,
9688            |_n, _r, _o, r| {
9689                recs.push(r.to_vec());
9690                None
9691            },
9692        );
9693        assert_eq!(recs.len(), N as usize, "leaf walk lost records");
9694        for w in recs.windows(2) {
9695            assert_eq!(
9696                Fs::extents_compare(&w[0], &w[1]),
9697                std::cmp::Ordering::Less,
9698                "extents-overflow leaves out of order — bad separator"
9699            );
9700        }
9701
9702        // The extents key portion is always the fixed 12 bytes (2-byte len + 10).
9703        fn ext_key(rec: &[u8]) -> &[u8] {
9704            &rec[..12.min(rec.len())]
9705        }
9706        let ext_cmp = |a: &[u8], b: &[u8]| Fs::extents_compare(a, b);
9707        for &file_id in &ids {
9708            let search = Fs::build_extents_overflow_record(file_id, 0, 8, &[]);
9709            let (found, _) = btree_find_record(&buf, &header, &search, &ext_key, &ext_cmp);
9710            assert!(
9711                found.is_some(),
9712                "extents record for file {file_id} not findable"
9713            );
9714        }
9715    }
9716
9717    #[test]
9718    fn test_hfsplus_attributes_grows_multilevel() {
9719        // P3: the attributes B-tree — 2-byte keys with *variable*-length index
9720        // separators — splits past one leaf level via
9721        // `BTreeKeyFormat::HFSPLUS_ATTRIBUTES`, the same path `insert_xattr_record`
9722        // uses. (P1 wired the descriptor in; this proves the attribute key layout,
9723        // distinct from the catalog's, routes correctly at every level.)
9724        use crate::hfs_common::{
9725            btree_find_record, btree_insert_full, BTreeHeader, BTreeKeyFormat,
9726        };
9727        use byteorder::{BigEndian, ByteOrder};
9728        type Fs = HfsPlusFilesystem<std::io::Cursor<Vec<u8>>>;
9729
9730        let node_size = 512usize;
9731        let total_nodes = 1800u32;
9732        let mut buf = vec![0u8; total_nodes as usize * node_size];
9733        write_blank_btree_header_node(
9734            &mut buf[0..node_size],
9735            node_size,
9736            0,
9737            total_nodes,
9738            total_nodes - 2,
9739            /* max_key_len= */ 264,
9740            /* key_compare_type= */ 0,
9741        );
9742        write_empty_leaf_node(&mut buf[node_size..2 * node_size]);
9743
9744        let kf = BTreeKeyFormat::HFSPLUS_ATTRIBUTES;
9745        const N: u32 = 600;
9746        let value = [0xABu8; 8];
9747        let mut cnids: Vec<u32> = Vec::with_capacity(N as usize);
9748        for i in 0..N {
9749            let cnid = i.wrapping_mul(2_654_435_761);
9750            let rec = Fs::build_inline_attr_record(cnid, "xattr", &value);
9751            btree_insert_full(&mut buf, &rec, &kf, &Fs::attr_compare)
9752                .unwrap_or_else(|e| panic!("attr insert #{i} (cnid {cnid}): {e}"));
9753            cnids.push(cnid);
9754        }
9755
9756        let header = BTreeHeader::read(&buf);
9757        assert!(
9758            header.depth >= 2,
9759            "attributes tree stayed depth {} — split past a leaf level failed",
9760            header.depth
9761        );
9762        assert_eq!(header.leaf_records, N);
9763
9764        let mut recs: Vec<Vec<u8>> = Vec::with_capacity(N as usize);
9765        crate::hfs_common::walk_leaf_records::<(), _>(
9766            &buf,
9767            header.first_leaf_node,
9768            node_size,
9769            |_n, _r, _o, r| {
9770                recs.push(r.to_vec());
9771                None
9772            },
9773        );
9774        assert_eq!(recs.len(), N as usize, "leaf walk lost records");
9775        for w in recs.windows(2) {
9776            assert_eq!(
9777                Fs::attr_compare(&w[0], &w[1]),
9778                std::cmp::Ordering::Less,
9779                "attributes leaves out of order — bad separator"
9780            );
9781        }
9782
9783        fn attr_key(rec: &[u8]) -> &[u8] {
9784            let kl = BigEndian::read_u16(&rec[0..2]) as usize;
9785            &rec[..(2 + kl).min(rec.len())]
9786        }
9787        let attr_cmp = |a: &[u8], b: &[u8]| Fs::attr_compare(a, b);
9788        for &cnid in &cnids {
9789            let search = Fs::build_inline_attr_record(cnid, "xattr", &value);
9790            let (found, _) = btree_find_record(&buf, &header, &search, &attr_key, &attr_cmp);
9791            assert!(found.is_some(), "attr record for cnid {cnid} not findable");
9792        }
9793    }
9794
9795    #[test]
9796    fn test_hfsplus_fragmented_file_splits_extents_overflow_btree_real_path() {
9797        // P3 real-path integration: a heavily fragmented file produces enough
9798        // extents-overflow records to split that B-tree past a single leaf level
9799        // through the real `insert_extents_overflow_record`, and the file reads
9800        // back byte-for-byte through the resulting multi-level tree. (Read-back,
9801        // not fsck, because the all-used-then-free-singletons bitmap trick used to
9802        // force fragmentation leaves the bitmap deliberately over-allocated; the
9803        // point here is the overflow B-tree's split + read path, which P3 enabled.)
9804        type Fs = HfsPlusFilesystem<std::io::Cursor<Vec<u8>>>;
9805        // Extents-overflow tree pinned to 256 KiB (64 nodes @ 4096) so it has room
9806        // to split — the 4-node default could not.
9807        let img = create_blank_hfsplus_sized(32 << 20, 4096, "Frag", false, 0, 256 << 10);
9808        let mut fs = Fs::open(std::io::Cursor::new(img), 0).unwrap();
9809        fs.prepare_for_edit().unwrap();
9810
9811        // Force maximal fragmentation: mark every block used, then free isolated
9812        // single blocks (every other block) in the user region. A file of N blocks
9813        // must then use N one-block extents: 8 inline + (N-8) overflow extents =
9814        // ceil((N-8)/8) overflow records. 520 blocks -> 64 overflow records, well
9815        // past one 4096-byte extents leaf (~52 records), so the tree must split.
9816        fs.ensure_bitmap().unwrap();
9817        let block_size = fs.vh.block_size as usize;
9818        let n_blocks: u32 = 520;
9819        let first_free = fs.vh.next_allocation;
9820        {
9821            let bitmap = fs.bitmap.as_mut().unwrap();
9822            for b in bitmap.iter_mut() {
9823                *b = 0xFF;
9824            }
9825            let mut blk = first_free + 1;
9826            for _ in 0..n_blocks {
9827                bitmap_clear_bit_be(bitmap, blk);
9828                blk += 2; // a used block between each free one -> singletons
9829            }
9830        }
9831        fs.vh.free_blocks = n_blocks;
9832
9833        let payload: Vec<u8> = (0..(n_blocks as usize * block_size))
9834            .map(|i| (i % 251) as u8)
9835            .collect();
9836        let mut reader = std::io::Cursor::new(payload.clone());
9837        let root = fs.root().unwrap();
9838        let entry = fs
9839            .create_file(
9840                &root,
9841                "frag.bin",
9842                &mut reader,
9843                payload.len() as u64,
9844                &CreateFileOptions::default(),
9845            )
9846            .expect("fragmented file create should succeed via a splitting overflow B-tree");
9847
9848        // The extents-overflow tree must have grown past a single leaf level.
9849        let depth =
9850            crate::hfs_common::BTreeHeader::read(fs.extents_overflow_data.as_ref().unwrap()).depth;
9851        assert!(
9852            depth >= 2,
9853            "extents-overflow tree depth {depth} < 2 — 64 overflow records did not split it"
9854        );
9855
9856        // The data fork is the expected single-block-extent shape...
9857        let cnid = entry.location as u32;
9858        let (data_fork, _rsrc) = fs.find_file_by_id(cnid).unwrap();
9859        assert_eq!(data_fork.total_blocks, n_blocks);
9860
9861        // ...and reads back byte-for-byte through the multi-level overflow tree.
9862        let got = fs.read_file(&entry, payload.len() + 1).unwrap();
9863        assert_eq!(got.len(), payload.len());
9864        assert_eq!(
9865            got, payload,
9866            "fragmented read-back mismatch through split overflow tree"
9867        );
9868    }
9869
9870    #[test]
9871    fn deleting_the_last_overflow_record_retires_the_extents_tree() {
9872        // fsck_hfs E_BadNode: the audit's H3 delete left the extents tree as a
9873        // depth-1 root leaf with no records, which Apple never writes.
9874        use crate::hfs_common::{btree_lonely_empty_leaves, BTreeHeader};
9875        type Fs = HfsPlusFilesystem<std::io::Cursor<Vec<u8>>>;
9876        let img = create_blank_hfsplus_sized(32 << 20, 4096, "Retire", false, 0, 256 << 10);
9877        let mut fs = Fs::open(std::io::Cursor::new(img), 0).unwrap();
9878        fs.prepare_for_edit().unwrap();
9879
9880        // Free singleton blocks only, so the file fragments into the overflow tree.
9881        fs.ensure_bitmap().unwrap();
9882        let block_size = fs.vh.block_size as usize;
9883        let n_blocks: u32 = 520;
9884        let first_free = fs.vh.next_allocation;
9885        {
9886            let bitmap = fs.bitmap.as_mut().unwrap();
9887            for b in bitmap.iter_mut() {
9888                *b = 0xFF;
9889            }
9890            let mut blk = first_free + 1;
9891            for _ in 0..n_blocks {
9892                bitmap_clear_bit_be(bitmap, blk);
9893                blk += 2;
9894            }
9895        }
9896        fs.vh.free_blocks = n_blocks;
9897        let payload: Vec<u8> = (0..(n_blocks as usize * block_size))
9898            .map(|i| (i % 251) as u8)
9899            .collect();
9900        let root = fs.root().unwrap();
9901        let entry = fs
9902            .create_file(
9903                &root,
9904                "frag.bin",
9905                &mut std::io::Cursor::new(payload.clone()),
9906                payload.len() as u64,
9907                &CreateFileOptions::default(),
9908            )
9909            .unwrap();
9910        let before = BTreeHeader::read(fs.extents_overflow_data.as_ref().unwrap());
9911        assert!(
9912            before.depth >= 2 && before.leaf_records > 0,
9913            "depth {} leaf_records {}",
9914            before.depth,
9915            before.leaf_records
9916        );
9917
9918        fs.delete_entry(&root, &entry).unwrap();
9919
9920        let tree = fs.extents_overflow_data.as_ref().unwrap();
9921        let h = BTreeHeader::read(tree);
9922        assert_eq!((h.leaf_records, h.root_node, h.depth), (0, 0, 0));
9923        assert_eq!(
9924            h.free_nodes,
9925            h.total_nodes - 1,
9926            "only the header node stays"
9927        );
9928        // The hand-made bitmap above never reaches disk, so no fsck here; the
9929        // audit's verify-fs-macos.sh runs fsck_hfs on the real image instead.
9930        assert!(btree_lonely_empty_leaves(tree, h.node_size as usize).is_empty());
9931    }
9932
9933    #[test]
9934    fn a_grow_moves_the_alternate_header_blocks_and_the_bitmap() {
9935        // fsck_hfs on the audit's H7 grow: "orphaned blocks" at the old tail,
9936        // "under-allocation" at the new one. The 2 KiB case also outgrows its
9937        // one-block allocation file, which has to move.
9938        type Fs = HfsPlusFilesystem<std::io::Cursor<Vec<u8>>>;
9939        for (bs, old_mib, new_mib) in [(4096u32, 24u64, 30u64), (2048u32, 32u64, 64u64)] {
9940            let mut img = create_blank_hfsplus(old_mib << 20, bs, "Grow", false);
9941            img.resize((new_mib << 20) as usize, 0);
9942            let mut cur = std::io::Cursor::new(img);
9943            let mut log = |_: &str| {};
9944            resize_hfsplus_in_place(&mut cur, 0, new_mib << 20, &mut log).unwrap();
9945
9946            let mut fs = Fs::open_sized(cur, 0, Some(new_mib << 20)).unwrap();
9947            let total = fs.vh.total_blocks;
9948            assert_eq!(total as u64, (new_mib << 20) / bs as u64, "bs {bs}");
9949            let bitmap = fs.read_allocation_bitmap_for_fsck().unwrap();
9950            let old_total = ((old_mib << 20) / bs as u64) as u32;
9951            for b in alt_vh_blocks(old_total, bs) {
9952                assert!(
9953                    !bitmap_test_bit_be(&bitmap, b),
9954                    "bs {bs}: old tail block {b} still marked"
9955                );
9956            }
9957            for b in alt_vh_blocks(total, bs) {
9958                assert!(
9959                    bitmap_test_bit_be(&bitmap, b),
9960                    "bs {bs}: new tail block {b} is free"
9961                );
9962            }
9963            let used = (0..total)
9964                .filter(|&b| bitmap_test_bit_be(&bitmap, b))
9965                .count() as u32;
9966            assert_eq!(fs.vh.free_blocks, total - used, "bs {bs}");
9967            assert!(
9968                fs.vh.allocation_file.total_blocks as u64 * bs as u64 * 8 >= total as u64,
9969                "bs {bs}: allocation file too small for {total} blocks"
9970            );
9971            let report = fs.fsck().unwrap().unwrap();
9972            assert!(
9973                report.errors.is_empty(),
9974                "bs {bs}: {:?}",
9975                report
9976                    .errors
9977                    .iter()
9978                    .map(|e| &e.message)
9979                    .take(4)
9980                    .collect::<Vec<_>>()
9981            );
9982        }
9983    }
9984
9985    #[test]
9986    fn test_hfsplus_catalog_shuffled_inserts_pack_densely() {
9987        // P5 (docs/hfsplus_btree_growth_plan.md §4d): with the B*-style rotation
9988        // now on the HFS+ catalog insert path (insert_catalog_record ->
9989        // btree_insert_full), shuffled multi-dir inserts must pack leaves to
9990        // ~80%+ occupancy, matching the classic-HFS result — not the ~69% a plain
9991        // split leaves. Measured through the same btree_insert_full the live path
9992        // uses, with the HFSPLUS_CATALOG (variable-key) format.
9993        use crate::hfs_common::{btree_insert_full, BTreeHeader, BTreeKeyFormat};
9994        use byteorder::{BigEndian, ByteOrder};
9995        type Fs = HfsPlusFilesystem<std::io::Cursor<Vec<u8>>>;
9996
9997        let node_size = 512usize;
9998        let total_nodes = 1800u32;
9999        let mut buf = vec![0u8; total_nodes as usize * node_size];
10000        write_blank_btree_header_node(
10001            &mut buf[0..node_size],
10002            node_size,
10003            0,
10004            total_nodes,
10005            total_nodes - 2,
10006            516,
10007            KEY_COMPARE_CASE_FOLDING,
10008        );
10009        write_empty_leaf_node(&mut buf[node_size..2 * node_size]);
10010
10011        let kf = BTreeKeyFormat::HFSPLUS_CATALOG;
10012        let cmp = |a: &[u8], b: &[u8]| Fs::catalog_compare(a, b, false);
10013        const DIRS: u32 = 40;
10014        const N: u32 = 3000;
10015        for i in 0..N {
10016            let hash = i.wrapping_mul(2_654_435_761);
10017            let parent = 16 + (hash % DIRS);
10018            let name = format!("f{hash:08x}");
10019            let mut rec = Fs::build_catalog_key(parent, &name);
10020            rec.extend_from_slice(&[0u8; 40]);
10021            btree_insert_full(&mut buf, &rec, &kf, &cmp)
10022                .unwrap_or_else(|e| panic!("insert #{i}: {e}"));
10023        }
10024
10025        // Average leaf occupancy across the whole leaf chain.
10026        let header = BTreeHeader::read(&buf);
10027        assert!(
10028            header.depth >= 3,
10029            "need a multi-level tree to judge packing"
10030        );
10031        let mut used_total = 0usize;
10032        let mut leaves = 0usize;
10033        let mut node = header.first_leaf_node;
10034        let mut seen = std::collections::HashSet::new();
10035        while node != 0 && seen.insert(node) {
10036            let off = node as usize * node_size;
10037            let num = BigEndian::read_u16(&buf[off + 10..off + 12]) as usize;
10038            // Records occupy 14..free_off; the offset table holds num+1 u16s.
10039            let free_off_pos = off + node_size - 2 * (num + 1);
10040            let free_off = BigEndian::read_u16(&buf[free_off_pos..free_off_pos + 2]) as usize;
10041            used_total += free_off + 2 * (num + 1);
10042            leaves += 1;
10043            node = BigEndian::read_u32(&buf[off..off + 4]); // forward link
10044        }
10045        // ~0.84 with the rotation (was ~0.69 with plain split), matching classic.
10046        let occupancy = used_total as f64 / (leaves * node_size) as f64;
10047        assert!(
10048            occupancy >= 0.80,
10049            "shuffled multi-dir leaf occupancy {occupancy:.3} (<0.80) over {leaves} leaves — \
10050             the rotation is not packing densely"
10051        );
10052    }
10053}