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 HFS+ alternate volume header occupies the second-to-last 512-byte sector
3419        // of the partition.  For volumes whose allocation block size is ≤ 1024 bytes this
3420        // overlaps with the last TWO allocation blocks; for larger block sizes (4 KiB, etc.)
3421        // it falls within only the last block.  Either way the last 1–2 blocks are reserved
3422        // for VH/alternate-VH and are always marked allocated — if we include them in the
3423        // search, find_last_set_bit returns the very end of the partition, making trimming
3424        // impossible.  Exclude the final two blocks so that the trim point reflects the
3425        // last genuine user-data block instead.
3426        let search_up_to = self.vh.total_blocks.saturating_sub(2);
3427        let last = find_last_set_bit(&bitmap, search_up_to);
3428        // The trim point must leave room for the alternate VH at the new end:
3429        // the resizer counts those trailing blocks as used (two at 512 bytes).
3430        let trailing: u64 = if self.vh.block_size <= 512 { 2 } else { 1 };
3431        match last {
3432            Some(block) => {
3433                let byte = (block as u64 + 1 + trailing) * self.vh.block_size as u64;
3434                Ok(byte.min(self.total_size()))
3435            }
3436            None => Ok(self.total_size()),
3437        }
3438    }
3439
3440    fn defragmented_minimum_size(&mut self) -> Result<u64, FilesystemError> {
3441        // After a packed clone the volume holds:
3442        //   - boot blocks + primary VH (in allocation block 0)
3443        //   - all currently-allocated user data + B-tree files + bitmap
3444        //   - the alternate VH at the last 1024 bytes of the volume
3445        //
3446        // We can't access the `SourceCatalogSnapshot` here (it's built
3447        // later by the clone pipeline), so we can't run the precise
3448        // record-byte tally that [`hfsplus_defrag::plan_defrag_layout`]
3449        // uses. Instead, over-estimate the target catalog as
3450        // `2× source_catalog_file.logical_size`, matching the packing
3451        // overhead multiplier the planner applies. Then add an extra ×2
3452        // safety pad: the planner's exact estimate could in theory exceed
3453        // 2× source catalog if a volume has been heavily deleted (lots of
3454        // empty space inside leaves that the precise estimator wouldn't
3455        // count), but it can't exceed 4×. This puts a hard ceiling on the
3456        // partition envelope without the catalog-growth invariant the
3457        // planner enforces precisely on the clone path.
3458        //
3459        // We also add 1024 bytes to host the new alt-VH at the end of the
3460        // smaller image and round up to a whole block.
3461        let block_size = self.vh.block_size as u64;
3462        let used_blocks = self.vh.total_blocks.saturating_sub(self.vh.free_blocks) as u64;
3463        let used_bytes = used_blocks.saturating_mul(block_size);
3464        // Catalog-growth pad: 3× source catalog logical size. Combined
3465        // with the catalog blocks already counted in `used_blocks` (the
3466        // source's allocated catalog) that's 4× source total — the safe
3467        // over-estimate for the planner's exact-record-bytes × 2 result.
3468        let catalog_pad = self.vh.catalog_file.logical_size.saturating_mul(3);
3469        let total = used_bytes.saturating_add(catalog_pad).saturating_add(1024);
3470        Ok(total.div_ceil(block_size) * block_size)
3471    }
3472
3473    fn write_resource_fork_to(
3474        &mut self,
3475        entry: &FileEntry,
3476        writer: &mut dyn std::io::Write,
3477    ) -> Result<u64, FilesystemError> {
3478        let file_id = entry.location as u32;
3479        let (_data_fork, rsrc_fork) = self.find_file_by_id(file_id).ok_or_else(|| {
3480            FilesystemError::NotFound(format!("file id {file_id} not found in catalog"))
3481        })?;
3482        if rsrc_fork.logical_size == 0 {
3483            return Ok(0);
3484        }
3485        let data = read_fork(
3486            &mut self.reader,
3487            self.partition_offset,
3488            self.vh.block_size,
3489            &rsrc_fork,
3490        )?;
3491        writer.write_all(&data)?;
3492        Ok(data.len() as u64)
3493    }
3494
3495    fn resource_fork_size(&mut self, entry: &FileEntry) -> u64 {
3496        let file_id = entry.location as u32;
3497        self.find_file_by_id(file_id)
3498            .map(|(_d, r)| r.logical_size)
3499            .unwrap_or(0)
3500    }
3501
3502    fn fragmentation_stats(
3503        &mut self,
3504    ) -> Option<Result<disk_core::filesystem::FragmentationStats, FilesystemError>> {
3505        Some(Ok(hfsplus_fragmentation_stats(
3506            self.catalog_data(),
3507            self.catalog_first_leaf(),
3508            self.catalog_node_size(),
3509        )))
3510    }
3511
3512    fn blessed_system_folder(&mut self) -> Option<(u64, String)> {
3513        // finderInfo[0] = Classic Mac OS System Folder CNID
3514        let cnid = self.vh.finder_info[0];
3515        if cnid == 0 {
3516            // Try finderInfo[5] = Mac OS X boot directory
3517            let cnid_x = self.vh.finder_info[5];
3518            if cnid_x == 0 {
3519                return None;
3520            }
3521            return self.lookup_folder_name(cnid_x);
3522        }
3523        self.lookup_folder_name(cnid)
3524    }
3525
3526    fn fsck(&mut self) -> Option<Result<disk_core::fsck::FsckResult, FilesystemError>> {
3527        Some(crate::hfsplus_fsck::check(self))
3528    }
3529
3530    fn journal_detail(&mut self) -> Result<Option<Box<dyn std::any::Any + Send>>, FilesystemError> {
3531        // Boxed so `dyn Filesystem` callers can downcast to `JournalDetail`; concrete calls hit the inherent method.
3532        Ok(HfsPlusFilesystem::journal_detail(self)?
3533            .map(|d| Box::new(d) as Box<dyn std::any::Any + Send>))
3534    }
3535}
3536
3537impl<R: Read + Seek + Send> HfsPlusFilesystem<R> {
3538    fn lookup_folder_name(&self, cnid: u32) -> Option<(u64, String)> {
3539        // Find the thread record for this CNID to get its name
3540        if let Some((_node, _rec, offset)) = self.find_catalog_record_by_cnid(cnid) {
3541            let node_size = self.catalog_header.node_size as usize;
3542            let key_len = BigEndian::read_u16(&self.catalog_data[offset..offset + 2]) as usize;
3543            let mut rec_data_start = offset + 2 + key_len;
3544            if !rec_data_start.is_multiple_of(2) {
3545                rec_data_start += 1;
3546            }
3547            // Thread record: type(2) + reserved(2) + parentID(4) + name_len(2) + name(UTF-16BE)
3548            if rec_data_start + 10 <= self.catalog_data.len() {
3549                let name_len = BigEndian::read_u16(
3550                    &self.catalog_data[rec_data_start + 8..rec_data_start + 10],
3551                ) as usize;
3552                let name_end = rec_data_start + 10 + name_len * 2;
3553                if name_end <= self.catalog_data.len() {
3554                    let name = decode_utf16be(&self.catalog_data[rec_data_start + 10..name_end]);
3555                    return Some((cnid as u64, name));
3556                }
3557            }
3558            let _ = node_size;
3559        }
3560        // CNID set but can't resolve name
3561        Some((cnid as u64, format!("CNID {}", cnid)))
3562    }
3563}
3564
3565/// Stream a fork's data through its extent descriptors to a writer.
3566fn write_fork_to<R: Read + Seek>(
3567    reader: &mut R,
3568    partition_offset: u64,
3569    block_size: u32,
3570    fork: &ForkData,
3571    writer: &mut dyn std::io::Write,
3572) -> Result<u64, FilesystemError> {
3573    let size = fork.logical_size;
3574    let mut written: u64 = 0;
3575    let mut buf = vec![0u8; 64 * 1024];
3576    for ext in &fork.extents {
3577        if ext.is_empty() || written >= size {
3578            break;
3579        }
3580        let offset = partition_offset + ext.start_block as u64 * block_size as u64;
3581        let extent_len = ext.block_count as u64 * block_size as u64;
3582        let to_emit = extent_len.min(size - written);
3583        reader.seek(SeekFrom::Start(offset))?;
3584        let mut left = to_emit;
3585        while left > 0 {
3586            let n = (buf.len() as u64).min(left) as usize;
3587            reader.read_exact(&mut buf[..n])?;
3588            writer.write_all(&buf[..n])?;
3589            left -= n as u64;
3590        }
3591        written += to_emit;
3592    }
3593    Ok(written)
3594}
3595
3596/// Read a fork's data through its extent descriptors.
3597fn read_fork<R: Read + Seek>(
3598    reader: &mut R,
3599    partition_offset: u64,
3600    block_size: u32,
3601    fork: &ForkData,
3602) -> Result<Vec<u8>, FilesystemError> {
3603    let size = fork.logical_size as usize;
3604    let mut data = Vec::with_capacity(size);
3605
3606    let extent_count = fork.extents.iter().filter(|e| !e.is_empty()).count();
3607    let t_total = std::time::Instant::now();
3608    log::debug!(
3609        "[HFS+ read_fork] start: logical_size={} bytes, inline_extents={}",
3610        size,
3611        extent_count
3612    );
3613
3614    for (idx, ext) in fork.extents.iter().enumerate() {
3615        if ext.is_empty() {
3616            break;
3617        }
3618        let offset = partition_offset + ext.start_block as u64 * block_size as u64;
3619        let len = ext.block_count as u64 * block_size as u64;
3620        let read_len = len.min((size - data.len()) as u64) as usize;
3621        let t_ext = std::time::Instant::now();
3622        reader.seek(SeekFrom::Start(offset))?;
3623        let mut buf = vec![0u8; read_len];
3624        reader.read_exact(&mut buf)?;
3625        let elapsed_ms = t_ext.elapsed().as_secs_f64() * 1000.0;
3626        let mb = read_len as f64 / (1024.0 * 1024.0);
3627        let mbps = if elapsed_ms > 0.0 {
3628            mb / (elapsed_ms / 1000.0)
3629        } else {
3630            f64::INFINITY
3631        };
3632        log::debug!(
3633            "[HFS+ read_fork] ext[{}]: offset={} bytes={} time={:.1}ms throughput={:.2} MB/s",
3634            idx,
3635            offset,
3636            read_len,
3637            elapsed_ms,
3638            mbps
3639        );
3640        data.extend_from_slice(&buf);
3641        if data.len() >= size {
3642            break;
3643        }
3644    }
3645
3646    data.truncate(size);
3647    let total_ms = t_total.elapsed().as_secs_f64() * 1000.0;
3648    let total_mb = size as f64 / (1024.0 * 1024.0);
3649    let total_mbps = if total_ms > 0.0 {
3650        total_mb / (total_ms / 1000.0)
3651    } else {
3652        f64::INFINITY
3653    };
3654    log::debug!(
3655        "[HFS+ read_fork] done: {} bytes in {:.1}ms ({:.2} MB/s)",
3656        size,
3657        total_ms,
3658        total_mbps
3659    );
3660    Ok(data)
3661}
3662
3663/// Walk the extents-overflow B-tree leaf chain and collect every extent
3664/// belonging to (`file_id`, `fork_type`) whose `startBlock` key is at or
3665/// past `min_start_block`.
3666///
3667/// The HFS+ extents-overflow record is:
3668/// ```text
3669/// HFSPlusExtentKey { keyLength: u16 = 10, forkType: u8, pad: u8,
3670///                    fileID: u32, startBlock: u32 }
3671/// HFSPlusExtentRecord { extents: [HFSPlusExtentDescriptor; 8] }
3672/// ```
3673/// Each `HFSPlusExtentDescriptor` is `{ startBlock: u32, blockCount: u32 }`.
3674fn collect_hfsplus_overflow_extents(
3675    extents_data: &[u8],
3676    file_id: u32,
3677    fork_type: u8,
3678    min_start_block: u32,
3679) -> Vec<ExtentDescriptor> {
3680    use crate::hfs_common::walk_leaf_records;
3681
3682    if extents_data.len() < 14 + 30 {
3683        return Vec::new();
3684    }
3685    let header = BTreeHeaderRecord::parse(&extents_data[14..14 + 30.min(extents_data.len() - 14)]);
3686    let node_size = header.node_size as usize;
3687    if node_size == 0 {
3688        return Vec::new();
3689    }
3690
3691    let mut out: Vec<(u32, ExtentDescriptor)> = Vec::new();
3692    walk_leaf_records::<(), _>(
3693        extents_data,
3694        header.first_leaf_node,
3695        node_size,
3696        |_node, _rec_idx, _abs_off, rec| {
3697            if rec.len() < 12 {
3698                return None;
3699            }
3700            let key_len = BigEndian::read_u16(&rec[0..2]) as usize;
3701            // HFS+ extent key: forkType(1) pad(1) fileID(4) startBlock(4) = 10 bytes.
3702            if key_len < 10 || 2 + key_len > rec.len() {
3703                return None;
3704            }
3705            let rec_fork = rec[2];
3706            let rec_file = BigEndian::read_u32(&rec[4..8]);
3707            let rec_start_block = BigEndian::read_u32(&rec[8..12]);
3708            if rec_fork != fork_type || rec_file != file_id {
3709                return None;
3710            }
3711            if rec_start_block < min_start_block {
3712                return None;
3713            }
3714            // Record body is 8 × 8-byte HFSPlusExtentDescriptor immediately
3715            // after the key; HFS+ keys are 2-byte aligned by construction.
3716            let data_off = 2 + key_len;
3717            if data_off + 64 > rec.len() {
3718                return None;
3719            }
3720            for j in 0..8 {
3721                let ext = ExtentDescriptor::parse(&rec[data_off + j * 8..data_off + j * 8 + 8]);
3722                if ext.block_count > 0 {
3723                    out.push((rec_start_block, ext));
3724                }
3725            }
3726            None
3727        },
3728    );
3729
3730    out.sort_by_key(|(sb, _)| *sb);
3731    out.into_iter().map(|(_, e)| e).collect()
3732}
3733
3734/// Like `read_fork`, but consults the extents-overflow B-tree when the inline
3735/// 8 extents don't cover `fork.logical_size`.
3736///
3737/// `extents_overflow_data` is the bytes of `vh.extents_file` (loaded once via
3738/// `read_fork`; the extents-overflow file itself can't have overflow records
3739/// — its 8 inline extents are authoritative). Pass `None` if the volume has
3740/// no extents-overflow file or you've already verified the fork fits inline.
3741fn read_fork_with_overflow<R: Read + Seek>(
3742    reader: &mut R,
3743    partition_offset: u64,
3744    block_size: u32,
3745    fork: &ForkData,
3746    file_id: u32,
3747    fork_type: u8,
3748    extents_overflow_data: Option<&[u8]>,
3749) -> Result<Vec<u8>, FilesystemError> {
3750    let mut data = read_fork(reader, partition_offset, block_size, fork)?;
3751    let target = fork.logical_size as usize;
3752    if data.len() >= target {
3753        return Ok(data);
3754    }
3755    let Some(ext_data) = extents_overflow_data else {
3756        return Err(FilesystemError::InvalidData(format!(
3757            "file {file_id} fork {fork_type:#x}: {} bytes requested but only \
3758             {} bytes in inline extents and no extents-overflow B-tree available",
3759            fork.logical_size,
3760            data.len()
3761        )));
3762    };
3763    let inline_blocks: u32 = fork.extents.iter().map(|e| e.block_count).sum();
3764    let overflow = collect_hfsplus_overflow_extents(ext_data, file_id, fork_type, inline_blocks);
3765    for ext in overflow {
3766        if data.len() >= target {
3767            break;
3768        }
3769        if ext.block_count == 0 {
3770            continue;
3771        }
3772        let offset = partition_offset + ext.start_block as u64 * block_size as u64;
3773        let extent_len = ext.block_count as u64 * block_size as u64;
3774        let to_read = extent_len.min((target - data.len()) as u64) as usize;
3775        reader.seek(SeekFrom::Start(offset))?;
3776        let mut buf = vec![0u8; to_read];
3777        reader.read_exact(&mut buf)?;
3778        data.extend_from_slice(&buf);
3779    }
3780    if data.len() < target {
3781        return Err(FilesystemError::InvalidData(format!(
3782            "file {file_id} fork {fork_type:#x}: extents (inline + overflow) \
3783             cover {} of {} bytes",
3784            data.len(),
3785            fork.logical_size
3786        )));
3787    }
3788    Ok(data)
3789}
3790
3791/// Translate a byte offset within a fork (file space) to a byte offset on
3792/// the underlying device, using the fork's inline 8 extents. Returns
3793/// `Some((device_offset, contiguous_bytes_remaining))` for the extent that
3794/// contains `fork_offset`, or `None` if `fork_offset` falls past the inline
3795/// extents (caller would need to consult the extents-overflow B-tree).
3796fn translate_fork_offset(
3797    fork: &ForkData,
3798    block_size: u32,
3799    partition_offset: u64,
3800    fork_offset: u64,
3801) -> Option<(u64, u64)> {
3802    let bs = block_size as u64;
3803    let mut cursor: u64 = 0;
3804    for ext in &fork.extents {
3805        if ext.block_count == 0 {
3806            continue;
3807        }
3808        let len = ext.block_count as u64 * bs;
3809        if fork_offset < cursor + len {
3810            let local = fork_offset - cursor;
3811            let dev = partition_offset + ext.start_block as u64 * bs + local;
3812            return Some((dev, len - local));
3813        }
3814        cursor += len;
3815    }
3816    None
3817}
3818
3819/// Read exactly one B-tree node from a fork, by node index.
3820fn read_node_from_fork<R: Read + Seek>(
3821    reader: &mut R,
3822    partition_offset: u64,
3823    block_size: u32,
3824    fork: &ForkData,
3825    node_idx: u32,
3826    node_size: usize,
3827) -> Option<Vec<u8>> {
3828    let fork_offset = (node_idx as u64).checked_mul(node_size as u64)?;
3829    let (dev_off, contig) = translate_fork_offset(fork, block_size, partition_offset, fork_offset)?;
3830    if (contig as usize) < node_size {
3831        // Node straddles an extent boundary — should never happen for HFS+
3832        // since node_size divides block_size. Bail rather than do a multi-
3833        // extent read for the cheap probe path.
3834        return None;
3835    }
3836    reader.seek(SeekFrom::Start(dev_off)).ok()?;
3837    let mut buf = vec![0u8; node_size];
3838    reader.read_exact(&mut buf).ok()?;
3839    Some(buf)
3840}
3841
3842/// Probe an HFS+ volume header at `partition_offset` and return the volume
3843/// label without loading the full catalog.
3844///
3845/// The volume name lives in the *key* of the root folder record at
3846/// `(parent=1, name=<vol_name>)`. Catalog records are sorted primarily by
3847/// parent CNID (ascending u32), and `parent=1` is smaller than every other
3848/// parent in the catalog — so the root folder record is guaranteed to be
3849/// record 0 of the first leaf node. We don't need to look at the root
3850/// thread record at all (it sorts after every `parent=1` and `parent=2`
3851/// non-thread record, and on a volume with many top-level entries it can
3852/// spill into a later leaf — which is what was causing this probe to come
3853/// back empty for real volumes).
3854///
3855/// Reads node 0 (the B-tree header) and the first leaf node of the catalog
3856/// directly via the catalog fork's inline extents — typically two small
3857/// reads. Returns None if the partition doesn't hold an HFS+/HFSX volume.
3858pub fn probe_hfsplus_volume_label<R: Read + Seek>(
3859    reader: &mut R,
3860    partition_offset: u64,
3861) -> Option<String> {
3862    // APM "Apple_HFS" partitions can be pure classic HFS, native HFS+, or
3863    // **HFS-wrapped HFS+** (the legacy hybrid where a classic HFS MDB at
3864    // partition_offset+1024 carries `drEmbedSigWord=0x482B` and points at
3865    // the embedded HFS+ region somewhere inside the partition). The wrapper
3866    // case is the failure mode we hit on real volumes — `resolve_apple_hfs`
3867    // returns the embedded HFS+ offset for all three cases.
3868    let (fs_type, hfsplus_offset) = resolve_apple_hfs(reader, partition_offset);
3869    if fs_type != "hfsplus" {
3870        return None;
3871    }
3872    reader.seek(SeekFrom::Start(hfsplus_offset + 1024)).ok()?;
3873    let mut vh_buf = [0u8; 512];
3874    reader.read_exact(&mut vh_buf).ok()?;
3875    let vh = HfsPlusVolumeHeader::parse(&vh_buf).ok()?;
3876    if vh.block_size == 0 {
3877        return None;
3878    }
3879
3880    // Read node 0 to learn the catalog's actual node_size and first_leaf_node.
3881    // We don't yet know node_size, so probe with the volume's allocation block
3882    // size (HFS+ catalog node sizes are typically 4 KiB or 8 KiB and never
3883    // exceed the allocation block size on commonly-encountered volumes).
3884    let probe_size = (vh.block_size as usize).clamp(512, 32 * 1024);
3885    let node0 = read_node_from_fork(
3886        reader,
3887        hfsplus_offset,
3888        vh.block_size,
3889        &vh.catalog_file,
3890        0,
3891        probe_size,
3892    )?;
3893    if node0.len() < 14 + 30 {
3894        return None;
3895    }
3896    let header = BTreeHeaderRecord::parse(&node0[14..14 + 30]);
3897    let node_size = header.node_size as usize;
3898    if node_size == 0 || node_size > 32 * 1024 || header.first_leaf_node == 0 {
3899        return None;
3900    }
3901
3902    // Read the first leaf node. Record 0 is the root folder record whose key
3903    // is (parent=1, nameLength, name in UTF-16BE).
3904    let leaf = read_node_from_fork(
3905        reader,
3906        hfsplus_offset,
3907        vh.block_size,
3908        &vh.catalog_file,
3909        header.first_leaf_node,
3910        node_size,
3911    )?;
3912    if leaf.len() < node_size || leaf[8] as i8 != -1 {
3913        return None;
3914    }
3915    let num_records = BigEndian::read_u16(&leaf[10..12]) as usize;
3916    if num_records == 0 {
3917        return None;
3918    }
3919    let (rec_start, rec_end) = crate::hfs_common::btree_record_range(&leaf, node_size, 0);
3920    if rec_start >= rec_end || rec_end > node_size {
3921        return None;
3922    }
3923    let rec = &leaf[rec_start..rec_end];
3924    if rec.len() < 8 {
3925        return None;
3926    }
3927    let key_len = BigEndian::read_u16(&rec[0..2]) as usize;
3928    if key_len < 6 || 2 + key_len > rec.len() {
3929        return None;
3930    }
3931    let parent_id = BigEndian::read_u32(&rec[2..6]);
3932    if parent_id != 1 {
3933        return None;
3934    }
3935    let name_length = BigEndian::read_u16(&rec[6..8]) as usize;
3936    if name_length == 0 {
3937        return None;
3938    }
3939    let body_start = 8;
3940    let body_end = body_start + name_length * 2;
3941    if body_end > rec.len() || body_end > 2 + key_len {
3942        return None;
3943    }
3944    let label = decode_utf16be(&rec[body_start..body_end]);
3945    if label.is_empty() {
3946        None
3947    } else {
3948        Some(label)
3949    }
3950}
3951
3952/// Write data through a fork's extent descriptors.
3953fn write_fork_data<R: Write + Seek>(
3954    writer: &mut R,
3955    partition_offset: u64,
3956    block_size: u32,
3957    fork: &ForkData,
3958    data: &[u8],
3959) -> Result<(), FilesystemError> {
3960    let mut written = 0usize;
3961    for ext in &fork.extents {
3962        if ext.is_empty() || written >= data.len() {
3963            break;
3964        }
3965        let offset = partition_offset + ext.start_block as u64 * block_size as u64;
3966        let extent_len = ext.block_count as u64 * block_size as u64;
3967        let to_write = extent_len.min((data.len() - written) as u64) as usize;
3968        writer.seek(SeekFrom::Start(offset))?;
3969        writer.write_all(&data[written..written + to_write])?;
3970        written += to_write;
3971    }
3972    Ok(())
3973}
3974
3975/// Write a special-file B-tree fork that may have grown past its 8 inline
3976/// extents (§4b Phase B). Writes the inline extents first, then continues
3977/// into the fork's overflow extents pulled from the extents-overflow B-tree
3978/// — the write-side mirror of [`read_fork_with_overflow`]. Without this, a
3979/// grown catalog/attributes fork would be silently truncated on disk to its
3980/// first 8 extents even though it looks complete in memory.
3981#[allow(clippy::too_many_arguments)]
3982fn write_fork_data_with_overflow<R: Write + Seek>(
3983    writer: &mut R,
3984    partition_offset: u64,
3985    block_size: u32,
3986    fork: &ForkData,
3987    file_id: u32,
3988    fork_type: u8,
3989    extents_overflow_data: Option<&[u8]>,
3990    data: &[u8],
3991) -> Result<(), FilesystemError> {
3992    let mut written = 0usize;
3993    let write_extent = |writer: &mut R, ext: &ExtentDescriptor, written: &mut usize| {
3994        if ext.block_count == 0 || *written >= data.len() {
3995            return Ok(false);
3996        }
3997        let offset = partition_offset + ext.start_block as u64 * block_size as u64;
3998        let extent_len = ext.block_count as u64 * block_size as u64;
3999        let to_write = extent_len.min((data.len() - *written) as u64) as usize;
4000        writer.seek(SeekFrom::Start(offset))?;
4001        writer.write_all(&data[*written..*written + to_write])?;
4002        *written += to_write;
4003        Ok::<bool, FilesystemError>(true)
4004    };
4005
4006    for ext in &fork.extents {
4007        if ext.is_empty() {
4008            break;
4009        }
4010        if !write_extent(writer, ext, &mut written)? {
4011            return Ok(());
4012        }
4013    }
4014    if written >= data.len() {
4015        return Ok(());
4016    }
4017    // Inline extents didn't cover the whole fork — continue into overflow.
4018    let Some(ext_data) = extents_overflow_data else {
4019        return Err(FilesystemError::InvalidData(format!(
4020            "special file {file_id} fork {fork_type:#x}: {} bytes to write but only \
4021             inline extents and no extents-overflow B-tree",
4022            data.len()
4023        )));
4024    };
4025    let inline_blocks: u32 = fork.extents.iter().map(|e| e.block_count).sum();
4026    for ext in collect_hfsplus_overflow_extents(ext_data, file_id, fork_type, inline_blocks) {
4027        if !write_extent(writer, &ext, &mut written)? {
4028            break;
4029        }
4030    }
4031    if written < data.len() {
4032        return Err(FilesystemError::InvalidData(format!(
4033            "special file {file_id} fork {fork_type:#x}: extents cover {written} of {} bytes",
4034            data.len()
4035        )));
4036    }
4037    Ok(())
4038}
4039
4040/// Decode a UTF-16BE byte slice to a String.
4041fn decode_utf16be(data: &[u8]) -> String {
4042    let chars: Vec<u16> = data.chunks_exact(2).map(BigEndian::read_u16).collect();
4043    String::from_utf16_lossy(&chars)
4044}
4045
4046/// Find the volume label from the catalog B-tree (root folder thread record).
4047/// Count data-fork extents per file by walking catalog leaves. Cheap —
4048/// all catalog data already lives in `catalog_data` (loaded at FS open),
4049/// so this is a pure in-memory scan. Each file record's data fork holds 8
4050/// inline extent descriptors at offset 88+16; we tally non-zero ones and
4051/// flag forks that overflow into the extents-overflow B-tree as having
4052/// >1 extent (the most common cause of real-world fragmentation).
4053fn hfsplus_fragmentation_stats(
4054    catalog_data: &[u8],
4055    first_leaf: u32,
4056    node_size: usize,
4057) -> disk_core::filesystem::FragmentationStats {
4058    use crate::hfs_common::walk_leaf_records;
4059    use byteorder::{BigEndian, ByteOrder};
4060
4061    let mut stats = disk_core::filesystem::FragmentationStats::default();
4062    if node_size == 0 || catalog_data.is_empty() {
4063        return stats;
4064    }
4065    walk_leaf_records::<(), _>(
4066        catalog_data,
4067        first_leaf,
4068        node_size,
4069        |_node_idx, _rec_idx, _abs_off, rec| {
4070            // HFS+ catalog leaf record: u16 keyLength at offset 0, then the
4071            // key payload (parentID + HFSUniStr255), then the record body
4072            // (padded to even offset). The body's first 2 bytes carry the
4073            // record type — 0x0002 = file, 0x0001 = folder, threads = 3/4.
4074            if rec.len() < 4 {
4075                return None;
4076            }
4077            let key_len = BigEndian::read_u16(&rec[0..2]) as usize;
4078            let mut body_start = 2 + key_len;
4079            if !body_start.is_multiple_of(2) {
4080                body_start += 1;
4081            }
4082            if body_start + 88 + 80 > rec.len() {
4083                return None;
4084            }
4085            let body = &rec[body_start..];
4086            let rec_type = BigEndian::read_u16(&body[0..2]);
4087            if rec_type != 0x0002 {
4088                return None;
4089            }
4090            let data_fork = &body[88..88 + 80];
4091            let total_blocks = BigEndian::read_u32(&data_fork[12..16]);
4092            if total_blocks == 0 {
4093                return None;
4094            }
4095            let mut inline_extents: u32 = 0;
4096            let mut inline_blocks: u32 = 0;
4097            for i in 0..8 {
4098                let off = 16 + i * 8;
4099                let count = BigEndian::read_u32(&data_fork[off + 4..off + 8]);
4100                if count > 0 {
4101                    inline_extents += 1;
4102                    inline_blocks = inline_blocks.saturating_add(count);
4103                }
4104            }
4105            stats.files_with_data += 1;
4106            // Multiple inline extents OR overflow into the extents B-tree
4107            // both count as fragmented for UI purposes.
4108            if inline_extents > 1 || total_blocks > inline_blocks {
4109                stats.fragmented_files += 1;
4110            }
4111            None
4112        },
4113    );
4114    stats
4115}
4116
4117fn find_volume_label(catalog_data: &[u8], header: &BTreeHeaderRecord) -> String {
4118    use crate::hfs_common::walk_leaf_records;
4119
4120    let node_size = header.node_size as usize;
4121    if node_size == 0 {
4122        return String::new();
4123    }
4124
4125    walk_leaf_records::<String, _>(
4126        catalog_data,
4127        header.first_leaf_node,
4128        node_size,
4129        |_node_idx, _rec_idx, _abs_off, rec| {
4130            if rec.len() < 8 {
4131                return None;
4132            }
4133            let key_len = BigEndian::read_u16(&rec[0..2]) as usize;
4134            if key_len < 6 || 2 + key_len > rec.len() {
4135                return None;
4136            }
4137            // Catalog key: parent_id(4) + name_length(2) + name(UTF-16BE)
4138            let parent_id = BigEndian::read_u32(&rec[2..6]);
4139            if parent_id != 2 {
4140                return None;
4141            }
4142            let name_length = BigEndian::read_u16(&rec[6..8]) as usize;
4143            if name_length != 0 {
4144                return None;
4145            }
4146
4147            // Record data follows the key, 2-byte aligned. HFS+ keys are
4148            // already 2-byte sized so no padding is needed in the standard
4149            // case, but tolerate odd offsets defensively.
4150            let mut data_start = 2 + key_len;
4151            if !data_start.is_multiple_of(2) {
4152                data_start += 1;
4153            }
4154            if data_start + 10 > rec.len() {
4155                return None;
4156            }
4157            let record_type = BigEndian::read_i16(&rec[data_start..data_start + 2]);
4158            // Folder thread = 3
4159            if record_type != 3 {
4160                return None;
4161            }
4162            let thread_name_len =
4163                BigEndian::read_u16(&rec[data_start + 8..data_start + 10]) as usize;
4164            let body_start = data_start + 10;
4165            let body_end = body_start + thread_name_len * 2;
4166            if body_end > rec.len() {
4167                return None;
4168            }
4169            Some(decode_utf16be(&rec[body_start..body_end]))
4170        },
4171    )
4172    .unwrap_or_default()
4173}
4174
4175/// Find the index of the last set bit in a bitmap (MSB-first).
4176///
4177/// MSB-first means bit position 7 of byte N (mask 0x80) corresponds to
4178/// global index `N*8`, and bit position 0 (mask 0x01) to global index
4179/// `N*8 + 7`. The "last" set bit is the one with the highest global index.
4180fn find_last_set_bit(bitmap: &[u8], max_bits: u32) -> Option<u32> {
4181    let max = max_bits as usize;
4182    let scan_bytes = max.div_ceil(8).min(bitmap.len());
4183    for byte_idx in (0..scan_bytes).rev() {
4184        let byte = bitmap[byte_idx];
4185        if byte == 0 {
4186            continue;
4187        }
4188        // Within the byte, the highest global index belongs to the lowest
4189        // bit position (LSB). Iterate bit positions 0..8 ascending and
4190        // return the first set one in range.
4191        for bit_pos in 0..8u32 {
4192            let global = (byte_idx as u32) * 8 + (7 - bit_pos);
4193            if global >= max_bits {
4194                continue;
4195            }
4196            if (byte >> bit_pos) & 1 == 1 {
4197                return Some(global);
4198            }
4199        }
4200    }
4201    None
4202}
4203
4204// --- EditableFilesystem implementation ---
4205
4206impl<R: Read + Write + Seek + Send> EditableFilesystem for HfsPlusFilesystem<R> {
4207    fn as_filesystem(&self) -> &dyn disk_core::filesystem::Filesystem {
4208        self
4209    }
4210    fn as_filesystem_mut(&mut self) -> &mut dyn disk_core::filesystem::Filesystem {
4211        self
4212    }
4213    fn create_file(
4214        &mut self,
4215        parent: &FileEntry,
4216        name: &str,
4217        data: &mut dyn std::io::Read,
4218        data_len: u64,
4219        options: &CreateFileOptions,
4220    ) -> Result<FileEntry, FilesystemError> {
4221        let snap = self.snapshot();
4222        let result = self.create_file_inner(parent, name, data, data_len, options);
4223        if result.is_err() {
4224            self.restore_snapshot(snap);
4225        }
4226        result
4227    }
4228
4229    fn create_directory(
4230        &mut self,
4231        parent: &FileEntry,
4232        name: &str,
4233        options: &CreateDirectoryOptions,
4234    ) -> Result<FileEntry, FilesystemError> {
4235        let snap = self.snapshot();
4236        let result = self.create_directory_inner(parent, name, options);
4237        if result.is_err() {
4238            self.restore_snapshot(snap);
4239        }
4240        result
4241    }
4242
4243    fn delete_entry(
4244        &mut self,
4245        parent: &FileEntry,
4246        entry: &FileEntry,
4247    ) -> Result<(), FilesystemError> {
4248        let snap = self.snapshot();
4249        let result = self.delete_entry_inner(parent, entry);
4250        if result.is_err() {
4251            self.restore_snapshot(snap);
4252        }
4253        result
4254    }
4255
4256    fn rename(
4257        &mut self,
4258        parent: &FileEntry,
4259        entry: &FileEntry,
4260        new_name: &str,
4261    ) -> Result<(), FilesystemError> {
4262        if new_name == entry.name {
4263            return Ok(());
4264        }
4265        validate_hfsplus_create_name(new_name)?;
4266        let snap = self.snapshot();
4267        let result = self.rename_inner(parent, entry, new_name);
4268        if result.is_err() {
4269            self.restore_snapshot(snap);
4270        }
4271        result
4272    }
4273
4274    fn set_type_creator(
4275        &mut self,
4276        entry: &FileEntry,
4277        type_code: &str,
4278        creator_code: &str,
4279    ) -> Result<(), FilesystemError> {
4280        let snap = self.snapshot();
4281        let result = self.set_type_creator_inner(entry, type_code, creator_code);
4282        if result.is_err() {
4283            self.restore_snapshot(snap);
4284        }
4285        result
4286    }
4287
4288    fn write_resource_fork(
4289        &mut self,
4290        entry: &FileEntry,
4291        data: &mut dyn std::io::Read,
4292        len: u64,
4293    ) -> Result<(), FilesystemError> {
4294        let snap = self.snapshot();
4295        let result = self.write_resource_fork_inner(entry, data, len);
4296        if result.is_err() {
4297            self.restore_snapshot(snap);
4298        }
4299        result
4300    }
4301
4302    fn set_permissions(&mut self, entry: &FileEntry, mode: u32) -> Result<(), FilesystemError> {
4303        let frec = self.record_body_offset(entry.location as u32)?;
4304        let base = Self::bsd_type_bits(&self.catalog_data, frec, entry.is_directory());
4305        let new = disk_core::unix_common::inode::with_permission_bits(base, mode) as u16;
4306        let at = frec + HfsPlusBsdInfo::OFFSET + 10;
4307        BigEndian::write_u16(&mut self.catalog_data[at..at + 2], new);
4308        Ok(())
4309    }
4310
4311    fn set_owner(&mut self, entry: &FileEntry, uid: u32, gid: u32) -> Result<(), FilesystemError> {
4312        // HFSPlusBSDInfo carries 32-bit ownerID / groupID.
4313        let frec = self.record_body_offset(entry.location as u32)?;
4314        let at = frec + HfsPlusBsdInfo::OFFSET;
4315        BigEndian::write_u32(&mut self.catalog_data[at..at + 4], uid);
4316        BigEndian::write_u32(&mut self.catalog_data[at + 4..at + 8], gid);
4317        // An ownership change on a record with no POSIX info yet would be
4318        // invisible to OS X, which reads ownership only where `fileMode`
4319        // says the block is populated. Give it the type bits so the file
4320        // gets a real mode instead of a half-filled one.
4321        let mode_at = frec + HfsPlusBsdInfo::OFFSET + 10;
4322        if BigEndian::read_u16(&self.catalog_data[mode_at..mode_at + 2]) == 0 {
4323            let default_mode = if entry.is_directory() {
4324                0o040_755
4325            } else {
4326                0o100_644
4327            };
4328            BigEndian::write_u16(
4329                &mut self.catalog_data[mode_at..mode_at + 2],
4330                default_mode as u16,
4331            );
4332        }
4333        Ok(())
4334    }
4335
4336    fn sync_metadata(&mut self) -> Result<(), FilesystemError> {
4337        self.do_sync_metadata()
4338    }
4339
4340    fn free_space(&mut self) -> Result<u64, FilesystemError> {
4341        Ok(self.vh.free_blocks as u64 * self.vh.block_size as u64)
4342    }
4343
4344    fn set_blessed_folder(&mut self, entry: &FileEntry) -> Result<(), FilesystemError> {
4345        let snap = self.snapshot();
4346        let result = self.set_blessed_folder_inner(entry);
4347        if result.is_err() {
4348            self.restore_snapshot(snap);
4349        }
4350        result
4351    }
4352
4353    fn write_boot_blocks(&mut self, blocks: &[u8; 1024]) -> Result<(), FilesystemError> {
4354        self.set_boot_blocks(blocks);
4355        Ok(())
4356    }
4357}
4358
4359impl<R: Read + Write + Seek + Send> HfsPlusFilesystem<R> {
4360    /// Set (or replace) an inline extended attribute on `cnid`.
4361    ///
4362    /// Any existing record keyed by `(cnid, name)` is removed first so the
4363    /// final state holds exactly one record per `(cnid, name)` pair. Records
4364    /// whose payload would not fit in a single B-tree leaf node are rejected
4365    /// with `XattrTooLarge` — fork-style storage is not yet wired up.
4366    ///
4367    /// Wrapped in the snapshot guard so partial failures (e.g. a leaf split
4368    /// running out of free B-tree nodes) leave the in-memory state byte-equal
4369    /// to the pre-call state.
4370    pub fn set_xattr(
4371        &mut self,
4372        cnid: u32,
4373        name: &str,
4374        value: &[u8],
4375    ) -> Result<(), FilesystemError> {
4376        let snap = self.snapshot();
4377        let result = (|| -> Result<(), FilesystemError> {
4378            self.remove_xattr_records(cnid, Some(name))?;
4379            let rec = Self::build_inline_attr_record(cnid, name, value);
4380            self.insert_xattr_record(&rec)?;
4381            if let Some(set) = self.xattr_cnids.as_mut() {
4382                set.insert(cnid);
4383            }
4384            Ok(())
4385        })();
4386        if result.is_err() {
4387            self.restore_snapshot(snap);
4388        }
4389        result
4390    }
4391
4392    /// Remove a single extended attribute by name. Returns `NotFound` if no
4393    /// matching record exists. Wrapped in the snapshot guard.
4394    pub fn remove_xattr(&mut self, cnid: u32, name: &str) -> Result<(), FilesystemError> {
4395        let snap = self.snapshot();
4396        let result = (|| -> Result<(), FilesystemError> {
4397            let removed = self.remove_xattr_records(cnid, Some(name))?;
4398            if removed == 0 {
4399                return Err(FilesystemError::NotFound(format!(
4400                    "extended attribute '{name}' on cnid {cnid}"
4401                )));
4402            }
4403            // Drop the cnid from the cached set if no records remain.
4404            if self.list_xattrs(cnid)?.is_empty() {
4405                if let Some(set) = self.xattr_cnids.as_mut() {
4406                    set.remove(&cnid);
4407                }
4408            }
4409            Ok(())
4410        })();
4411        if result.is_err() {
4412            self.restore_snapshot(snap);
4413        }
4414        result
4415    }
4416}
4417
4418/// Captured per-record metadata, replayed by [`HfsPlusFilesystem::set_record_metadata`]
4419/// onto a freshly-created target catalog record. Field offsets are
4420/// identical for HFS+ file (248-byte) and folder (88-byte) records, so the
4421/// same struct serves both shapes.
4422///
4423/// Used by the clone pipeline (Step 21 of `docs/hfsplus_enhancements.md`)
4424/// to stamp the source's dates / Finder metadata / BSD info / text
4425/// encoding onto each cloned entry after `create_file` /
4426/// `create_directory` has assigned it a new CNID.
4427#[derive(Debug, Clone, Default)]
4428pub(crate) struct RecordMetadata {
4429    pub create_date: u32,
4430    pub content_mod_date: u32,
4431    pub attribute_mod_date: u32,
4432    pub access_date: u32,
4433    pub backup_date: u32,
4434    pub bsd_info: [u8; 16],
4435    pub finder_info: [u8; 16],
4436    pub extended_finder_info: [u8; 16],
4437    pub text_encoding: u32,
4438}
4439
4440impl<R: Read + Write + Seek + Send> HfsPlusFilesystem<R> {
4441    /// Replay captured per-record metadata onto the catalog record for
4442    /// `cnid`. Patches dates, BSD info, FInfo / FXInfo (or DInfo / DXInfo
4443    /// for folders — same offsets), and textEncoding. The locked-bit and
4444    /// flags-word fields are intentionally untouched here so callers can
4445    /// stamp them via dedicated paths (none today; the source's `flags` is
4446    /// already mirrored by `create_file` / `create_directory` for the
4447    /// fields the clone cares about).
4448    ///
4449    /// Mostly used by the clone replay path; tests cover the round-trip
4450    /// in `clone_round_trip_*`.
4451    /// Byte offset of catalog record `cnid`'s **body** (past the key) inside
4452    /// `catalog_data`, resolved through the record's thread entry.
4453    ///
4454    /// Shared by every in-place record patch — the metadata replay below and
4455    /// the POSIX-permission setters — so the thread-lookup / key-skip /
4456    /// even-alignment dance lives in one place.
4457    pub(crate) fn record_body_offset(&mut self, cnid: u32) -> Result<usize, FilesystemError> {
4458        let (_, _, t_offset) = self.find_catalog_record_by_cnid(cnid).ok_or_else(|| {
4459            FilesystemError::NotFound(format!("thread record for CNID {cnid} not found"))
4460        })?;
4461        let key_len = BigEndian::read_u16(&self.catalog_data[t_offset..t_offset + 2]) as usize;
4462        let mut rec_data_start = t_offset + 2 + key_len;
4463        if !rec_data_start.is_multiple_of(2) {
4464            rec_data_start += 1;
4465        }
4466        let thread_parent =
4467            BigEndian::read_u32(&self.catalog_data[rec_data_start + 4..rec_data_start + 8]);
4468        let thread_name_len =
4469            BigEndian::read_u16(&self.catalog_data[rec_data_start + 8..rec_data_start + 10])
4470                as usize;
4471        let thread_name = decode_utf16be(
4472            &self.catalog_data[rec_data_start + 10..rec_data_start + 10 + thread_name_len * 2],
4473        );
4474
4475        let (_, _, f_offset) = self
4476            .find_catalog_record(thread_parent, &thread_name)
4477            .ok_or_else(|| {
4478                FilesystemError::NotFound(format!(
4479                    "catalog record for CNID {cnid} ('{thread_name}') not found"
4480                ))
4481            })?;
4482        let fkey_len = BigEndian::read_u16(&self.catalog_data[f_offset..f_offset + 2]) as usize;
4483        let mut frec = f_offset + 2 + fkey_len;
4484        if !frec.is_multiple_of(2) {
4485            frec += 1;
4486        }
4487        if frec + 84 > self.catalog_data.len() {
4488            return Err(FilesystemError::InvalidData(format!(
4489                "catalog record for CNID {cnid} truncated"
4490            )));
4491        }
4492        Ok(frec)
4493    }
4494
4495    /// The `S_IFMT` bits to build a new `fileMode` on, for a record at
4496    /// `frec`.
4497    ///
4498    /// Normally the record's existing `fileMode` supplies them. A volume
4499    /// that has never carried POSIX info has `fileMode == 0` and therefore
4500    /// no type bits — writing bare permission bits there would leave a
4501    /// typeless mode that OS X reads as neither file nor directory, so the
4502    /// type comes from the catalog record's own kind instead.
4503    fn bsd_type_bits(catalog: &[u8], frec: usize, is_dir: bool) -> u32 {
4504        let at = frec + HfsPlusBsdInfo::OFFSET + 10;
4505        let cur = BigEndian::read_u16(&catalog[at..at + 2]) as u32;
4506        if cur & 0o170_000 != 0 {
4507            cur
4508        } else if is_dir {
4509            0o040_000
4510        } else {
4511            0o100_000
4512        }
4513    }
4514
4515    pub(crate) fn set_record_metadata(
4516        &mut self,
4517        cnid: u32,
4518        meta: &RecordMetadata,
4519    ) -> Result<(), FilesystemError> {
4520        let frec = self.record_body_offset(cnid)?;
4521
4522        BigEndian::write_u32(
4523            &mut self.catalog_data[frec + 12..frec + 16],
4524            meta.create_date,
4525        );
4526        BigEndian::write_u32(
4527            &mut self.catalog_data[frec + 16..frec + 20],
4528            meta.content_mod_date,
4529        );
4530        BigEndian::write_u32(
4531            &mut self.catalog_data[frec + 20..frec + 24],
4532            meta.attribute_mod_date,
4533        );
4534        BigEndian::write_u32(
4535            &mut self.catalog_data[frec + 24..frec + 28],
4536            meta.access_date,
4537        );
4538        BigEndian::write_u32(
4539            &mut self.catalog_data[frec + 28..frec + 32],
4540            meta.backup_date,
4541        );
4542        self.catalog_data[frec + 32..frec + 48].copy_from_slice(&meta.bsd_info);
4543        self.catalog_data[frec + 48..frec + 64].copy_from_slice(&meta.finder_info);
4544        self.catalog_data[frec + 64..frec + 80].copy_from_slice(&meta.extended_finder_info);
4545        BigEndian::write_u32(
4546            &mut self.catalog_data[frec + 80..frec + 84],
4547            meta.text_encoding,
4548        );
4549        Ok(())
4550    }
4551
4552    /// Set the volume's create / modify / backup / checked dates. The
4553    /// `modify_date` is overwritten by `do_sync_metadata` with `hfs_now()`
4554    /// at sync time, so callers that want the source's modifyDate to
4555    /// survive should call this *after* the final mutation (i.e. as the
4556    /// last step before sync) — but for the clone pipeline the modifyDate
4557    /// is intentionally bumped to "now" anyway, so the input value here is
4558    /// a fallback used only if sync doesn't run.
4559    pub(crate) fn set_volume_dates(
4560        &mut self,
4561        create_date: u32,
4562        modify_date: u32,
4563        backup_date: u32,
4564        checked_date: u32,
4565    ) {
4566        self.vh.create_date = create_date;
4567        self.vh.modify_date = modify_date;
4568        self.vh.backup_date = backup_date;
4569        self.vh.checked_date = checked_date;
4570    }
4571
4572    /// Replace the volume header's 8 × u32 Finder Info slots verbatim. The
4573    /// caller is responsible for any CNID remap (slots 0/3/5 etc. may hold
4574    /// CNIDs that point at the now-renumbered blessed folder / OS folder).
4575    pub(crate) fn set_volume_finder_info(&mut self, finder_info: [u32; 8]) {
4576        self.vh.finder_info = finder_info;
4577    }
4578
4579    /// Stage the source volume's first 1024 bytes (boot blocks) for write
4580    /// at the next `sync_metadata`. Macs only ever care about the boot
4581    /// blocks on the System volume, but the area is part of the volume's
4582    /// signature, so the clone copies it verbatim for round-trip parity.
4583    pub(crate) fn set_boot_blocks(&mut self, blocks: &[u8; 1024]) {
4584        self.pending_boot_blocks = Some(Box::new(*blocks));
4585    }
4586}
4587
4588impl<R: Read + Write + Seek + Send> HfsPlusFilesystem<R> {
4589    /// Locate the `\0\0\0\0HFS+ Private Data` directory at root, creating
4590    /// it if missing. Returns its `FileEntry`. The directory's existence is
4591    /// the prerequisite for any hardlink-related write — both
4592    /// [`Self::create_hardlink_inode`] and [`Self::create_hardlink`] call
4593    /// this internally so the replay path doesn't need to.
4594    pub fn ensure_private_dir(&mut self) -> Result<FileEntry, FilesystemError> {
4595        let private_name = hfsplus_private_dir_name();
4596        let root = self.root()?;
4597        for entry in self.list_directory(&root)? {
4598            if entry.is_directory() && entry.name == private_name {
4599                return Ok(entry);
4600            }
4601        }
4602        self.create_directory(&root, &private_name, &CreateDirectoryOptions::default())
4603    }
4604
4605    /// Locate the `.HFS+ Private Directory Data\r` directory at root,
4606    /// creating it if missing. Companion to [`Self::ensure_private_dir`]
4607    /// for directory hardlinks.
4608    pub fn ensure_dir_private_dir(&mut self) -> Result<FileEntry, FilesystemError> {
4609        let dir_private_name = hfsplus_dir_private_dir_name();
4610        let root = self.root()?;
4611        for entry in self.list_directory(&root)? {
4612            if entry.is_directory() && entry.name == dir_private_name {
4613                return Ok(entry);
4614            }
4615        }
4616        self.create_directory(&root, &dir_private_name, &CreateDirectoryOptions::default())
4617    }
4618
4619    /// Create a hardlink inode (`iNode<inode_num>`) under the volume's
4620    /// private directory. The inode's data and resource forks hold the
4621    /// shared payload; its `bsdInfo.special` (linkCount) starts at zero
4622    /// and is bumped per-link by [`Self::create_hardlink`]. The caller is
4623    /// responsible for calling `sync_metadata` once all replay edits are
4624    /// staged.
4625    ///
4626    /// Returns an `AlreadyExists` error if `iNode<inode_num>` already lives
4627    /// in the private dir, so callers don't accidentally double-emit.
4628    pub fn create_hardlink_inode(
4629        &mut self,
4630        inode_num: u32,
4631        data: &mut dyn std::io::Read,
4632        data_len: u64,
4633        options: &CreateFileOptions,
4634    ) -> Result<FileEntry, FilesystemError> {
4635        let snap = self.snapshot();
4636        let result = (|| -> Result<FileEntry, FilesystemError> {
4637            let private = self.ensure_private_dir()?;
4638            let inode_name = format!("iNode{inode_num}");
4639            self.create_file(&private, &inode_name, data, data_len, options)
4640        })();
4641        if result.is_err() {
4642            self.restore_snapshot(snap);
4643        }
4644        result
4645    }
4646
4647    /// Create a file hardlink stub at `(parent, name)` referencing the
4648    /// inode `iNode<inode_num>` under the private dir. The stub carries
4649    /// empty forks and FInfo `('hlnk', 'hfs+')` with
4650    /// `bsdInfo.special = inode_num`. The matching inode's linkCount
4651    /// (`bsdInfo.special`) is bumped by 1.
4652    ///
4653    /// Returns the stub's `FileEntry` with `link_target_cnid` set to the
4654    /// inode's CNID — callers that want the inode bytes can read through
4655    /// it directly via [`Self::read_file`] / [`Self::write_file_to`].
4656    pub fn create_hardlink(
4657        &mut self,
4658        parent: &FileEntry,
4659        name: &str,
4660        inode_num: u32,
4661    ) -> Result<FileEntry, FilesystemError> {
4662        let snap = self.snapshot();
4663        let result = (|| -> Result<FileEntry, FilesystemError> {
4664            let private_cnid = self.find_private_dir_cnid()?.ok_or_else(|| {
4665                FilesystemError::NotFound(
4666                    "HFS+ Private Data directory missing — call create_hardlink_inode first".into(),
4667                )
4668            })?;
4669            // Locate the inode and confirm it exists; bump its linkCount.
4670            let inode_name = format!("iNode{inode_num}");
4671            let inode_cnid = self
4672                .list_children(private_cnid)?
4673                .into_iter()
4674                .find_map(|c| match c {
4675                    CatalogEntry::File {
4676                        file_id, name: n, ..
4677                    } if n == inode_name => Some(file_id),
4678                    _ => None,
4679                })
4680                .ok_or_else(|| {
4681                    FilesystemError::NotFound(format!(
4682                        "hardlink inode '{inode_name}' missing — create it before linking"
4683                    ))
4684                })?;
4685
4686            // Create the stub as an empty file with type/creator overrides.
4687            let mut empty: &[u8] = &[];
4688            let stub = self.create_file(
4689                parent,
4690                name,
4691                &mut empty,
4692                0,
4693                &CreateFileOptions {
4694                    os_type: Some(*b"hlnk"),
4695                    os_creator: Some(*b"hfs+"),
4696                    ..Default::default()
4697                },
4698            )?;
4699
4700            // Patch bsdInfo.special on the stub to hold the iNodeNum.
4701            let parent_cnid = parent.location as u32;
4702            let (_, _, abs) = self.find_catalog_record(parent_cnid, name).ok_or_else(|| {
4703                FilesystemError::NotFound(format!(
4704                    "freshly-created stub '{name}' missing from catalog"
4705                ))
4706            })?;
4707            let key_len = BigEndian::read_u16(&self.catalog_data[abs..abs + 2]) as usize;
4708            let mut body = abs + 2 + key_len;
4709            if !body.is_multiple_of(2) {
4710                body += 1;
4711            }
4712            BigEndian::write_u32(&mut self.catalog_data[body + 44..body + 48], inode_num);
4713
4714            // Bump linkCount on the inode (saturates if it overflows).
4715            self.adjust_inode_link_count(private_cnid, inode_num, 1)?;
4716
4717            // Drop the cached inode_num -> CNID map so the next hardlink
4718            // resolution sees the new inode without picking up stale state.
4719            self.hardlink_inode_map = None;
4720
4721            // Surface the inode CNID on the returned entry so callers can
4722            // inspect it without re-walking the catalog.
4723            let mut fe = stub;
4724            fe.link_target_cnid = Some(inode_cnid as u64);
4725            Ok(fe)
4726        })();
4727        if result.is_err() {
4728            self.restore_snapshot(snap);
4729        }
4730        result
4731    }
4732
4733    /// Create a directory-hardlink inode (`dir_<inode_num>`) under the
4734    /// volume's directory-private directory. The inode is a regular folder
4735    /// — callers populate its contents like any other directory. Its
4736    /// `bsdInfo.special` (linkCount) starts at zero and is bumped per stub
4737    /// by [`Self::create_dir_hardlink`].
4738    pub fn create_dir_hardlink_inode(
4739        &mut self,
4740        inode_num: u32,
4741        options: &CreateDirectoryOptions,
4742    ) -> Result<FileEntry, FilesystemError> {
4743        let snap = self.snapshot();
4744        let result = (|| -> Result<FileEntry, FilesystemError> {
4745            let private = self.ensure_dir_private_dir()?;
4746            let inode_name = format!("dir_{inode_num}");
4747            self.create_directory(&private, &inode_name, options)
4748        })();
4749        if result.is_err() {
4750            self.restore_snapshot(snap);
4751        }
4752        result
4753    }
4754
4755    /// Create a directory-hardlink stub at `(parent, name)` referencing
4756    /// `dir_<inode_num>` under the directory-private directory. The stub
4757    /// is a FILE record (not a folder) with FInfo `('fdrp', 'MACS')` and
4758    /// `bsdInfo.special = inode_num`; `list_directory` surfaces it as a
4759    /// `Directory` entry that follows the link automatically. The matching
4760    /// inode's linkCount (`bsdInfo.special`) is bumped by 1.
4761    pub fn create_dir_hardlink(
4762        &mut self,
4763        parent: &FileEntry,
4764        name: &str,
4765        inode_num: u32,
4766    ) -> Result<FileEntry, FilesystemError> {
4767        let snap = self.snapshot();
4768        let result = (|| -> Result<FileEntry, FilesystemError> {
4769            let dir_private_cnid = self.find_dir_private_dir_cnid()?.ok_or_else(|| {
4770                FilesystemError::NotFound(
4771                    ".HFS+ Private Directory Data missing — call create_dir_hardlink_inode first"
4772                        .into(),
4773                )
4774            })?;
4775            let inode_name = format!("dir_{inode_num}");
4776            let inode_cnid = self
4777                .list_children(dir_private_cnid)?
4778                .into_iter()
4779                .find_map(|c| match c {
4780                    CatalogEntry::Folder {
4781                        folder_id, name: n, ..
4782                    } if n == inode_name => Some(folder_id),
4783                    _ => None,
4784                })
4785                .ok_or_else(|| {
4786                    FilesystemError::NotFound(format!(
4787                        "directory inode '{inode_name}' missing — create it before linking"
4788                    ))
4789                })?;
4790
4791            let mut empty: &[u8] = &[];
4792            let stub = self.create_file(
4793                parent,
4794                name,
4795                &mut empty,
4796                0,
4797                &CreateFileOptions {
4798                    os_type: Some(*b"fdrp"),
4799                    os_creator: Some(*b"MACS"),
4800                    ..Default::default()
4801                },
4802            )?;
4803
4804            // Patch bsdInfo.special on the stub to hold the iNodeNum.
4805            let parent_cnid = parent.location as u32;
4806            let (_, _, abs) = self.find_catalog_record(parent_cnid, name).ok_or_else(|| {
4807                FilesystemError::NotFound(format!(
4808                    "freshly-created stub '{name}' missing from catalog"
4809                ))
4810            })?;
4811            let key_len = BigEndian::read_u16(&self.catalog_data[abs..abs + 2]) as usize;
4812            let mut body = abs + 2 + key_len;
4813            if !body.is_multiple_of(2) {
4814                body += 1;
4815            }
4816            BigEndian::write_u32(&mut self.catalog_data[body + 44..body + 48], inode_num);
4817
4818            // Bump linkCount on the dir-inode's folder record (lives at
4819            // body+44..48 of the folder record too — DInfo doesn't overlap).
4820            self.adjust_dir_inode_link_count(dir_private_cnid, inode_num, 1)?;
4821
4822            // Drop the cached map so the next lookup sees the new inode.
4823            self.dir_hardlink_inode_map = None;
4824
4825            // Surface the inode CNID on the returned entry; flip its type
4826            // to Directory so callers treat it as one.
4827            let mut fe = stub;
4828            fe.entry_type = disk_core::entry::EntryType::Directory;
4829            fe.size = 0;
4830            fe.link_target_cnid = Some(inode_cnid as u64);
4831            Ok(fe)
4832        })();
4833        if result.is_err() {
4834            self.restore_snapshot(snap);
4835        }
4836        result
4837    }
4838
4839    /// Adjust the directory-inode `linkCount` (`bsdInfo.special` u32 — at
4840    /// folder-record body offset 32+12 = 44, same as file records) by
4841    /// `delta`. Mirrors `adjust_inode_link_count` for file inodes.
4842    fn adjust_dir_inode_link_count(
4843        &mut self,
4844        dir_private_cnid: u32,
4845        inode_num: u32,
4846        delta: i64,
4847    ) -> Result<u32, FilesystemError> {
4848        let inode_name = format!("dir_{inode_num}");
4849        let (_, _, abs_off) = self
4850            .find_catalog_record(dir_private_cnid, &inode_name)
4851            .ok_or_else(|| {
4852                FilesystemError::InvalidData(format!(
4853                    "directory inode '{inode_name}' missing from .HFS+ Private Directory Data"
4854                ))
4855            })?;
4856        let key_len = BigEndian::read_u16(&self.catalog_data[abs_off..abs_off + 2]) as usize;
4857        let mut body_start = abs_off + 2 + key_len;
4858        if !body_start.is_multiple_of(2) {
4859            body_start += 1;
4860        }
4861        let cur = BigEndian::read_u32(&self.catalog_data[body_start + 44..body_start + 48]) as i64;
4862        let new = (cur + delta).max(0) as u32;
4863        BigEndian::write_u32(
4864            &mut self.catalog_data[body_start + 44..body_start + 48],
4865            new,
4866        );
4867        Ok(new)
4868    }
4869
4870    fn create_file_inner(
4871        &mut self,
4872        parent: &FileEntry,
4873        name: &str,
4874        data: &mut dyn std::io::Read,
4875        data_len: u64,
4876        options: &CreateFileOptions,
4877    ) -> Result<FileEntry, FilesystemError> {
4878        let parent_cnid = parent.location as u32;
4879
4880        validate_hfsplus_create_name(name)?;
4881
4882        // Check for duplicates
4883        if self.find_catalog_record(parent_cnid, name).is_some() {
4884            return Err(FilesystemError::AlreadyExists(name.into()));
4885        }
4886
4887        // Assign CNID
4888        let file_id = self.vh.next_catalog_id;
4889        self.vh.next_catalog_id += 1;
4890
4891        // Determine type/creator: prefer caller-supplied, fill any missing or
4892        // blank half from the extension dictionary so a partial FInfo from an
4893        // imported AppleDouble isn't thrown away.
4894        let (type_code, creator_code) = hfs_common::resolve_create_type_creator(
4895            name,
4896            options.os_type,
4897            options.os_creator,
4898            options.type_code.as_deref(),
4899            options.creator_code.as_deref(),
4900        );
4901
4902        // Allocate blocks and write data
4903        let data_fork = self.write_data_to_blocks(data, data_len, file_id, HFSPLUS_FORK_DATA)?;
4904
4905        // Handle resource fork
4906        let rsrc_fork = if let Some(ref rsrc_src) = options.resource_fork {
4907            match rsrc_src {
4908                disk_core::filesystem::ResourceForkSource::Data(rsrc_data) => {
4909                    let mut cursor = std::io::Cursor::new(rsrc_data);
4910                    self.write_data_to_blocks(
4911                        &mut cursor,
4912                        rsrc_data.len() as u64,
4913                        file_id,
4914                        HFSPLUS_FORK_RESOURCE,
4915                    )?
4916                }
4917                disk_core::filesystem::ResourceForkSource::File(path) => {
4918                    let mut f = std::fs::File::open(path)?;
4919                    let len = f.metadata()?.len();
4920                    self.write_data_to_blocks(&mut f, len, file_id, HFSPLUS_FORK_RESOURCE)?
4921                }
4922            }
4923        } else {
4924            ForkData::empty()
4925        };
4926
4927        // Build file record. A cross-fs copy passes the source mtime through
4928        // options.unix_times; a genuinely-new file leaves it None and takes
4929        // `now`. The after-create Commander `set_dates` path still overrides.
4930        let mtime = options.unix_times.map(|t| t.mtime_or_now());
4931        let file_rec = Self::build_file_record_with_dates(
4932            file_id,
4933            &data_fork,
4934            &rsrc_fork,
4935            &type_code,
4936            &creator_code,
4937            mtime,
4938            options.finder_flags.unwrap_or(0),
4939        );
4940
4941        // Build key + record for catalog insertion
4942        let key = Self::build_catalog_key(parent_cnid, name);
4943        let mut key_record = key.clone();
4944        // Pad to even boundary if needed
4945        if key_record.len() % 2 != 0 {
4946            key_record.push(0);
4947        }
4948        key_record.extend_from_slice(&file_rec);
4949
4950        // Insert file record
4951        self.insert_catalog_record(&key_record)?;
4952
4953        // Build and insert thread record
4954        let (thread_key, thread_data) =
4955            Self::build_thread_record(CATALOG_FILE_THREAD, parent_cnid, name);
4956        let mut thread_record = thread_key;
4957        if thread_record.len() % 2 != 0 {
4958            thread_record.push(0);
4959        }
4960        thread_record.extend_from_slice(&thread_data);
4961        // Thread key is (file_id, ""), but build_thread_record used parent_cnid...
4962        // Actually, thread records are keyed by (file_id, "")
4963        let actual_thread_key = Self::build_catalog_key(file_id, "");
4964        let mut actual_thread_record = actual_thread_key;
4965        if actual_thread_record.len() % 2 != 0 {
4966            actual_thread_record.push(0);
4967        }
4968        actual_thread_record.extend_from_slice(&thread_data);
4969        self.insert_catalog_record(&actual_thread_record)?;
4970
4971        // Update parent valence
4972        self.update_parent_valence(parent_cnid, 1)?;
4973
4974        // Update VH counts
4975        self.vh.file_count += 1;
4976
4977        let path = if parent.path == "/" {
4978            format!("/{name}")
4979        } else {
4980            format!("{}/{name}", parent.path)
4981        };
4982        let mut fe = FileEntry::new_file(name.to_string(), path, data_len, file_id as u64);
4983        if type_code != [0; 4] {
4984            fe.type_code = Some(type_code);
4985            fe.creator_code = Some(creator_code);
4986        }
4987        if rsrc_fork.logical_size > 0 {
4988            fe.resource_fork_size = Some(rsrc_fork.logical_size);
4989        }
4990        let stamped = mtime.unwrap_or_else(disk_core::times::now);
4991        fe.modified_unix = Some(stamped);
4992        Ok(fe)
4993    }
4994
4995    fn create_directory_inner(
4996        &mut self,
4997        parent: &FileEntry,
4998        name: &str,
4999        options: &CreateDirectoryOptions,
5000    ) -> Result<FileEntry, FilesystemError> {
5001        let parent_cnid = parent.location as u32;
5002
5003        validate_hfsplus_create_name(name)?;
5004
5005        // Diagnostic: refuse to create a second `\0\0\0\0HFS+ Private Data`
5006        // or `.HFS+ Private Directory Data\r` directory at the volume root.
5007        // The hardlink subsystem is the only legitimate creator of these
5008        // magic-named directories (via `ensure_private_dir`), and it walks
5009        // `list_directory(root)` to detect an existing one before falling
5010        // back to create. If `find_catalog_record` later misses it (e.g.
5011        // due to a key-compare quirk), the resulting duplicate is the bug
5012        // observed in HFSTest6 (2026-05). Belt-and-suspenders: in addition
5013        // to the find_catalog_record check below, scan root by CNID-aware
5014        // lookup so neither a name-fold mismatch nor a misplaced record
5015        // slips through. See docs/hfs_write_tweaks.md.
5016        if parent_cnid == 2
5017            && (name == hfsplus_private_dir_name() || name == hfsplus_dir_private_dir_name())
5018        {
5019            let already_present = self
5020                .find_private_dir_cnid()?
5021                .filter(|_| name == hfsplus_private_dir_name())
5022                .is_some()
5023                || self
5024                    .find_dir_private_dir_cnid()?
5025                    .filter(|_| name == hfsplus_dir_private_dir_name())
5026                    .is_some();
5027            if already_present {
5028                let bt = std::backtrace::Backtrace::force_capture();
5029                log::error!(
5030                    "[HFS+] refused create_directory for magic name (already \
5031                     present). parent_cnid={}, leading NULs={}\nbacktrace:\n{bt}",
5032                    parent_cnid,
5033                    name.chars().take_while(|c| *c == '\0').count()
5034                );
5035                return Err(FilesystemError::AlreadyExists(name.into()));
5036            }
5037        }
5038
5039        // Check duplicates
5040        if self.find_catalog_record(parent_cnid, name).is_some() {
5041            return Err(FilesystemError::AlreadyExists(name.into()));
5042        }
5043
5044        // Assign CNID
5045        let folder_id = self.vh.next_catalog_id;
5046        self.vh.next_catalog_id += 1;
5047
5048        // Build folder record
5049        // See build_file_record_with_dates for the mtime semantics.
5050        let mtime = options.unix_times.map(|t| t.mtime_or_now());
5051        let folder_rec = Self::build_folder_record_with_dates(folder_id, mtime);
5052
5053        // Build key + record
5054        let key = Self::build_catalog_key(parent_cnid, name);
5055        let mut key_record = key.clone();
5056        if key_record.len() % 2 != 0 {
5057            key_record.push(0);
5058        }
5059        key_record.extend_from_slice(&folder_rec);
5060
5061        self.insert_catalog_record(&key_record)?;
5062
5063        // Thread record (type 3 = folder thread)
5064        let (_thread_key, thread_data) =
5065            Self::build_thread_record(CATALOG_FOLDER_THREAD, parent_cnid, name);
5066        let actual_thread_key = Self::build_catalog_key(folder_id, "");
5067        let mut actual_thread_record = actual_thread_key;
5068        if actual_thread_record.len() % 2 != 0 {
5069            actual_thread_record.push(0);
5070        }
5071        actual_thread_record.extend_from_slice(&thread_data);
5072        self.insert_catalog_record(&actual_thread_record)?;
5073
5074        // Update parent valence
5075        self.update_parent_valence(parent_cnid, 1)?;
5076
5077        // Update VH
5078        self.vh.folder_count += 1;
5079
5080        let path = if parent.path == "/" {
5081            format!("/{name}")
5082        } else {
5083            format!("{}/{name}", parent.path)
5084        };
5085        let mut fe = FileEntry::new_directory(name.to_string(), path, folder_id as u64);
5086        let stamped = mtime.unwrap_or_else(disk_core::times::now);
5087        fe.modified_unix = Some(stamped);
5088        Ok(fe)
5089    }
5090
5091    fn delete_entry_inner(
5092        &mut self,
5093        parent: &FileEntry,
5094        entry: &FileEntry,
5095    ) -> Result<(), FilesystemError> {
5096        let parent_cnid = parent.location as u32;
5097        let cnid = entry.location as u32;
5098
5099        // Drop every attribute record attached to this CNID so we don't leave
5100        // dangling rows in the attributes B-tree pointing at a freed CNID.
5101        // No-op on volumes without an attributes file. Errors here trip the
5102        // snapshot rollback in `delete_entry`.
5103        let xattrs_removed = self.remove_xattr_records(cnid, None)?;
5104        if xattrs_removed > 0 {
5105            if let Some(set) = self.xattr_cnids.as_mut() {
5106                set.remove(&cnid);
5107            }
5108        }
5109
5110        // Check directory is empty
5111        if entry.is_directory() {
5112            let children = self.list_children(cnid)?;
5113            if !children.is_empty() {
5114                return Err(FilesystemError::InvalidData(
5115                    "cannot delete non-empty directory".into(),
5116                ));
5117            }
5118        }
5119
5120        // File hardlink stub? If so, decrement the inode's linkCount before
5121        // we touch the stub's catalog row. Last reference also frees the
5122        // inode's forks and removes its iNode<N> entry from the private
5123        // directory.
5124        let link_inode_num = if entry.is_directory() {
5125            None
5126        } else {
5127            self.read_file_record_link_inode_num(parent_cnid, &entry.name)?
5128        };
5129        if let Some(inode_num) = link_inode_num {
5130            self.delete_hardlink_inode_ref(inode_num)?;
5131        }
5132
5133        // Find and remove the entry record
5134        let (node_idx, rec_idx, _offset) = self
5135            .find_catalog_record(parent_cnid, &entry.name)
5136            .ok_or_else(|| {
5137                FilesystemError::NotFound(format!("entry '{}' not found in catalog", entry.name))
5138            })?;
5139
5140        // If it's a file, free its data and resource fork blocks (including
5141        // any overflow extents) and remove its overflow records. Hardlink
5142        // stubs have empty forks (the data lives on the inode we already
5143        // adjusted above), so this is a no-op for them.
5144        if !entry.is_directory() {
5145            if let Some((data_fork, rsrc_fork)) = self.find_file_by_id(cnid) {
5146                self.free_fork_overflow_extents(cnid, HFSPLUS_FORK_DATA, &data_fork);
5147                self.free_fork_overflow_extents(cnid, HFSPLUS_FORK_RESOURCE, &rsrc_fork);
5148                self.remove_extents_overflow_records_for(cnid, HFSPLUS_FORK_DATA)?;
5149                self.remove_extents_overflow_records_for(cnid, HFSPLUS_FORK_RESOURCE)?;
5150                self.free_fork_blocks(&data_fork);
5151                self.free_fork_blocks(&rsrc_fork);
5152            }
5153        }
5154
5155        self.remove_catalog_record(node_idx, rec_idx)?;
5156
5157        // Find and remove the thread record
5158        if let Some((t_node, t_rec, _)) = self.find_catalog_record_by_cnid(cnid) {
5159            self.remove_catalog_record(t_node, t_rec)?;
5160        }
5161
5162        // Update parent valence
5163        self.update_parent_valence(parent_cnid, -1)?;
5164
5165        // Update VH counts
5166        if entry.is_directory() {
5167            self.vh.folder_count = self.vh.folder_count.saturating_sub(1);
5168        } else {
5169            self.vh.file_count = self.vh.file_count.saturating_sub(1);
5170        }
5171
5172        Ok(())
5173    }
5174
5175    fn rename_inner(
5176        &mut self,
5177        parent: &FileEntry,
5178        entry: &FileEntry,
5179        new_name: &str,
5180    ) -> Result<(), FilesystemError> {
5181        let parent_cnid = parent.location as u32;
5182        let cnid = entry.location as u32;
5183
5184        // Locate the entry's catalog record by its current (parent, name) key.
5185        let (node_idx, rec_idx, _abs) = self
5186            .find_catalog_record(parent_cnid, &entry.name)
5187            .ok_or_else(|| {
5188                FilesystemError::NotFound(format!("entry '{}' not found in catalog", entry.name))
5189            })?;
5190
5191        // Reject a collision only with a DIFFERENT record. On a case-insensitive
5192        // volume a case-only rename folds back to this same record (allowed).
5193        if let Some((n2, r2, _)) = self.find_catalog_record(parent_cnid, new_name) {
5194            if (n2, r2) != (node_idx, rec_idx) {
5195                return Err(FilesystemError::AlreadyExists(new_name.to_string()));
5196            }
5197        }
5198
5199        // Extract the record's data body to re-emit it verbatim under the new
5200        // key (CNID, forks, Finder info, BSD info all preserved).
5201        let node_size = self.catalog_node_size();
5202        let node_off = node_idx as usize * node_size;
5203        let node = &self.catalog_data[node_off..node_off + node_size];
5204        let (rec_start, rec_end) = crate::hfs_common::btree_record_range(node, node_size, rec_idx);
5205        let key_len = BigEndian::read_u16(&node[rec_start..rec_start + 2]) as usize;
5206        let mut data_rel = 2 + key_len;
5207        if !data_rel.is_multiple_of(2) {
5208            data_rel += 1;
5209        }
5210        let data_bytes = node[rec_start + data_rel..rec_end].to_vec();
5211
5212        let mut new_record = Self::build_catalog_key(parent_cnid, new_name);
5213        if new_record.len() % 2 != 0 {
5214            new_record.push(0);
5215        }
5216        new_record.extend_from_slice(&data_bytes);
5217
5218        self.remove_catalog_record(node_idx, rec_idx)?;
5219        self.insert_catalog_record(&new_record)?;
5220
5221        // The thread record's name is a variable-length UTF-16 string, so its
5222        // footprint changes with the new name — rebuild it (remove + reinsert)
5223        // rather than patching in place. Folders always have a thread; files
5224        // generally do.
5225        if let Some((t_node, t_rec, _)) = self.find_catalog_record_by_cnid(cnid) {
5226            self.remove_catalog_record(t_node, t_rec)?;
5227            let thread_type = if entry.is_directory() {
5228                CATALOG_FOLDER_THREAD
5229            } else {
5230                CATALOG_FILE_THREAD
5231            };
5232            let (_k, thread_data) = Self::build_thread_record(thread_type, parent_cnid, new_name);
5233            let mut thread_record = Self::build_catalog_key(cnid, "");
5234            if thread_record.len() % 2 != 0 {
5235                thread_record.push(0);
5236            }
5237            thread_record.extend_from_slice(&thread_data);
5238            self.insert_catalog_record(&thread_record)?;
5239        }
5240        // A same-parent rename leaves parent valence and VH counts unchanged.
5241        Ok(())
5242    }
5243
5244    /// Adjust the inode-record `linkCount` (`bsdInfo.special`, body offset
5245    /// 44..48) by `delta` and return the post-update count. Locates the
5246    /// record by `(private_dir_cnid, "iNode<N>")` so the in-memory edit
5247    /// hits the live catalog leaf. Touches `attributeModDate` so users can
5248    /// see when the inode was last referenced.
5249    fn adjust_inode_link_count(
5250        &mut self,
5251        private_cnid: u32,
5252        inode_num: u32,
5253        delta: i64,
5254    ) -> Result<u32, FilesystemError> {
5255        let inode_name = format!("iNode{inode_num}");
5256        let (_, _, abs_off) = self
5257            .find_catalog_record(private_cnid, &inode_name)
5258            .ok_or_else(|| {
5259                FilesystemError::InvalidData(format!(
5260                    "hardlink inode '{inode_name}' missing from HFS+ Private Data"
5261                ))
5262            })?;
5263        let key_len = BigEndian::read_u16(&self.catalog_data[abs_off..abs_off + 2]) as usize;
5264        let mut body_start = abs_off + 2 + key_len;
5265        if !body_start.is_multiple_of(2) {
5266            body_start += 1;
5267        }
5268        let cur = BigEndian::read_u32(&self.catalog_data[body_start + 44..body_start + 48]) as i64;
5269        let new = (cur + delta).max(0) as u32;
5270        BigEndian::write_u32(
5271            &mut self.catalog_data[body_start + 44..body_start + 48],
5272            new,
5273        );
5274        self.stamp_record_dates(body_start, false);
5275        Ok(new)
5276    }
5277
5278    /// Decrement `linkCount` on the inode named `iNode<inode_num>` under the
5279    /// volume's `HFS+ Private Data` dir. When the count drops to zero, free
5280    /// the inode's forks, drop its xattrs, and remove the inode catalog row
5281    /// plus thread plus map entry. Bumps `vh.file_count` and the private
5282    /// dir's valence accordingly.
5283    fn delete_hardlink_inode_ref(&mut self, inode_num: u32) -> Result<(), FilesystemError> {
5284        let private_cnid = self.find_private_dir_cnid()?.ok_or_else(|| {
5285            FilesystemError::InvalidData(
5286                "hardlink stub on volume with no HFS+ Private Data directory".into(),
5287            )
5288        })?;
5289        let inode_cnid = self.resolve_hardlink_inode(inode_num)?.ok_or_else(|| {
5290            FilesystemError::InvalidData(format!(
5291                "hardlink target iNode{inode_num} missing from HFS+ Private Data"
5292            ))
5293        })?;
5294
5295        let new_count = self.adjust_inode_link_count(private_cnid, inode_num, -1)?;
5296        if new_count > 0 {
5297            return Ok(());
5298        }
5299
5300        // Last reference — free the inode's forks and drop its rows.
5301        if let Some((data_fork, rsrc_fork)) = self.find_file_by_id(inode_cnid) {
5302            self.free_fork_overflow_extents(inode_cnid, HFSPLUS_FORK_DATA, &data_fork);
5303            self.free_fork_overflow_extents(inode_cnid, HFSPLUS_FORK_RESOURCE, &rsrc_fork);
5304            self.remove_extents_overflow_records_for(inode_cnid, HFSPLUS_FORK_DATA)?;
5305            self.remove_extents_overflow_records_for(inode_cnid, HFSPLUS_FORK_RESOURCE)?;
5306            self.free_fork_blocks(&data_fork);
5307            self.free_fork_blocks(&rsrc_fork);
5308        }
5309
5310        let xattrs_removed = self.remove_xattr_records(inode_cnid, None)?;
5311        if xattrs_removed > 0 {
5312            if let Some(set) = self.xattr_cnids.as_mut() {
5313                set.remove(&inode_cnid);
5314            }
5315        }
5316
5317        let inode_name = format!("iNode{inode_num}");
5318        if let Some((n, r, _)) = self.find_catalog_record(private_cnid, &inode_name) {
5319            self.remove_catalog_record(n, r)?;
5320        }
5321        if let Some((tn, tr, _)) = self.find_catalog_record_by_cnid(inode_cnid) {
5322            self.remove_catalog_record(tn, tr)?;
5323        }
5324        self.update_parent_valence(private_cnid, -1)?;
5325        self.vh.file_count = self.vh.file_count.saturating_sub(1);
5326
5327        if let Some(map) = self.hardlink_inode_map.as_mut() {
5328            map.remove(&inode_num);
5329        }
5330        Ok(())
5331    }
5332
5333    fn set_type_creator_inner(
5334        &mut self,
5335        entry: &FileEntry,
5336        type_code: &str,
5337        creator_code: &str,
5338    ) -> Result<(), FilesystemError> {
5339        let cnid = entry.location as u32;
5340
5341        // Find the thread to get parent + name
5342        let (_t_node, _t_rec, t_offset) =
5343            self.find_catalog_record_by_cnid(cnid).ok_or_else(|| {
5344                FilesystemError::NotFound(format!("thread for CNID {cnid} not found"))
5345            })?;
5346
5347        // Read parent_cnid and name from thread data
5348        let node_size = self.catalog_header.node_size as usize;
5349        let key_len = BigEndian::read_u16(&self.catalog_data[t_offset..t_offset + 2]) as usize;
5350        let mut rec_data_start = t_offset + 2 + key_len;
5351        if !rec_data_start.is_multiple_of(2) {
5352            rec_data_start += 1;
5353        }
5354        let thread_parent =
5355            BigEndian::read_u32(&self.catalog_data[rec_data_start + 4..rec_data_start + 8]);
5356        let thread_name_len =
5357            BigEndian::read_u16(&self.catalog_data[rec_data_start + 8..rec_data_start + 10])
5358                as usize;
5359        let thread_name = decode_utf16be(
5360            &self.catalog_data[rec_data_start + 10..rec_data_start + 10 + thread_name_len * 2],
5361        );
5362
5363        // Find the actual file record
5364        let (f_node, f_rec, f_offset) = self
5365            .find_catalog_record(thread_parent, &thread_name)
5366            .ok_or_else(|| {
5367                FilesystemError::NotFound(format!("file record for '{}' not found", thread_name))
5368            })?;
5369
5370        // Compute the record data offset
5371        let fkey_len = BigEndian::read_u16(&self.catalog_data[f_offset..f_offset + 2]) as usize;
5372        let mut frec_start = f_offset + 2 + fkey_len;
5373        if !frec_start.is_multiple_of(2) {
5374            frec_start += 1;
5375        }
5376
5377        // Write type and creator at offsets 48-52 and 52-56
5378        let tc = hfs_common::encode_fourcc(type_code);
5379        let cc = hfs_common::encode_fourcc(creator_code);
5380        self.catalog_data[frec_start + 48..frec_start + 52].copy_from_slice(&tc);
5381        self.catalog_data[frec_start + 52..frec_start + 56].copy_from_slice(&cc);
5382
5383        // Type/creator is an attribute-only change.
5384        self.stamp_record_dates(frec_start, false);
5385
5386        let _ = (f_node, f_rec, node_size); // suppress warnings
5387        Ok(())
5388    }
5389
5390    fn write_resource_fork_inner(
5391        &mut self,
5392        entry: &FileEntry,
5393        data: &mut dyn std::io::Read,
5394        len: u64,
5395    ) -> Result<(), FilesystemError> {
5396        let cnid = entry.location as u32;
5397
5398        // Free existing resource fork blocks (inline + any overflow extents).
5399        if let Some((_data_fork, rsrc_fork)) = self.find_file_by_id(cnid) {
5400            self.free_fork_overflow_extents(cnid, HFSPLUS_FORK_RESOURCE, &rsrc_fork);
5401            self.remove_extents_overflow_records_for(cnid, HFSPLUS_FORK_RESOURCE)?;
5402            self.free_fork_blocks(&rsrc_fork);
5403        }
5404
5405        // Allocate and write new resource fork data
5406        let new_rsrc = self.write_data_to_blocks(data, len, cnid, HFSPLUS_FORK_RESOURCE)?;
5407
5408        // Find the file record and update the resource fork
5409        let (t_node, _t_rec, t_offset) =
5410            self.find_catalog_record_by_cnid(cnid).ok_or_else(|| {
5411                FilesystemError::NotFound(format!("thread for CNID {cnid} not found"))
5412            })?;
5413
5414        let key_len = BigEndian::read_u16(&self.catalog_data[t_offset..t_offset + 2]) as usize;
5415        let mut rec_data_start = t_offset + 2 + key_len;
5416        if !rec_data_start.is_multiple_of(2) {
5417            rec_data_start += 1;
5418        }
5419        let thread_parent =
5420            BigEndian::read_u32(&self.catalog_data[rec_data_start + 4..rec_data_start + 8]);
5421        let thread_name_len =
5422            BigEndian::read_u16(&self.catalog_data[rec_data_start + 8..rec_data_start + 10])
5423                as usize;
5424        let thread_name = decode_utf16be(
5425            &self.catalog_data[rec_data_start + 10..rec_data_start + 10 + thread_name_len * 2],
5426        );
5427
5428        let (_f_node, _f_rec, f_offset) = self
5429            .find_catalog_record(thread_parent, &thread_name)
5430            .ok_or_else(|| {
5431                FilesystemError::NotFound(format!("file record for '{}' not found", thread_name))
5432            })?;
5433
5434        let fkey_len = BigEndian::read_u16(&self.catalog_data[f_offset..f_offset + 2]) as usize;
5435        let mut frec_start = f_offset + 2 + fkey_len;
5436        if !frec_start.is_multiple_of(2) {
5437            frec_start += 1;
5438        }
5439
5440        // Write resource fork data (at offset 168 in file record)
5441        let mut rsrc_bytes = [0u8; 80];
5442        new_rsrc.serialize(&mut rsrc_bytes);
5443        self.catalog_data[frec_start + 168..frec_start + 248].copy_from_slice(&rsrc_bytes);
5444
5445        // Fork rewrite is a content change, so contentModDate also moves.
5446        self.stamp_record_dates(frec_start, true);
5447
5448        let _ = t_node;
5449        Ok(())
5450    }
5451
5452    fn set_blessed_folder_inner(&mut self, entry: &FileEntry) -> Result<(), FilesystemError> {
5453        if !entry.is_directory() {
5454            return Err(FilesystemError::InvalidData(
5455                "can only bless a directory".into(),
5456            ));
5457        }
5458        self.vh.finder_info[0] = entry.location as u32;
5459        Ok(())
5460    }
5461}
5462
5463// --- Compact reader ---
5464
5465/// Compact reader for HFS+: layout-preserving image with zeros for unallocated blocks.
5466///
5467/// Outputs `total_blocks * block_size` bytes. Allocated blocks are read from the
5468/// source; unallocated blocks are emitted as zeros. This preserves the original
5469/// block layout so that `block_N` is always at byte offset `N * block_size`,
5470/// enabling correct filesystem browsing and reliable restore.
5471/// Streaming reader over a single user fork. Built by
5472/// [`HfsPlusFilesystem::fork_stream_reader`] with all inline + overflow
5473/// extents resolved up front; `read` walks the extent list, seeking the
5474/// underlying source reader as it crosses extent boundaries. Bounded
5475/// strictly by the fork's `logical_size` — the trailing partial block is
5476/// truncated at the byte level rather than the block level.
5477pub struct ForkStreamReader<'a, R: Read + Seek> {
5478    reader: &'a mut R,
5479    /// `(device_offset, byte_length)` for each extent, in fork order.
5480    extents: Vec<(u64, u64)>,
5481    /// Total bytes the reader is allowed to emit (`fork.logical_size`).
5482    logical_size: u64,
5483    /// Bytes already produced.
5484    pos: u64,
5485    cur_extent: usize,
5486    /// Bytes already consumed from the current extent.
5487    cur_extent_consumed: u64,
5488}
5489
5490impl<'a, R: Read + Seek> Read for ForkStreamReader<'a, R> {
5491    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
5492        if self.pos >= self.logical_size || buf.is_empty() {
5493            return Ok(0);
5494        }
5495        // Skip past extents whose bytes are fully consumed.
5496        while self.cur_extent < self.extents.len() {
5497            let (_, len) = self.extents[self.cur_extent];
5498            if self.cur_extent_consumed < len {
5499                break;
5500            }
5501            self.cur_extent += 1;
5502            self.cur_extent_consumed = 0;
5503        }
5504        if self.cur_extent >= self.extents.len() {
5505            return Ok(0);
5506        }
5507        let (off, len) = self.extents[self.cur_extent];
5508        let remaining_in_extent = len - self.cur_extent_consumed;
5509        let remaining_logical = self.logical_size - self.pos;
5510        let max_chunk = remaining_in_extent.min(remaining_logical);
5511        let to_read = (buf.len() as u64).min(max_chunk) as usize;
5512        self.reader
5513            .seek(SeekFrom::Start(off + self.cur_extent_consumed))?;
5514        let n = self.reader.read(&mut buf[..to_read])?;
5515        if n == 0 {
5516            return Ok(0);
5517        }
5518        self.cur_extent_consumed += n as u64;
5519        self.pos += n as u64;
5520        Ok(n)
5521    }
5522}
5523
5524pub struct CompactHfsPlusReader<R: Read + Seek> {
5525    reader: R,
5526    partition_offset: u64,
5527    block_size: u32,
5528    total_blocks: u32,
5529    bitmap: Vec<u8>,
5530    /// Current allocation block being output.
5531    current_block: u32,
5532    /// Byte position within the current block.
5533    block_pos: u64,
5534    #[allow(dead_code)]
5535    original_size: u64,
5536}
5537
5538impl<R: Read + Seek> CompactHfsPlusReader<R> {
5539    pub fn new(
5540        mut reader: R,
5541        partition_offset: u64,
5542    ) -> Result<(Self, CompactResult), FilesystemError> {
5543        // Read volume header
5544        log::debug!(
5545            "[HFS+ compact] seeking to VH at offset {}",
5546            partition_offset + 1024
5547        );
5548        reader.seek(SeekFrom::Start(partition_offset + 1024))?;
5549        let mut vh_buf = [0u8; 512];
5550        reader.read_exact(&mut vh_buf)?;
5551        let vh = HfsPlusVolumeHeader::parse(&vh_buf)?;
5552        log::debug!(
5553            "[HFS+ compact] VH ok: block_size={}, total_blocks={}, alloc_file extents[0]=(start={}, count={})",
5554            vh.block_size,
5555            vh.total_blocks,
5556            vh.allocation_file.extents[0].start_block,
5557            vh.allocation_file.extents[0].block_count,
5558        );
5559
5560        // Load extents-overflow first so the allocation file can use it if
5561        // it's fragmented past 8 inline extents.
5562        let extents_overflow_data = if vh.extents_file.logical_size > 0 {
5563            Some(read_fork(
5564                &mut reader,
5565                partition_offset,
5566                vh.block_size,
5567                &vh.extents_file,
5568            )?)
5569        } else {
5570            None
5571        };
5572
5573        // Read allocation file
5574        let alloc_data = read_fork_with_overflow(
5575            &mut reader,
5576            partition_offset,
5577            vh.block_size,
5578            &vh.allocation_file,
5579            HFSPLUS_ALLOCATION_FILE_ID,
5580            HFSPLUS_FORK_DATA,
5581            extents_overflow_data.as_deref(),
5582        )
5583        .map_err(|e| {
5584            log::debug!("[HFS+ compact] read_fork(alloc_file) failed: {e}");
5585            e
5586        })?;
5587        log::debug!(
5588            "[HFS+ compact] allocation bitmap read: {} bytes",
5589            alloc_data.len()
5590        );
5591
5592        // Count allocated blocks
5593        let mut allocated = 0u32;
5594        for bit in 0..vh.total_blocks {
5595            let byte_idx = bit as usize / 8;
5596            let bit_idx = 7 - (bit % 8);
5597            if byte_idx < alloc_data.len() && (alloc_data[byte_idx] >> bit_idx) & 1 == 1 {
5598                allocated += 1;
5599            }
5600        }
5601        log::debug!(
5602            "[HFS+ compact] allocated={} / {} total blocks ({} free)",
5603            allocated,
5604            vh.total_blocks,
5605            vh.total_blocks - allocated,
5606        );
5607
5608        let original_size = vh.total_blocks as u64 * vh.block_size as u64;
5609        // Layout-preserving: output size equals the original partition size.
5610        // Unallocated blocks are zeroed, so they compress extremely well.
5611        let compacted_size = original_size;
5612        // data_size: only allocated blocks require disk reads.
5613        let data_size = allocated as u64 * vh.block_size as u64;
5614        log::debug!(
5615            "[HFS+ compact] compacted_size={} data_size={} original_size={} (layout-preserving; free blocks -> zeros)",
5616            compacted_size, data_size, original_size
5617        );
5618
5619        let result = CompactResult {
5620            original_size,
5621            compacted_size,
5622            data_size,
5623            clusters_used: allocated,
5624        };
5625
5626        Ok((
5627            CompactHfsPlusReader {
5628                reader,
5629                partition_offset,
5630                block_size: vh.block_size,
5631                total_blocks: vh.total_blocks,
5632                bitmap: alloc_data,
5633                current_block: 0,
5634                block_pos: 0,
5635                original_size,
5636            },
5637            result,
5638        ))
5639    }
5640
5641    fn is_block_allocated(&self, block: u32) -> bool {
5642        let byte_idx = block as usize / 8;
5643        let bit_idx = 7 - (block % 8);
5644        byte_idx < self.bitmap.len() && (self.bitmap[byte_idx] >> bit_idx) & 1 == 1
5645    }
5646}
5647
5648impl<R: Read + Seek> Read for CompactHfsPlusReader<R> {
5649    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
5650        if self.current_block >= self.total_blocks {
5651            return Ok(0);
5652        }
5653
5654        let block_size = self.block_size as u64;
5655
5656        // Find the longest run starting at the current block whose
5657        // allocation state matches the current block's, then service as
5658        // much of that run as fits in `buf`. Coalescing turns a 24 GiB
5659        // partition's ~6M per-4-KiB-block syscalls into far fewer reads
5660        // (typically one per file extent), critical on slow source media
5661        // where each seek + read costs milliseconds.
5662        let allocated = self.is_block_allocated(self.current_block);
5663        let mut run_end = self.current_block + 1;
5664        while run_end < self.total_blocks && self.is_block_allocated(run_end) == allocated {
5665            run_end += 1;
5666        }
5667
5668        let bytes_in_run = (run_end - self.current_block) as u64 * block_size - self.block_pos;
5669        let to_read = (bytes_in_run as usize).min(buf.len());
5670
5671        let n = if allocated {
5672            let offset =
5673                self.partition_offset + self.current_block as u64 * block_size + self.block_pos;
5674            self.reader.seek(SeekFrom::Start(offset))?;
5675            self.reader.read(&mut buf[..to_read])?
5676        } else {
5677            // Unallocated run — emit zeros so free space compresses to nothing.
5678            buf[..to_read].fill(0);
5679            to_read
5680        };
5681
5682        // Advance cursor by however many bytes we actually produced.
5683        let mut remaining_in_n = n as u64;
5684        // First, finish the partial block we may have been mid-way through.
5685        if self.block_pos > 0 {
5686            let want = block_size - self.block_pos;
5687            let take = remaining_in_n.min(want);
5688            self.block_pos += take;
5689            remaining_in_n -= take;
5690            if self.block_pos >= block_size {
5691                self.block_pos = 0;
5692                self.current_block += 1;
5693            }
5694        }
5695        // Then jump whole blocks.
5696        let whole = (remaining_in_n / block_size) as u32;
5697        self.current_block += whole;
5698        remaining_in_n -= whole as u64 * block_size;
5699        // Finally the trailing partial block.
5700        if remaining_in_n > 0 {
5701            self.block_pos = remaining_in_n;
5702        }
5703
5704        Ok(n)
5705    }
5706}
5707
5708// --- Blank-volume builder ---
5709
5710/// Build a freshly-formatted, mountable HFS+ (or HFSX) image as a raw byte
5711/// buffer.
5712///
5713/// Layout (sized for `block_size` ≤ `node_size`; node_size is fixed at
5714/// 4096 to match Apple's default):
5715///
5716/// - Block 0: VH region (1024 boot bytes + primary VH at byte 1024)
5717/// - Bitmap blocks: enough to hold `total_blocks / 8` bytes
5718/// - Extents-overflow B-tree: 4 nodes (header + empty leaf + 2 free)
5719/// - Catalog B-tree: 4 nodes (header + leaf with root folder + thread)
5720/// - Free user-data area
5721/// - Last 1024 bytes: alt VH (mirror of primary)
5722///
5723/// The catalog leaf carries two records: the root folder thread keyed by
5724/// `(parent=2, name="")` (data: `recordType=3`, `parentID=1`, nodeName =
5725/// `name`) and the root folder record keyed by `(parent=1, name=name)`
5726/// (data: `recordType=1`, `folderID=2`, valence=0, dates set to now).
5727///
5728/// `case_sensitive=true` produces an HFSX volume (signature `H+`/`HX`,
5729/// catalog header `keyCompareType = kHFSBinaryCompare`); `false` produces
5730/// plain HFS+ with case-folding compare. The journaled bit is never set —
5731/// edit mode refuses journaled volumes (Step 4) so a freshly built blank
5732/// must opt out.
5733///
5734/// Panics if `size_bytes` is too small to hold the reserved region or if
5735/// `block_size` isn't a power of 2 in `[512, 4096]`.
5736pub fn create_blank_hfsplus(
5737    size_bytes: u64,
5738    block_size: u32,
5739    name: &str,
5740    case_sensitive: bool,
5741) -> Vec<u8> {
5742    create_blank_hfsplus_sized(size_bytes, block_size, name, case_sensitive, 0, 0)
5743}
5744
5745/// Volume-scaled default catalog B-tree size, in bytes, for a fresh HFS+
5746/// volume. Mirrors classic HFS's `default_btree_sizes`: ~0.5% of the volume so
5747/// a blank holds thousands of records before exhausting its node budget (HFS+
5748/// has no grow-on-full path — docs/hfsplus_btree_growth_plan.md §4c). The
5749/// `build_blank_hfsplus_front` caller clamps the result to whole nodes in
5750/// `[4, header-bitmap capacity]`.
5751pub fn default_hfsplus_catalog_bytes(volume_bytes: u64) -> u64 {
5752    volume_bytes / 200
5753}
5754
5755/// Variant of [`create_blank_hfsplus`] that lets the caller request minimum
5756/// catalog and extents-overflow B-tree sizes (in bytes) — e.g. a clone target
5757/// that must hold every record from a fragmented source, or a test that needs a
5758/// deep catalog without a multi-GiB volume. Each minimum is rounded up to whole
5759/// nodes and raised to the 4-node floor; the catalog also never drops below the
5760/// volume-scaled [`default_hfsplus_catalog_bytes`].
5761pub fn create_blank_hfsplus_sized(
5762    size_bytes: u64,
5763    block_size: u32,
5764    name: &str,
5765    case_sensitive: bool,
5766    min_catalog_bytes: u32,
5767    min_extents_bytes: u32,
5768) -> Vec<u8> {
5769    let (front, vh_bytes, image_size) = build_blank_hfsplus_front(
5770        size_bytes,
5771        block_size,
5772        name,
5773        case_sensitive,
5774        min_catalog_bytes,
5775        min_extents_bytes,
5776    );
5777    let mut img = vec![0u8; image_size];
5778    img[..front.len()].copy_from_slice(&front);
5779    let alt = image_size - 1024;
5780    img[alt..alt + 512].copy_from_slice(&vh_bytes);
5781    img
5782}
5783
5784/// Build only the front (boot/VH/bitmap/B-trees) region and the 512-byte
5785/// VH bytes. Returns `(front_bytes, vh_bytes, total_image_size)`. Used by
5786/// both [`create_blank_hfsplus`] (full Vec) and
5787/// [`write_blank_hfsplus_into`] (sparse-file streaming).
5788fn build_blank_hfsplus_front(
5789    size_bytes: u64,
5790    block_size: u32,
5791    name: &str,
5792    case_sensitive: bool,
5793    min_catalog_bytes: u32,
5794    min_extents_bytes: u32,
5795) -> (Vec<u8>, [u8; 512], usize) {
5796    assert!(
5797        block_size.is_power_of_two() && (512..=4096).contains(&block_size),
5798        "block_size must be a power of 2 in [512, 4096], got {block_size}"
5799    );
5800    let bs = block_size as u64;
5801    let total_blocks = (size_bytes / bs) as u32;
5802    assert!(total_blocks >= 64, "image too small ({size_bytes} bytes)");
5803
5804    // node_size pinned at 4096 — Apple's default and what every existing
5805    // helper in this file assumes (compute_record_offsets, etc.).
5806    let node_size: usize = 4096;
5807    let nodes_per_block: u32 = (node_size as u32 / block_size).max(1);
5808    let blocks_per_node: u32 = (block_size / node_size as u32).max(1);
5809    let _ = nodes_per_block;
5810
5811    // Size the catalog B-tree from the volume (docs/hfsplus_btree_growth_plan.md
5812    // §4c): HFS+ has no grow-on-full path, so a blank that reserved only the
5813    // legacy 4 nodes exhausted after ~24 files. Scale it to ~0.5% of the volume
5814    // (mirroring classic HFS's `default_btree_sizes`) so a fresh volume holds
5815    // thousands of records, honouring an explicit `min_catalog_bytes` floor from
5816    // sized/clone callers. The extents-overflow tree keeps the 4-node default
5817    // unless a caller asks for more (its own scaling is P3 work).
5818    //
5819    // `to_nodes` rounds a byte budget up to whole nodes and clamps to
5820    // [4, header-bitmap capacity]. The blank's node-allocation bitmap lives
5821    // entirely in the header node's record 2 (bytes 248..node_size-8 — see
5822    // `write_blank_btree_header_node`), which addresses `(node_size - 256) * 8`
5823    // nodes without dedicated map nodes; cap there. Volumes whose 0.5% catalog
5824    // would exceed this (~123 MiB at 4096, i.e. hundreds of GiB of volume) are
5825    // far larger than this tool targets, and the defrag/clone path — which does
5826    // build map nodes — covers them. (The §4b live-grow path appends map nodes
5827    // incrementally past this cap; see `grow_btree_fork`.)
5828    let max_btree_nodes: u32 = ((node_size - 256) * 8) as u32;
5829    let to_nodes = |bytes: u64| -> u32 {
5830        bytes
5831            .div_ceil(node_size as u64)
5832            .clamp(4, max_btree_nodes as u64) as u32
5833    };
5834    let catalog_node_count =
5835        to_nodes(default_hfsplus_catalog_bytes(size_bytes).max(min_catalog_bytes as u64));
5836    let extents_node_count = to_nodes(min_extents_bytes as u64);
5837
5838    let catalog_btree_blocks: u32 = catalog_node_count * blocks_per_node;
5839    let extents_btree_blocks: u32 = extents_node_count * blocks_per_node;
5840
5841    let bitmap_bytes = total_blocks.div_ceil(8) as u64;
5842    let bitmap_blocks = bitmap_bytes.div_ceil(bs) as u32;
5843
5844    let bitmap_start: u32 = 1;
5845    let extents_start: u32 = bitmap_start + bitmap_blocks;
5846    let catalog_start: u32 = extents_start + extents_btree_blocks;
5847    let reserved_blocks: u32 = catalog_start + catalog_btree_blocks;
5848    assert!(
5849        reserved_blocks + 1 < total_blocks,
5850        "image too small for reserved region: need {} blocks, have {total_blocks}",
5851        reserved_blocks + 1
5852    );
5853
5854    let image_size = total_blocks as usize * block_size as usize;
5855    // Build only the front (boot/VH/bitmap/B-trees) and the 512-byte alt VH
5856    // separately, so multi-GB blanks don't have to materialise a full image
5857    // in RAM. The middle of the volume is free space — sparse-file zeroes
5858    // are good enough.
5859    let front_size = reserved_blocks as usize * block_size as usize;
5860    let mut front = vec![0u8; front_size];
5861
5862    // The alternate volume header occupies the last 1024 bytes of the volume.
5863    // Apple always marks the trailing allocation block(s) it touches as used
5864    // (one block for block_size >= 1024, two for 512-byte blocks) and excludes
5865    // them from the free-block count. Without this, fsck_hfs reports
5866    // "Invalid volume free block count" / bitmap under-allocation.
5867    let alt_vh_start_block: u32 = ((image_size as u64 - 1024) / bs) as u32;
5868    let trailing_blocks: u32 = total_blocks - alt_vh_start_block;
5869
5870    // --- Allocation bitmap ---
5871    {
5872        let bitmap_off = bitmap_start as usize * block_size as usize;
5873        let bitmap = &mut front[bitmap_off..bitmap_off + (bitmap_bytes as usize).max(1)];
5874        let mut mark = |blk: u32| {
5875            // MSB-first big-endian: bit position (7 - blk%8) of byte (blk/8).
5876            let byte_idx = (blk / 8) as usize;
5877            let bit_pos = 7 - (blk % 8);
5878            bitmap[byte_idx] |= 1 << bit_pos;
5879        };
5880        for blk in 0..reserved_blocks {
5881            mark(blk);
5882        }
5883        for blk in alt_vh_start_block..total_blocks {
5884            mark(blk);
5885        }
5886    }
5887
5888    // --- Extents-overflow B-tree (empty: header node only) ---
5889    // An empty tree is depth-0 / root-0 with no leaf node, exactly as Apple's
5890    // newfs_hfs lays it out (see `write_blank_btree_header_node`). The first
5891    // fragmented-file insert bootstraps a root leaf via `btree_insert_full`.
5892    {
5893        let off = extents_start as usize * block_size as usize;
5894        write_blank_btree_header_node(
5895            &mut front[off..off + node_size],
5896            node_size,
5897            /* leaf_records= */ 0,
5898            /* total_nodes= */ extents_node_count,
5899            /* free_nodes= */ extents_node_count - 1, // only the header node is used
5900            /* max_key_len= */ 10,
5901            /* key_compare_type= */ 0, // unused for extents
5902        );
5903    }
5904
5905    // --- Catalog B-tree (header + leaf with root + thread) ---
5906    let name_utf16: Vec<u16> = fs_mac_unicode::hfs_unicode::decompose_str(name);
5907    {
5908        let off = catalog_start as usize * block_size as usize;
5909        let key_compare = if case_sensitive {
5910            KEY_COMPARE_BINARY
5911        } else {
5912            KEY_COMPARE_CASE_FOLDING
5913        };
5914        write_blank_btree_header_node(
5915            &mut front[off..off + node_size],
5916            node_size,
5917            /* leaf_records= */ 2,
5918            catalog_node_count,
5919            catalog_node_count - 2,
5920            /* max_key_len= */ 516, // HFS+ catalog max key
5921            key_compare,
5922        );
5923        let leaf_off = off + node_size;
5924        write_root_catalog_leaf(&mut front[leaf_off..leaf_off + node_size], &name_utf16);
5925    }
5926
5927    // --- Volume header ---
5928    let signature = if case_sensitive {
5929        HFSX_SIGNATURE
5930    } else {
5931        HFS_PLUS_SIGNATURE
5932    };
5933    let now = hfs_common::hfs_now();
5934    let vh = HfsPlusVolumeHeader {
5935        signature,
5936        version: if case_sensitive { 5 } else { 4 },
5937        attributes: HFSPLUS_VOLUME_UNMOUNTED_BIT,
5938        last_mounted_version: 0,
5939        journal_info_block: 0,
5940        create_date: now,
5941        modify_date: now,
5942        backup_date: 0,
5943        checked_date: now,
5944        file_count: 0,
5945        // Per Apple TN1150, folder_count EXCLUDES the root directory.
5946        // Matches what macOS / Disk Utility / fsck_hfs expect on a fresh
5947        // volume; mismatching this causes false-positive
5948        // FolderCountMismatch reports when fsck'ing volumes created
5949        // elsewhere and edited here.
5950        folder_count: 0,
5951        block_size,
5952        total_blocks,
5953        free_blocks: total_blocks - reserved_blocks - trailing_blocks,
5954        next_allocation: reserved_blocks,
5955        rsrc_clump_size: block_size,
5956        data_clump_size: block_size,
5957        next_catalog_id: 16,
5958        write_count: 0,
5959        encodings_bitmap: 1, // bit 0 = MacRoman, conventional default
5960        finder_info: [0u32; 8],
5961        allocation_file: ForkData {
5962            logical_size: bitmap_blocks as u64 * bs,
5963            clump_size: 0,
5964            total_blocks: bitmap_blocks,
5965            extents: extent_array(bitmap_start, bitmap_blocks),
5966        },
5967        extents_file: ForkData {
5968            logical_size: extents_btree_blocks as u64 * bs,
5969            clump_size: 0,
5970            total_blocks: extents_btree_blocks,
5971            extents: extent_array(extents_start, extents_btree_blocks),
5972        },
5973        catalog_file: ForkData {
5974            logical_size: catalog_btree_blocks as u64 * bs,
5975            clump_size: 0,
5976            total_blocks: catalog_btree_blocks,
5977            extents: extent_array(catalog_start, catalog_btree_blocks),
5978        },
5979        attributes_file: ForkData::empty(),
5980        startup_file: ForkData::empty(),
5981    };
5982    let vh_bytes = vh.serialize();
5983
5984    // Primary VH at offset 1024 (always inside the front region).
5985    front[1024..1536].copy_from_slice(&vh_bytes);
5986
5987    (front, vh_bytes, image_size)
5988}
5989
5990/// Streaming variant of [`create_blank_hfsplus`]. Writes the metadata
5991/// regions (boot, VH, bitmap, B-trees, alt-VH) into `target` at `at_offset`
5992/// and leaves the free middle untouched. Caller is responsible for sizing
5993/// the underlying file (e.g. via `File::set_len`) so reads of the free area
5994/// return zeros.
5995///
5996/// Used by the defrag-clone backup path and the Mac CD-ROM builder so a
5997/// multi-hundred-MB blank target doesn't need that much contiguous RAM. Only
5998/// the front region (~2 MiB worst case) plus the 512-byte alt VH is held in
5999/// memory. `size_bytes` should be a whole multiple of `block_size`, otherwise
6000/// the alt VH lands past the volume's last allocation block.
6001pub fn write_blank_hfsplus_into<W: Write + Seek>(
6002    target: &mut W,
6003    at_offset: u64,
6004    size_bytes: u64,
6005    block_size: u32,
6006    name: &str,
6007    case_sensitive: bool,
6008) -> std::io::Result<()> {
6009    let (front, vh_bytes, _image_size) =
6010        build_blank_hfsplus_front(size_bytes, block_size, name, case_sensitive, 0, 0);
6011    target.seek(SeekFrom::Start(at_offset))?;
6012    target.write_all(&front)?;
6013    target.seek(SeekFrom::Start(at_offset + size_bytes - 1024))?;
6014    target.write_all(&vh_bytes)?;
6015    target.write_all(&[0u8; 512])?;
6016    Ok(())
6017}
6018
6019/// Helper for `create_blank_hfsplus`: build a single-extent inline array.
6020fn extent_array(start: u32, count: u32) -> [ExtentDescriptor; 8] {
6021    let mut e = [ExtentDescriptor {
6022        start_block: 0,
6023        block_count: 0,
6024    }; 8];
6025    e[0] = ExtentDescriptor {
6026        start_block: start,
6027        block_count: count,
6028    };
6029    e
6030}
6031
6032/// Stamp a B-tree header node into `node` (must be exactly `node_size`
6033/// bytes). Lays out three records — header(106), user-data(128), node
6034/// bitmap (rest) — and marks node 0 (header) and node 1 (leaf) as
6035/// allocated in the node bitmap.
6036pub(crate) fn write_blank_btree_header_node(
6037    node: &mut [u8],
6038    node_size: usize,
6039    leaf_records: u32,
6040    total_nodes: u32,
6041    free_nodes: u32,
6042    max_key_len: u16,
6043    key_compare_type: u8,
6044) {
6045    node.fill(0);
6046    node[8] = 1; // kind = header
6047    BigEndian::write_u16(&mut node[10..12], 3); // 3 records
6048
6049    // Record 0: BTHeaderRec at offset 14 (106 bytes).
6050    //
6051    // An *empty* tree (no leaf records, e.g. a fresh extents-overflow or
6052    // attributes B-tree) is represented exactly as Apple's `newfs_hfs` does:
6053    // treeDepth = 0, rootNode = 0, firstLeafNode = lastLeafNode = 0, and no
6054    // leaf node allocated — only the header node is in use. A populated blank
6055    // (the catalog, with its root folder + thread records) has a single
6056    // root *leaf* at node 1. `fsck_hfs` rejects a depth-1 tree whose root is
6057    // an empty leaf ("Invalid node structure"), which is why the empty case
6058    // must collapse to depth 0. `btree_insert_full` bootstraps the first leaf
6059    // on the initial insert into a depth-0 tree.
6060    let empty = leaf_records == 0;
6061    let (depth, root_first_last): (u16, u32) = if empty { (0, 0) } else { (1, 1) };
6062    let hr = 14usize;
6063    BigEndian::write_u16(&mut node[hr..hr + 2], depth);
6064    BigEndian::write_u32(&mut node[hr + 2..hr + 6], root_first_last); // root
6065    BigEndian::write_u32(&mut node[hr + 6..hr + 10], leaf_records);
6066    BigEndian::write_u32(&mut node[hr + 10..hr + 14], root_first_last); // first leaf
6067    BigEndian::write_u32(&mut node[hr + 14..hr + 18], root_first_last); // last leaf
6068    BigEndian::write_u16(&mut node[hr + 18..hr + 20], node_size as u16);
6069    BigEndian::write_u16(&mut node[hr + 20..hr + 22], max_key_len);
6070    BigEndian::write_u32(&mut node[hr + 22..hr + 26], total_nodes);
6071    BigEndian::write_u32(&mut node[hr + 26..hr + 30], free_nodes);
6072    // BTHeaderRec offset 36 = btreeType (0=control); offset 37 = keyCompareType
6073    node[hr + 36] = 0; // kBTHFSTreeType
6074    node[hr + 37] = key_compare_type;
6075    // Offset 38 = attributes: Apple sets kBTBigKeysMask on every HFS+ tree and
6076    // kBTVariableIndexKeysMask on the catalog and attributes trees (not extents).
6077    let attributes = if max_key_len == 10 {
6078        hfs_common::KBT_BIG_KEYS_MASK
6079    } else {
6080        hfs_common::KBT_BIG_KEYS_MASK | hfs_common::KBT_VARIABLE_INDEX_KEYS_MASK
6081    };
6082    BigEndian::write_u32(&mut node[hr + 38..hr + 42], attributes);
6083
6084    // Canonical TN1150 B-tree header node layout. The BTHeaderRec (record 0)
6085    // is *exactly* 106 bytes — Apple's `fsck_hfs` rejects any other length
6086    // ("Invalid BTH length"). So record 1 (the 128-byte reserved user-data
6087    // record) starts at 14 + 106 = 120, and record 2 (the node-allocation
6088    // bitmap) starts at 120 + 128 = 248, filling the node to the offset
6089    // table. The bitmap therefore addresses (node_size - 256) * 8 nodes,
6090    // which is exactly what `hfs_common::map_nodes_required` assumes — so the
6091    // header-vs-map-node boundary is consistent (see the discrepancy noted in
6092    // docs/todo_hfsplus_fork_growth.md §5, resolved by using 248 here).
6093    let user_off: u16 = 120;
6094    let bitmap_off: u16 = 248;
6095    let free_off: u16 = (node_size as u16).saturating_sub(8); // fills to offset table
6096
6097    let ot_end = node_size;
6098    BigEndian::write_u16(&mut node[ot_end - 2..ot_end], 14); // record 0
6099    BigEndian::write_u16(&mut node[ot_end - 4..ot_end - 2], user_off);
6100    BigEndian::write_u16(&mut node[ot_end - 6..ot_end - 4], bitmap_off);
6101    BigEndian::write_u16(&mut node[ot_end - 8..ot_end - 6], free_off);
6102
6103    // Mark allocated nodes in the bitmap: node 0 (header) always; node 1
6104    // (the root leaf) only for a populated tree. An empty tree's node 1 is
6105    // free and zero-filled, matching Apple.
6106    node[bitmap_off as usize] = if empty { 0b10000000 } else { 0b11000000 };
6107}
6108
6109/// Write an empty leaf node (kind=-1, height=1, 0 records). Only used by test
6110/// fixtures now — the production blank/defrag builders represent an empty tree
6111/// as depth 0 with no leaf node (the first insert bootstraps the root leaf).
6112#[cfg(test)]
6113pub(crate) fn write_empty_leaf_node(node: &mut [u8]) {
6114    node.fill(0);
6115    node[8] = 0xFF; // kind = -1
6116    node[9] = 1; // height
6117    BigEndian::write_u16(&mut node[10..12], 0); // 0 records
6118    let n = node.len();
6119    BigEndian::write_u16(&mut node[n - 2..n], 14); // free-space offset
6120}
6121
6122/// Write the catalog leaf node containing the root folder record (key =
6123/// `(parent=1, name=<volume>)`) and the root thread record (key =
6124/// `(parent=2, name="")`, data = recordType=3 + parentID=1 + name=volume).
6125fn write_root_catalog_leaf(node: &mut [u8], name_utf16: &[u16]) {
6126    node.fill(0);
6127    let n = node.len();
6128    node[8] = 0xFF; // kind = -1 leaf
6129    node[9] = 1; // height
6130    BigEndian::write_u16(&mut node[10..12], 2); // 2 records
6131
6132    let now = hfs_common::hfs_now();
6133
6134    // ---- Record 0: root folder record ----
6135    // Key: key_len(2) + parent_id(4) + name_len(2) + name(UTF-16BE)
6136    let r0_off = 14usize;
6137    let key_body = 4 + 2 + name_utf16.len() * 2;
6138    BigEndian::write_u16(&mut node[r0_off..r0_off + 2], key_body as u16);
6139    BigEndian::write_u32(&mut node[r0_off + 2..r0_off + 6], 1); // parent = 1
6140    BigEndian::write_u16(&mut node[r0_off + 6..r0_off + 8], name_utf16.len() as u16);
6141    for (i, &u) in name_utf16.iter().enumerate() {
6142        BigEndian::write_u16(&mut node[r0_off + 8 + i * 2..r0_off + 10 + i * 2], u);
6143    }
6144    let mut r0_data = r0_off + 2 + key_body;
6145    if !r0_data.is_multiple_of(2) {
6146        r0_data += 1;
6147    }
6148    BigEndian::write_i16(&mut node[r0_data..r0_data + 2], CATALOG_FOLDER);
6149    // valence = 0 at +4, folderID = 2 at +8, then dates.
6150    BigEndian::write_u32(&mut node[r0_data + 8..r0_data + 12], 2);
6151    BigEndian::write_u32(&mut node[r0_data + 12..r0_data + 16], now);
6152    BigEndian::write_u32(&mut node[r0_data + 16..r0_data + 20], now);
6153    BigEndian::write_u32(&mut node[r0_data + 20..r0_data + 24], now);
6154    BigEndian::write_u32(&mut node[r0_data + 24..r0_data + 28], now);
6155    let r0_end = r0_data + 88; // folder record is 88 bytes total
6156
6157    // ---- Record 1: root thread record ----
6158    // Key: key_len(2) + parent_id(4=2) + name_len(2=0)
6159    let r1_off = r0_end;
6160    BigEndian::write_u16(&mut node[r1_off..r1_off + 2], 6); // key_len
6161    BigEndian::write_u32(&mut node[r1_off + 2..r1_off + 6], 2); // parent = 2 (root CNID)
6162    BigEndian::write_u16(&mut node[r1_off + 6..r1_off + 8], 0); // name_len = 0
6163    let mut r1_data = r1_off + 2 + 6;
6164    if !r1_data.is_multiple_of(2) {
6165        r1_data += 1;
6166    }
6167    // Thread data: type(2)=3, reserved(2)=0, parentID(4)=1, name_len(2),
6168    // name(UTF-16BE).
6169    BigEndian::write_i16(&mut node[r1_data..r1_data + 2], 3); // folder thread
6170    BigEndian::write_u32(&mut node[r1_data + 4..r1_data + 8], 1);
6171    BigEndian::write_u16(
6172        &mut node[r1_data + 8..r1_data + 10],
6173        name_utf16.len() as u16,
6174    );
6175    for (i, &u) in name_utf16.iter().enumerate() {
6176        BigEndian::write_u16(&mut node[r1_data + 10 + i * 2..r1_data + 12 + i * 2], u);
6177    }
6178    let r1_end = r1_data + 10 + name_utf16.len() * 2;
6179
6180    // Offset table at the end of the node.
6181    BigEndian::write_u16(&mut node[n - 2..n], 14); // record 0 offset
6182    BigEndian::write_u16(&mut node[n - 4..n - 2], r1_off as u16);
6183    BigEndian::write_u16(&mut node[n - 6..n - 4], r1_end as u16); // free-space offset
6184}
6185
6186// --- Resize and validation ---
6187
6188/// Resize an HFS+ filesystem in place.
6189pub fn resize_hfsplus_in_place(
6190    device: &mut (impl Read + Write + Seek),
6191    partition_offset: u64,
6192    new_size_bytes: u64,
6193    log: &mut impl FnMut(&str),
6194) -> anyhow::Result<()> {
6195    // Read volume header
6196    device.seek(SeekFrom::Start(partition_offset + 1024))?;
6197    let mut vh_buf = [0u8; 512];
6198    device.read_exact(&mut vh_buf)?;
6199
6200    let sig = BigEndian::read_u16(&vh_buf[0..2]);
6201    if sig != HFS_PLUS_SIGNATURE && sig != HFSX_SIGNATURE {
6202        log("HFS+ resize: not an HFS+ volume, skipping");
6203        return Ok(());
6204    }
6205    let mut vh = HfsPlusVolumeHeader::parse(&vh_buf)?;
6206    let bs = vh.block_size;
6207    let old_total = vh.total_blocks;
6208    let new_total = (new_size_bytes / bs as u64) as u32;
6209    if new_total < 8 {
6210        anyhow::bail!("HFS+ resize: {new_size_bytes} bytes is too small for a volume");
6211    }
6212
6213    // The allocation file is one extent on every volume Apple or we format.
6214    let af = &vh.allocation_file;
6215    if af.extents[0].block_count == 0 || af.extents[1].block_count != 0 {
6216        anyhow::bail!("HFS+ resize: the allocation file is not a single extent; refusing");
6217    }
6218    let (mut af_start, mut af_blocks) = (af.extents[0].start_block, af.extents[0].block_count);
6219    let mut bitmap = vec![0u8; af_blocks as usize * bs as usize];
6220    device.seek(SeekFrom::Start(
6221        partition_offset + af_start as u64 * bs as u64,
6222    ))?;
6223    device.read_exact(&mut bitmap)?;
6224
6225    let old_tail = alt_vh_blocks(old_total, bs);
6226    let new_tail = alt_vh_blocks(new_total, bs);
6227    if new_total < old_total {
6228        // A count says nothing about position: the last used block must fit.
6229        let highest = find_last_set_bit(&bitmap, old_tail.start).map_or(0, |b| b + 1);
6230        if highest > new_tail.start {
6231            anyhow::bail!(
6232                "HFS+ resize: new size {} blocks ends before the last allocated block ({})",
6233                new_total,
6234                highest
6235            );
6236        }
6237    }
6238
6239    // The alternate header's blocks move with the end of the volume (R-056);
6240    // bits past the old end were padding and the grown area starts free.
6241    let cap_bits = (bitmap.len() as u64 * 8).min(u32::MAX as u64) as u32;
6242    for b in old_tail.start..old_total.min(cap_bits) {
6243        bitmap_clear_bit_be(&mut bitmap, b);
6244    }
6245    for b in old_total..new_total.min(cap_bits) {
6246        bitmap_clear_bit_be(&mut bitmap, b);
6247    }
6248
6249    // A bigger volume may need a bigger allocation file: extend it in place
6250    // when the blocks after it are free, else move it to a free run.
6251    let needed_blocks = (new_total as u64).div_ceil(8).div_ceil(bs as u64) as u32;
6252    if needed_blocks > af_blocks {
6253        let mut grown = bitmap.clone();
6254        grown.resize(needed_blocks as usize * bs as usize, 0);
6255        let usable = new_tail.start;
6256        let extend_ok = (af_start + af_blocks..af_start + needed_blocks)
6257            .all(|b| b < usable && !bitmap_test_bit_be(&grown, b));
6258        let new_start = if extend_ok {
6259            af_start
6260        } else {
6261            hfs_common::bitmap_find_clear_run_be(&grown, usable, needed_blocks).ok_or_else(
6262                || {
6263                    anyhow::anyhow!(
6264                        "HFS+ resize: no free run of {needed_blocks} blocks for the allocation file"
6265                    )
6266                },
6267            )?
6268        };
6269        if new_start != af_start {
6270            for b in af_start..af_start + af_blocks {
6271                bitmap_clear_bit_be(&mut grown, b);
6272            }
6273        }
6274        for b in new_start..new_start + needed_blocks {
6275            bitmap_set_bit_be(&mut grown, b);
6276        }
6277        log(&format!(
6278            "HFS+ resize: allocation file grows from {af_blocks} to {needed_blocks} blocks at \
6279             block {new_start}"
6280        ));
6281        bitmap = grown;
6282        af_start = new_start;
6283        af_blocks = needed_blocks;
6284        vh.allocation_file.extents[0] = ExtentDescriptor {
6285            start_block: af_start,
6286            block_count: af_blocks,
6287        };
6288        vh.allocation_file.total_blocks = af_blocks;
6289        vh.allocation_file.logical_size = af_blocks as u64 * bs as u64;
6290    }
6291    for b in new_tail.clone() {
6292        bitmap_set_bit_be(&mut bitmap, b);
6293    }
6294    let cap_bits = (bitmap.len() as u64 * 8).min(u32::MAX as u64) as u32;
6295    for b in new_total..cap_bits {
6296        bitmap_clear_bit_be(&mut bitmap, b);
6297    }
6298
6299    let used = count_used_blocks(&bitmap, new_total);
6300    vh.total_blocks = new_total;
6301    vh.free_blocks = new_total - used;
6302    if vh.next_allocation >= new_tail.start {
6303        vh.next_allocation = 0;
6304    }
6305    log(&format!(
6306        "HFS+ resize: {} -> {} blocks ({} free)",
6307        old_total, new_total, vh.free_blocks
6308    ));
6309
6310    device.seek(SeekFrom::Start(
6311        partition_offset + af_start as u64 * bs as u64,
6312    ))?;
6313    device.write_all(&bitmap)?;
6314    let out = vh.serialize();
6315    device.seek(SeekFrom::Start(partition_offset + 1024))?;
6316    device.write_all(&out)?;
6317    if new_size_bytes > 1024 {
6318        device.seek(SeekFrom::Start(partition_offset + new_size_bytes - 1024))?;
6319        device.write_all(&out)?;
6320    }
6321    device.flush()?;
6322    Ok(())
6323}
6324
6325/// Mirror the volume header 1024 bytes before the partition end when the
6326/// volume is smaller than its partition, where Mac OS looks for it (R-057).
6327/// Returns whether a copy was written.
6328pub fn mirror_alternate_volume_header(
6329    device: &mut (impl Read + Write + Seek),
6330    partition_offset: u64,
6331    partition_len: u64,
6332    log: &mut impl FnMut(&str),
6333) -> anyhow::Result<bool> {
6334    device.seek(SeekFrom::Start(partition_offset + 1024))?;
6335    let mut vh_buf = [0u8; 512];
6336    device.read_exact(&mut vh_buf)?;
6337    let sig = BigEndian::read_u16(&vh_buf[0..2]);
6338    if sig != HFS_PLUS_SIGNATURE && sig != HFSX_SIGNATURE {
6339        return Ok(false);
6340    }
6341    let block_size = BigEndian::read_u32(&vh_buf[40..44]) as u64;
6342    let total_blocks = BigEndian::read_u32(&vh_buf[44..48]) as u64;
6343    let volume_len = total_blocks * block_size;
6344    if partition_len < 2048 || volume_len >= partition_len {
6345        return Ok(false);
6346    }
6347    device.seek(SeekFrom::Start(partition_offset + partition_len - 1024))?;
6348    device.write_all(&vh_buf)?;
6349    device.flush()?;
6350    log(&format!(
6351        "HFS+ volume of {volume_len} bytes in a {partition_len}-byte partition: alternate \
6352         volume header mirrored at the partition end"
6353    ));
6354    Ok(true)
6355}
6356
6357/// The blocks that hold a volume's alternate volume header, its last 1024
6358/// bytes: one block at 1024 bytes and up, two at 512.
6359fn alt_vh_blocks(total_blocks: u32, block_size: u32) -> std::ops::Range<u32> {
6360    if total_blocks == 0 {
6361        return 0..0;
6362    }
6363    let bytes = total_blocks as u64 * block_size as u64;
6364    let start = (bytes.saturating_sub(1024) / block_size as u64) as u32;
6365    start..total_blocks
6366}
6367
6368/// Set bits among the first `total_blocks` of the allocation bitmap.
6369fn count_used_blocks(bitmap: &[u8], total_blocks: u32) -> u32 {
6370    let full = (total_blocks / 8) as usize;
6371    let mut used: u32 = bitmap[..full.min(bitmap.len())]
6372        .iter()
6373        .map(|b| b.count_ones())
6374        .sum();
6375    let keep = total_blocks % 8;
6376    if keep > 0 {
6377        if let Some(&last) = bitmap.get(full) {
6378            let mask = 0xFFu8 << (8 - keep);
6379            used += (last & mask).count_ones();
6380        }
6381    }
6382    used
6383}
6384
6385/// Validate HFS+ filesystem integrity.
6386pub fn validate_hfsplus_integrity(
6387    device: &mut (impl Read + Seek),
6388    partition_offset: u64,
6389    log: &mut impl FnMut(&str),
6390) -> anyhow::Result<()> {
6391    device.seek(SeekFrom::Start(partition_offset + 1024))?;
6392    let mut vh_buf = [0u8; 512];
6393    device.read_exact(&mut vh_buf)?;
6394
6395    let sig = BigEndian::read_u16(&vh_buf[0..2]);
6396    if sig != HFS_PLUS_SIGNATURE && sig != HFSX_SIGNATURE {
6397        log("HFS+ validate: not an HFS+ volume, skipping");
6398        return Ok(());
6399    }
6400
6401    let block_size = BigEndian::read_u32(&vh_buf[40..44]);
6402    let total_blocks = BigEndian::read_u32(&vh_buf[44..48]);
6403
6404    if !block_size.is_power_of_two() || block_size < 512 {
6405        anyhow::bail!("HFS+ validate: invalid block size {block_size}");
6406    }
6407    if total_blocks == 0 {
6408        anyhow::bail!("HFS+ validate: zero total blocks");
6409    }
6410
6411    log(&format!(
6412        "HFS+ validate: OK ({total_blocks} blocks, {block_size} block size)"
6413    ));
6414    Ok(())
6415}
6416
6417/// Resolve the actual HFS filesystem variant for an "Apple_HFS" APM partition.
6418///
6419/// Returns `(fs_type, hfsplus_offset)` where `fs_type` is `"hfs"`, `"hfsplus"`,
6420/// or `"unknown"`, and `hfsplus_offset` is the partition_offset to pass to
6421/// `HfsPlusFilesystem::open` (accounts for the embedded volume's position).
6422///
6423/// Three cases are handled:
6424/// - Native HFS+ (0x482B/0x4858 at partition_offset+1024): `hfsplus_offset == partition_offset`.
6425/// - Embedded HFS+ (HFS wrapper with 0x4244 MDB, drEmbedSigWord == 0x482B): `hfsplus_offset`
6426///   is calculated from the MDB's drAlBlSt/drAlBlkSiz/drEmbedExtent fields.
6427/// - Pure HFS (0x4244, no embedded HFS+): returns `"hfs"`.
6428pub fn resolve_apple_hfs<R: Read + Seek>(
6429    reader: &mut R,
6430    partition_offset: u64,
6431) -> (&'static str, u64) {
6432    // The HFS MDB / HFS+ volume header sits at partition_offset + 1024 (sector-aligned).
6433    // Read 512 bytes (one sector) — all required fields are within the first 512 bytes
6434    // of the MDB (largest field needed is drEmbedExtent.startBlock at MDB offset 127).
6435    // This keeps the read sector-aligned for raw character device compatibility.
6436    if reader
6437        .seek(SeekFrom::Start(partition_offset + 1024))
6438        .is_err()
6439    {
6440        return ("unknown", partition_offset);
6441    }
6442    let mut buf = [0u8; 512];
6443    if reader.read_exact(&mut buf).is_err() {
6444        return ("unknown", partition_offset);
6445    }
6446    let sig = u16::from_be_bytes([buf[0], buf[1]]);
6447    match sig {
6448        0x4244 => {
6449            // HFS MDB — check for embedded HFS+ (drEmbedSigWord at MDB offset 124)
6450            let embedded_sig = u16::from_be_bytes([buf[124], buf[125]]);
6451            if embedded_sig == 0x482B {
6452                // Embedded HFS+: calculate the embedded volume's starting offset.
6453                // drAlBlkSiz (allocation block size, bytes) at MDB offset 20 (u32 BE).
6454                // drAlBlSt (first alloc block in 512-byte sectors) at MDB offset 28 (u16 BE).
6455                // drEmbedExtent.startBlock at MDB offset 126 (u16 BE).
6456                let block_size = u32::from_be_bytes([buf[20], buf[21], buf[22], buf[23]]) as u64;
6457                let first_alloc_block = u16::from_be_bytes([buf[28], buf[29]]) as u64;
6458                let embedded_start = u16::from_be_bytes([buf[126], buf[127]]) as u64;
6459                // HFS+ volume starts at: partition_offset + drAlBlSt*512 + startBlock*drAlBlkSiz
6460                let hfsplus_offset =
6461                    partition_offset + first_alloc_block * 512 + embedded_start * block_size;
6462                ("hfsplus", hfsplus_offset)
6463            } else {
6464                ("hfs", partition_offset)
6465            }
6466        }
6467        // Native HFS+ or HFSX — volume header is directly at partition_offset+1024
6468        0x482B | 0x4858 => ("hfsplus", partition_offset),
6469        _ => ("unknown", partition_offset),
6470    }
6471}
6472
6473#[cfg(test)]
6474#[allow(clippy::identity_op)] // `1usize * block_size` keeps offset rows aligned
6475mod tests {
6476    use super::*;
6477
6478    /// A thousand files in, one more in and out, the thousand out, one in:
6479    /// the catalog must stay clean through every split, merge and retire.
6480    #[test]
6481    fn thousand_file_churn_keeps_the_catalog_clean() {
6482        use std::io::Cursor;
6483        let img = create_blank_hfsplus(64 * 1024 * 1024, 4096, "Churn", false);
6484        let mut fs = HfsPlusFilesystem::open(Cursor::new(img), 0).unwrap();
6485        fs.prepare_for_edit().unwrap();
6486        let root = fs.root().unwrap();
6487        let clean = |fs: &mut HfsPlusFilesystem<Cursor<Vec<u8>>>, stage: &str| {
6488            fs.sync_metadata().unwrap();
6489            let r = fs.fsck().expect("hfsplus has fsck").unwrap();
6490            assert!(r.errors.is_empty(), "{stage}: {:?}", r.errors);
6491        };
6492        let put = |fs: &mut HfsPlusFilesystem<Cursor<Vec<u8>>>, name: &str, data: &[u8]| {
6493            fs.create_file(
6494                &root,
6495                name,
6496                &mut Cursor::new(data),
6497                data.len() as u64,
6498                &CreateFileOptions::default(),
6499            )
6500            .unwrap_or_else(|e| panic!("creating {name}: {e}"))
6501        };
6502        let free0 = fs.vh.free_blocks;
6503        let trees0 = fs.vh.catalog_file.total_blocks + fs.vh.extents_file.total_blocks;
6504        for i in 0..1000 {
6505            put(
6506                &mut fs,
6507                &format!("f{i:04}.txt"),
6508                format!("file {i:04}").as_bytes(),
6509            );
6510        }
6511        clean(&mut fs, "after 1000 creates");
6512        let extra = put(&mut fs, "extra.bin", &[7u8; 5000]);
6513        clean(&mut fs, "after one more");
6514        fs.delete_entry(&root, &extra).unwrap();
6515        clean(&mut fs, "after deleting it");
6516        for e in fs.list_directory(&root).unwrap() {
6517            fs.delete_entry(&root, &e).unwrap();
6518        }
6519        // The B-trees keep what they grew by; every other block comes back.
6520        let trees = fs.vh.catalog_file.total_blocks + fs.vh.extents_file.total_blocks;
6521        assert_eq!(
6522            fs.vh.free_blocks + (trees - trees0),
6523            free0,
6524            "every block came back"
6525        );
6526        clean(&mut fs, "after deleting the 1000");
6527        put(&mut fs, "last.bin", &[9u8; 100]);
6528        clean(&mut fs, "after the last create");
6529        let names: Vec<String> = fs
6530            .list_directory(&root)
6531            .unwrap()
6532            .into_iter()
6533            .map(|e| e.name)
6534            .collect();
6535        assert_eq!(names, ["last.bin"]);
6536    }
6537
6538    /// Apple stamps kBTBigKeysMask on every HFS+ tree and adds
6539    /// kBTVariableIndexKeysMask on the catalog and attributes trees; so do we.
6540    #[test]
6541    fn blank_btree_headers_carry_apples_attribute_bits() {
6542        let img = create_blank_hfsplus(16 * 1024 * 1024, 4096, "Attrs", false);
6543        let vh = &img[1024..1536];
6544        let block_size = BigEndian::read_u32(&vh[0x28..0x2c]) as usize;
6545        // BTHeaderRec.attributes sits at node offset 14 + 38; a fork's first
6546        // extent start is 16 bytes into its HFSPlusForkData (extents file at
6547        // 0xC0, catalog at 0x110 in the volume header).
6548        let attrs = |fork: usize| {
6549            let start = BigEndian::read_u32(&vh[fork + 0x10..fork + 0x14]) as usize;
6550            BigEndian::read_u32(&img[start * block_size + 52..start * block_size + 56])
6551        };
6552        assert_eq!(attrs(0xC0), hfs_common::KBT_BIG_KEYS_MASK, "extents");
6553        let variable = hfs_common::KBT_BIG_KEYS_MASK | hfs_common::KBT_VARIABLE_INDEX_KEYS_MASK;
6554        assert_eq!(attrs(0x110), variable, "catalog");
6555    }
6556
6557    #[test]
6558    fn test_volume_header_parse() {
6559        let mut data = [0u8; 512];
6560        BigEndian::write_u16(&mut data[0..2], HFS_PLUS_SIGNATURE);
6561        BigEndian::write_u16(&mut data[2..4], 4); // version
6562        BigEndian::write_u32(&mut data[40..44], 4096); // block_size
6563        BigEndian::write_u32(&mut data[44..48], 100000); // total_blocks
6564        BigEndian::write_u32(&mut data[48..52], 30000); // free_blocks
6565
6566        let vh = HfsPlusVolumeHeader::parse(&data).unwrap();
6567        assert_eq!(vh.signature, HFS_PLUS_SIGNATURE);
6568        assert_eq!(vh.block_size, 4096);
6569        assert_eq!(vh.total_blocks, 100000);
6570        assert_eq!(vh.free_blocks, 30000);
6571        assert!(!vh.is_hfsx());
6572    }
6573
6574    #[test]
6575    fn test_hfsx_signature() {
6576        let mut data = [0u8; 512];
6577        BigEndian::write_u16(&mut data[0..2], HFSX_SIGNATURE);
6578        BigEndian::write_u32(&mut data[40..44], 4096);
6579        BigEndian::write_u32(&mut data[44..48], 100000);
6580        BigEndian::write_u32(&mut data[48..52], 30000);
6581
6582        let vh = HfsPlusVolumeHeader::parse(&data).unwrap();
6583        assert!(vh.is_hfsx());
6584    }
6585
6586    #[test]
6587    fn test_decode_utf16be() {
6588        // "Hello" in UTF-16BE
6589        let data = [0x00, 0x48, 0x00, 0x65, 0x00, 0x6C, 0x00, 0x6C, 0x00, 0x6F];
6590        assert_eq!(decode_utf16be(&data), "Hello");
6591    }
6592
6593    #[test]
6594    fn test_decode_utf16be_with_non_ascii() {
6595        // "Ä" (U+00C4) in UTF-16BE
6596        let data = [0x00, 0xC4];
6597        assert_eq!(decode_utf16be(&data), "Ä");
6598    }
6599
6600    #[test]
6601    fn test_extent_descriptor() {
6602        let mut data = [0u8; 8];
6603        BigEndian::write_u32(&mut data[0..4], 100);
6604        BigEndian::write_u32(&mut data[4..8], 50);
6605
6606        let ext = ExtentDescriptor::parse(&data);
6607        assert_eq!(ext.start_block, 100);
6608        assert_eq!(ext.block_count, 50);
6609        assert!(!ext.is_empty());
6610    }
6611
6612    #[test]
6613    fn test_find_last_set_bit() {
6614        let bitmap = [0b10100000, 0b00001000]; // bits 0, 2, 12
6615        assert_eq!(find_last_set_bit(&bitmap, 16), Some(12));
6616
6617        let empty = [0u8; 2];
6618        assert_eq!(find_last_set_bit(&empty, 16), None);
6619    }
6620
6621    #[test]
6622    fn test_find_last_set_bit_dense_byte() {
6623        // Regression: when the highest non-zero byte has multiple set bits,
6624        // the result must be the highest global index in that byte, not the
6625        // lowest. 0b11111100 has bits 0..=5 set (MSB-first), so the answer
6626        // is 5, not 0.
6627        assert_eq!(find_last_set_bit(&[0b11111100], 8), Some(5));
6628        assert_eq!(find_last_set_bit(&[0b11111111], 8), Some(7));
6629        assert_eq!(find_last_set_bit(&[0b00000001], 8), Some(7));
6630        assert_eq!(find_last_set_bit(&[0b10000000], 8), Some(0));
6631        // Multi-byte: byte 0 dense, byte 1 zero — answer is in byte 0.
6632        assert_eq!(find_last_set_bit(&[0b11111100, 0b00000000], 16), Some(5));
6633        // Bound clipping: 0b11111111 with max_bits=4 → only bits 0..=3 are
6634        // valid; the highest-valid one is 3.
6635        assert_eq!(find_last_set_bit(&[0b11111111], 4), Some(3));
6636    }
6637
6638    #[test]
6639    fn test_volume_header_serialize_roundtrip() {
6640        let mut data = [0u8; 512];
6641        BigEndian::write_u16(&mut data[0..2], HFS_PLUS_SIGNATURE);
6642        BigEndian::write_u16(&mut data[2..4], 4);
6643        BigEndian::write_u32(&mut data[40..44], 4096);
6644        BigEndian::write_u32(&mut data[44..48], 100);
6645        BigEndian::write_u32(&mut data[48..52], 50);
6646        BigEndian::write_u32(&mut data[64..68], 10); // next_catalog_id
6647        BigEndian::write_u32(&mut data[80..84], 42); // finder_info[0]
6648
6649        let vh = HfsPlusVolumeHeader::parse(&data).unwrap();
6650        assert_eq!(vh.next_catalog_id, 10);
6651        assert_eq!(vh.finder_info[0], 42);
6652
6653        let serialized = vh.serialize();
6654        let vh2 = HfsPlusVolumeHeader::parse(&serialized).unwrap();
6655        assert_eq!(vh2.block_size, 4096);
6656        assert_eq!(vh2.total_blocks, 100);
6657        assert_eq!(vh2.free_blocks, 50);
6658        assert_eq!(vh2.next_catalog_id, 10);
6659        assert_eq!(vh2.finder_info[0], 42);
6660    }
6661
6662    #[test]
6663    fn test_fork_data_serialize_roundtrip() {
6664        let fork = ForkData {
6665            logical_size: 12345,
6666            clump_size: 0,
6667            total_blocks: 3,
6668            extents: [
6669                ExtentDescriptor {
6670                    start_block: 10,
6671                    block_count: 3,
6672                },
6673                ExtentDescriptor {
6674                    start_block: 0,
6675                    block_count: 0,
6676                },
6677                ExtentDescriptor {
6678                    start_block: 0,
6679                    block_count: 0,
6680                },
6681                ExtentDescriptor {
6682                    start_block: 0,
6683                    block_count: 0,
6684                },
6685                ExtentDescriptor {
6686                    start_block: 0,
6687                    block_count: 0,
6688                },
6689                ExtentDescriptor {
6690                    start_block: 0,
6691                    block_count: 0,
6692                },
6693                ExtentDescriptor {
6694                    start_block: 0,
6695                    block_count: 0,
6696                },
6697                ExtentDescriptor {
6698                    start_block: 0,
6699                    block_count: 0,
6700                },
6701            ],
6702        };
6703        let mut buf = [0u8; 80];
6704        fork.serialize(&mut buf);
6705        let fork2 = ForkData::parse(&buf);
6706        assert_eq!(fork2.logical_size, 12345);
6707        assert_eq!(fork2.total_blocks, 3);
6708        assert_eq!(fork2.extents[0].start_block, 10);
6709        assert_eq!(fork2.extents[0].block_count, 3);
6710    }
6711
6712    /// Create a minimal valid in-memory HFS+ image for testing.
6713    /// Layout: 256 blocks × 4096 bytes = 1 MB
6714    /// - Block 0: unused (VH at byte 1024 within this block)
6715    /// - Block 1: allocation bitmap
6716    /// - Block 2-5: catalog B-tree (4 blocks = 16 KB = 4 nodes of 4096 bytes)
6717    ///   - Node 0: header node
6718    ///   - Node 1: leaf node with root folder record + thread
6719    ///   - Nodes 2-3: free
6720    /// - Blocks 6+: free for user data
6721    fn make_editable_hfsplus_image() -> Vec<u8> {
6722        let block_size = 4096u32;
6723        let total_blocks = 256u32;
6724        let image_size = total_blocks as usize * block_size as usize; // 1 MB
6725        let mut img = vec![0u8; image_size];
6726
6727        // Allocation bitmap: blocks 0-5 allocated (VH area + bitmap + catalog)
6728        let bitmap_block = 1u32;
6729        let catalog_start_block = 2u32;
6730        let catalog_blocks = 4u32;
6731        let alloc_blocks = 1 + 1 + catalog_blocks; // VH + bitmap + catalog = 6 blocks
6732        let bitmap_data = &mut img[bitmap_block as usize * block_size as usize
6733            ..(bitmap_block + 1) as usize * block_size as usize];
6734        // Set bits 0-5 (MSB-first): byte 0 = 0b11111100
6735        bitmap_data[0] = 0b11111100;
6736
6737        // Build catalog B-tree in blocks 2-5 (4 nodes × 4096)
6738        let node_size = 4096usize;
6739        let catalog_offset = catalog_start_block as usize * block_size as usize;
6740        let catalog_size = catalog_blocks as usize * block_size as usize;
6741
6742        // Node 0: Header node
6743        let hdr_off = catalog_offset;
6744        // Node descriptor: next=0, prev=0, kind=1(header), height=0, numRecords=3
6745        img[hdr_off + 8] = 1; // kind = header
6746        BigEndian::write_u16(&mut img[hdr_off + 10..hdr_off + 12], 3); // 3 records
6747
6748        // B-tree header record (record 0, at offset 14)
6749        let hr = hdr_off + 14;
6750        BigEndian::write_u16(&mut img[hr..hr + 2], 1); // depth = 1
6751        BigEndian::write_u32(&mut img[hr + 2..hr + 6], 1); // root_node = 1
6752        BigEndian::write_u32(&mut img[hr + 6..hr + 10], 2); // leaf_records = 2 (folder + thread)
6753        BigEndian::write_u32(&mut img[hr + 10..hr + 14], 1); // first_leaf_node = 1
6754        BigEndian::write_u32(&mut img[hr + 14..hr + 18], 1); // last_leaf_node = 1
6755        BigEndian::write_u16(&mut img[hr + 18..hr + 20], node_size as u16); // node_size
6756        BigEndian::write_u16(&mut img[hr + 20..hr + 22], 516); // max_key_len
6757        BigEndian::write_u32(&mut img[hr + 22..hr + 26], 4); // total_nodes = 4
6758        BigEndian::write_u32(&mut img[hr + 26..hr + 30], 2); // free_nodes = 2
6759
6760        // Record offsets for header node (3 records + free space offset)
6761        // Record 0: header record at offset 14
6762        // Record 1: user data record (128 bytes at offset 14+128=142)
6763        // Record 2: bitmap record (256 bytes at offset 142+128=270)
6764        // Free space offset at end
6765        let ot = hdr_off + node_size; // offset table at end of node
6766        BigEndian::write_u16(&mut img[ot - 2..ot], 14); // record 0 offset
6767        BigEndian::write_u16(&mut img[ot - 4..ot - 2], 142); // record 1 offset
6768        BigEndian::write_u16(&mut img[ot - 6..ot - 4], 270); // record 2 (bitmap)
6769        BigEndian::write_u16(&mut img[ot - 8..ot - 6], 526); // free space offset
6770
6771        // Node bitmap (record 2): mark nodes 0 and 1 as allocated
6772        img[hdr_off + 270] = 0b11000000;
6773
6774        // Node 1: Leaf node with root folder record + thread record
6775        let leaf_off = catalog_offset + node_size;
6776        // Node descriptor: next=0, prev=0, kind=-1(leaf), height=1, numRecords=2
6777        img[leaf_off + 8] = 0xFF; // kind = -1 (leaf)
6778        img[leaf_off + 9] = 1; // height = 1
6779        BigEndian::write_u16(&mut img[leaf_off + 10..leaf_off + 12], 2); // 2 records
6780
6781        // Record 0: Root folder record (CNID 2)
6782        // Key: key_len(2) + parent_id(4, =1) + name_len(2, =0)  — root folder's parent is CNID 1
6783        let r0_off = leaf_off + 14;
6784        BigEndian::write_u16(&mut img[r0_off..r0_off + 2], 6); // key_len = 6
6785        BigEndian::write_u32(&mut img[r0_off + 2..r0_off + 6], 1); // parent_id = 1 (root parent)
6786        BigEndian::write_u16(&mut img[r0_off + 6..r0_off + 8], 0); // name_len = 0
6787                                                                   // Record data starts after key (at offset 8, even-aligned)
6788        let r0_data = r0_off + 8;
6789        BigEndian::write_i16(&mut img[r0_data..r0_data + 2], CATALOG_FOLDER); // type = folder
6790        BigEndian::write_u32(&mut img[r0_data + 8..r0_data + 12], 2); // folderID = 2
6791                                                                      // Folder record is 88 bytes total
6792
6793        // Record 1: Thread record for root folder (CNID 2)
6794        // Thread key: parent_id = CNID = 2, name = empty
6795        let r1_off = r0_data + 88; // after folder record
6796        BigEndian::write_u16(&mut img[r1_off..r1_off + 2], 6); // key_len = 6
6797        BigEndian::write_u32(&mut img[r1_off + 2..r1_off + 6], 2); // parent_id = 2 (CNID)
6798        BigEndian::write_u16(&mut img[r1_off + 6..r1_off + 8], 0); // name_len = 0
6799                                                                   // Thread record data
6800        let r1_data = r1_off + 8;
6801        BigEndian::write_i16(&mut img[r1_data..r1_data + 2], 3); // type = folder thread
6802        BigEndian::write_u32(&mut img[r1_data + 4..r1_data + 8], 1); // parentID = 1
6803        BigEndian::write_u16(&mut img[r1_data + 8..r1_data + 10], 0); // name_len = 0
6804                                                                      // Thread record is variable length, but at minimum 10 bytes
6805
6806        // Record offset table for leaf node (2 records + free space)
6807        let lot = leaf_off + node_size;
6808        BigEndian::write_u16(&mut img[lot - 2..lot], 14); // record 0 offset
6809        let r1_rel = (r1_off - leaf_off) as u16;
6810        BigEndian::write_u16(&mut img[lot - 4..lot - 2], r1_rel); // record 1 offset
6811        let free_rel = (r1_data + 10 - leaf_off) as u16;
6812        BigEndian::write_u16(&mut img[lot - 6..lot - 4], free_rel); // free space offset
6813
6814        // Volume Header at byte 1024
6815        let vh = HfsPlusVolumeHeader {
6816            signature: HFS_PLUS_SIGNATURE,
6817            version: 4,
6818            attributes: 0,
6819            last_mounted_version: 0,
6820            journal_info_block: 0,
6821            create_date: hfs_common::hfs_now(),
6822            modify_date: hfs_common::hfs_now(),
6823            backup_date: 0,
6824            checked_date: 0,
6825            file_count: 0,
6826            folder_count: 0, // Apple TN1150: excludes root
6827            block_size,
6828            total_blocks,
6829            free_blocks: total_blocks - alloc_blocks,
6830            next_allocation: alloc_blocks,
6831            rsrc_clump_size: 0,
6832            data_clump_size: 0,
6833            next_catalog_id: 16, // next available CNID
6834            write_count: 0,
6835            encodings_bitmap: 0,
6836            finder_info: [0u32; 8],
6837            allocation_file: ForkData {
6838                logical_size: block_size as u64,
6839                clump_size: 0,
6840                total_blocks: 1,
6841                extents: {
6842                    let mut e = [ExtentDescriptor {
6843                        start_block: 0,
6844                        block_count: 0,
6845                    }; 8];
6846                    e[0] = ExtentDescriptor {
6847                        start_block: bitmap_block,
6848                        block_count: 1,
6849                    };
6850                    e
6851                },
6852            },
6853            extents_file: ForkData::empty(),
6854            catalog_file: ForkData {
6855                logical_size: catalog_size as u64,
6856                clump_size: 0,
6857                total_blocks: catalog_blocks,
6858                extents: {
6859                    let mut e = [ExtentDescriptor {
6860                        start_block: 0,
6861                        block_count: 0,
6862                    }; 8];
6863                    e[0] = ExtentDescriptor {
6864                        start_block: catalog_start_block,
6865                        block_count: catalog_blocks,
6866                    };
6867                    e
6868                },
6869            },
6870            attributes_file: ForkData::empty(),
6871            startup_file: ForkData::empty(),
6872        };
6873
6874        let vh_bytes = vh.serialize();
6875        img[1024..1024 + 512].copy_from_slice(&vh_bytes);
6876
6877        // Backup VH at last 1024 bytes
6878        let backup_pos = image_size - 1024;
6879        img[backup_pos..backup_pos + 512].copy_from_slice(&vh_bytes);
6880
6881        img
6882    }
6883
6884    /// Build an editable HFS+ image that also carries a 4-node empty
6885    /// extents-overflow B-tree at blocks 6..9. Used by tests that exercise
6886    /// fragmented files needing more than 8 extents (Step 7).
6887    ///
6888    /// Layout (4 KiB blocks, 256 total = 1 MiB image):
6889    /// - Block 0: VH/boot region
6890    /// - Block 1: allocation bitmap
6891    /// - Blocks 2..5: catalog B-tree (4 nodes)
6892    /// - Blocks 6..9: extents-overflow B-tree (4 nodes — header + empty leaf
6893    ///   + 2 free)
6894    /// - Blocks 10..255: free user data area
6895    fn make_editable_hfsplus_image_with_extents_btree() -> Vec<u8> {
6896        // Start from the standard editable image, then carve in an
6897        // extents-overflow B-tree.
6898        let mut img = make_editable_hfsplus_image();
6899        let block_size = 4096u32;
6900        let node_size = 4096usize;
6901        let extents_start_block = 6u32;
6902        let extents_blocks = 4u32;
6903
6904        // Mark blocks 6..9 allocated in the bitmap (byte 0 was 0b11111100 →
6905        // blocks 0..=5 allocated; flip bits 1..=3 of byte 1 to allocate
6906        // blocks 6..=9, leaving bits 4..=7 free for user data continuation).
6907        let bitmap_off = 1usize * block_size as usize;
6908        img[bitmap_off] = 0xFF; // blocks 0..=7 allocated
6909        img[bitmap_off + 1] = 0b11000000; // blocks 8..=9 allocated
6910
6911        // --- Header node (block 6, node 0) ---
6912        let hdr_off = extents_start_block as usize * block_size as usize;
6913        img[hdr_off + 8] = 1; // kind = header
6914        BigEndian::write_u16(&mut img[hdr_off + 10..hdr_off + 12], 3); // 3 records
6915
6916        // B-tree header record at offset 14
6917        let hr = hdr_off + 14;
6918        BigEndian::write_u16(&mut img[hr..hr + 2], 1); // depth = 1
6919        BigEndian::write_u32(&mut img[hr + 2..hr + 6], 1); // root_node = 1
6920        BigEndian::write_u32(&mut img[hr + 6..hr + 10], 0); // leaf_records = 0
6921        BigEndian::write_u32(&mut img[hr + 10..hr + 14], 1); // first_leaf = 1
6922        BigEndian::write_u32(&mut img[hr + 14..hr + 18], 1); // last_leaf = 1
6923        BigEndian::write_u16(&mut img[hr + 18..hr + 20], node_size as u16);
6924        BigEndian::write_u16(&mut img[hr + 20..hr + 22], 10); // max_key_len
6925        BigEndian::write_u32(&mut img[hr + 22..hr + 26], 4); // total_nodes
6926        BigEndian::write_u32(&mut img[hr + 26..hr + 30], 2); // free_nodes (2,3)
6927
6928        // Offset table at end of node 0: header(14), user(142), bitmap(270),
6929        // free(526) — same shape as the catalog header node.
6930        let ot = hdr_off + node_size;
6931        BigEndian::write_u16(&mut img[ot - 2..ot], 14);
6932        BigEndian::write_u16(&mut img[ot - 4..ot - 2], 142);
6933        BigEndian::write_u16(&mut img[ot - 6..ot - 4], 270);
6934        BigEndian::write_u16(&mut img[ot - 8..ot - 6], 526);
6935
6936        // Node bitmap (record 2): mark nodes 0 and 1 allocated.
6937        img[hdr_off + 270] = 0b11000000;
6938
6939        // --- Empty leaf node (block 7, node 1) ---
6940        let leaf_off = hdr_off + node_size;
6941        img[leaf_off + 8] = 0xFF; // kind = -1 (leaf)
6942        img[leaf_off + 9] = 1; // height = 1
6943        BigEndian::write_u16(&mut img[leaf_off + 10..leaf_off + 12], 0); // 0 records
6944
6945        // Offset table: just the free-space offset at byte 14 (start of
6946        // record area).
6947        let lot = leaf_off + node_size;
6948        BigEndian::write_u16(&mut img[lot - 2..lot], 14);
6949
6950        // Patch the volume header: point extents_file at blocks 6..9 with
6951        // a 16 KiB logical size, and decrement free_blocks by 4.
6952        // VH lives at offset 1024; offset 4..8 is `attributes`, but
6953        // extents_file fork lives at VH bytes 192..272.
6954        // Easier path: re-parse the VH, patch the fields, re-serialize.
6955        let mut vh = HfsPlusVolumeHeader::parse(&img[1024..1536]).expect("parse seed VH");
6956        vh.extents_file = ForkData {
6957            logical_size: (extents_blocks as u64) * (block_size as u64),
6958            clump_size: 0,
6959            total_blocks: extents_blocks,
6960            extents: {
6961                let mut e = [ExtentDescriptor {
6962                    start_block: 0,
6963                    block_count: 0,
6964                }; 8];
6965                e[0] = ExtentDescriptor {
6966                    start_block: extents_start_block,
6967                    block_count: extents_blocks,
6968                };
6969                e
6970            },
6971        };
6972        vh.free_blocks -= extents_blocks;
6973        vh.next_allocation = 10;
6974        let vh_bytes = vh.serialize();
6975        img[1024..1536].copy_from_slice(&vh_bytes);
6976        let alt = img.len() - 1024;
6977        img[alt..alt + 512].copy_from_slice(&vh_bytes);
6978        img
6979    }
6980
6981    /// Build an editable HFS+ image whose attributes B-tree carries one inline
6982    /// `com.apple.FinderInfo` record for `cnid` with `value` as the payload.
6983    /// Used by the Step 12 round-trip test.
6984    ///
6985    /// Layout (4 KiB blocks, 256 total = 1 MiB image):
6986    /// - Block 0: VH/boot region
6987    /// - Block 1: allocation bitmap
6988    /// - Blocks 2-5: catalog B-tree
6989    /// - Blocks 6-7: attributes B-tree (header + leaf)
6990    /// - Blocks 8..255: free
6991    fn make_editable_hfsplus_image_with_inline_xattr(cnid: u32, value: &[u8]) -> Vec<u8> {
6992        let mut img = make_editable_hfsplus_image();
6993        let block_size = 4096u32;
6994        let node_size = 4096usize;
6995        let attr_start_block = 6u32;
6996        let attr_blocks = 2u32;
6997
6998        // Mark blocks 6 and 7 allocated. Seed image had byte 0 = 0b11111100
6999        // (blocks 0..=5); set bits 6..=7 of byte 0.
7000        let bitmap_off = 1usize * block_size as usize;
7001        img[bitmap_off] = 0xFF; // blocks 0..=7 allocated
7002
7003        // --- Header node (block 6, node 0) ---
7004        let hdr_off = attr_start_block as usize * block_size as usize;
7005        img[hdr_off + 8] = 1; // kind = header
7006        BigEndian::write_u16(&mut img[hdr_off + 10..hdr_off + 12], 3);
7007
7008        let hr = hdr_off + 14;
7009        BigEndian::write_u16(&mut img[hr..hr + 2], 1); // depth = 1
7010        BigEndian::write_u32(&mut img[hr + 2..hr + 6], 1); // root_node = 1
7011        BigEndian::write_u32(&mut img[hr + 6..hr + 10], 1); // leaf_records = 1
7012        BigEndian::write_u32(&mut img[hr + 10..hr + 14], 1); // first_leaf = 1
7013        BigEndian::write_u32(&mut img[hr + 14..hr + 18], 1); // last_leaf = 1
7014        BigEndian::write_u16(&mut img[hr + 18..hr + 20], node_size as u16);
7015        BigEndian::write_u16(&mut img[hr + 20..hr + 22], 512); // max_key_len
7016        BigEndian::write_u32(&mut img[hr + 22..hr + 26], attr_blocks); // total_nodes = 2
7017
7018        // Offset table for header node (header(14) / user(142) / bitmap(270) /
7019        // free(526) — same shape as the catalog header node).
7020        let ot = hdr_off + node_size;
7021        BigEndian::write_u16(&mut img[ot - 2..ot], 14);
7022        BigEndian::write_u16(&mut img[ot - 4..ot - 2], 142);
7023        BigEndian::write_u16(&mut img[ot - 6..ot - 4], 270);
7024        BigEndian::write_u16(&mut img[ot - 8..ot - 6], 526);
7025
7026        // Node bitmap (record 2): mark nodes 0 and 1 allocated.
7027        img[hdr_off + 270] = 0b11000000;
7028
7029        // --- Leaf node (block 7, node 1) carrying one inline xattr record ---
7030        let leaf_off = hdr_off + node_size;
7031        img[leaf_off + 8] = 0xFF; // kind = -1 (leaf)
7032        img[leaf_off + 9] = 1; // height = 1
7033        BigEndian::write_u16(&mut img[leaf_off + 10..leaf_off + 12], 1); // 1 record
7034
7035        // Build the record: key + record-data.
7036        // Key body: pad(2) + fileID(4) + startBlock(4) + nameLen(2) + name UTF-16BE
7037        let name = "com.apple.FinderInfo";
7038        let name_utf16: Vec<u16> = name.encode_utf16().collect();
7039        let name_byte_len = name_utf16.len() * 2;
7040        let key_body_len = 12 + name_byte_len;
7041        // recordType(4) + reserved[2](8) + attrSize(4) + attrData(value.len())
7042        let data_section_len = 4 + 8 + 4 + value.len();
7043        let rec_total = 2 + key_body_len + data_section_len;
7044
7045        let r0 = leaf_off + 14;
7046        BigEndian::write_u16(&mut img[r0..r0 + 2], key_body_len as u16);
7047        // pad already zero
7048        BigEndian::write_u32(&mut img[r0 + 4..r0 + 8], cnid);
7049        BigEndian::write_u32(&mut img[r0 + 8..r0 + 12], 0); // startBlock = 0
7050        BigEndian::write_u16(&mut img[r0 + 12..r0 + 14], name_utf16.len() as u16);
7051        for (i, ch) in name_utf16.iter().enumerate() {
7052            BigEndian::write_u16(&mut img[r0 + 14 + i * 2..r0 + 14 + i * 2 + 2], *ch);
7053        }
7054        let data_off = r0 + 2 + key_body_len;
7055        BigEndian::write_u32(&mut img[data_off..data_off + 4], HFSPLUS_ATTR_INLINE_DATA);
7056        // reserved[2] (8 bytes) already zero
7057        BigEndian::write_u32(&mut img[data_off + 12..data_off + 16], value.len() as u32);
7058        img[data_off + 16..data_off + 16 + value.len()].copy_from_slice(value);
7059
7060        // Offset table: record 0 at 14, free-space at 14 + rec_total.
7061        let lot = leaf_off + node_size;
7062        BigEndian::write_u16(&mut img[lot - 2..lot], 14);
7063        BigEndian::write_u16(&mut img[lot - 4..lot - 2], (14 + rec_total) as u16);
7064
7065        // Patch the volume header: point attributes_file at blocks 6..7,
7066        // decrement free_blocks by 2.
7067        let mut vh = HfsPlusVolumeHeader::parse(&img[1024..1536]).expect("parse seed VH");
7068        vh.attributes_file = ForkData {
7069            logical_size: (attr_blocks as u64) * (block_size as u64),
7070            clump_size: 0,
7071            total_blocks: attr_blocks,
7072            extents: {
7073                let mut e = [ExtentDescriptor {
7074                    start_block: 0,
7075                    block_count: 0,
7076                }; 8];
7077                e[0] = ExtentDescriptor {
7078                    start_block: attr_start_block,
7079                    block_count: attr_blocks,
7080                };
7081                e
7082            },
7083        };
7084        vh.free_blocks -= attr_blocks;
7085        vh.next_allocation = 8;
7086        let vh_bytes = vh.serialize();
7087        img[1024..1536].copy_from_slice(&vh_bytes);
7088        let alt = img.len() - 1024;
7089        img[alt..alt + 512].copy_from_slice(&vh_bytes);
7090        img
7091    }
7092
7093    /// Build a minimal HFS+ image whose first leaf node holds the root folder
7094    /// record at `(parent=1, name=<volume_name>)` followed by N synthetic
7095    /// "child" records keyed by `(parent=2, name=childN)` — pushing the root
7096    /// thread record `(parent=2, name="")` into a later leaf. This mirrors
7097    /// the failure mode `probe_hfsplus_volume_label` had on real volumes
7098    /// where many top-level entries existed under the root.
7099    fn make_hfsplus_image_with_label(volume_name: &str, num_children: usize) -> Vec<u8> {
7100        let block_size = 4096u32;
7101        let total_blocks = 256u32;
7102        let image_size = total_blocks as usize * block_size as usize;
7103        let mut img = vec![0u8; image_size];
7104
7105        // Layout: block 0 (VH region), block 1 (bitmap), blocks 2..=5 (catalog, 4 nodes).
7106        let bitmap_block = 1u32;
7107        let catalog_start_block = 2u32;
7108        let catalog_blocks = 4u32;
7109        // Bitmap byte 0 = 0b11111100 (blocks 0..=5 allocated).
7110        img[bitmap_block as usize * block_size as usize] = 0b11111100;
7111
7112        let node_size = 4096usize;
7113        let catalog_offset = catalog_start_block as usize * block_size as usize;
7114
7115        // Node 0: header.
7116        let hdr = catalog_offset;
7117        img[hdr + 8] = 1; // kind = header
7118        BigEndian::write_u16(&mut img[hdr + 10..hdr + 12], 3);
7119        let hr = hdr + 14;
7120        BigEndian::write_u16(&mut img[hr..hr + 2], 1); // depth
7121        BigEndian::write_u32(&mut img[hr + 2..hr + 6], 1); // root_node = 1 (first leaf)
7122        BigEndian::write_u32(&mut img[hr + 6..hr + 10], (1 + num_children + 1) as u32);
7123        BigEndian::write_u32(&mut img[hr + 10..hr + 14], 1); // first_leaf
7124                                                             // Probe only ever reads the first leaf, so last_leaf can be anything ≥ 1.
7125        BigEndian::write_u32(&mut img[hr + 14..hr + 18], 2);
7126        BigEndian::write_u16(&mut img[hr + 18..hr + 20], node_size as u16);
7127        BigEndian::write_u16(&mut img[hr + 20..hr + 22], 516);
7128        BigEndian::write_u32(&mut img[hr + 22..hr + 26], 4);
7129        BigEndian::write_u32(&mut img[hr + 26..hr + 30], 0);
7130        // Header offset table.
7131        let ot = hdr + node_size;
7132        BigEndian::write_u16(&mut img[ot - 2..ot], 14); // header rec
7133        BigEndian::write_u16(&mut img[ot - 4..ot - 2], 142); // user data
7134        BigEndian::write_u16(&mut img[ot - 6..ot - 4], 270); // bitmap
7135        BigEndian::write_u16(&mut img[ot - 8..ot - 6], 526); // free space
7136
7137        // Node 1: first leaf. Record 0 = root folder record at
7138        // (parent=1, name=<volume_name>). The probe reads the volume name
7139        // from this record's key alone.
7140        let leaf = catalog_offset + node_size;
7141        img[leaf + 8] = 0xFF; // kind = -1 (leaf)
7142        img[leaf + 9] = 1; // height
7143        let total_recs = 1 + num_children;
7144        BigEndian::write_u16(&mut img[leaf + 10..leaf + 12], total_recs as u16);
7145
7146        // Record 0: root folder record key `(parent=1, name_len=N, name)` +
7147        // a 88-byte folder data payload.
7148        let name_units: Vec<u16> = volume_name.encode_utf16().collect();
7149        let name_bytes = name_units.len() * 2;
7150        let r0 = leaf + 14;
7151        let key_len = 6 + name_bytes; // parent(4) + name_len(2) + name
7152        BigEndian::write_u16(&mut img[r0..r0 + 2], key_len as u16);
7153        BigEndian::write_u32(&mut img[r0 + 2..r0 + 6], 1); // parent = 1
7154        BigEndian::write_u16(&mut img[r0 + 6..r0 + 8], name_units.len() as u16);
7155        for (i, u) in name_units.iter().enumerate() {
7156            BigEndian::write_u16(&mut img[r0 + 8 + i * 2..r0 + 10 + i * 2], *u);
7157        }
7158        let r0_data = r0 + 2 + key_len + ((2 + key_len) % 2); // 2-byte align
7159        BigEndian::write_i16(&mut img[r0_data..r0_data + 2], CATALOG_FOLDER);
7160        BigEndian::write_u32(&mut img[r0_data + 8..r0_data + 12], 2); // folderID
7161
7162        // Records 1..=N: synthetic child records keyed by (parent=2, name="cN")
7163        // so the root thread (parent=2, name="") gets pushed past the first leaf.
7164        // Using the smallest plausible record (we don't actually open the FS
7165        // here, just probe), so payload size doesn't matter beyond bounds.
7166        let mut rec_offsets = vec![14u16];
7167        let mut cursor = r0_data + 88;
7168        for i in 0..num_children {
7169            let child_name = format!("c{i}");
7170            let cn_units: Vec<u16> = child_name.encode_utf16().collect();
7171            let key_len = 6 + cn_units.len() * 2;
7172            let off = cursor;
7173            BigEndian::write_u16(&mut img[off..off + 2], key_len as u16);
7174            BigEndian::write_u32(&mut img[off + 2..off + 6], 2);
7175            BigEndian::write_u16(&mut img[off + 6..off + 8], cn_units.len() as u16);
7176            for (j, u) in cn_units.iter().enumerate() {
7177                BigEndian::write_u16(&mut img[off + 8 + j * 2..off + 10 + j * 2], *u);
7178            }
7179            // 2-byte aligned data offset, minimal 4-byte folder-record stub
7180            // (the probe never inspects it).
7181            let data = off + 2 + key_len + ((2 + key_len) % 2);
7182            BigEndian::write_i16(&mut img[data..data + 2], CATALOG_FOLDER);
7183            rec_offsets.push((off - leaf) as u16);
7184            cursor = data + 88;
7185        }
7186
7187        // Offset table at end of leaf, MSB-first record-offset slots.
7188        let lot = leaf + node_size;
7189        for (i, off) in rec_offsets.iter().enumerate() {
7190            BigEndian::write_u16(&mut img[lot - 2 - i * 2..lot - i * 2], *off);
7191        }
7192        BigEndian::write_u16(
7193            &mut img[lot - 2 - rec_offsets.len() * 2..lot - rec_offsets.len() * 2],
7194            (cursor - leaf) as u16,
7195        );
7196
7197        // Volume header.
7198        let vh = HfsPlusVolumeHeader {
7199            signature: HFS_PLUS_SIGNATURE,
7200            version: 4,
7201            attributes: 0,
7202            last_mounted_version: 0,
7203            journal_info_block: 0,
7204            create_date: 0,
7205            modify_date: 0,
7206            backup_date: 0,
7207            checked_date: 0,
7208            file_count: 0,
7209            folder_count: num_children as u32,
7210            block_size,
7211            total_blocks,
7212            free_blocks: total_blocks - 6,
7213            next_allocation: 6,
7214            rsrc_clump_size: 0,
7215            data_clump_size: 0,
7216            next_catalog_id: (16 + num_children) as u32,
7217            write_count: 0,
7218            encodings_bitmap: 0,
7219            finder_info: [0; 8],
7220            allocation_file: ForkData {
7221                logical_size: block_size as u64,
7222                clump_size: 0,
7223                total_blocks: 1,
7224                extents: {
7225                    let mut e = [ExtentDescriptor {
7226                        start_block: 0,
7227                        block_count: 0,
7228                    }; 8];
7229                    e[0] = ExtentDescriptor {
7230                        start_block: bitmap_block,
7231                        block_count: 1,
7232                    };
7233                    e
7234                },
7235            },
7236            extents_file: ForkData::empty(),
7237            catalog_file: ForkData {
7238                logical_size: (catalog_blocks as u64) * (block_size as u64),
7239                clump_size: 0,
7240                total_blocks: catalog_blocks,
7241                extents: {
7242                    let mut e = [ExtentDescriptor {
7243                        start_block: 0,
7244                        block_count: 0,
7245                    }; 8];
7246                    e[0] = ExtentDescriptor {
7247                        start_block: catalog_start_block,
7248                        block_count: catalog_blocks,
7249                    };
7250                    e
7251                },
7252            },
7253            attributes_file: ForkData::empty(),
7254            startup_file: ForkData::empty(),
7255        };
7256        let vh_bytes = vh.serialize();
7257        img[1024..1024 + 512].copy_from_slice(&vh_bytes);
7258        let alt = image_size - 1024;
7259        img[alt..alt + 512].copy_from_slice(&vh_bytes);
7260        img
7261    }
7262
7263    #[test]
7264    fn test_probe_hfsplus_volume_label_reads_from_root_key() {
7265        // Volume name lives in the root folder record's key, even when the
7266        // first leaf is dominated by `parent=2` child records that push the
7267        // root thread out of the first leaf. This is the regression case
7268        // that broke the probe on real volumes like Ariel-backup.
7269        let img = make_hfsplus_image_with_label("Ariel-backup", 20);
7270        let mut cursor = std::io::Cursor::new(img);
7271        let label = probe_hfsplus_volume_label(&mut cursor, 0);
7272        assert_eq!(label.as_deref(), Some("Ariel-backup"));
7273    }
7274
7275    #[test]
7276    fn test_probe_hfsplus_volume_label_short_name() {
7277        let img = make_hfsplus_image_with_label("X", 0);
7278        let mut cursor = std::io::Cursor::new(img);
7279        assert_eq!(
7280            probe_hfsplus_volume_label(&mut cursor, 0).as_deref(),
7281            Some("X")
7282        );
7283    }
7284
7285    #[test]
7286    fn test_prepare_for_edit_refuses_journaled_volume_without_valid_journal() {
7287        // Set the journaled bit but provide no valid journal (journalInfoBlock
7288        // points at zeroed boot blocks). Since we can't verify the journal is
7289        // clean, `prepare_for_edit` refuses; a plain read-only `open` still
7290        // succeeds. (Clean journaled volumes — verified empty — are accepted;
7291        // see hfsplus_fsck::tests::prepare_for_edit_allows_clean_journaled_volume.)
7292        let mut img = make_editable_hfsplus_image();
7293        let attr_off = 1024 + 4; // VH offset 4 = attributes (u32 BE)
7294        let mut attrs = BigEndian::read_u32(&img[attr_off..attr_off + 4]);
7295        attrs |= HFSPLUS_VOLUME_JOURNALED_BIT;
7296        BigEndian::write_u32(&mut img[attr_off..attr_off + 4], attrs);
7297
7298        let cursor = std::io::Cursor::new(img);
7299        let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("read-only open should succeed");
7300        let err = fs
7301            .prepare_for_edit()
7302            .expect_err("journaled volume with no valid journal must refuse edit prep");
7303        match err {
7304            FilesystemError::Unsupported(msg) => assert!(msg.contains("journal")),
7305            other => panic!("expected Unsupported(journal...), got {other:?}"),
7306        }
7307    }
7308
7309    #[test]
7310    fn test_sync_metadata_stamps_clean_unmount_attributes() {
7311        // Regression: an HFS+ volume opened from a "mounted/dirty" on-disk
7312        // state (bit 8 clear, bit 14 set) must be persisted as cleanly
7313        // unmounted (bit 8 set, bits 11/14 clear) after sync_metadata.
7314        // Without this, Disk Utility / fsck_hfs flag the volume as
7315        // inconsistent on next mount. See ~/Downloads/HFSTests/ case.
7316        let mut img = make_editable_hfsplus_image();
7317        let attr_off = 1024 + 4;
7318        let dirty = 0x80004000u32 | HFSPLUS_BOOT_VOLUME_INCONSISTENT_BIT;
7319        BigEndian::write_u32(&mut img[attr_off..attr_off + 4], dirty);
7320
7321        let cursor = std::io::Cursor::new(img);
7322        let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("open");
7323        fs.prepare_for_edit().expect("prep");
7324        fs.sync_metadata().expect("sync");
7325
7326        let img = fs.reader.into_inner();
7327        let after = BigEndian::read_u32(&img[attr_off..attr_off + 4]);
7328        assert!(
7329            after & HFSPLUS_VOLUME_UNMOUNTED_BIT != 0,
7330            "unmounted bit must be set after sync, got 0x{after:08x}"
7331        );
7332        assert!(
7333            after & HFSPLUS_VOLUME_INCONSISTENT_BIT == 0,
7334            "inconsistent bit must be cleared, got 0x{after:08x}"
7335        );
7336        assert!(
7337            after & HFSPLUS_BOOT_VOLUME_INCONSISTENT_BIT == 0,
7338            "boot-inconsistent bit must be cleared, got 0x{after:08x}"
7339        );
7340    }
7341
7342    #[test]
7343    fn test_prepare_for_edit_clean_volume_succeeds() {
7344        let img = make_editable_hfsplus_image();
7345        let cursor = std::io::Cursor::new(img);
7346        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
7347        fs.prepare_for_edit()
7348            .expect("clean volume should accept edit prep");
7349        // No xattrs on the synthetic image — the cached set is empty.
7350        assert!(fs.xattr_cnids.as_ref().unwrap().is_empty());
7351    }
7352
7353    /// Fill many leaves, delete everything, fill again: no index record may
7354    /// point at a freed leaf, or the next insert lands in a free node.
7355    #[test]
7356    fn deleting_a_leafs_first_record_refreshes_the_separators() {
7357        // fsck_hfs E_IKey: after the audit's fill-and-delete run the parent
7358        // still carried the first key the leaf had before the delete.
7359        use crate::hfs_common::{
7360            btree_record_range, btree_stale_index_keys, BTreeHeader, BTreeKeyFormat,
7361        };
7362        type Fs = HfsPlusFilesystem<std::io::Cursor<Vec<u8>>>;
7363        let img = create_blank_hfsplus_sized(64 * 1024 * 1024, 4096, "IKey", false, 16 << 20, 0);
7364        let mut fs = Fs::open(std::io::Cursor::new(img), 0).unwrap();
7365        fs.prepare_for_edit().unwrap();
7366        let root = fs.root().unwrap();
7367        let options = CreateFileOptions::default();
7368        for i in 0..1200 {
7369            let mut empty = std::io::Cursor::new(Vec::new());
7370            fs.create_file(&root, &format!("f{i:04}.txt"), &mut empty, 0, &options)
7371                .unwrap();
7372        }
7373        let node_size = fs.catalog_node_size();
7374        let header = BTreeHeader::read(fs.catalog_data());
7375        assert!(
7376            header.depth >= 2,
7377            "need an index level, depth {}",
7378            header.depth
7379        );
7380
7381        // The first record of the second leaf is a file record: deleting that
7382        // file changes the key its parent's separator must carry.
7383        let first = header.first_leaf_node as usize;
7384        let second =
7385            BigEndian::read_u32(&fs.catalog_data()[first * node_size..first * node_size + 4])
7386                as usize;
7387        let name = {
7388            let node = &fs.catalog_data()[second * node_size..(second + 1) * node_size];
7389            let (s, e) = btree_record_range(node, node_size, 0);
7390            let rec = &node[s..e];
7391            let name_len = BigEndian::read_u16(&rec[6..8]) as usize;
7392            (0..name_len)
7393                .map(|i| BigEndian::read_u16(&rec[8 + 2 * i..10 + 2 * i]) as u8 as char)
7394                .collect::<String>()
7395        };
7396        assert!(
7397            name.starts_with('f'),
7398            "expected a file record first, got {name:?}"
7399        );
7400        let victim = fs
7401            .list_directory(&root)
7402            .unwrap()
7403            .into_iter()
7404            .find(|e| e.name == name)
7405            .expect("victim listed");
7406        fs.delete_entry(&root, &victim).unwrap();
7407
7408        let cmp = |a: &[u8], b: &[u8]| Fs::catalog_compare(a, b, false);
7409        let stale = btree_stale_index_keys(
7410            fs.catalog_data(),
7411            node_size,
7412            &BTreeKeyFormat::HFSPLUS_CATALOG,
7413            &cmp,
7414        );
7415        assert!(stale.is_empty(), "separators still stale: {stale:?}");
7416        let report = fs.fsck().unwrap().unwrap();
7417        assert!(
7418            report.errors.is_empty(),
7419            "{:?}",
7420            report
7421                .errors
7422                .iter()
7423                .map(|e| &e.message)
7424                .take(4)
7425                .collect::<Vec<_>>()
7426        );
7427    }
7428
7429    #[test]
7430    fn emptied_leaves_leave_the_index_too() {
7431        use crate::hfs_common::{btree_bitmap_test, btree_record_range, BTREE_INDEX_NODE};
7432        let img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "Leaves", false);
7433        let mut fs = HfsPlusFilesystem::open(std::io::Cursor::new(img), 0).unwrap();
7434        fs.prepare_for_edit().unwrap();
7435        let root = fs.root().unwrap();
7436        let names: Vec<String> = (0..220).map(|i| format!("leaf-file-{i:04}.txt")).collect();
7437        let fill = |fs: &mut HfsPlusFilesystem<std::io::Cursor<Vec<u8>>>| {
7438            for n in &names {
7439                let mut src = std::io::Cursor::new(n.as_bytes().to_vec());
7440                fs.create_file(
7441                    &root,
7442                    n,
7443                    &mut src,
7444                    n.len() as u64,
7445                    &CreateFileOptions::default(),
7446                )
7447                .unwrap_or_else(|e| panic!("create {n}: {e:?}"));
7448            }
7449        };
7450        fill(&mut fs);
7451        assert!(
7452            fs.catalog_header.depth > 1,
7453            "the test needs a split catalog"
7454        );
7455
7456        for e in fs.list_directory(&root).unwrap() {
7457            fs.delete_entry(&root, &e).unwrap();
7458        }
7459        // No index record may point at a free node.
7460        let node_size = fs.catalog_header.node_size as usize;
7461        let data = &fs.catalog_data;
7462        for idx in 1..(data.len() / node_size) as u32 {
7463            if !btree_bitmap_test(data, node_size, idx) {
7464                continue;
7465            }
7466            let off = idx as usize * node_size;
7467            let node = &data[off..off + node_size];
7468            if node[8] as i8 != BTREE_INDEX_NODE {
7469                continue;
7470            }
7471            let n = BigEndian::read_u16(&node[10..12]) as usize;
7472            for r in 0..n {
7473                let (_, end) = btree_record_range(node, node_size, r);
7474                let child = BigEndian::read_u32(&node[end - 4..end]);
7475                assert!(
7476                    btree_bitmap_test(data, node_size, child),
7477                    "index node {idx} points at free node {child}"
7478                );
7479            }
7480        }
7481
7482        fill(&mut fs);
7483        let listed = fs.list_directory(&root).unwrap();
7484        assert_eq!(listed.len(), names.len());
7485        for e in &listed {
7486            assert_eq!(fs.read_file(e, usize::MAX).unwrap(), e.name.as_bytes());
7487        }
7488        fs.sync_metadata().unwrap();
7489        let report = fs.fsck().unwrap().unwrap();
7490        assert!(report.errors.is_empty(), "{:?}", report.errors);
7491    }
7492
7493    #[test]
7494    fn test_create_blank_hfsplus_32mib() {
7495        let img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "MyVol", false);
7496        assert_eq!(img.len(), 32 * 1024 * 1024);
7497
7498        let cursor = std::io::Cursor::new(img);
7499        let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("blank should open");
7500        assert_eq!(fs.fs_type(), "HFS+");
7501        assert_eq!(fs.label, "MyVol");
7502
7503        let root = fs.root().unwrap();
7504        let entries = fs.list_directory(&root).unwrap();
7505        assert!(entries.is_empty(), "freshly built blank must list empty");
7506
7507        // 32 MiB / 4096 = 8192 blocks. The catalog is now volume-scaled
7508        // (~0.5%, docs/hfsplus_btree_growth_plan.md §4c): 32 MiB / 200 =
7509        // 167,772 bytes -> 41 nodes @ 4096. Reserved = 1 (boot/VH) + 1 (bitmap)
7510        // + 4 (extents, default) + 41 (catalog) = 47 at the front, plus the
7511        // trailing allocation block holding the alternate volume header (Apple
7512        // reserves it; required for an fsck_hfs-clean free-block count).
7513        assert_eq!(fs.vh.total_blocks, 8192);
7514        assert_eq!(fs.vh.free_blocks, 8192 - 47 - 1);
7515        // Edit-mode prep should succeed (unmounted bit set, no journal).
7516        fs.prepare_for_edit().expect("blank must accept edit prep");
7517    }
7518
7519    #[test]
7520    fn test_create_blank_hfsx_32mib_signature() {
7521        let img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "CSVol", true);
7522        let cursor = std::io::Cursor::new(img);
7523        let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("HFSX blank should open");
7524        assert_eq!(fs.fs_type(), "HFSX");
7525        assert!(fs.vh.is_hfsx());
7526        assert_eq!(fs.label, "CSVol");
7527        let root = fs.root().unwrap();
7528        assert!(fs.list_directory(&root).unwrap().is_empty());
7529    }
7530
7531    #[test]
7532    fn test_create_blank_hfsplus_supports_create_file() {
7533        // Round-trip: build a blank, create a file inside it, sync, reopen.
7534        let img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "Work", false);
7535        let cursor = std::io::Cursor::new(img);
7536        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
7537        fs.prepare_for_edit().unwrap();
7538        let root = fs.root().unwrap();
7539        let payload = b"hello world".to_vec();
7540        let mut data = std::io::Cursor::new(payload.clone());
7541        fs.create_file(
7542            &root,
7543            "hello.txt",
7544            &mut data,
7545            payload.len() as u64,
7546            &CreateFileOptions::default(),
7547        )
7548        .expect("create_file on blank");
7549        fs.sync_metadata().unwrap();
7550
7551        let img2 = fs.reader.into_inner();
7552        let mut cursor2 = std::io::Cursor::new(img2);
7553        let mut fs2 = HfsPlusFilesystem::open(&mut cursor2, 0).unwrap();
7554        let root2 = fs2.root().unwrap();
7555        let entries = fs2.list_directory(&root2).unwrap();
7556        assert_eq!(entries.len(), 1);
7557        assert_eq!(entries[0].name, "hello.txt");
7558        let got = fs2.read_file(&entries[0], 1024).unwrap();
7559        assert_eq!(got, payload);
7560    }
7561
7562    #[test]
7563    fn test_hfsplus_rename_round_trips() {
7564        let img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "Work", false);
7565        let mut fs = HfsPlusFilesystem::open(std::io::Cursor::new(img), 0).unwrap();
7566        fs.prepare_for_edit().unwrap();
7567        let root = fs.root().unwrap();
7568
7569        let payload = b"keep my bytes across a rename".to_vec();
7570        let mut data = std::io::Cursor::new(payload.clone());
7571        let fe = fs
7572            .create_file(
7573                &root,
7574                "before.txt",
7575                &mut data,
7576                payload.len() as u64,
7577                &CreateFileOptions::default(),
7578            )
7579            .unwrap();
7580        let cnid = fe.location;
7581        fs.create_directory(&root, "olddir", &CreateDirectoryOptions::default())
7582            .unwrap();
7583
7584        // File rename to a longer name — exercises the variable-length thread
7585        // record rebuild (not an in-place patch).
7586        fs.rename(&root, &fe, "a much longer renamed file.txt")
7587            .unwrap();
7588        // Directory rename — its thread record is rebuilt too.
7589        let old_dir = fs
7590            .list_directory(&root)
7591            .unwrap()
7592            .into_iter()
7593            .find(|e| e.name == "olddir")
7594            .unwrap();
7595        fs.rename(&root, &old_dir, "newdir").unwrap();
7596
7597        // A collision with a different entry is rejected.
7598        let mut t = std::io::Cursor::new(b"x".as_slice());
7599        fs.create_file(&root, "taken.txt", &mut t, 1, &CreateFileOptions::default())
7600            .unwrap();
7601        let renamed_now = fs
7602            .list_directory(&root)
7603            .unwrap()
7604            .into_iter()
7605            .find(|e| e.name == "a much longer renamed file.txt")
7606            .unwrap();
7607        assert!(matches!(
7608            fs.rename(&root, &renamed_now, "taken.txt"),
7609            Err(FilesystemError::AlreadyExists(_))
7610        ));
7611
7612        // Persist and reopen: the renames survive a round-trip to disk.
7613        fs.sync_metadata().unwrap();
7614        let img2 = fs.reader.into_inner();
7615        let mut c2 = std::io::Cursor::new(img2);
7616        let mut fs2 = HfsPlusFilesystem::open(&mut c2, 0).unwrap();
7617        let root2 = fs2.root().unwrap();
7618        let entries = fs2.list_directory(&root2).unwrap();
7619        assert!(
7620            entries
7621                .iter()
7622                .any(|e| e.name == "a much longer renamed file.txt"),
7623            "{entries:?}"
7624        );
7625        assert!(
7626            !entries.iter().any(|e| e.name == "before.txt"),
7627            "{entries:?}"
7628        );
7629        let renamed = entries
7630            .iter()
7631            .find(|e| e.name == "a much longer renamed file.txt")
7632            .unwrap();
7633        // CNID and content preserved through the catalog re-key.
7634        assert_eq!(renamed.location, cnid);
7635        assert_eq!(fs2.read_file(renamed, 4096).unwrap(), payload);
7636        // The renamed directory is still browsable.
7637        let newdir = entries.iter().find(|e| e.name == "newdir").unwrap();
7638        assert!(fs2.list_directory(newdir).is_ok());
7639    }
7640
7641    #[test]
7642    fn test_create_file_stamps_dates() {
7643        // Newly-created files must have createDate / contentModDate /
7644        // attributeModDate / accessDate equal to sync-time hfs_now() to
7645        // within a couple seconds (test wall-clock jitter).
7646        let img = make_editable_hfsplus_image();
7647        let cursor = std::io::Cursor::new(img);
7648        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
7649        fs.prepare_for_edit().unwrap();
7650
7651        let now_before = hfs_common::hfs_now();
7652        let root = fs.root().unwrap();
7653        let mut data = std::io::Cursor::new(b"hi".as_slice());
7654        let entry = fs
7655            .create_file(
7656                &root,
7657                "dated.txt",
7658                &mut data,
7659                2,
7660                &CreateFileOptions::default(),
7661            )
7662            .unwrap();
7663        let now_after = hfs_common::hfs_now();
7664
7665        // Locate the new file's catalog record and read all four dates.
7666        let cnid = entry.location as u32;
7667        let (_n, _r, f_off) = fs.find_catalog_record(2, "dated.txt").unwrap();
7668        let fkey_len = BigEndian::read_u16(&fs.catalog_data[f_off..f_off + 2]) as usize;
7669        let mut frec = f_off + 2 + fkey_len;
7670        if !frec.is_multiple_of(2) {
7671            frec += 1;
7672        }
7673        let create_date = BigEndian::read_u32(&fs.catalog_data[frec + 12..frec + 16]);
7674        let content_mod = BigEndian::read_u32(&fs.catalog_data[frec + 16..frec + 20]);
7675        let attr_mod = BigEndian::read_u32(&fs.catalog_data[frec + 20..frec + 24]);
7676        let access = BigEndian::read_u32(&fs.catalog_data[frec + 24..frec + 28]);
7677
7678        for (label, value) in [
7679            ("createDate", create_date),
7680            ("contentModDate", content_mod),
7681            ("attributeModDate", attr_mod),
7682            ("accessDate", access),
7683        ] {
7684            assert!(
7685                value >= now_before && value <= now_after + 1,
7686                "{label} {value} not in [{now_before}, {now_after}+1]"
7687            );
7688        }
7689
7690        // Counter parity: VH file_count must have incremented by one.
7691        assert_eq!(fs.vh.file_count, 1);
7692        let _ = cnid;
7693    }
7694
7695    #[test]
7696    fn test_set_type_creator_bumps_attribute_mod_date() {
7697        // set_type_creator is an attribute-only change: attributeModDate
7698        // moves forward, but contentModDate (set at create-time) stays
7699        // pinned to the original value.
7700        let img = make_editable_hfsplus_image();
7701        let cursor = std::io::Cursor::new(img);
7702        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
7703        fs.prepare_for_edit().unwrap();
7704
7705        let root = fs.root().unwrap();
7706        let mut data = std::io::Cursor::new(b"x".as_slice());
7707        let entry = fs
7708            .create_file(&root, "a.txt", &mut data, 1, &CreateFileOptions::default())
7709            .unwrap();
7710
7711        let (_n, _r, f_off) = fs.find_catalog_record(2, "a.txt").unwrap();
7712        let fkey_len = BigEndian::read_u16(&fs.catalog_data[f_off..f_off + 2]) as usize;
7713        let mut frec = f_off + 2 + fkey_len;
7714        if !frec.is_multiple_of(2) {
7715            frec += 1;
7716        }
7717        let content_before = BigEndian::read_u32(&fs.catalog_data[frec + 16..frec + 20]);
7718
7719        // Force the timestamp to advance — hfs_now() has 1-second resolution.
7720        // Stuff the create-time content date into the past so we can assert
7721        // it stays put while attributeModDate moves to "now".
7722        BigEndian::write_u32(
7723            &mut fs.catalog_data[frec + 16..frec + 20],
7724            content_before - 60,
7725        );
7726        BigEndian::write_u32(
7727            &mut fs.catalog_data[frec + 20..frec + 24],
7728            content_before - 60,
7729        );
7730
7731        let now_before = hfs_common::hfs_now();
7732        fs.set_type_creator(&entry, "TEXT", "ttxt").unwrap();
7733        let now_after = hfs_common::hfs_now();
7734
7735        let content_after = BigEndian::read_u32(&fs.catalog_data[frec + 16..frec + 20]);
7736        let attr_after = BigEndian::read_u32(&fs.catalog_data[frec + 20..frec + 24]);
7737        assert_eq!(
7738            content_after,
7739            content_before - 60,
7740            "contentModDate must not move on attribute-only change"
7741        );
7742        assert!(
7743            attr_after >= now_before && attr_after <= now_after + 1,
7744            "attributeModDate {attr_after} not in [{now_before}, {now_after}+1]"
7745        );
7746    }
7747
7748    #[test]
7749    fn test_extents_overflow_round_trip_12_extents() {
7750        // Pre-fragment so 12 isolated 1-block runs are the only free space,
7751        // then create a 12-block file. The first 8 land in the inline
7752        // ForkData slots, the remaining 4 in one extents-overflow record.
7753        // Sync, reopen, verify byte-equal read via read_fork_with_overflow.
7754        let img = make_editable_hfsplus_image_with_extents_btree();
7755        let cursor = std::io::Cursor::new(img);
7756        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
7757        fs.prepare_for_edit().unwrap();
7758
7759        // Mark every block 0..=255 allocated, then clear 12 isolated single
7760        // blocks at evenly-spaced odd indices in the user-data region. The
7761        // 12 free blocks are at positions 11, 13, 15, ..., 33.
7762        fs.ensure_bitmap().unwrap();
7763        let bitmap = fs.bitmap.as_mut().unwrap();
7764        for byte in bitmap.iter_mut().take(32) {
7765            *byte = 0xFF;
7766        }
7767        let free_positions: Vec<u32> = (0..12).map(|i| 11 + 2 * i as u32).collect();
7768        for &blk in &free_positions {
7769            bitmap_clear_bit_be(bitmap, blk);
7770        }
7771        fs.vh.free_blocks = 12;
7772
7773        let block_size = fs.vh.block_size as usize;
7774        let payload: Vec<u8> = (0..(12 * block_size)).map(|i| (i % 251) as u8).collect();
7775        let mut reader = std::io::Cursor::new(payload.clone());
7776        let root = fs.root().unwrap();
7777        let entry = fs
7778            .create_file(
7779                &root,
7780                "frag12.bin",
7781                &mut reader,
7782                payload.len() as u64,
7783                &CreateFileOptions::default(),
7784            )
7785            .expect("12-extent allocation should succeed via overflow B-tree");
7786
7787        let cnid = entry.location as u32;
7788        let (data_fork, _rsrc) = fs.find_file_by_id(cnid).unwrap();
7789        // 8 inline extents, all 1-block.
7790        for slot in &data_fork.extents {
7791            assert_eq!(slot.block_count, 1);
7792        }
7793        assert_eq!(data_fork.total_blocks, 12);
7794
7795        // Sync to disk.
7796        fs.sync_metadata().unwrap();
7797
7798        // Reopen the underlying buffer and confirm read_fork_with_overflow
7799        // reproduces the data byte-for-byte.
7800        let img_after = fs.reader.into_inner();
7801        let mut cursor2 = std::io::Cursor::new(img_after);
7802        let mut fs2 = HfsPlusFilesystem::open(&mut cursor2, 0).unwrap();
7803        let root2 = fs2.root().unwrap();
7804        let entries = fs2.list_directory(&root2).unwrap();
7805        let reopened = entries.iter().find(|e| e.name == "frag12.bin").unwrap();
7806        let got = fs2.read_file(reopened, payload.len() + 1).unwrap();
7807        assert_eq!(got.len(), payload.len());
7808        assert_eq!(got, payload);
7809    }
7810
7811    #[test]
7812    fn test_allocate_extents_fragmented_bitmap() {
7813        // Pre-fragment the synthetic volume so 4 free blocks exist as four
7814        // 1-block runs (blocks 7, 9, 11, 13). Asking for 4 must return four
7815        // single-block extents and the data must round-trip byte-equal.
7816        let img = make_editable_hfsplus_image();
7817        let cursor = std::io::Cursor::new(img);
7818        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
7819        fs.prepare_for_edit().unwrap();
7820
7821        // Force the bitmap to load, then rewrite it. Block 0 = first byte
7822        // (bits 0..=7 = blocks 0..=7); we want blocks 0..=255 all allocated
7823        // EXCEPT 7, 9, 11, 13.
7824        fs.ensure_bitmap().unwrap();
7825        let bitmap = fs.bitmap.as_mut().unwrap();
7826        for byte in bitmap.iter_mut().take(32) {
7827            *byte = 0xFF;
7828        }
7829        // Clear blocks 7, 9, 11, 13.
7830        for blk in [7u32, 9, 11, 13] {
7831            bitmap_clear_bit_be(bitmap, blk);
7832        }
7833        fs.vh.free_blocks = 4;
7834
7835        let block_size = fs.vh.block_size as usize;
7836        let payload: Vec<u8> = (0..(4 * block_size)).map(|i| (i % 251) as u8).collect();
7837        let mut reader = std::io::Cursor::new(payload.clone());
7838        let root = fs.root().unwrap();
7839        let entry = fs
7840            .create_file(
7841                &root,
7842                "frag.bin",
7843                &mut reader,
7844                payload.len() as u64,
7845                &CreateFileOptions::default(),
7846            )
7847            .expect("fragmented allocation should succeed");
7848
7849        let cnid = entry.location as u32;
7850        let (data_fork, _rsrc) = fs.find_file_by_id(cnid).expect("file record present");
7851
7852        // 4 single-block extents in slots 0..3.
7853        for (i, expected_start) in [7u32, 9, 11, 13].iter().enumerate() {
7854            assert_eq!(data_fork.extents[i].block_count, 1, "slot {i}");
7855            assert_eq!(
7856                data_fork.extents[i].start_block, *expected_start,
7857                "slot {i}"
7858            );
7859        }
7860        for slot in &data_fork.extents[4..] {
7861            assert!(slot.is_empty(), "slot past 4 should be empty");
7862        }
7863        assert_eq!(data_fork.total_blocks, 4);
7864
7865        // Read it back and confirm byte-equal.
7866        let mut got = Vec::new();
7867        fs.write_file_to(&entry, &mut got).unwrap();
7868        assert_eq!(got, payload);
7869        assert_eq!(fs.vh.free_blocks, 0);
7870    }
7871
7872    #[test]
7873    fn test_snapshot_rollback_on_disk_full_create_file() {
7874        // The synthetic editable image has 250 free 4 KiB blocks. Asking
7875        // create_file for 251 blocks' worth of data should trip the
7876        // allocator's DiskFull and the snapshot guard must rewind every
7877        // mutation made before the failure (next_catalog_id bump, bitmap
7878        // dirty load, etc.).
7879        let img = make_editable_hfsplus_image();
7880        let cursor = std::io::Cursor::new(img);
7881        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
7882        fs.prepare_for_edit().unwrap();
7883
7884        let next_cnid_before = fs.vh.next_catalog_id;
7885        let free_blocks_before = fs.vh.free_blocks;
7886        let file_count_before = fs.vh.file_count;
7887        let catalog_before = fs.catalog_data.clone();
7888
7889        let root = fs.root().unwrap();
7890        let big_len: u64 = 251 * 4096 + 1; // exceeds the 250 free blocks
7891        let big_buf = vec![0u8; big_len as usize];
7892        let mut reader = std::io::Cursor::new(big_buf);
7893        let err = fs
7894            .create_file(
7895                &root,
7896                "too-big.bin",
7897                &mut reader,
7898                big_len,
7899                &CreateFileOptions::default(),
7900            )
7901            .expect_err("create_file should fail with DiskFull");
7902        assert!(matches!(err, FilesystemError::DiskFull(_)), "got {err:?}");
7903
7904        assert_eq!(fs.vh.next_catalog_id, next_cnid_before);
7905        assert_eq!(fs.vh.free_blocks, free_blocks_before);
7906        assert_eq!(fs.vh.file_count, file_count_before);
7907        assert_eq!(fs.catalog_data, catalog_before);
7908    }
7909
7910    #[test]
7911    fn test_set_xattr_round_trip_and_delete_cleanup() {
7912        // Step 13 round-trip: on a volume with an attributes B-tree, create a
7913        // file, attach 3 inline xattrs, sync, reopen, verify all 3 round-trip,
7914        // then delete the file and confirm every attribute record for its
7915        // CNID is gone.
7916        //
7917        // Seed image carries a sentinel record on cnid=99 so the attributes
7918        // file actually exists; the test never reads it back, just verifies
7919        // it survives unaffected when an unrelated CNID's xattrs are deleted.
7920        let sentinel_cnid = 99u32;
7921        let img = make_editable_hfsplus_image_with_inline_xattr(sentinel_cnid, &[0xAA; 8]);
7922        let cursor = std::io::Cursor::new(img);
7923        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
7924        fs.prepare_for_edit().unwrap();
7925
7926        let root = fs.root().unwrap();
7927        let mut data = std::io::Cursor::new(b"x".as_slice());
7928        let entry = fs
7929            .create_file(
7930                &root,
7931                "with-xattr.txt",
7932                &mut data,
7933                1,
7934                &CreateFileOptions::default(),
7935            )
7936            .expect("create_file");
7937        let cnid = entry.location as u32;
7938
7939        let pairs: [(&str, &[u8]); 3] = [
7940            ("com.apple.FinderInfo", &[1u8; 32]),
7941            ("com.apple.metadata:_kMDItemUserTags", b"\x00bplist00 tag"),
7942            ("user.short", b"hi"),
7943        ];
7944        for (name, value) in pairs.iter() {
7945            fs.set_xattr(cnid, name, value).expect("set_xattr");
7946        }
7947        fs.sync_metadata().unwrap();
7948
7949        // Reopen the freshly synced bytes and verify all three records survived.
7950        let img2 = fs.reader.into_inner();
7951        let mut cursor2 = std::io::Cursor::new(img2);
7952        let mut fs2 = HfsPlusFilesystem::open(&mut cursor2, 0).unwrap();
7953        let mut got = fs2.list_xattrs(cnid).expect("list_xattrs");
7954        got.sort_by(|a, b| a.name.cmp(&b.name));
7955        let mut want: Vec<(String, Vec<u8>)> = pairs
7956            .iter()
7957            .map(|(n, v)| (n.to_string(), v.to_vec()))
7958            .collect();
7959        want.sort_by(|a, b| a.0.cmp(&b.0));
7960        assert_eq!(got.len(), want.len());
7961        for (rec, (wname, wval)) in got.iter().zip(want.iter()) {
7962            assert_eq!(rec.name, *wname);
7963            match &rec.kind {
7964                XattrKind::Inline(bytes) => assert_eq!(bytes, wval),
7965                other => panic!("expected Inline xattr, got {other:?}"),
7966            }
7967        }
7968
7969        // Sentinel xattr on the unrelated CNID still present.
7970        let sentinel = fs2
7971            .list_xattrs(sentinel_cnid)
7972            .expect("sentinel list_xattrs");
7973        assert_eq!(sentinel.len(), 1, "sentinel record should survive");
7974
7975        // Delete the file: every attribute row keyed at our cnid must vanish.
7976        fs2.prepare_for_edit().unwrap();
7977        let root2 = fs2.root().unwrap();
7978        let listing = fs2.list_directory(&root2).unwrap();
7979        let target = listing
7980            .iter()
7981            .find(|e| e.name == "with-xattr.txt")
7982            .cloned()
7983            .expect("entry on reopened volume");
7984        fs2.delete_entry(&root2, &target).expect("delete");
7985        let after = fs2.list_xattrs(cnid).expect("post-delete list_xattrs");
7986        assert!(
7987            after.is_empty(),
7988            "expected 0 records after delete, got {}",
7989            after.len()
7990        );
7991        let sentinel_after = fs2
7992            .list_xattrs(sentinel_cnid)
7993            .expect("sentinel post-delete");
7994        assert_eq!(sentinel_after.len(), 1, "sentinel must survive delete");
7995    }
7996
7997    #[test]
7998    fn test_remove_xattr_not_found_and_cleanup() {
7999        // remove_xattr on a missing name returns NotFound, and removing the
8000        // last xattr drops the cnid from the cached set.
8001        let cnid = 17u32;
8002        let img = make_editable_hfsplus_image_with_inline_xattr(cnid, &[0u8; 16]);
8003        let cursor = std::io::Cursor::new(img);
8004        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
8005        fs.prepare_for_edit().unwrap();
8006        assert!(fs.xattr_cnids.as_ref().unwrap().contains(&cnid));
8007
8008        let err = fs
8009            .remove_xattr(cnid, "com.apple.does-not-exist")
8010            .expect_err("missing xattr must error");
8011        assert!(matches!(err, FilesystemError::NotFound(_)));
8012
8013        fs.remove_xattr(cnid, "com.apple.FinderInfo")
8014            .expect("remove existing xattr");
8015        assert!(
8016            !fs.xattr_cnids.as_ref().unwrap().contains(&cnid),
8017            "cnid should be dropped from cache once its last xattr is gone"
8018        );
8019    }
8020
8021    #[test]
8022    fn test_list_xattrs_round_trips_inline_finder_info() {
8023        // Synthesize a 32-byte com.apple.FinderInfo inline xattr on CNID 17,
8024        // assert list_xattrs returns it byte-for-byte and reports an empty
8025        // list for an unrelated CNID.
8026        let cnid = 17u32;
8027        let finder_info: Vec<u8> = (0..32u8).collect();
8028        let img = make_editable_hfsplus_image_with_inline_xattr(cnid, &finder_info);
8029        let cursor = std::io::Cursor::new(img);
8030        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
8031
8032        let recs = fs.list_xattrs(cnid).expect("list_xattrs cnid=17");
8033        assert_eq!(recs.len(), 1, "expected exactly one xattr record");
8034        let rec = &recs[0];
8035        assert_eq!(rec.name, "com.apple.FinderInfo");
8036        assert_eq!(rec.start_block, 0);
8037        match &rec.kind {
8038            XattrKind::Inline(bytes) => assert_eq!(bytes, &finder_info),
8039            other => panic!("expected Inline xattr, got {other:?}"),
8040        }
8041
8042        // Unrelated CNID returns empty.
8043        let none = fs.list_xattrs(99).expect("list_xattrs cnid=99");
8044        assert!(none.is_empty(), "expected no xattrs for cnid=99");
8045
8046        // Volume with no attributes file (the seed image) returns empty too.
8047        let bare = make_editable_hfsplus_image();
8048        let mut bare_fs = HfsPlusFilesystem::open(std::io::Cursor::new(bare), 0).unwrap();
8049        let empty = bare_fs.list_xattrs(cnid).expect("list_xattrs no attr file");
8050        assert!(empty.is_empty(), "no attributes file → no records");
8051    }
8052
8053    #[test]
8054    fn test_hardlink_resolution_two_links_one_inode() {
8055        // Step 15: synthesize a volume with two hardlinks pointing at one
8056        // inode under `HFS+ Private Data`. Both link rows must surface as
8057        // regular files in `list_directory`, carry `link_target_cnid` set
8058        // to the inode CNID, and read back the inode's data fork bytes.
8059        const PAYLOAD: &[u8] = b"INODE_PAYLOAD_42\n";
8060        const I_NODE_NUM: u32 = 99;
8061
8062        let mut img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "Links", false);
8063
8064        // Build the volume contents: private dir, inode, two link stubs.
8065        // Patch the link stubs' catalog records to set FInfo type='hlnk'
8066        // creator='hfs+' and bsdInfo.special = I_NODE_NUM. We do this via
8067        // direct catalog-byte edits while holding the editable handle, then
8068        // sync once at the end.
8069        let inode_cnid: u32;
8070        {
8071            let cursor = std::io::Cursor::new(&mut img);
8072            let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("open blank");
8073            fs.prepare_for_edit().expect("prepare_for_edit");
8074            let root = fs.root().expect("root");
8075
8076            let private_dir = fs
8077                .create_directory(
8078                    &root,
8079                    &hfsplus_private_dir_name(),
8080                    &CreateDirectoryOptions::default(),
8081                )
8082                .expect("create private dir");
8083
8084            let mut inode_data = std::io::Cursor::new(PAYLOAD);
8085            let inode_entry = fs
8086                .create_file(
8087                    &private_dir,
8088                    &format!("iNode{I_NODE_NUM}"),
8089                    &mut inode_data,
8090                    PAYLOAD.len() as u64,
8091                    &CreateFileOptions::default(),
8092                )
8093                .expect("create inode file");
8094            inode_cnid = inode_entry.location as u32;
8095
8096            for link_name in ["linkA", "linkB"] {
8097                let mut empty = std::io::Cursor::new(&[][..]);
8098                fs.create_file(
8099                    &root,
8100                    link_name,
8101                    &mut empty,
8102                    0,
8103                    &CreateFileOptions {
8104                        os_type: Some(*b"hlnk"),
8105                        os_creator: Some(*b"hfs+"),
8106                        ..Default::default()
8107                    },
8108                )
8109                .expect("create link stub");
8110
8111                // Patch bsdInfo.special on the freshly-created stub to point
8112                // at the inode number. body offset 32+12 = 44.
8113                let (_node, _rec, abs) = fs
8114                    .find_catalog_record(2, link_name)
8115                    .expect("link stub in catalog");
8116                let key_len = BigEndian::read_u16(&fs.catalog_data[abs..abs + 2]) as usize;
8117                let mut body_start = abs + 2 + key_len;
8118                if !body_start.is_multiple_of(2) {
8119                    body_start += 1;
8120                }
8121                BigEndian::write_u32(
8122                    &mut fs.catalog_data[body_start + 44..body_start + 48],
8123                    I_NODE_NUM,
8124                );
8125            }
8126
8127            fs.sync_metadata().expect("sync");
8128        }
8129
8130        // Re-open, walk root, verify both link stubs resolve to the inode
8131        // and read back the inode's bytes.
8132        let cursor = std::io::Cursor::new(&mut img);
8133        let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("re-open");
8134        let root = fs.root().expect("root");
8135        let entries = fs.list_directory(&root).expect("list root");
8136        let link_a = entries
8137            .iter()
8138            .find(|e| e.name == "linkA")
8139            .cloned()
8140            .expect("linkA");
8141        let link_b = entries
8142            .iter()
8143            .find(|e| e.name == "linkB")
8144            .cloned()
8145            .expect("linkB");
8146
8147        assert_eq!(
8148            link_a.link_target_cnid,
8149            Some(inode_cnid as u64),
8150            "linkA must resolve to inode CNID"
8151        );
8152        assert_eq!(
8153            link_b.link_target_cnid,
8154            Some(inode_cnid as u64),
8155            "linkB must resolve to inode CNID"
8156        );
8157        // Display size should reflect the inode's data fork, not the empty
8158        // stub fork.
8159        assert_eq!(link_a.size, PAYLOAD.len() as u64);
8160        assert_eq!(link_b.size, PAYLOAD.len() as u64);
8161
8162        let bytes_a = fs.read_file(&link_a, usize::MAX).expect("read linkA");
8163        let bytes_b = fs.read_file(&link_b, usize::MAX).expect("read linkB");
8164        assert_eq!(bytes_a, PAYLOAD);
8165        assert_eq!(bytes_b, PAYLOAD);
8166
8167        // Sanity: a file with no hardlink magic stays plain.
8168        // The private dir itself shows up at root with `link_target_cnid`
8169        // unset (it's a folder, not a file hardlink).
8170        let private = entries
8171            .iter()
8172            .find(|e| e.name == hfsplus_private_dir_name())
8173            .expect("private dir at root");
8174        assert!(private.link_target_cnid.is_none());
8175    }
8176
8177    #[test]
8178    fn test_hardlink_delete_decrements_then_frees_inode() {
8179        // Step 16: deleting a hardlink decrements `linkCount` on the inode;
8180        // deleting the last reference frees the inode, drops it from the
8181        // private directory, and reclaims its data fork blocks.
8182        const PAYLOAD: &[u8] = b"INODE_DATA_FOR_DELETE\n";
8183        const I_NODE_NUM: u32 = 7;
8184
8185        let mut img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "DelLinks", false);
8186
8187        // Capture initial free-block count after the build, then synthesize
8188        // private dir + inode (linkCount=2) + two stubs.
8189        let inode_cnid: u32;
8190        let private_cnid: u32;
8191        let payload_blocks_used: u32;
8192        let free_after_build: u32;
8193        let free_with_inode: u32;
8194        {
8195            let cursor = std::io::Cursor::new(&mut img);
8196            let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("open blank");
8197            fs.prepare_for_edit().expect("prepare_for_edit");
8198            free_after_build = fs.vh.free_blocks;
8199            let root = fs.root().expect("root");
8200            let private_dir = fs
8201                .create_directory(
8202                    &root,
8203                    &hfsplus_private_dir_name(),
8204                    &CreateDirectoryOptions::default(),
8205                )
8206                .expect("create private dir");
8207            private_cnid = private_dir.location as u32;
8208
8209            let mut inode_data = std::io::Cursor::new(PAYLOAD);
8210            let inode_entry = fs
8211                .create_file(
8212                    &private_dir,
8213                    &format!("iNode{I_NODE_NUM}"),
8214                    &mut inode_data,
8215                    PAYLOAD.len() as u64,
8216                    &CreateFileOptions::default(),
8217                )
8218                .expect("create inode file");
8219            inode_cnid = inode_entry.location as u32;
8220            free_with_inode = fs.vh.free_blocks;
8221            payload_blocks_used = free_after_build - free_with_inode;
8222            assert!(
8223                payload_blocks_used >= 1,
8224                "inode payload must consume at least one block"
8225            );
8226
8227            // Initialize bsdInfo.special on the inode record to linkCount=2.
8228            {
8229                let (_, _, abs) = fs
8230                    .find_catalog_record(private_cnid, &format!("iNode{I_NODE_NUM}"))
8231                    .expect("inode catalog record");
8232                let key_len = BigEndian::read_u16(&fs.catalog_data[abs..abs + 2]) as usize;
8233                let mut body = abs + 2 + key_len;
8234                if !body.is_multiple_of(2) {
8235                    body += 1;
8236                }
8237                BigEndian::write_u32(&mut fs.catalog_data[body + 44..body + 48], 2);
8238            }
8239
8240            for link_name in ["linkA", "linkB"] {
8241                let mut empty = std::io::Cursor::new(&[][..]);
8242                fs.create_file(
8243                    &root,
8244                    link_name,
8245                    &mut empty,
8246                    0,
8247                    &CreateFileOptions {
8248                        os_type: Some(*b"hlnk"),
8249                        os_creator: Some(*b"hfs+"),
8250                        ..Default::default()
8251                    },
8252                )
8253                .expect("create stub");
8254                let (_, _, abs) = fs.find_catalog_record(2, link_name).expect("stub");
8255                let key_len = BigEndian::read_u16(&fs.catalog_data[abs..abs + 2]) as usize;
8256                let mut body = abs + 2 + key_len;
8257                if !body.is_multiple_of(2) {
8258                    body += 1;
8259                }
8260                BigEndian::write_u32(&mut fs.catalog_data[body + 44..body + 48], I_NODE_NUM);
8261            }
8262            fs.sync_metadata().expect("sync");
8263        }
8264
8265        // Re-open editable, delete linkA. linkB and the inode must remain
8266        // readable; linkCount must drop to 1.
8267        let cursor = std::io::Cursor::new(&mut img);
8268        let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("re-open");
8269        fs.prepare_for_edit().expect("prepare_for_edit");
8270        let root = fs.root().expect("root");
8271        let entries = fs.list_directory(&root).expect("list");
8272        let link_a = entries
8273            .iter()
8274            .find(|e| e.name == "linkA")
8275            .cloned()
8276            .expect("linkA");
8277        let link_b = entries
8278            .iter()
8279            .find(|e| e.name == "linkB")
8280            .cloned()
8281            .expect("linkB");
8282        let free_before_delete = fs.vh.free_blocks;
8283
8284        fs.delete_entry(&root, &link_a).expect("delete linkA");
8285        // linkCount should now be 1; inode + linkB still alive.
8286        let cnt_after_first = {
8287            let (_, _, abs) = fs
8288                .find_catalog_record(private_cnid, &format!("iNode{I_NODE_NUM}"))
8289                .expect("inode still present");
8290            let key_len = BigEndian::read_u16(&fs.catalog_data[abs..abs + 2]) as usize;
8291            let mut body = abs + 2 + key_len;
8292            if !body.is_multiple_of(2) {
8293                body += 1;
8294            }
8295            BigEndian::read_u32(&fs.catalog_data[body + 44..body + 48])
8296        };
8297        assert_eq!(cnt_after_first, 1, "linkCount should decrement to 1");
8298        assert_eq!(
8299            fs.vh.free_blocks, free_before_delete,
8300            "no blocks freed while inode still has refs"
8301        );
8302        // linkB still resolves and reads the original payload.
8303        let bytes_b = fs.read_file(&link_b, usize::MAX).expect("read linkB");
8304        assert_eq!(bytes_b, PAYLOAD);
8305
8306        fs.delete_entry(&root, &link_b).expect("delete linkB");
8307        // Inode must be gone from the private dir, blocks reclaimed.
8308        let private_children = fs.list_children(private_cnid).expect("list private dir");
8309        assert!(
8310            private_children.is_empty(),
8311            "iNode entry should be removed once linkCount hits 0; got {} children",
8312            private_children.len()
8313        );
8314        assert!(
8315            fs.find_file_by_id(inode_cnid).is_none(),
8316            "inode catalog row should be gone"
8317        );
8318        assert_eq!(
8319            fs.vh.free_blocks,
8320            free_before_delete + payload_blocks_used,
8321            "inode payload blocks should be reclaimed"
8322        );
8323    }
8324
8325    #[test]
8326    fn test_hardlink_capture_and_replay_dedupes_inode() {
8327        // Step 17: source carries 1 inode + 3 file hardlinks. Capture
8328        // routes the inode into `files` with `is_inode = true` and the
8329        // three stubs into `hardlinks`. Replay onto a freshly built blank
8330        // via `create_hardlink_inode` + `create_hardlink` produces a
8331        // target with one inode + three stub rows (not four independent
8332        // files), all reading the same payload bytes.
8333        const PAYLOAD: &[u8] = b"shared inode contents (3 names)\n";
8334        const I_NODE_NUM: u32 = 17;
8335
8336        // Build the source.
8337        let mut source_img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "Src", false);
8338        let source_inode_cnid: u32;
8339        {
8340            let cursor = std::io::Cursor::new(&mut source_img);
8341            let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("open src blank");
8342            fs.prepare_for_edit().expect("prepare_for_edit");
8343            let mut data = std::io::Cursor::new(PAYLOAD);
8344            let inode = fs
8345                .create_hardlink_inode(
8346                    I_NODE_NUM,
8347                    &mut data,
8348                    PAYLOAD.len() as u64,
8349                    &CreateFileOptions::default(),
8350                )
8351                .expect("create source inode");
8352            source_inode_cnid = inode.location as u32;
8353            // Initialize linkCount=0 on the inode then let create_hardlink
8354            // bump it three times.
8355            let private_cnid = fs.find_private_dir_cnid().unwrap().expect("private dir");
8356            let _ = fs
8357                .adjust_inode_link_count(private_cnid, I_NODE_NUM, 0)
8358                .expect("init linkCount");
8359            let root = fs.root().expect("root");
8360            for name in ["a.txt", "b.txt", "c.txt"] {
8361                fs.create_hardlink(&root, name, I_NODE_NUM)
8362                    .expect("create stub");
8363            }
8364            fs.sync_metadata().expect("sync src");
8365        }
8366
8367        // Capture from the source.
8368        let cursor = std::io::Cursor::new(&mut source_img);
8369        let mut src_fs = HfsPlusFilesystem::open(cursor, 0).expect("re-open src");
8370        let snap =
8371            crate::hfsplus_clone::SourceCatalogSnapshot::capture(&mut src_fs).expect("capture");
8372
8373        // Snapshot expectations: exactly one inode, three hardlink stubs.
8374        let inodes: Vec<_> = snap.files.iter().filter(|f| f.is_inode).collect();
8375        assert_eq!(inodes.len(), 1, "expected exactly one inode in snapshot");
8376        let inode = inodes[0];
8377        assert_eq!(inode.cnid, source_inode_cnid);
8378        assert_eq!(inode.inode_num, Some(I_NODE_NUM));
8379        assert_eq!(inode.data_fork_size, PAYLOAD.len() as u64);
8380        // Stream the inode's data fork off the source and verify byte-equal.
8381        let inode_payload = {
8382            use std::io::Read;
8383            let mut buf = Vec::new();
8384            src_fs
8385                .fork_stream_reader(inode.cnid, 0x00, &inode.data_fork_raw)
8386                .expect("fork reader")
8387                .read_to_end(&mut buf)
8388                .expect("read fork");
8389            buf
8390        };
8391        assert_eq!(inode_payload, PAYLOAD);
8392
8393        let user_files: Vec<_> = snap.files.iter().filter(|f| !f.is_inode).collect();
8394        assert!(
8395            user_files.is_empty(),
8396            "user-tree files should be empty (the three stubs are in `hardlinks`); \
8397             got {:?}",
8398            user_files.iter().map(|f| &f.name).collect::<Vec<_>>()
8399        );
8400
8401        assert_eq!(snap.hardlinks.len(), 3, "expected 3 hardlink stubs");
8402        for h in &snap.hardlinks {
8403            assert_eq!(h.inode_num, I_NODE_NUM);
8404            assert_eq!(h.inode_source_cnid, source_inode_cnid);
8405            assert_eq!(h.parent_cnid, 2, "stubs live at root");
8406        }
8407
8408        // Replay onto a fresh blank target using the capture data.
8409        let mut target_img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "Tgt", false);
8410        let target_inode_cnid: u32;
8411        {
8412            let cursor = std::io::Cursor::new(&mut target_img);
8413            let mut tgt = HfsPlusFilesystem::open(cursor, 0).expect("open tgt blank");
8414            tgt.prepare_for_edit().expect("prepare_for_edit");
8415
8416            let mut data = std::io::Cursor::new(&inode_payload);
8417            let target_inode = tgt
8418                .create_hardlink_inode(
8419                    inode.inode_num.unwrap(),
8420                    &mut data,
8421                    inode.data_fork_size,
8422                    &CreateFileOptions::default(),
8423                )
8424                .expect("replay inode");
8425            target_inode_cnid = target_inode.location as u32;
8426
8427            let root = tgt.root().expect("tgt root");
8428            for h in &snap.hardlinks {
8429                tgt.create_hardlink(&root, &h.name, h.inode_num)
8430                    .expect("replay stub");
8431            }
8432            tgt.sync_metadata().expect("sync tgt");
8433        }
8434
8435        // Verify the target.
8436        let cursor = std::io::Cursor::new(&mut target_img);
8437        let mut tgt_fs = HfsPlusFilesystem::open(cursor, 0).expect("re-open tgt");
8438        let root = tgt_fs.root().expect("tgt root");
8439        let entries = tgt_fs.list_directory(&root).expect("list tgt root");
8440
8441        // Three stubs at root, each pointing at the same inode CNID, each
8442        // reading back the original payload.
8443        let stubs: Vec<&FileEntry> = entries
8444            .iter()
8445            .filter(|e| ["a.txt", "b.txt", "c.txt"].contains(&e.name.as_str()))
8446            .collect();
8447        assert_eq!(stubs.len(), 3);
8448        for stub in &stubs {
8449            assert_eq!(
8450                stub.link_target_cnid,
8451                Some(target_inode_cnid as u64),
8452                "stub {} must resolve to the single target inode",
8453                stub.name
8454            );
8455            assert_eq!(stub.size, PAYLOAD.len() as u64);
8456        }
8457        for stub in &stubs {
8458            let bytes = tgt_fs.read_file(stub, usize::MAX).expect("read tgt stub");
8459            assert_eq!(bytes, PAYLOAD);
8460        }
8461
8462        // Inode's linkCount on the target must be 3 (one per stub).
8463        let private_cnid = tgt_fs
8464            .find_private_dir_cnid()
8465            .unwrap()
8466            .expect("private dir");
8467        let inode_name = format!("iNode{I_NODE_NUM}");
8468        let (_, _, abs) = tgt_fs
8469            .find_catalog_record(private_cnid, &inode_name)
8470            .expect("inode in private dir");
8471        let key_len = BigEndian::read_u16(&tgt_fs.catalog_data[abs..abs + 2]) as usize;
8472        let mut body = abs + 2 + key_len;
8473        if !body.is_multiple_of(2) {
8474            body += 1;
8475        }
8476        let link_count = BigEndian::read_u32(&tgt_fs.catalog_data[body + 44..body + 48]);
8477        assert_eq!(link_count, 3, "inode linkCount must equal stub count");
8478    }
8479
8480    #[test]
8481    fn test_dir_hardlink_capture_and_replay_resolves_to_inode() {
8482        // Step 18: Time-Machine-shaped image — one directory inode under
8483        // `.HFS+ Private Directory Data\r` with two hardlink stubs at the
8484        // root. Capture must dedupe the inode (one entry in `dirs` with
8485        // `is_dir_inode = true`, two entries in `dir_hardlinks`); replay
8486        // must produce a target whose two link rows resolve to the same
8487        // directory contents byte-for-byte.
8488        const PAYLOAD: &[u8] = b"file inside dir-hardlinked folder\n";
8489        const D_INODE_NUM: u32 = 13;
8490
8491        // ---- Build the source. ----
8492        let mut source_img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "DhSrc", false);
8493        let source_inode_cnid: u32;
8494        {
8495            let cursor = std::io::Cursor::new(&mut source_img);
8496            let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("open src");
8497            fs.prepare_for_edit().expect("prepare_for_edit");
8498            let inode_dir = fs
8499                .create_dir_hardlink_inode(D_INODE_NUM, &CreateDirectoryOptions::default())
8500                .expect("create dir inode");
8501            source_inode_cnid = inode_dir.location as u32;
8502            // Put a file inside the inode dir so `list_directory` has
8503            // something to compare on the replayed side.
8504            let mut data = std::io::Cursor::new(PAYLOAD);
8505            fs.create_file(
8506                &inode_dir,
8507                "inside.txt",
8508                &mut data,
8509                PAYLOAD.len() as u64,
8510                &CreateFileOptions::default(),
8511            )
8512            .expect("create file in dir inode");
8513            // Initialize linkCount=0 then bump per stub.
8514            let dir_priv = fs
8515                .find_dir_private_dir_cnid()
8516                .unwrap()
8517                .expect("dir private");
8518            let _ = fs
8519                .adjust_dir_inode_link_count(dir_priv, D_INODE_NUM, 0)
8520                .expect("init dir linkCount");
8521            let root = fs.root().expect("root");
8522            for name in ["alpha", "beta"] {
8523                fs.create_dir_hardlink(&root, name, D_INODE_NUM)
8524                    .expect("create dir hardlink stub");
8525            }
8526            fs.sync_metadata().expect("sync src");
8527        }
8528
8529        // ---- Capture from the source. ----
8530        let cursor = std::io::Cursor::new(&mut source_img);
8531        let mut src_fs = HfsPlusFilesystem::open(cursor, 0).expect("re-open src");
8532        let snap =
8533            crate::hfsplus_clone::SourceCatalogSnapshot::capture(&mut src_fs).expect("capture");
8534
8535        // Snapshot expectations: exactly one directory inode, two stubs.
8536        let dir_inodes: Vec<_> = snap.dirs.iter().filter(|d| d.is_dir_inode).collect();
8537        assert_eq!(
8538            dir_inodes.len(),
8539            1,
8540            "expected exactly one dir inode in snapshot; got {}",
8541            dir_inodes.len()
8542        );
8543        let dir_inode = dir_inodes[0];
8544        assert_eq!(dir_inode.cnid, source_inode_cnid);
8545        assert_eq!(dir_inode.inode_num, Some(D_INODE_NUM));
8546
8547        assert_eq!(snap.dir_hardlinks.len(), 2, "expected 2 dir-hardlink stubs");
8548        for h in &snap.dir_hardlinks {
8549            assert_eq!(h.inode_num, D_INODE_NUM);
8550            assert_eq!(h.inode_dir_source_cnid, source_inode_cnid);
8551            assert_eq!(h.parent_cnid, 2, "stubs live at root");
8552        }
8553
8554        // ---- Replay onto a fresh blank target. ----
8555        let mut target_img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "DhTgt", false);
8556        let target_inode_cnid: u32;
8557        {
8558            let cursor = std::io::Cursor::new(&mut target_img);
8559            let mut tgt = HfsPlusFilesystem::open(cursor, 0).expect("open tgt");
8560            tgt.prepare_for_edit().expect("prepare_for_edit");
8561
8562            // Create the inode directory.
8563            let inode = tgt
8564                .create_dir_hardlink_inode(
8565                    dir_inode.inode_num.unwrap(),
8566                    &CreateDirectoryOptions::default(),
8567                )
8568                .expect("replay dir inode");
8569            target_inode_cnid = inode.location as u32;
8570
8571            // Replay the inode's children. (For a real clone Step 21 will
8572            // recursively walk; here it's just one file.)
8573            let mut data = std::io::Cursor::new(PAYLOAD);
8574            tgt.create_file(
8575                &inode,
8576                "inside.txt",
8577                &mut data,
8578                PAYLOAD.len() as u64,
8579                &CreateFileOptions::default(),
8580            )
8581            .expect("replay child file");
8582
8583            // Replay the stubs.
8584            let root = tgt.root().expect("tgt root");
8585            for h in &snap.dir_hardlinks {
8586                tgt.create_dir_hardlink(&root, &h.name, h.inode_num)
8587                    .expect("replay dir stub");
8588            }
8589            tgt.sync_metadata().expect("sync tgt");
8590        }
8591
8592        // ---- Verify the target. ----
8593        let cursor = std::io::Cursor::new(&mut target_img);
8594        let mut tgt_fs = HfsPlusFilesystem::open(cursor, 0).expect("re-open tgt");
8595        let root = tgt_fs.root().expect("tgt root");
8596        let entries = tgt_fs.list_directory(&root).expect("list tgt root");
8597
8598        // Both stubs surface as Directory entries pointing at the single
8599        // inode, and listing either of them returns the same children.
8600        for stub_name in ["alpha", "beta"] {
8601            let stub = entries
8602                .iter()
8603                .find(|e| e.name == stub_name)
8604                .cloned()
8605                .unwrap_or_else(|| panic!("stub {stub_name} missing from target root"));
8606            assert!(
8607                stub.is_directory(),
8608                "stub {stub_name} must surface as a directory"
8609            );
8610            assert_eq!(
8611                stub.link_target_cnid,
8612                Some(target_inode_cnid as u64),
8613                "stub {stub_name} must resolve to the single target inode"
8614            );
8615
8616            let children = tgt_fs.list_directory(&stub).expect("list stub");
8617            let inside = children
8618                .iter()
8619                .find(|c| c.name == "inside.txt")
8620                .unwrap_or_else(|| panic!("inside.txt missing under {stub_name}"));
8621            let bytes = tgt_fs
8622                .read_file(inside, usize::MAX)
8623                .expect("read inside.txt via stub");
8624            assert_eq!(bytes, PAYLOAD);
8625        }
8626
8627        // Inode's linkCount on the target must equal stub count (2).
8628        let dir_priv = tgt_fs
8629            .find_dir_private_dir_cnid()
8630            .unwrap()
8631            .expect("tgt dir private");
8632        let inode_name = format!("dir_{D_INODE_NUM}");
8633        let (_, _, abs) = tgt_fs
8634            .find_catalog_record(dir_priv, &inode_name)
8635            .expect("dir inode in private dir");
8636        let key_len = BigEndian::read_u16(&tgt_fs.catalog_data[abs..abs + 2]) as usize;
8637        let mut body = abs + 2 + key_len;
8638        if !body.is_multiple_of(2) {
8639            body += 1;
8640        }
8641        let link_count = BigEndian::read_u32(&tgt_fs.catalog_data[body + 44..body + 48]);
8642        assert_eq!(link_count, 2, "dir inode linkCount must equal stub count");
8643    }
8644
8645    #[test]
8646    fn test_hfsx_case_sensitive_allows_distinct_case_siblings() {
8647        // Step 19: HFSX volumes with `keyCompareType == 0xBC` (binary)
8648        // must treat `Foo` and `foo` as distinct names. Plain HFS+ —
8649        // including HFSX volumes built with `keyCompareType == 0xCF`
8650        // (case-folding NFD compare) — must reject the second create with
8651        // `AlreadyExists`.
8652        const PAYLOAD: &[u8] = b"hi\n";
8653
8654        // ---- HFSX, case-sensitive: both creates succeed. ----
8655        {
8656            let mut img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "HFSX", true);
8657            let cursor = std::io::Cursor::new(&mut img);
8658            let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("open hfsx");
8659            assert!(
8660                fs.case_sensitive(),
8661                "fresh HFSX blank must be case-sensitive"
8662            );
8663            fs.prepare_for_edit().expect("prepare");
8664            let root = fs.root().expect("root");
8665
8666            let mut data = std::io::Cursor::new(PAYLOAD);
8667            fs.create_file(
8668                &root,
8669                "Foo",
8670                &mut data,
8671                PAYLOAD.len() as u64,
8672                &CreateFileOptions::default(),
8673            )
8674            .expect("create Foo");
8675
8676            let mut data = std::io::Cursor::new(PAYLOAD);
8677            fs.create_file(
8678                &root,
8679                "foo",
8680                &mut data,
8681                PAYLOAD.len() as u64,
8682                &CreateFileOptions::default(),
8683            )
8684            .expect("create foo on HFSX must succeed");
8685
8686            fs.sync_metadata().expect("sync");
8687            // Both must list separately.
8688            let entries = fs.list_directory(&root).expect("list");
8689            let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
8690            assert!(names.contains(&"Foo"), "Foo missing: {names:?}");
8691            assert!(names.contains(&"foo"), "foo missing: {names:?}");
8692        }
8693
8694        // ---- HFS+, case-folding: second create rejects. ----
8695        {
8696            let mut img = create_blank_hfsplus(32 * 1024 * 1024, 4096, "HFSPLUS", false);
8697            let cursor = std::io::Cursor::new(&mut img);
8698            let mut fs = HfsPlusFilesystem::open(cursor, 0).expect("open hfs+");
8699            assert!(
8700                !fs.case_sensitive(),
8701                "plain HFS+ blank must be case-folding"
8702            );
8703            fs.prepare_for_edit().expect("prepare");
8704            let root = fs.root().expect("root");
8705
8706            let mut data = std::io::Cursor::new(PAYLOAD);
8707            fs.create_file(
8708                &root,
8709                "Foo",
8710                &mut data,
8711                PAYLOAD.len() as u64,
8712                &CreateFileOptions::default(),
8713            )
8714            .expect("create Foo");
8715
8716            let mut data = std::io::Cursor::new(PAYLOAD);
8717            let err = fs
8718                .create_file(
8719                    &root,
8720                    "foo",
8721                    &mut data,
8722                    PAYLOAD.len() as u64,
8723                    &CreateFileOptions::default(),
8724                )
8725                .expect_err("create foo must fail on case-folding HFS+");
8726            match err {
8727                FilesystemError::AlreadyExists(_) => {}
8728                other => panic!("expected AlreadyExists, got {other:?}"),
8729            }
8730        }
8731    }
8732
8733    #[test]
8734    fn test_clone_capture_carries_xattrs_byte_equal() {
8735        // Step 14: SourceCatalogSnapshot::capture must populate
8736        // SourceFileSnapshot::xattrs / SourceDirSnapshot::xattrs with the
8737        // inline records found in the attributes B-tree, byte-for-byte.
8738        //
8739        // Seed image already places an inline `com.apple.FinderInfo` xattr
8740        // on a sentinel CNID. We attach a second key on the same CNID via
8741        // `set_xattr`, sync, then graft a thread+file record into the
8742        // catalog so the walk associates that CNID with a real file —
8743        // capture should surface both inline values verbatim.
8744        let cnid = 99u32;
8745        let seed_value: Vec<u8> = (0..32u8).collect();
8746        let extra_value: Vec<u8> = b"\x00bplist00 demo".to_vec();
8747        let img = make_editable_hfsplus_image_with_inline_xattr(cnid, &seed_value);
8748
8749        // Open editable, attach a second xattr to the sentinel cnid, attach an
8750        // xattr to an existing directory (CNID 2 = root), and sync.
8751        let cursor = std::io::Cursor::new(img);
8752        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
8753        fs.prepare_for_edit().unwrap();
8754        fs.set_xattr(cnid, "com.apple.metadata:demo", &extra_value)
8755            .expect("set extra xattr on sentinel cnid");
8756        fs.set_xattr(2u32, "com.apple.FinderInfo", &seed_value)
8757            .expect("set xattr on root dir");
8758        // Create a real file so the snapshot walk has a file row whose CNID
8759        // equals our sentinel — we relocate the captured xattrs onto it
8760        // by overriding the new file's catalog cnid is non-trivial, so
8761        // instead we capture and just look up by cnid directly.
8762        let root = fs.root().expect("root");
8763        let mut data = std::io::Cursor::new(b"hi".as_slice());
8764        fs.create_file(
8765            &root,
8766            "carrier.txt",
8767            &mut data,
8768            2,
8769            &CreateFileOptions::default(),
8770        )
8771        .expect("create_file");
8772        fs.sync_metadata().unwrap();
8773        let img2 = fs.reader.into_inner();
8774
8775        let mut cursor2 = std::io::Cursor::new(img2);
8776        let mut fs2 = HfsPlusFilesystem::open(&mut cursor2, 0).unwrap();
8777        let snap = crate::hfsplus_clone::SourceCatalogSnapshot::capture(&mut fs2).expect("capture");
8778
8779        // The freshly-created file should snapshot with no xattrs (we never
8780        // attached any to its CNID).
8781        let carrier = snap
8782            .files
8783            .iter()
8784            .find(|f| f.name == "carrier.txt")
8785            .expect("carrier file in snapshot");
8786        assert!(
8787            carrier.xattrs.is_empty(),
8788            "fresh file should have no xattrs, got {}",
8789            carrier.xattrs.len()
8790        );
8791
8792        // The root dir snapshot must carry the xattr we attached.
8793        let root_dir = snap
8794            .dirs
8795            .iter()
8796            .find(|d| d.cnid == 2)
8797            .expect("root dir in snapshot");
8798        assert_eq!(root_dir.xattrs.len(), 1, "expected 1 xattr on root dir");
8799        assert_eq!(root_dir.xattrs[0].name, "com.apple.FinderInfo");
8800        match &root_dir.xattrs[0].kind {
8801            XattrKind::Inline(bytes) => assert_eq!(bytes, &seed_value),
8802            other => panic!("expected inline FinderInfo on root, got {other:?}"),
8803        }
8804
8805        // Attributes B-tree still carries the sentinel cnid's two records;
8806        // verify directly via list_xattrs (capture only attaches xattrs to
8807        // catalog rows it walks, and the sentinel cnid is not catalog-rooted).
8808        let mut sentinel = fs2.list_xattrs(cnid).expect("list_xattrs sentinel");
8809        sentinel.sort_by(|a, b| a.name.cmp(&b.name));
8810        assert_eq!(sentinel.len(), 2, "sentinel should carry 2 xattrs");
8811        let mut want: Vec<(String, Vec<u8>)> = vec![
8812            ("com.apple.FinderInfo".into(), seed_value.clone()),
8813            ("com.apple.metadata:demo".into(), extra_value.clone()),
8814        ];
8815        want.sort_by(|a, b| a.0.cmp(&b.0));
8816        for (rec, (wn, wv)) in sentinel.iter().zip(want.iter()) {
8817            assert_eq!(rec.name, *wn);
8818            match &rec.kind {
8819                XattrKind::Inline(bytes) => assert_eq!(bytes, wv),
8820                other => panic!("expected inline, got {other:?}"),
8821            }
8822        }
8823    }
8824
8825    #[test]
8826    fn test_hfsplus_editable_open_and_free_space() {
8827        let img = make_editable_hfsplus_image();
8828        let cursor = std::io::Cursor::new(img);
8829        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
8830        assert_eq!(fs.fs_type(), "HFS+");
8831        let free = fs.free_space().unwrap();
8832        assert!(free > 0);
8833        // 250 free blocks × 4096 = 1,024,000
8834        assert_eq!(free, 250 * 4096);
8835    }
8836
8837    #[test]
8838    fn test_hfsplus_defragmented_minimum_size() {
8839        // The synthetic image has 256 blocks of 4096 bytes total, with 6
8840        // blocks allocated near the front (VH/bitmap/catalog) and 250 free.
8841        let img = make_editable_hfsplus_image();
8842        let cursor = std::io::Cursor::new(img);
8843        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
8844
8845        let total = fs.total_size();
8846        let in_place = fs.last_data_byte().unwrap();
8847        let defrag = fs.defragmented_minimum_size().unwrap();
8848
8849        assert_eq!(total, 256 * 4096);
8850        // Last allocated block is 5 (bits 0..=5 set in bitmap byte 0), plus
8851        // one block for the alternate VH at the new end: (5+1+1) * 4096.
8852        assert_eq!(in_place, 7 * 4096);
8853        // Defrag = (used + catalog_total_blocks*3) blocks * block_size +
8854        // 1024 alt-VH, rounded up. Synthetic image's catalog_total_blocks
8855        // is 4 → delta is 12 → 6 + 12 = 18 user blocks + alt-VH = 19.
8856        assert_eq!(defrag, 19 * 4096);
8857        assert!(defrag <= total);
8858    }
8859
8860    #[test]
8861    fn test_hfsplus_defragmented_min_smaller_on_fragmented() {
8862        // Take the synthetic image and forge an extra allocation near the END
8863        // of the bitmap. The in-place trim point must hug the tail; the
8864        // defragmented minimum must reflect *only* the allocated count, not
8865        // the position — that's the whole point of the metric.
8866        let mut img = make_editable_hfsplus_image();
8867        let block_size = 4096usize;
8868        // Mark block 200 allocated (MSB-first bitmap, byte index 25, bit 0
8869        // -> shift 7 to set the most-significant bit).
8870        let bitmap_byte = block_size + 25; // bitmap lives in block 1
8871        img[bitmap_byte] |= 0b1000_0000;
8872        // Reflect the change in the VH so the defragmented calc sees one
8873        // fewer free block.
8874        let free_off = 1024 + 48;
8875        let free = BigEndian::read_u32(&img[free_off..free_off + 4]);
8876        BigEndian::write_u32(&mut img[free_off..free_off + 4], free - 1);
8877        // Mirror the change to the alternate VH at the tail.
8878        let image_size = img.len();
8879        let alt_free_off = image_size - 1024 + 48;
8880        BigEndian::write_u32(&mut img[alt_free_off..alt_free_off + 4], free - 1);
8881
8882        let cursor = std::io::Cursor::new(img);
8883        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
8884
8885        let in_place = fs.last_data_byte().unwrap();
8886        let defrag = fs.defragmented_minimum_size().unwrap();
8887
8888        // In-place trim hugs the new tail allocation plus the alternate-VH
8889        // block: (200+1+1)*4096.
8890        assert_eq!(in_place, 202 * 4096);
8891        // Defrag: (7 used + 12 catalog-growth) blocks * 4096 + 1024 alt-VH,
8892        // rounded up = 20 blocks. Still well under in_place (201 blocks).
8893        assert_eq!(defrag, 20 * 4096);
8894        assert!(defrag < in_place);
8895    }
8896
8897    #[test]
8898    fn test_hfsplus_editable_create_file_and_read() {
8899        let img = make_editable_hfsplus_image();
8900        let cursor = std::io::Cursor::new(img);
8901        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
8902
8903        let root = fs.root().unwrap();
8904        let test_data = b"Hello, HFS+ World!";
8905        let mut data_reader = std::io::Cursor::new(test_data.as_slice());
8906
8907        let options = CreateFileOptions::default();
8908        let fe = fs
8909            .create_file(
8910                &root,
8911                "test.txt",
8912                &mut data_reader,
8913                test_data.len() as u64,
8914                &options,
8915            )
8916            .unwrap();
8917
8918        assert_eq!(fe.name, "test.txt");
8919        assert_eq!(fe.size, test_data.len() as u64);
8920
8921        // Verify we can list and find it
8922        let entries = fs.list_directory(&root).unwrap();
8923        assert!(entries.iter().any(|e| e.name == "test.txt"));
8924
8925        // Verify we can read it back
8926        let read_back = fs.read_file(&fe, 1024).unwrap();
8927        assert_eq!(&read_back, test_data);
8928    }
8929
8930    #[test]
8931    fn test_hfsplus_editable_create_directory() {
8932        let img = make_editable_hfsplus_image();
8933        let cursor = std::io::Cursor::new(img);
8934        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
8935
8936        let root = fs.root().unwrap();
8937        let options = CreateDirectoryOptions::default();
8938        let dir = fs.create_directory(&root, "TestDir", &options).unwrap();
8939
8940        assert_eq!(dir.name, "TestDir");
8941        assert!(dir.is_directory());
8942
8943        let entries = fs.list_directory(&root).unwrap();
8944        assert!(entries
8945            .iter()
8946            .any(|e| e.name == "TestDir" && e.is_directory()));
8947    }
8948
8949    #[test]
8950    fn test_hfsplus_editable_delete_file() {
8951        let img = make_editable_hfsplus_image();
8952        let cursor = std::io::Cursor::new(img);
8953        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
8954
8955        let root = fs.root().unwrap();
8956        let test_data = b"delete me";
8957        let mut data_reader = std::io::Cursor::new(test_data.as_slice());
8958        let options = CreateFileOptions::default();
8959        let fe = fs
8960            .create_file(
8961                &root,
8962                "gone.txt",
8963                &mut data_reader,
8964                test_data.len() as u64,
8965                &options,
8966            )
8967            .unwrap();
8968
8969        let free_before = fs.free_space().unwrap();
8970
8971        // Delete it
8972        fs.delete_entry(&root, &fe).unwrap();
8973
8974        // Verify it's gone
8975        let entries = fs.list_directory(&root).unwrap();
8976        assert!(!entries.iter().any(|e| e.name == "gone.txt"));
8977
8978        // Verify free space recovered
8979        let free_after = fs.free_space().unwrap();
8980        assert!(free_after > free_before);
8981    }
8982
8983    #[test]
8984    fn test_hfsplus_editable_duplicate_name_rejected() {
8985        let img = make_editable_hfsplus_image();
8986        let cursor = std::io::Cursor::new(img);
8987        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
8988
8989        let root = fs.root().unwrap();
8990        let test_data = b"first";
8991        let mut r1 = std::io::Cursor::new(test_data.as_slice());
8992        let options = CreateFileOptions::default();
8993        fs.create_file(&root, "dup.txt", &mut r1, 5, &options)
8994            .unwrap();
8995
8996        // Second creation should fail
8997        let mut r2 = std::io::Cursor::new(test_data.as_slice());
8998        let result = fs.create_file(&root, "dup.txt", &mut r2, 5, &options);
8999        assert!(matches!(result, Err(FilesystemError::AlreadyExists(_))));
9000    }
9001
9002    #[test]
9003    fn test_hfsplus_editable_blessed_folder() {
9004        let img = make_editable_hfsplus_image();
9005        let cursor = std::io::Cursor::new(img);
9006        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
9007
9008        // Initially no blessed folder
9009        assert!(fs.blessed_system_folder().is_none());
9010
9011        // Create a system folder
9012        let root = fs.root().unwrap();
9013        let options = CreateDirectoryOptions::default();
9014        let sys_dir = fs
9015            .create_directory(&root, "System Folder", &options)
9016            .unwrap();
9017
9018        // Bless it
9019        fs.set_blessed_folder(&sys_dir).unwrap();
9020
9021        // Verify it's blessed
9022        let blessed = fs.blessed_system_folder();
9023        assert!(blessed.is_some());
9024        let (cnid, name) = blessed.unwrap();
9025        assert_eq!(cnid, sys_dir.location);
9026        assert_eq!(name, "System Folder");
9027    }
9028
9029    #[test]
9030    fn test_hfsplus_editable_type_creator_auto_detect() {
9031        let img = make_editable_hfsplus_image();
9032        let cursor = std::io::Cursor::new(img);
9033        let mut fs = HfsPlusFilesystem::open(cursor, 0).unwrap();
9034
9035        let root = fs.root().unwrap();
9036        let test_data = b"text content";
9037        let mut data_reader = std::io::Cursor::new(test_data.as_slice());
9038        let options = CreateFileOptions::default();
9039        let fe = fs
9040            .create_file(
9041                &root,
9042                "hello.txt",
9043                &mut data_reader,
9044                test_data.len() as u64,
9045                &options,
9046            )
9047            .unwrap();
9048
9049        // "txt" extension should auto-detect to TEXT/ttxt
9050        assert!(fe.type_code.is_some());
9051        assert_eq!(fe.type_code, Some(*b"TEXT"));
9052    }
9053
9054    #[test]
9055    fn test_hfsplus_catalog_variable_index_keys_grow_multilevel() {
9056        // P1 (docs/hfsplus_btree_growth_plan.md §4a): the HFS+ catalog B-tree
9057        // must grow past a single index level without corrupting. The shared
9058        // split/grow helpers used to force the classic 1-byte-length, fixed-0x25
9059        // index-key shape onto HFS+ records, so the first leaf split produced a
9060        // malformed separator (it read the high byte of the 2-byte key length as
9061        // the whole length) and descent misrouted — the "leaves must be strictly
9062        // ascending" / mis-routed-descent corruption. With
9063        // `BTreeKeyFormat::HFSPLUS_CATALOG` the separator is the child's
9064        // variable-length 2-byte key, verbatim.
9065        //
9066        // Driven at the catalog-buffer level through the exact shared
9067        // `btree_insert_full` path `insert_catalog_record` uses. node_size is
9068        // 512 (vs the production 4096) so a few thousand multi-parent records
9069        // reach depth >= 3 in a < 1 MiB buffer; the machinery is node-size
9070        // agnostic. The volume-level fsck gate lands in P2 once blank catalogs
9071        // are sized to hold the records (today a blank catalog is only 4 nodes).
9072        use crate::hfs_common::{
9073            btree_find_record, btree_insert_full, BTreeHeader, BTreeKeyFormat,
9074        };
9075        use byteorder::{BigEndian, ByteOrder};
9076        type Fs = HfsPlusFilesystem<std::io::Cursor<Vec<u8>>>;
9077
9078        let node_size = 512usize;
9079        // A single-node bitmap (512-byte node) covers ~1872 nodes, so stay under
9080        // that — no map nodes needed, matching a real blank-built tree.
9081        let total_nodes = 1800u32;
9082        let mut buf = vec![0u8; total_nodes as usize * node_size];
9083        write_blank_btree_header_node(
9084            &mut buf[0..node_size],
9085            node_size,
9086            /* leaf_records= */ 0,
9087            total_nodes,
9088            /* free_nodes= */ total_nodes - 2,
9089            /* max_key_len= */ 516,
9090            KEY_COMPARE_CASE_FOLDING,
9091        );
9092        write_empty_leaf_node(&mut buf[node_size..2 * node_size]);
9093
9094        let kf = BTreeKeyFormat::HFSPLUS_CATALOG;
9095        let cmp = |a: &[u8], b: &[u8]| Fs::catalog_compare(a, b, false);
9096
9097        // ~2500 files in shuffled key order (multiplicative hash is a bijection
9098        // mod 2^32 — unique names that land mid-leaf, not appended) sprayed
9099        // across 50 parent directories, so inserts exercise every leaf and
9100        // index split, not just tail appends.
9101        const DIRS: u32 = 50;
9102        const N: u32 = 2500;
9103        let mut inserted: Vec<(u32, String)> = Vec::with_capacity(N as usize);
9104        for i in 0..N {
9105            let hash = i.wrapping_mul(2_654_435_761);
9106            let parent = 16 + (hash % DIRS); // arbitrary distinct parent CNIDs
9107            let name = format!("f{hash:08x}");
9108            let mut rec = Fs::build_catalog_key(parent, &name);
9109            // Append filler so records have a realistic size (the compare only
9110            // reads the leading key portion; the body is irrelevant here).
9111            rec.extend_from_slice(&[0u8; 40]);
9112            btree_insert_full(&mut buf, &rec, &kf, &cmp)
9113                .unwrap_or_else(|e| panic!("insert #{i} (parent {parent}, {name}): {e}"));
9114            inserted.push((parent, name));
9115        }
9116
9117        // The tree must have grown past a single index level.
9118        let header = BTreeHeader::read(&buf);
9119        assert!(
9120            header.depth >= 3,
9121            "catalog did not reach depth >= 3 (got {}); test needs a deeper tree to \
9122             exercise index-node splits",
9123            header.depth
9124        );
9125        assert_eq!(
9126            header.leaf_records, N,
9127            "leaf_records header count drifted from inserts"
9128        );
9129
9130        // (a) Every leaf key, walked in sibling-chain order, is strictly
9131        // ascending — the direct symptom of a malformed separator was
9132        // out-of-order leaves.
9133        let mut keys: Vec<Vec<u8>> = Vec::with_capacity(N as usize);
9134        crate::hfs_common::walk_leaf_records::<(), _>(
9135            &buf,
9136            header.first_leaf_node,
9137            node_size,
9138            |_n, _r, _o, rec| {
9139                let kl = BigEndian::read_u16(&rec[0..2]) as usize;
9140                keys.push(rec[..(2 + kl).min(rec.len())].to_vec());
9141                None
9142            },
9143        );
9144        assert_eq!(
9145            keys.len(),
9146            N as usize,
9147            "leaf walk recovered {} of {N} records — chain is broken or has cycles",
9148            keys.len()
9149        );
9150        for w in keys.windows(2) {
9151            assert_eq!(
9152                cmp(&w[0], &w[1]),
9153                std::cmp::Ordering::Less,
9154                "catalog leaves are not strictly ascending — separator/descent corruption"
9155            );
9156        }
9157
9158        // (b) Every inserted record is findable by a root-to-leaf descent, i.e.
9159        // every index separator routes correctly at every level. (A nested `fn`
9160        // rather than a closure so it satisfies the `for<'a> Fn(&'a [u8]) ->
9161        // &'a [u8]` bound `btree_find_record` requires.)
9162        fn key_extract(rec: &[u8]) -> &[u8] {
9163            let kl = BigEndian::read_u16(&rec[0..2]) as usize;
9164            &rec[..(2 + kl).min(rec.len())]
9165        }
9166        for (parent, name) in &inserted {
9167            let search = Fs::build_catalog_key(*parent, name);
9168            let (found, _chain) = btree_find_record(&buf, &header, &search, &key_extract, &cmp);
9169            assert!(
9170                found.is_some(),
9171                "record (parent {parent}, {name}) not findable by descent — misrouted index"
9172            );
9173        }
9174    }
9175
9176    #[test]
9177    fn test_hfsplus_blank_sized_catalog_20k_inserts_fsck_clean() {
9178        // P2 (docs/hfsplus_btree_growth_plan.md §4c): a fresh HFS+ volume sized
9179        // to hold the records accepts >= 20k incremental catalog inserts across
9180        // many parent dirs and stays fsck-clean — the volume-level gate P1
9181        // deferred. A blank catalog used to be a fixed 4 nodes (exhausting after
9182        // ~24 files); it is now volume-scaled, and `create_blank_hfsplus_sized`
9183        // lets this test pin a large catalog into a modest 64 MiB image instead
9184        // of needing a multi-GiB volume to scale into.
9185        use crate::hfs_common::BTreeHeader;
9186        type Fs = HfsPlusFilesystem<std::io::Cursor<Vec<u8>>>;
9187
9188        // 64 MiB volume; catalog pinned to 16 MiB (~4096 nodes @ 4096) — ample
9189        // headroom for 20k file records at the byte-split's ~69% leaf occupancy.
9190        let img =
9191            create_blank_hfsplus_sized(64 * 1024 * 1024, 4096, "PutShuffle", false, 16 << 20, 0);
9192        let mut fs = Fs::open(std::io::Cursor::new(img), 0).unwrap();
9193        fs.prepare_for_edit().unwrap();
9194
9195        // Real directories so threads / valence / folder_count stay fsck-correct.
9196        const DIRS: u32 = 50;
9197        let root = fs.root().unwrap();
9198        let mut dir_ids = Vec::with_capacity(DIRS as usize);
9199        for d in 0..DIRS {
9200            let de = fs
9201                .create_directory(&root, &format!("dir{d:03}"), &Default::default())
9202                .unwrap();
9203            dir_ids.push(de.location as u32);
9204        }
9205        let base_cnid = fs.vh.next_catalog_id;
9206
9207        // 20k files in shuffled key order (multiplicative hash is a bijection
9208        // mod 2^32 — unique names landing mid-leaf, not appended), sprayed across
9209        // the directories. Insert the catalog records directly: `create_file`
9210        // snapshots the whole catalog per call for rollback, which would make a
9211        // 20k-record build O(N * catalog_size). fsck doesn't require per-file
9212        // thread records (only flags orphaned threads), so — like the classic
9213        // HFS scaling test — we insert just the file records and fix up counts.
9214        let n: u32 = 20_000;
9215        let mut per_dir = vec![0i32; DIRS as usize];
9216        let empty_fork = ForkData::empty();
9217        for i in 0..n {
9218            let hash = i.wrapping_mul(2_654_435_761);
9219            let d = (hash % DIRS) as usize;
9220            let name = format!("f{hash:08x}");
9221            let mut kr = Fs::build_catalog_key(dir_ids[d], &name);
9222            if !kr.len().is_multiple_of(2) {
9223                kr.push(0);
9224            }
9225            kr.extend_from_slice(&Fs::build_file_record_with_dates(
9226                base_cnid + i,
9227                &empty_fork,
9228                &empty_fork,
9229                &[0u8; 4],
9230                &[0u8; 4],
9231                None,
9232                0,
9233            ));
9234            fs.insert_catalog_record(&kr)
9235                .unwrap_or_else(|e| panic!("insert #{i} into dir{d}: {e}"));
9236            per_dir[d] += 1;
9237        }
9238        // Mirror the bookkeeping the create path maintains so fsck's cross-checks
9239        // (file_count, next_catalog_id, per-dir valence) pass.
9240        fs.vh.file_count += n;
9241        fs.vh.next_catalog_id = base_cnid + n;
9242        for (d, &cnt) in per_dir.iter().enumerate() {
9243            fs.update_parent_valence(dir_ids[d], cnt).unwrap();
9244        }
9245
9246        // The catalog must have grown several index levels...
9247        let h = BTreeHeader::read(fs.catalog_data());
9248        assert!(
9249            h.depth >= 3,
9250            "catalog depth {} < 3 after 20k inserts",
9251            h.depth
9252        );
9253        // ...without a premature DiskFull (every insert above succeeded).
9254
9255        // No IndexSiblingLinkBroken / order / count damage at scale.
9256        let result = fs.fsck().unwrap().unwrap();
9257        assert!(
9258            result.errors.is_empty(),
9259            "fsck found {} errors after 20k inserts: {:?}",
9260            result.errors.len(),
9261            result
9262                .errors
9263                .iter()
9264                .take(8)
9265                .map(|e| &e.message)
9266                .collect::<Vec<_>>()
9267        );
9268    }
9269
9270    #[test]
9271    fn test_hfsplus_catalog_grows_map_nodes_past_cap_phase_c() {
9272        // §4b Phase C (docs/todo_hfsplus_fork_growth.md): once a B-tree fork
9273        // grows past the header node's bitmap capacity ((node_size-256)*8 =
9274        // 30,720 nodes @ 4096), a map node (BTNodeKind=2) must be appended to
9275        // the node-allocation bitmap chain so the new node indices are
9276        // addressable. Pre-size the catalog just under the cap, then grow a few
9277        // times to cross it — exercising the map-node append without needing
9278        // the millions of records a from-scratch fill would take.
9279        use crate::hfs_common::{btree_bitmap_segments, BTreeHeader};
9280        type Fs = HfsPlusFilesystem<std::io::Cursor<Vec<u8>>>;
9281        let node_size = 4096usize;
9282        let header_cap = ((node_size - 256) * 8) as u32; // 30,720
9283
9284        // Catalog pinned to ~16 nodes below the cap; 180 MiB volume holds it.
9285        let min_catalog = (header_cap - 16) * node_size as u32;
9286        let img =
9287            create_blank_hfsplus_sized(180 * 1024 * 1024, 4096, "MapCap", false, min_catalog, 0);
9288        let mut fs = Fs::open(std::io::Cursor::new(img), 0).unwrap();
9289        fs.prepare_for_edit().unwrap();
9290
9291        let start = BTreeHeader::read(fs.catalog_data()).total_nodes;
9292        assert!(
9293            start <= header_cap && start > header_cap - 64,
9294            "blank catalog {start} not near cap"
9295        );
9296        // No map node yet — the whole bitmap lives in the header node.
9297        assert_eq!(
9298            btree_bitmap_segments(fs.catalog_data(), node_size).len(),
9299            1,
9300            "fresh near-cap catalog should have no map nodes"
9301        );
9302
9303        // Grow until we cross the header bitmap cap.
9304        let mut guard = 0;
9305        while BTreeHeader::read(fs.catalog_data()).total_nodes <= header_cap {
9306            fs.grow_btree_fork(BTreeFork::Catalog).unwrap();
9307            guard += 1;
9308            assert!(guard < 64, "too many grows to cross the cap");
9309        }
9310        let h = BTreeHeader::read(fs.catalog_data());
9311        assert!(
9312            h.total_nodes > header_cap,
9313            "did not cross the cap: {}",
9314            h.total_nodes
9315        );
9316        // A map-node segment must now exist in the bitmap chain.
9317        let segs = btree_bitmap_segments(fs.catalog_data(), node_size);
9318        assert!(
9319            segs.len() >= 2,
9320            "expected a map-node segment after crossing the cap, got {} segment(s)",
9321            segs.len()
9322        );
9323
9324        // fsck-clean with the map node in place.
9325        fs.sync_metadata().unwrap();
9326        let result = fs.fsck().unwrap().unwrap();
9327        assert!(
9328            result.errors.is_empty(),
9329            "fsck after map-node grow: {:?}",
9330            result
9331                .errors
9332                .iter()
9333                .take(8)
9334                .map(|e| &e.message)
9335                .collect::<Vec<_>>()
9336        );
9337
9338        // Reopen re-reads the grown fork and the map chain.
9339        let img = fs.reader.get_ref().clone();
9340        if let Ok(path) = std::env::var("RB_EMIT_MAPC_IMAGE") {
9341            std::fs::write(&path, &img).unwrap();
9342        }
9343        let fs2 = Fs::open(std::io::Cursor::new(img), 0).unwrap();
9344        assert_eq!(
9345            BTreeHeader::read(fs2.catalog_data()).total_nodes,
9346            h.total_nodes,
9347            "reopen lost grown nodes"
9348        );
9349        assert!(
9350            btree_bitmap_segments(fs2.catalog_data(), node_size).len() >= 2,
9351            "reopen lost the map node"
9352        );
9353    }
9354
9355    #[test]
9356    fn test_hfsplus_catalog_grow_spills_to_overflow_phase_b() {
9357        // §4b Phase B (docs/todo_hfsplus_fork_growth.md): real `create_file`
9358        // calls interleave a data-block allocation with each catalog grow, so
9359        // contiguous tail growth fails and the catalog fork fragments. Once it
9360        // exhausts its 8 inline extents, further growth spills into the
9361        // extents-overflow B-tree (keyed by the catalog's reserved CNID 4) and
9362        // is persisted by the overflow-aware write path. The volume stays
9363        // readable + fsck-clean, and a reopen re-reads the > 8-extent fork.
9364        type Fs = HfsPlusFilesystem<std::io::Cursor<Vec<u8>>>;
9365        let img = create_blank_hfsplus_sized(16 * 1024 * 1024, 4096, "SpillVol", false, 0, 0);
9366        let mut fs = Fs::open(std::io::Cursor::new(img), 0).unwrap();
9367        fs.prepare_for_edit().unwrap();
9368        let root = fs.root().unwrap();
9369        let mut dirs = Vec::new();
9370        for d in 0..20 {
9371            dirs.push(
9372                fs.create_directory(&root, &format!("d{d}"), &Default::default())
9373                    .unwrap(),
9374            );
9375        }
9376        const N: u32 = 1200;
9377        for i in 0..N {
9378            let d = &dirs[(i % 20) as usize];
9379            let mut data = std::io::Cursor::new(vec![b'x']);
9380            fs.create_file(
9381                d,
9382                &format!("f{i:05}.txt"),
9383                &mut data,
9384                1,
9385                &Default::default(),
9386            )
9387            .unwrap_or_else(|e| panic!("create_file #{i} (grow+spill should cover it): {e}"));
9388        }
9389
9390        // The catalog fork saturated its 8 inline extents and spilled the rest
9391        // into the extents-overflow B-tree.
9392        let inline: u32 = fs
9393            .vh
9394            .catalog_file
9395            .extents
9396            .iter()
9397            .map(|e| e.block_count)
9398            .sum();
9399        let inline_count = fs
9400            .vh
9401            .catalog_file
9402            .extents
9403            .iter()
9404            .filter(|e| e.block_count != 0)
9405            .count();
9406        assert_eq!(
9407            inline_count, 8,
9408            "catalog should have filled all 8 inline extents"
9409        );
9410        assert!(
9411            fs.vh.catalog_file.total_blocks > inline,
9412            "catalog total_blocks {} should exceed inline-extent blocks {inline} (overflow used)",
9413            fs.vh.catalog_file.total_blocks
9414        );
9415
9416        fs.sync_metadata().unwrap();
9417        let result = fs.fsck().unwrap().unwrap();
9418        assert!(
9419            result.errors.is_empty(),
9420            "fsck after overflow spill: {:?}",
9421            result
9422                .errors
9423                .iter()
9424                .take(8)
9425                .map(|e| &e.message)
9426                .collect::<Vec<_>>()
9427        );
9428
9429        // Reopen re-reads the > 8-extent catalog fork (inline + overflow) and
9430        // every file is still enumerable.
9431        let img = fs.reader.get_ref().clone();
9432        if let Ok(path) = std::env::var("RB_EMIT_SPILL_IMAGE") {
9433            std::fs::write(&path, &img).unwrap();
9434        }
9435        let mut fs2 = Fs::open(std::io::Cursor::new(img), 0).unwrap();
9436        let r2 = fs2.root().unwrap();
9437        let mut total = 0;
9438        for de in fs2.list_directory(&r2).unwrap() {
9439            if de.is_directory() {
9440                total += fs2.list_directory(&de).unwrap().len();
9441            }
9442        }
9443        assert_eq!(total as u32, N, "reopen lost files: {total} != {N}");
9444    }
9445
9446    /// H8: with blocks larger than nodes a grow added one block per node,
9447    /// twice (or four times) what the nodes occupy, on every grow.
9448    /// H7: the alternate volume header went to total_blocks * block_size - 1024;
9449    /// fsck_hfs and the Mac read 1024 bytes before the partition end.
9450    #[test]
9451    fn the_alternate_volume_header_follows_the_partition_end_when_known() {
9452        use disk_core::filesystem::{CreateFileOptions, EditableFilesystem, Filesystem};
9453        const VOLUME: u64 = 8 * 1024 * 1024;
9454        const PARTITION: u64 = VOLUME + 6 * 1024;
9455        let mut img = create_blank_hfsplus(VOLUME, 4096, "Slack", false);
9456        img.resize(PARTITION as usize, 0);
9457        {
9458            let mut fs =
9459                HfsPlusFilesystem::open_sized(std::io::Cursor::new(&mut img), 0, Some(PARTITION))
9460                    .unwrap();
9461            fs.prepare_for_edit().unwrap();
9462            let root = fs.root().unwrap();
9463            fs.create_file(&root, "a", &mut &b"x"[..], 1, &CreateFileOptions::default())
9464                .unwrap();
9465            fs.sync_metadata().unwrap();
9466        }
9467        let primary = &img[1024..1536];
9468        let alt = &img[PARTITION as usize - 1024..PARTITION as usize - 512];
9469        assert_eq!(
9470            &alt[0..2],
9471            b"H+",
9472            "alternate volume header missing at the partition end"
9473        );
9474        assert_eq!(primary, alt);
9475    }
9476
9477    /// H14: an imported file's Finder flags had no way into create_file.
9478    #[test]
9479    fn create_file_writes_the_finder_flags_it_is_given() {
9480        use disk_core::filesystem::{CreateFileOptions, EditableFilesystem, Filesystem};
9481        let mut img = create_blank_hfsplus(8 * 1024 * 1024, 4096, "Flags", false);
9482        let mut fs = HfsPlusFilesystem::open(std::io::Cursor::new(&mut img), 0).unwrap();
9483        fs.prepare_for_edit().unwrap();
9484        let root = fs.root().unwrap();
9485        let opts = CreateFileOptions {
9486            os_type: Some(*b"APPL"),
9487            os_creator: Some(*b"ttxt"),
9488            finder_flags: Some(0x2000),
9489            ..Default::default()
9490        };
9491        fs.create_file(&root, "app", &mut &b"x"[..], 1, &opts)
9492            .unwrap();
9493        fs.sync_metadata().unwrap();
9494        let entry = fs
9495            .list_directory(&root)
9496            .unwrap()
9497            .into_iter()
9498            .find(|e| e.name == "app")
9499            .unwrap();
9500        assert_eq!(entry.finder_flags, Some(0x2000));
9501    }
9502
9503    #[test]
9504    fn a_grow_allocates_the_blocks_the_new_nodes_occupy() {
9505        // 4 KiB nodes in 8 KiB blocks: 8 nodes are 4 blocks, not 8.
9506        assert_eq!(btree_grow_blocks(10, 8, 4096, 8192), 4);
9507        // 1 KiB nodes in 4 KiB blocks: 8 nodes are 2 blocks.
9508        assert_eq!(btree_grow_blocks(4, 8, 1024, 4096), 2);
9509        // An odd node count leaves half a block of slack that the grow uses first.
9510        assert_eq!(btree_grow_blocks(11, 8, 4096, 8192), 4);
9511        assert_eq!(btree_grow_blocks(11, 1, 4096, 8192), 0);
9512        // Nodes larger than blocks keep the old one-node-many-blocks answer.
9513        assert_eq!(btree_grow_blocks(10, 8, 4096, 512), 64);
9514        assert_eq!(btree_grow_blocks(10, 8, 4096, 4096), 8);
9515    }
9516
9517    #[test]
9518    fn test_hfsplus_catalog_grows_fork_on_full_phase_a() {
9519        // §4b Phase A (docs/todo_hfsplus_fork_growth.md): a volume whose catalog
9520        // fork is *too small* for its eventual record count grows the fork in
9521        // place through live inserts — no spurious DiskFull while the volume has
9522        // free blocks — and stays fsck-clean. Contiguous tail growth keeps the
9523        // catalog fork at a single extent. A reopen re-reads the grown fork.
9524        use crate::hfs_common::BTreeHeader;
9525        type Fs = HfsPlusFilesystem<std::io::Cursor<Vec<u8>>>;
9526
9527        // 16 MiB volume, minimal (volume-scaled, ~21-node) catalog. Inserting
9528        // far more records than that forces many grows.
9529        let img = create_blank_hfsplus_sized(16 * 1024 * 1024, 4096, "GrowVol", false, 0, 0);
9530        let mut fs = Fs::open(std::io::Cursor::new(img), 0).unwrap();
9531        fs.prepare_for_edit().unwrap();
9532
9533        let start_nodes = BTreeHeader::read(fs.catalog_data()).total_nodes;
9534
9535        const DIRS: u32 = 20;
9536        let root = fs.root().unwrap();
9537        let mut dir_ids = Vec::with_capacity(DIRS as usize);
9538        for d in 0..DIRS {
9539            let de = fs
9540                .create_directory(&root, &format!("dir{d:03}"), &Default::default())
9541                .unwrap();
9542            dir_ids.push(de.location as u32);
9543        }
9544        let base_cnid = fs.vh.next_catalog_id;
9545
9546        let n: u32 = 6_000;
9547        let mut per_dir = vec![0i32; DIRS as usize];
9548        let empty_fork = ForkData::empty();
9549        for i in 0..n {
9550            let hash = i.wrapping_mul(2_654_435_761);
9551            let d = (hash % DIRS) as usize;
9552            let name = format!("f{hash:08x}");
9553            let mut kr = Fs::build_catalog_key(dir_ids[d], &name);
9554            if !kr.len().is_multiple_of(2) {
9555                kr.push(0);
9556            }
9557            kr.extend_from_slice(&Fs::build_file_record_with_dates(
9558                base_cnid + i,
9559                &empty_fork,
9560                &empty_fork,
9561                &[0u8; 4],
9562                &[0u8; 4],
9563                None,
9564                0,
9565            ));
9566            fs.insert_catalog_record(&kr)
9567                .unwrap_or_else(|e| panic!("insert #{i} into dir{d} (grow should cover it): {e}"));
9568            per_dir[d] += 1;
9569        }
9570        fs.vh.file_count += n;
9571        fs.vh.next_catalog_id = base_cnid + n;
9572        for (d, &cnt) in per_dir.iter().enumerate() {
9573            fs.update_parent_valence(dir_ids[d], cnt).unwrap();
9574        }
9575
9576        // The fork grew (each grow adds >= 8 nodes; >= 2 grows expected here).
9577        let end_nodes = BTreeHeader::read(fs.catalog_data()).total_nodes;
9578        assert!(
9579            end_nodes >= start_nodes + 16,
9580            "catalog total_nodes only went {start_nodes} -> {end_nodes}; expected >= 2 grows"
9581        );
9582        // Contiguous tail growth keeps the catalog fork at a single extent.
9583        let cat_extents = fs
9584            .vh
9585            .catalog_file
9586            .extents
9587            .iter()
9588            .filter(|e| e.block_count != 0)
9589            .count();
9590        assert_eq!(
9591            cat_extents, 1,
9592            "contiguous tail growth should keep the catalog fork at 1 extent, got {cat_extents}"
9593        );
9594
9595        // Persist the grown fork, bitmap, and VH, then fsck the result.
9596        fs.sync_metadata().unwrap();
9597
9598        // fsck-clean after all the grows.
9599        let result = fs.fsck().unwrap().unwrap();
9600        assert!(
9601            result.errors.is_empty(),
9602            "fsck found {} errors after grow: {:?}",
9603            result.errors.len(),
9604            result
9605                .errors
9606                .iter()
9607                .take(8)
9608                .map(|e| &e.message)
9609                .collect::<Vec<_>>()
9610        );
9611
9612        // Reopen re-reads the grown fork (proves the fork extents + header
9613        // persisted) and every record is still findable.
9614        let img = fs.reader.get_ref().clone();
9615        // Optionally emit the grown image so a Mac can fsck_hfs it (the real
9616        // §4b acceptance test): set RB_EMIT_GROW_IMAGE=/path.
9617        if let Ok(path) = std::env::var("RB_EMIT_GROW_IMAGE") {
9618            std::fs::write(&path, &img).unwrap();
9619        }
9620        let mut fs2 = Fs::open(std::io::Cursor::new(img), 0).unwrap();
9621        let reopened_nodes = BTreeHeader::read(fs2.catalog_data()).total_nodes;
9622        assert_eq!(reopened_nodes, end_nodes, "reopen lost grown nodes");
9623        let r2 = fs2.root().unwrap();
9624        let dirs = fs2.list_directory(&r2).unwrap();
9625        assert_eq!(dirs.len(), DIRS as usize, "reopen lost directories");
9626    }
9627
9628    #[test]
9629    fn test_hfsplus_extents_overflow_grows_multilevel() {
9630        // P3 (docs/hfsplus_btree_growth_plan.md): the extents-overflow B-tree —
9631        // 2-byte ("big") keys with *fixed* 10-byte index separators — must split
9632        // past a single leaf level. It was pinned depth-1 because the shared
9633        // helpers forced the classic key shape; it now threads
9634        // `BTreeKeyFormat::HFSPLUS_EXTENTS`. Driven through the same
9635        // `btree_insert_full` machinery `insert_extents_overflow_record` uses.
9636        use crate::hfs_common::{
9637            btree_find_record, btree_insert_full, BTreeHeader, BTreeKeyFormat,
9638        };
9639        type Fs = HfsPlusFilesystem<std::io::Cursor<Vec<u8>>>;
9640
9641        let node_size = 512usize;
9642        let total_nodes = 1800u32;
9643        let mut buf = vec![0u8; total_nodes as usize * node_size];
9644        write_blank_btree_header_node(
9645            &mut buf[0..node_size],
9646            node_size,
9647            0,
9648            total_nodes,
9649            total_nodes - 2,
9650            /* max_key_len= */ 10,
9651            /* key_compare_type= */ 0,
9652        );
9653        write_empty_leaf_node(&mut buf[node_size..2 * node_size]);
9654
9655        let kf = BTreeKeyFormat::HFSPLUS_EXTENTS;
9656        // 600 overflow records keyed by distinct shuffled fileIDs (the multiplier
9657        // is odd, so it's a bijection mod 2^32 — distinct i give distinct keys
9658        // that land mid-leaf, not appended).
9659        const N: u32 = 600;
9660        let mut ids: Vec<u32> = Vec::with_capacity(N as usize);
9661        for i in 0..N {
9662            let file_id = i.wrapping_mul(2_654_435_761);
9663            let rec = Fs::build_extents_overflow_record(file_id, 0, 8, &[]);
9664            btree_insert_full(&mut buf, &rec, &kf, &Fs::extents_compare)
9665                .unwrap_or_else(|e| panic!("extents insert #{i} (file {file_id}): {e}"));
9666            ids.push(file_id);
9667        }
9668
9669        let header = BTreeHeader::read(&buf);
9670        assert!(
9671            header.depth >= 2,
9672            "extents-overflow tree stayed depth {} — split past a leaf level failed",
9673            header.depth
9674        );
9675        assert_eq!(header.leaf_records, N);
9676
9677        let mut recs: Vec<Vec<u8>> = Vec::with_capacity(N as usize);
9678        crate::hfs_common::walk_leaf_records::<(), _>(
9679            &buf,
9680            header.first_leaf_node,
9681            node_size,
9682            |_n, _r, _o, r| {
9683                recs.push(r.to_vec());
9684                None
9685            },
9686        );
9687        assert_eq!(recs.len(), N as usize, "leaf walk lost records");
9688        for w in recs.windows(2) {
9689            assert_eq!(
9690                Fs::extents_compare(&w[0], &w[1]),
9691                std::cmp::Ordering::Less,
9692                "extents-overflow leaves out of order — bad separator"
9693            );
9694        }
9695
9696        // The extents key portion is always the fixed 12 bytes (2-byte len + 10).
9697        fn ext_key(rec: &[u8]) -> &[u8] {
9698            &rec[..12.min(rec.len())]
9699        }
9700        let ext_cmp = |a: &[u8], b: &[u8]| Fs::extents_compare(a, b);
9701        for &file_id in &ids {
9702            let search = Fs::build_extents_overflow_record(file_id, 0, 8, &[]);
9703            let (found, _) = btree_find_record(&buf, &header, &search, &ext_key, &ext_cmp);
9704            assert!(
9705                found.is_some(),
9706                "extents record for file {file_id} not findable"
9707            );
9708        }
9709    }
9710
9711    #[test]
9712    fn test_hfsplus_attributes_grows_multilevel() {
9713        // P3: the attributes B-tree — 2-byte keys with *variable*-length index
9714        // separators — splits past one leaf level via
9715        // `BTreeKeyFormat::HFSPLUS_ATTRIBUTES`, the same path `insert_xattr_record`
9716        // uses. (P1 wired the descriptor in; this proves the attribute key layout,
9717        // distinct from the catalog's, routes correctly at every level.)
9718        use crate::hfs_common::{
9719            btree_find_record, btree_insert_full, BTreeHeader, BTreeKeyFormat,
9720        };
9721        use byteorder::{BigEndian, ByteOrder};
9722        type Fs = HfsPlusFilesystem<std::io::Cursor<Vec<u8>>>;
9723
9724        let node_size = 512usize;
9725        let total_nodes = 1800u32;
9726        let mut buf = vec![0u8; total_nodes as usize * node_size];
9727        write_blank_btree_header_node(
9728            &mut buf[0..node_size],
9729            node_size,
9730            0,
9731            total_nodes,
9732            total_nodes - 2,
9733            /* max_key_len= */ 264,
9734            /* key_compare_type= */ 0,
9735        );
9736        write_empty_leaf_node(&mut buf[node_size..2 * node_size]);
9737
9738        let kf = BTreeKeyFormat::HFSPLUS_ATTRIBUTES;
9739        const N: u32 = 600;
9740        let value = [0xABu8; 8];
9741        let mut cnids: Vec<u32> = Vec::with_capacity(N as usize);
9742        for i in 0..N {
9743            let cnid = i.wrapping_mul(2_654_435_761);
9744            let rec = Fs::build_inline_attr_record(cnid, "xattr", &value);
9745            btree_insert_full(&mut buf, &rec, &kf, &Fs::attr_compare)
9746                .unwrap_or_else(|e| panic!("attr insert #{i} (cnid {cnid}): {e}"));
9747            cnids.push(cnid);
9748        }
9749
9750        let header = BTreeHeader::read(&buf);
9751        assert!(
9752            header.depth >= 2,
9753            "attributes tree stayed depth {} — split past a leaf level failed",
9754            header.depth
9755        );
9756        assert_eq!(header.leaf_records, N);
9757
9758        let mut recs: Vec<Vec<u8>> = Vec::with_capacity(N as usize);
9759        crate::hfs_common::walk_leaf_records::<(), _>(
9760            &buf,
9761            header.first_leaf_node,
9762            node_size,
9763            |_n, _r, _o, r| {
9764                recs.push(r.to_vec());
9765                None
9766            },
9767        );
9768        assert_eq!(recs.len(), N as usize, "leaf walk lost records");
9769        for w in recs.windows(2) {
9770            assert_eq!(
9771                Fs::attr_compare(&w[0], &w[1]),
9772                std::cmp::Ordering::Less,
9773                "attributes leaves out of order — bad separator"
9774            );
9775        }
9776
9777        fn attr_key(rec: &[u8]) -> &[u8] {
9778            let kl = BigEndian::read_u16(&rec[0..2]) as usize;
9779            &rec[..(2 + kl).min(rec.len())]
9780        }
9781        let attr_cmp = |a: &[u8], b: &[u8]| Fs::attr_compare(a, b);
9782        for &cnid in &cnids {
9783            let search = Fs::build_inline_attr_record(cnid, "xattr", &value);
9784            let (found, _) = btree_find_record(&buf, &header, &search, &attr_key, &attr_cmp);
9785            assert!(found.is_some(), "attr record for cnid {cnid} not findable");
9786        }
9787    }
9788
9789    #[test]
9790    fn test_hfsplus_fragmented_file_splits_extents_overflow_btree_real_path() {
9791        // P3 real-path integration: a heavily fragmented file produces enough
9792        // extents-overflow records to split that B-tree past a single leaf level
9793        // through the real `insert_extents_overflow_record`, and the file reads
9794        // back byte-for-byte through the resulting multi-level tree. (Read-back,
9795        // not fsck, because the all-used-then-free-singletons bitmap trick used to
9796        // force fragmentation leaves the bitmap deliberately over-allocated; the
9797        // point here is the overflow B-tree's split + read path, which P3 enabled.)
9798        type Fs = HfsPlusFilesystem<std::io::Cursor<Vec<u8>>>;
9799        // Extents-overflow tree pinned to 256 KiB (64 nodes @ 4096) so it has room
9800        // to split — the 4-node default could not.
9801        let img = create_blank_hfsplus_sized(32 << 20, 4096, "Frag", false, 0, 256 << 10);
9802        let mut fs = Fs::open(std::io::Cursor::new(img), 0).unwrap();
9803        fs.prepare_for_edit().unwrap();
9804
9805        // Force maximal fragmentation: mark every block used, then free isolated
9806        // single blocks (every other block) in the user region. A file of N blocks
9807        // must then use N one-block extents: 8 inline + (N-8) overflow extents =
9808        // ceil((N-8)/8) overflow records. 520 blocks -> 64 overflow records, well
9809        // past one 4096-byte extents leaf (~52 records), so the tree must split.
9810        fs.ensure_bitmap().unwrap();
9811        let block_size = fs.vh.block_size as usize;
9812        let n_blocks: u32 = 520;
9813        let first_free = fs.vh.next_allocation;
9814        {
9815            let bitmap = fs.bitmap.as_mut().unwrap();
9816            for b in bitmap.iter_mut() {
9817                *b = 0xFF;
9818            }
9819            let mut blk = first_free + 1;
9820            for _ in 0..n_blocks {
9821                bitmap_clear_bit_be(bitmap, blk);
9822                blk += 2; // a used block between each free one -> singletons
9823            }
9824        }
9825        fs.vh.free_blocks = n_blocks;
9826
9827        let payload: Vec<u8> = (0..(n_blocks as usize * block_size))
9828            .map(|i| (i % 251) as u8)
9829            .collect();
9830        let mut reader = std::io::Cursor::new(payload.clone());
9831        let root = fs.root().unwrap();
9832        let entry = fs
9833            .create_file(
9834                &root,
9835                "frag.bin",
9836                &mut reader,
9837                payload.len() as u64,
9838                &CreateFileOptions::default(),
9839            )
9840            .expect("fragmented file create should succeed via a splitting overflow B-tree");
9841
9842        // The extents-overflow tree must have grown past a single leaf level.
9843        let depth =
9844            crate::hfs_common::BTreeHeader::read(fs.extents_overflow_data.as_ref().unwrap()).depth;
9845        assert!(
9846            depth >= 2,
9847            "extents-overflow tree depth {depth} < 2 — 64 overflow records did not split it"
9848        );
9849
9850        // The data fork is the expected single-block-extent shape...
9851        let cnid = entry.location as u32;
9852        let (data_fork, _rsrc) = fs.find_file_by_id(cnid).unwrap();
9853        assert_eq!(data_fork.total_blocks, n_blocks);
9854
9855        // ...and reads back byte-for-byte through the multi-level overflow tree.
9856        let got = fs.read_file(&entry, payload.len() + 1).unwrap();
9857        assert_eq!(got.len(), payload.len());
9858        assert_eq!(
9859            got, payload,
9860            "fragmented read-back mismatch through split overflow tree"
9861        );
9862    }
9863
9864    #[test]
9865    fn deleting_the_last_overflow_record_retires_the_extents_tree() {
9866        // fsck_hfs E_BadNode: the audit's H3 delete left the extents tree as a
9867        // depth-1 root leaf with no records, which Apple never writes.
9868        use crate::hfs_common::{btree_lonely_empty_leaves, BTreeHeader};
9869        type Fs = HfsPlusFilesystem<std::io::Cursor<Vec<u8>>>;
9870        let img = create_blank_hfsplus_sized(32 << 20, 4096, "Retire", false, 0, 256 << 10);
9871        let mut fs = Fs::open(std::io::Cursor::new(img), 0).unwrap();
9872        fs.prepare_for_edit().unwrap();
9873
9874        // Free singleton blocks only, so the file fragments into the overflow tree.
9875        fs.ensure_bitmap().unwrap();
9876        let block_size = fs.vh.block_size as usize;
9877        let n_blocks: u32 = 520;
9878        let first_free = fs.vh.next_allocation;
9879        {
9880            let bitmap = fs.bitmap.as_mut().unwrap();
9881            for b in bitmap.iter_mut() {
9882                *b = 0xFF;
9883            }
9884            let mut blk = first_free + 1;
9885            for _ in 0..n_blocks {
9886                bitmap_clear_bit_be(bitmap, blk);
9887                blk += 2;
9888            }
9889        }
9890        fs.vh.free_blocks = n_blocks;
9891        let payload: Vec<u8> = (0..(n_blocks as usize * block_size))
9892            .map(|i| (i % 251) as u8)
9893            .collect();
9894        let root = fs.root().unwrap();
9895        let entry = fs
9896            .create_file(
9897                &root,
9898                "frag.bin",
9899                &mut std::io::Cursor::new(payload.clone()),
9900                payload.len() as u64,
9901                &CreateFileOptions::default(),
9902            )
9903            .unwrap();
9904        let before = BTreeHeader::read(fs.extents_overflow_data.as_ref().unwrap());
9905        assert!(
9906            before.depth >= 2 && before.leaf_records > 0,
9907            "depth {} leaf_records {}",
9908            before.depth,
9909            before.leaf_records
9910        );
9911
9912        fs.delete_entry(&root, &entry).unwrap();
9913
9914        let tree = fs.extents_overflow_data.as_ref().unwrap();
9915        let h = BTreeHeader::read(tree);
9916        assert_eq!((h.leaf_records, h.root_node, h.depth), (0, 0, 0));
9917        assert_eq!(
9918            h.free_nodes,
9919            h.total_nodes - 1,
9920            "only the header node stays"
9921        );
9922        // The hand-made bitmap above never reaches disk, so no fsck here; the
9923        // audit's verify-fs-macos.sh runs fsck_hfs on the real image instead.
9924        assert!(btree_lonely_empty_leaves(tree, h.node_size as usize).is_empty());
9925    }
9926
9927    #[test]
9928    fn a_grow_moves_the_alternate_header_blocks_and_the_bitmap() {
9929        // fsck_hfs on the audit's H7 grow: "orphaned blocks" at the old tail,
9930        // "under-allocation" at the new one. The 2 KiB case also outgrows its
9931        // one-block allocation file, which has to move.
9932        type Fs = HfsPlusFilesystem<std::io::Cursor<Vec<u8>>>;
9933        for (bs, old_mib, new_mib) in [(4096u32, 24u64, 30u64), (2048u32, 32u64, 64u64)] {
9934            let mut img = create_blank_hfsplus(old_mib << 20, bs, "Grow", false);
9935            img.resize((new_mib << 20) as usize, 0);
9936            let mut cur = std::io::Cursor::new(img);
9937            let mut log = |_: &str| {};
9938            resize_hfsplus_in_place(&mut cur, 0, new_mib << 20, &mut log).unwrap();
9939
9940            let mut fs = Fs::open_sized(cur, 0, Some(new_mib << 20)).unwrap();
9941            let total = fs.vh.total_blocks;
9942            assert_eq!(total as u64, (new_mib << 20) / bs as u64, "bs {bs}");
9943            let bitmap = fs.read_allocation_bitmap_for_fsck().unwrap();
9944            let old_total = ((old_mib << 20) / bs as u64) as u32;
9945            for b in alt_vh_blocks(old_total, bs) {
9946                assert!(
9947                    !bitmap_test_bit_be(&bitmap, b),
9948                    "bs {bs}: old tail block {b} still marked"
9949                );
9950            }
9951            for b in alt_vh_blocks(total, bs) {
9952                assert!(
9953                    bitmap_test_bit_be(&bitmap, b),
9954                    "bs {bs}: new tail block {b} is free"
9955                );
9956            }
9957            let used = (0..total)
9958                .filter(|&b| bitmap_test_bit_be(&bitmap, b))
9959                .count() as u32;
9960            assert_eq!(fs.vh.free_blocks, total - used, "bs {bs}");
9961            assert!(
9962                fs.vh.allocation_file.total_blocks as u64 * bs as u64 * 8 >= total as u64,
9963                "bs {bs}: allocation file too small for {total} blocks"
9964            );
9965            let report = fs.fsck().unwrap().unwrap();
9966            assert!(
9967                report.errors.is_empty(),
9968                "bs {bs}: {:?}",
9969                report
9970                    .errors
9971                    .iter()
9972                    .map(|e| &e.message)
9973                    .take(4)
9974                    .collect::<Vec<_>>()
9975            );
9976        }
9977    }
9978
9979    #[test]
9980    fn test_hfsplus_catalog_shuffled_inserts_pack_densely() {
9981        // P5 (docs/hfsplus_btree_growth_plan.md §4d): with the B*-style rotation
9982        // now on the HFS+ catalog insert path (insert_catalog_record ->
9983        // btree_insert_full), shuffled multi-dir inserts must pack leaves to
9984        // ~80%+ occupancy, matching the classic-HFS result — not the ~69% a plain
9985        // split leaves. Measured through the same btree_insert_full the live path
9986        // uses, with the HFSPLUS_CATALOG (variable-key) format.
9987        use crate::hfs_common::{btree_insert_full, BTreeHeader, BTreeKeyFormat};
9988        use byteorder::{BigEndian, ByteOrder};
9989        type Fs = HfsPlusFilesystem<std::io::Cursor<Vec<u8>>>;
9990
9991        let node_size = 512usize;
9992        let total_nodes = 1800u32;
9993        let mut buf = vec![0u8; total_nodes as usize * node_size];
9994        write_blank_btree_header_node(
9995            &mut buf[0..node_size],
9996            node_size,
9997            0,
9998            total_nodes,
9999            total_nodes - 2,
10000            516,
10001            KEY_COMPARE_CASE_FOLDING,
10002        );
10003        write_empty_leaf_node(&mut buf[node_size..2 * node_size]);
10004
10005        let kf = BTreeKeyFormat::HFSPLUS_CATALOG;
10006        let cmp = |a: &[u8], b: &[u8]| Fs::catalog_compare(a, b, false);
10007        const DIRS: u32 = 40;
10008        const N: u32 = 3000;
10009        for i in 0..N {
10010            let hash = i.wrapping_mul(2_654_435_761);
10011            let parent = 16 + (hash % DIRS);
10012            let name = format!("f{hash:08x}");
10013            let mut rec = Fs::build_catalog_key(parent, &name);
10014            rec.extend_from_slice(&[0u8; 40]);
10015            btree_insert_full(&mut buf, &rec, &kf, &cmp)
10016                .unwrap_or_else(|e| panic!("insert #{i}: {e}"));
10017        }
10018
10019        // Average leaf occupancy across the whole leaf chain.
10020        let header = BTreeHeader::read(&buf);
10021        assert!(
10022            header.depth >= 3,
10023            "need a multi-level tree to judge packing"
10024        );
10025        let mut used_total = 0usize;
10026        let mut leaves = 0usize;
10027        let mut node = header.first_leaf_node;
10028        let mut seen = std::collections::HashSet::new();
10029        while node != 0 && seen.insert(node) {
10030            let off = node as usize * node_size;
10031            let num = BigEndian::read_u16(&buf[off + 10..off + 12]) as usize;
10032            // Records occupy 14..free_off; the offset table holds num+1 u16s.
10033            let free_off_pos = off + node_size - 2 * (num + 1);
10034            let free_off = BigEndian::read_u16(&buf[free_off_pos..free_off_pos + 2]) as usize;
10035            used_total += free_off + 2 * (num + 1);
10036            leaves += 1;
10037            node = BigEndian::read_u32(&buf[off..off + 4]); // forward link
10038        }
10039        // ~0.84 with the rotation (was ~0.69 with plain split), matching classic.
10040        let occupancy = used_total as f64 / (leaves * node_size) as f64;
10041        assert!(
10042            occupancy >= 0.80,
10043            "shuffled multi-dir leaf occupancy {occupancy:.3} (<0.80) over {leaves} leaves — \
10044             the rotation is not packing densely"
10045        );
10046    }
10047}