Skip to main content

fs_mac/
hfs.rs

1use byteorder::{BigEndian, ByteOrder};
2#[cfg(feature = "rust173-polyfill")]
3use disk_core::rust173::IntIsMultipleOf as _;
4use std::cmp::Ordering;
5use std::io::{Read, Seek, SeekFrom, Write};
6
7use crate::hfs_common::{
8    self, bitmap_clear_bit_be, bitmap_collect_clear_runs_be, bitmap_find_clear_run_be,
9    bitmap_set_bit_be, bitmap_test_bit_be, btree_free_node, btree_grow_blocks,
10    btree_map_chain_tail, btree_refresh_index_keys, btree_remove_record, BTreeHeader,
11    BTreeKeyFormat,
12};
13use disk_core::entry::{EntryType, FileEntry};
14use disk_core::filesystem::CompactResult;
15use disk_core::filesystem::{
16    CreateDirectoryOptions, CreateFileOptions, EditableFilesystem, Filesystem, FilesystemError,
17};
18
19const HFS_SIGNATURE: u16 = 0x4244;
20const HFS_PLUS_EMBEDDED_SIGNATURE: u16 = 0x482B;
21
22/// Mac Roman to Unicode lookup table for bytes 0x80-0xFF.
23static MAC_ROMAN_TABLE: [char; 128] = [
24    '\u{00C4}', '\u{00C5}', '\u{00C7}', '\u{00C9}', '\u{00D1}', '\u{00D6}', '\u{00DC}', '\u{00E1}',
25    '\u{00E0}', '\u{00E2}', '\u{00E4}', '\u{00E3}', '\u{00E5}', '\u{00E7}', '\u{00E9}', '\u{00E8}',
26    '\u{00EA}', '\u{00EB}', '\u{00ED}', '\u{00EC}', '\u{00EE}', '\u{00EF}', '\u{00F1}', '\u{00F3}',
27    '\u{00F2}', '\u{00F4}', '\u{00F6}', '\u{00F5}', '\u{00FA}', '\u{00F9}', '\u{00FB}', '\u{00FC}',
28    '\u{2020}', '\u{00B0}', '\u{00A2}', '\u{00A3}', '\u{00A7}', '\u{2022}', '\u{00B6}', '\u{00DF}',
29    '\u{00AE}', '\u{00A9}', '\u{2122}', '\u{00B4}', '\u{00A8}', '\u{2260}', '\u{00C6}', '\u{00D8}',
30    '\u{221E}', '\u{00B1}', '\u{2264}', '\u{2265}', '\u{00A5}', '\u{00B5}', '\u{2202}', '\u{2211}',
31    '\u{220F}', '\u{03C0}', '\u{222B}', '\u{00AA}', '\u{00BA}', '\u{03A9}', '\u{00E6}', '\u{00F8}',
32    '\u{00BF}', '\u{00A1}', '\u{00AC}', '\u{221A}', '\u{0192}', '\u{2248}', '\u{2206}', '\u{00AB}',
33    '\u{00BB}', '\u{2026}', '\u{00A0}', '\u{00C0}', '\u{00C3}', '\u{00D5}', '\u{0152}', '\u{0153}',
34    '\u{2013}', '\u{2014}', '\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}', '\u{00F7}', '\u{25CA}',
35    '\u{00FF}', '\u{0178}', '\u{2044}', '\u{20AC}', '\u{2039}', '\u{203A}', '\u{FB01}', '\u{FB02}',
36    '\u{2021}', '\u{00B7}', '\u{201A}', '\u{201E}', '\u{2030}', '\u{00C2}', '\u{00CA}', '\u{00C1}',
37    '\u{00CB}', '\u{00C8}', '\u{00CD}', '\u{00CE}', '\u{00CF}', '\u{00CC}', '\u{00D3}', '\u{00D4}',
38    '\u{F8FF}', '\u{00D2}', '\u{00DA}', '\u{00DB}', '\u{00D9}', '\u{0131}', '\u{02C6}', '\u{02DC}',
39    '\u{00AF}', '\u{02D8}', '\u{02D9}', '\u{02DA}', '\u{00B8}', '\u{02DD}', '\u{02DB}', '\u{02C7}',
40];
41
42/// Convert a UTF-8 string to Mac Roman bytes.
43/// Returns Err if any character cannot be represented in Mac Roman.
44pub fn utf8_to_mac_roman(s: &str) -> Result<Vec<u8>, FilesystemError> {
45    let mut result = Vec::with_capacity(s.len());
46    for ch in s.chars() {
47        if (ch as u32) < 0x80 {
48            result.push(ch as u8);
49        } else {
50            // Search the Mac Roman table for this character
51            let mut found = false;
52            for (i, &table_char) in MAC_ROMAN_TABLE.iter().enumerate() {
53                if table_char == ch {
54                    result.push((i as u8) + 0x80);
55                    found = true;
56                    break;
57                }
58            }
59            if !found {
60                return Err(FilesystemError::InvalidData(format!(
61                    "character '{}' (U+{:04X}) cannot be encoded in Mac Roman",
62                    ch, ch as u32
63                )));
64            }
65        }
66    }
67    Ok(result)
68}
69
70/// Decode a Mac Roman byte string to UTF-8.
71pub fn mac_roman_to_utf8(data: &[u8]) -> String {
72    data.iter()
73        .map(|&b| {
74            if b < 0x80 {
75                b as char
76            } else {
77                MAC_ROMAN_TABLE[(b - 0x80) as usize]
78            }
79        })
80        .collect()
81}
82
83/// Decode an archive/container entry name whose encoding is ambiguous.
84///
85/// Vintage Mac archives (StuffIt, Compact Pro, MacBinary, MAR) store names in
86/// Mac Roman, but some have been re-packed by modern tools that emit UTF-8 — so
87/// a `™` arrives as the three UTF-8 bytes `E2 84 A2` instead of Mac Roman `0xAA`.
88/// Decoding those blindly as Mac Roman yields mojibake (`‚Ñ¢`).
89///
90/// Heuristic: if the bytes are valid UTF-8 *and* carry at least one multi-byte
91/// (non-ASCII) sequence, treat them as UTF-8; otherwise fall back to Mac Roman.
92/// This is safe because a genuine Mac Roman high byte (e.g. `é` = `0x8E`) is a
93/// lone byte ≥ 0x80, which is never a valid standalone UTF-8 sequence — so real
94/// Mac Roman names always take the Mac Roman path. Pure-ASCII names decode
95/// identically either way.
96pub fn decode_mac_filename(data: &[u8]) -> String {
97    if data.iter().any(|&b| b >= 0x80) {
98        if let Ok(s) = std::str::from_utf8(data) {
99            return s.to_string();
100        }
101    }
102    mac_roman_to_utf8(data)
103}
104
105/// Render a 4-byte Mac OSType (a type or creator code) for display.
106///
107/// OSType codes are raw Mac Roman bytes, so high bytes like `•` (`0xA5`) must go
108/// through the Mac Roman table rather than `from_utf8_lossy` (which renders them
109/// as `�`). Control bytes (`< 0x20`) and `0x00` padding render as spaces so the
110/// fixed 4-glyph shape is preserved. Trailing spaces are trimmed.
111pub fn format_ostype(code: &[u8; 4]) -> String {
112    code.iter()
113        .map(|&b| match b {
114            0x00..=0x1f => ' ',
115            b if b < 0x80 => b as char,
116            b => MAC_ROMAN_TABLE[(b - 0x80) as usize],
117        })
118        .collect::<String>()
119        .trim_end()
120        .to_string()
121}
122
123/// Returns `true` if the catalog B-tree header is all zeros (uninitialized).
124/// Validate a name for a new file or directory on a classic HFS volume.
125/// Returns the encoded Mac Roman bytes on success.
126fn validate_hfs_create_name(name: &str) -> Result<Vec<u8>, FilesystemError> {
127    if name.is_empty() {
128        return Err(FilesystemError::InvalidData(
129            "filename is empty — pick a non-blank name".into(),
130        ));
131    }
132    if name.contains(':') {
133        return Err(FilesystemError::InvalidData(
134            "filename contains ':', which classic Mac OS uses as a path separator — \
135             rename the file (try '-' or '_' instead)"
136                .into(),
137        ));
138    }
139    let name_bytes = utf8_to_mac_roman(name).map_err(|_| {
140        FilesystemError::InvalidData(
141            "filename contains characters that can't be encoded in Mac Roman \
142             (HFS predates Unicode) — rename using plain ASCII or common accented letters"
143                .into(),
144        )
145    })?;
146    if name_bytes.is_empty() {
147        return Err(FilesystemError::InvalidData(
148            "filename is empty — pick a non-blank name".into(),
149        ));
150    }
151    if name_bytes.len() > 31 {
152        return Err(FilesystemError::InvalidData(format!(
153            "filename is too long ({} chars); classic HFS allows up to 31 — shorten the name",
154            name_bytes.len()
155        )));
156    }
157    Ok(name_bytes)
158}
159
160/// Some formatters (notably Disk Jockey's "Empty HFS" image) allocate the
161/// catalog extents but leave the bytes zeroed, which would otherwise cause a
162/// panic on the first insert because `node_size` parses as 0.
163/// Count data-fork extents per file by walking classic HFS catalog leaves.
164/// Mirror of `hfsplus_fragmentation_stats` but for the smaller HFS file
165/// record layout: 3 inline extents at offset 74, physical-size at offset
166/// 30. Forks whose `physical_size_blocks > sum(inline blockCounts)` are
167/// flagged as fragmented (overflow into the extents B-tree).
168fn hfs_fragmentation_stats(catalog_data: &[u8]) -> disk_core::filesystem::FragmentationStats {
169    use crate::hfs_common::walk_leaf_records;
170
171    let mut stats = disk_core::filesystem::FragmentationStats::default();
172    if catalog_data.len() < 34 {
173        return stats;
174    }
175    let node_size = BigEndian::read_u16(&catalog_data[32..34]) as usize;
176    let first_leaf = BigEndian::read_u32(&catalog_data[24..28]);
177    if node_size < crate::hfs_common::BTREE_MIN_NODE_SIZE || first_leaf == 0 {
178        return stats;
179    }
180    walk_leaf_records::<(), _>(
181        catalog_data,
182        first_leaf,
183        node_size,
184        |_node_idx, _rec_idx, _abs_off, rec| {
185            if rec.is_empty() {
186                return None;
187            }
188            let key_len = rec[0] as usize;
189            let mut data_off = 1 + key_len;
190            if !data_off.is_multiple_of(2) {
191                data_off += 1;
192            }
193            if data_off + 102 > rec.len() {
194                return None;
195            }
196            if rec[data_off] as i8 != CATALOG_FILE {
197                return None;
198            }
199            let body = &rec[data_off..];
200            let phys_bytes = BigEndian::read_u32(&body[30..34]);
201            if phys_bytes == 0 {
202                return None;
203            }
204            let mut inline_extents: u32 = 0;
205            let mut inline_blocks: u32 = 0;
206            for j in 0..3 {
207                let count = BigEndian::read_u16(&body[74 + j * 4 + 2..74 + j * 4 + 4]);
208                if count > 0 {
209                    inline_extents += 1;
210                    inline_blocks = inline_blocks.saturating_add(count as u32);
211                }
212            }
213            stats.files_with_data += 1;
214            // Compare physical-size in blocks against inline block coverage.
215            // We don't have block_size here (it's in MDB, not catalog), so use
216            // ratio approximately: any data fork where inline blocks < physical
217            // bytes / 512 (the smallest possible block size) must overflow.
218            // Since physical-size is already block-rounded, `inline_blocks
219            // == 0 && phys_bytes > 0` is the unambiguous overflow signal.
220            let overflows = inline_blocks == 0 && phys_bytes > 0;
221            if inline_extents > 1 || overflows {
222                stats.fragmented_files += 1;
223            }
224            None
225        },
226    );
227    stats
228}
229
230fn is_catalog_uninitialized(catalog_data: &[u8]) -> bool {
231    if catalog_data.len() < 34 {
232        return true;
233    }
234    // node_size field in the BTHeaderRec sits at catalog byte 32..34.
235    BigEndian::read_u16(&catalog_data[32..34]) == 0
236}
237
238/// The two B-tree files a classic HFS volume keeps in its MDB.
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240enum HfsTree {
241    Catalog,
242    Extents,
243}
244
245impl HfsTree {
246    fn label(self) -> &'static str {
247        match self {
248            HfsTree::Catalog => "catalog",
249            HfsTree::Extents => "extents-overflow",
250        }
251    }
252}
253
254/// HFS extent descriptor: start_block (u16) + block_count (u16).
255#[derive(Debug, Clone, Copy)]
256pub struct HfsExtDescriptor {
257    pub start_block: u16,
258    pub block_count: u16,
259}
260
261/// Result of [`HfsFilesystem::locate_file_by_cnid`]. `offset_in_image` is
262/// absolute within the underlying image file, so scripts patching
263/// disk-byte offsets into boot blocks or supervisor payloads can use it
264/// directly without re-adding any partition base.
265#[derive(Debug, Clone, Copy)]
266pub struct HfsFileLocation {
267    pub offset_in_image: u64,
268    pub length: u64,
269    pub fragmented: bool,
270}
271
272/// Lightweight, public snapshot of an HFS volume's headline numbers.
273/// Returned by [`HfsFilesystem::volume_summary`] so GUI code outside the
274/// library crate can populate display fields without touching pub(crate)
275/// internals.
276#[derive(Debug, Clone)]
277pub struct HfsVolumeSummary {
278    pub volume_name: String,
279    pub block_size: u32,
280    pub total_blocks: u16,
281    pub free_blocks: u16,
282    pub used_bytes: u64,
283    pub file_count: u32,
284    pub folder_count: u32,
285    pub catalog_file_size: u32,
286    pub extents_file_size: u32,
287}
288
289impl HfsExtDescriptor {
290    pub fn parse(data: &[u8]) -> Self {
291        HfsExtDescriptor {
292            start_block: BigEndian::read_u16(&data[0..2]),
293            block_count: BigEndian::read_u16(&data[2..4]),
294        }
295    }
296
297    fn serialize(&self, out: &mut [u8]) {
298        BigEndian::write_u16(&mut out[0..2], self.start_block);
299        BigEndian::write_u16(&mut out[2..4], self.block_count);
300    }
301}
302
303/// HFS Master Directory Block (MDB) — at partition_offset + 1024.
304#[derive(Debug, Clone)]
305#[allow(dead_code)]
306pub(crate) struct HfsMasterDirectoryBlock {
307    pub(crate) signature: u16,
308    pub(crate) create_date: u32,
309    pub(crate) modify_date: u32,
310    pub(crate) total_blocks: u16,
311    pub(crate) block_size: u32,
312    pub(crate) free_blocks: u16,
313    pub(crate) volume_name: String,
314    pub(crate) volume_name_raw: Vec<u8>,
315    pub(crate) volume_bitmap_block: u16,
316    /// First allocation block's offset in 512-byte sectors from partition start.
317    pub(crate) first_alloc_block: u16,
318    pub(crate) next_catalog_id: u32,
319    pub(crate) file_count: u32,
320    pub(crate) folder_count: u32,
321    pub(crate) finder_info: [u32; 8],
322    pub(crate) catalog_file_size: u32,
323    pub(crate) catalog_file_extents: [HfsExtDescriptor; 3],
324    pub(crate) extents_file_size: u32,
325    pub(crate) extents_file_extents: [HfsExtDescriptor; 3],
326    pub(crate) embedded_signature: u16,
327    pub(crate) embedded_start_block: u16,
328    pub(crate) embedded_block_count: u16,
329    /// Raw 512-byte sector for serialization (preserve fields we don't explicitly parse).
330    pub(crate) raw_sector: [u8; 512],
331}
332
333impl HfsMasterDirectoryBlock {
334    fn parse(data: &[u8]) -> Result<Self, FilesystemError> {
335        if data.len() < 162 {
336            return Err(FilesystemError::Parse("MDB too short".into()));
337        }
338        let sig = BigEndian::read_u16(&data[0..2]);
339        if sig != HFS_SIGNATURE {
340            return Err(FilesystemError::Parse(format!(
341                "bad MDB signature: 0x{sig:04X}"
342            )));
343        }
344
345        // Volume name: Pascal string at offset 36 (length byte + up to 27 chars)
346        let name_len = data[36] as usize;
347        let name_bytes = &data[37..37 + name_len.min(27)];
348        let volume_name = mac_roman_to_utf8(name_bytes);
349        let volume_name_raw = name_bytes.to_vec();
350
351        let mut extents_extents = [HfsExtDescriptor {
352            start_block: 0,
353            block_count: 0,
354        }; 3];
355        for i in 0..3 {
356            extents_extents[i] = HfsExtDescriptor::parse(&data[134 + i * 4..138 + i * 4]);
357        }
358
359        let mut catalog_extents = [HfsExtDescriptor {
360            start_block: 0,
361            block_count: 0,
362        }; 3];
363        for i in 0..3 {
364            catalog_extents[i] = HfsExtDescriptor::parse(&data[150 + i * 4..154 + i * 4]);
365        }
366
367        // Embedded HFS+ info at offsets 124-130
368        let embedded_sig = BigEndian::read_u16(&data[124..126]);
369        let embedded_start = BigEndian::read_u16(&data[126..128]);
370        let embedded_count = BigEndian::read_u16(&data[128..130]);
371
372        // Finder info at offset 92 (32 bytes = 8 × u32)
373        let mut finder_info = [0u32; 8];
374        for i in 0..8 {
375            finder_info[i] = BigEndian::read_u32(&data[92 + i * 4..96 + i * 4]);
376        }
377
378        // Preserve raw sector for faithful serialization
379        let mut raw_sector = [0u8; 512];
380        let copy_len = data.len().min(512);
381        raw_sector[..copy_len].copy_from_slice(&data[..copy_len]);
382
383        Ok(HfsMasterDirectoryBlock {
384            signature: sig,
385            create_date: BigEndian::read_u32(&data[2..6]),
386            modify_date: BigEndian::read_u32(&data[6..10]),
387            total_blocks: BigEndian::read_u16(&data[18..20]),
388            block_size: BigEndian::read_u32(&data[20..24]),
389            free_blocks: BigEndian::read_u16(&data[34..36]),
390            volume_name,
391            volume_name_raw,
392            volume_bitmap_block: BigEndian::read_u16(&data[14..16]),
393            first_alloc_block: BigEndian::read_u16(&data[28..30]),
394            next_catalog_id: BigEndian::read_u32(&data[30..34]),
395            file_count: BigEndian::read_u32(&data[84..88]),
396            folder_count: BigEndian::read_u32(&data[88..92]),
397            finder_info,
398            extents_file_size: BigEndian::read_u32(&data[130..134]),
399            extents_file_extents: extents_extents,
400            catalog_file_size: BigEndian::read_u32(&data[146..150]),
401            catalog_file_extents: catalog_extents,
402            embedded_signature: embedded_sig,
403            embedded_start_block: embedded_start,
404            embedded_block_count: embedded_count,
405            raw_sector,
406        })
407    }
408
409    /// Serialize MDB back to a 512-byte sector.
410    /// Uses the raw sector as a base and overwrites fields we may have modified.
411    fn serialize_to_sector(&self) -> [u8; 512] {
412        let mut out = self.raw_sector;
413        BigEndian::write_u32(&mut out[6..10], self.modify_date);
414        BigEndian::write_u16(&mut out[18..20], self.total_blocks);
415        BigEndian::write_u16(&mut out[34..36], self.free_blocks);
416        BigEndian::write_u32(&mut out[30..34], self.next_catalog_id);
417        BigEndian::write_u32(&mut out[84..88], self.file_count);
418        BigEndian::write_u32(&mut out[88..92], self.folder_count);
419        for i in 0..8 {
420            BigEndian::write_u32(&mut out[92 + i * 4..96 + i * 4], self.finder_info[i]);
421        }
422        BigEndian::write_u32(&mut out[130..134], self.extents_file_size);
423        for i in 0..3 {
424            self.extents_file_extents[i].serialize(&mut out[134 + i * 4..138 + i * 4]);
425        }
426        BigEndian::write_u32(&mut out[146..150], self.catalog_file_size);
427        for i in 0..3 {
428            self.catalog_file_extents[i].serialize(&mut out[150 + i * 4..154 + i * 4]);
429        }
430        out
431    }
432
433    /// True if this MDB wraps an embedded HFS+ volume.
434    fn has_embedded_hfs_plus(&self) -> bool {
435        self.embedded_signature == HFS_PLUS_EMBEDDED_SIGNATURE
436    }
437}
438
439/// Catalog record types.
440pub(crate) const CATALOG_DIR: i8 = 1;
441/// The catalog file's own CNID; extents past the MDB's three sit in the overflow tree.
442const CNID_CATALOG: u32 = 4;
443pub(crate) const CATALOG_FILE: i8 = 2;
444
445/// Decode a 4-byte Mac OS type/creator code to a string.
446/// Non-printable bytes are replaced with '.'.
447/// A parsed HFS catalog record.
448#[derive(Debug, Clone)]
449#[allow(dead_code)]
450enum CatalogRecord {
451    Directory {
452        dir_id: u32,
453        name: String,
454        parent_id: u32,
455        /// Catalog dates `(create, modify, backup)` in Mac-epoch seconds.
456        dates: (u32, u32, u32),
457    },
458    File {
459        file_id: u32,
460        name: String,
461        parent_id: u32,
462        data_size: u32,
463        data_extents: [HfsExtDescriptor; 3],
464        rsrc_size: u32,
465        rsrc_extents: [HfsExtDescriptor; 3],
466        /// Raw 4-byte Mac OSType type/creator, straight off disk (no lossy
467        /// display decode) so high-bit codes survive round-trips.
468        type_code: [u8; 4],
469        creator_code: [u8; 4],
470        /// Finder flags (FInfo.fdFlags) — bit 0x8000 is `kIsAlias`.
471        finder_flags: u16,
472        /// Catalog dates `(create, modify, backup)` in Mac-epoch seconds.
473        dates: (u32, u32, u32),
474    },
475}
476
477/// Classic HFS filesystem implementation.
478pub struct HfsFilesystem<R: Read + Seek> {
479    reader: R,
480    partition_offset: u64,
481    /// Partition length when the caller knows it; the alternate MDB lives in
482    /// the partition's last kilobyte, which need not follow the last block.
483    partition_size: Option<u64>,
484    mdb: HfsMasterDirectoryBlock,
485    /// Cached catalog file data.
486    catalog_data: Vec<u8>,
487    /// Cached extents-overflow B-tree file data, lazily loaded on first
488    /// read of a file that needs more than its 3 inline extents.
489    extents_overflow_data: Option<Vec<u8>>,
490    /// Cached volume bitmap (lazy-loaded on first edit operation).
491    bitmap: Option<Vec<u8>>,
492    /// Boot block payload staged by `set_boot_blocks`; flushed on `sync_metadata`.
493    pending_boot_blocks: Option<Box<[u8; 1024]>>,
494    /// Volume (create, modify, backup) dates staged by `set_volume_dates`.
495    /// When Some, overrides the automatic `hfs_now()` stamp in `sync_metadata`.
496    pending_volume_dates: Option<(u32, u32, u32)>,
497    /// When set (via `begin_bulk` / `end_bulk`), per-operation rollback
498    /// snapshots are skipped: `snapshot()` returns `None`. The caller takes
499    /// responsibility for batch-level atomicity (discarding all changes if any
500    /// operation fails). Used by `tar_import` so a large import doesn't clone
501    /// the whole multi-megabyte catalog on every single `create_file`.
502    bulk_mode: bool,
503    /// Cache for `ensure_catalog_initialized`: once both B-trees are confirmed
504    /// live, this short-circuits the check. Without it every edit re-reads the
505    /// (multi-megabyte) extents-overflow fork from disk just to test whether
506    /// it's blank — death by a thousand reads on a large import.
507    btrees_initialized: bool,
508}
509
510impl<R: Read + Seek> HfsFilesystem<R> {
511    /// Where the alternate MDB lives: the partition's next-to-last sector when
512    /// the length is known, else the sector after the last allocation block.
513    fn alt_mdb_offset(&self) -> Option<u64> {
514        if let Some(size) = self.partition_size.filter(|&s| s >= 2048) {
515            return Some(self.partition_offset + size - 1024);
516        }
517        if self.mdb.block_size == 0 || self.mdb.total_blocks == 0 {
518            return None;
519        }
520        let sectors_per_block = self.mdb.block_size as u64 / 512;
521        let alt_sector =
522            self.mdb.first_alloc_block as u64 + self.mdb.total_blocks as u64 * sectors_per_block;
523        Some(self.partition_offset + alt_sector * 512)
524    }
525
526    pub fn open(reader: R, partition_offset: u64) -> Result<Self, FilesystemError> {
527        Self::open_sized(reader, partition_offset, None)
528    }
529
530    /// [`open_sized`](Self::open_sized) for an edit: a volume smaller than its
531    /// partition is warned about once, since Mac OS will refuse it (R-058).
532    pub fn open_for_edit(
533        reader: R,
534        partition_offset: u64,
535        partition_size: Option<u64>,
536    ) -> Result<Self, FilesystemError> {
537        let fs = Self::open_sized(reader, partition_offset, partition_size)?;
538        if let Some(short) = fs.partition_shortfall() {
539            log::warn!(
540                "the HFS volume is {short} bytes smaller than its partition; Mac OS and Disk \
541                 First Aid expect a classic HFS volume to fill its partition. Grow it with \
542                 `rb-cli resize IMG@N --size <partition size>` (R-058)"
543            );
544        }
545        Ok(fs)
546    }
547
548    /// Bytes by which the volume falls short of its partition, when the
549    /// partition length is known and the volume does not fill it.
550    pub fn partition_shortfall(&self) -> Option<u64> {
551        let size = self.partition_size.filter(|&s| s >= 2048)?;
552        if self.mdb.block_size == 0 {
553            return None;
554        }
555        let volume_len = self.mdb.first_alloc_block as u64 * 512
556            + self.mdb.total_blocks as u64 * self.mdb.block_size as u64
557            + 1024;
558        if volume_len < size {
559            Some(size - volume_len)
560        } else {
561            None
562        }
563    }
564
565    /// [`open`](Self::open) with the partition length, so the alternate MDB is
566    /// read and written at the partition end as Mac OS and Disk First Aid expect.
567    pub fn open_sized(
568        mut reader: R,
569        partition_offset: u64,
570        partition_size: Option<u64>,
571    ) -> Result<Self, FilesystemError> {
572        // Read MDB at offset + 1024 (sector 2) — full 512-byte sector
573        reader.seek(SeekFrom::Start(partition_offset + 1024))?;
574        let mut mdb_buf = [0u8; 512];
575        reader.read_exact(&mut mdb_buf)?;
576        let mdb = HfsMasterDirectoryBlock::parse(&mdb_buf)?;
577
578        if mdb.has_embedded_hfs_plus() {
579            return Err(FilesystemError::Unsupported(
580                "this HFS volume contains an embedded HFS+ volume; use HFS+ reader instead".into(),
581            ));
582        }
583
584        // Read the catalog file
585        let catalog_data = read_fork_data(
586            &mut reader,
587            partition_offset,
588            &mdb,
589            &mdb.catalog_file_extents,
590            mdb.catalog_file_size as u64,
591        )?;
592
593        let mut fs = HfsFilesystem {
594            reader,
595            partition_offset,
596            partition_size,
597            mdb,
598            catalog_data,
599            extents_overflow_data: None,
600            bitmap: None,
601            pending_boot_blocks: None,
602            pending_volume_dates: None,
603            bulk_mode: false,
604            btrees_initialized: false,
605        };
606        // A catalog grown past its three inline extents continues in the
607        // extents-overflow tree under CNID 4, the way Mac OS extends it.
608        if (fs.catalog_data.len() as u64) < fs.mdb.catalog_file_size as u64 {
609            let inline = fs.mdb.catalog_file_extents;
610            let size = fs.mdb.catalog_file_size as u64;
611            fs.catalog_data = fs.read_fork_with_overflow(CNID_CATALOG, 0x00, &inline, size)?;
612        }
613        Ok(fs)
614    }
615
616    /// List all catalog records with a given parent_id.
617    fn list_children(&self, parent_id: u32) -> Result<Vec<CatalogRecord>, FilesystemError> {
618        if self.catalog_data.len() < 512 {
619            return Ok(vec![]);
620        }
621
622        // Read B-tree header from node 0
623        // Node descriptor: 14 bytes, then BTHeaderRec starts
624        // BTHeaderRec: treeDepth(2) + rootNode(4) + leafRecords(4) + firstLeafNode(4) + ...
625        let node_size = BigEndian::read_u16(&self.catalog_data[32..34]) as u32;
626        if (node_size as usize) < crate::hfs_common::BTREE_MIN_NODE_SIZE
627            || self.catalog_data.len() < node_size as usize
628        {
629            return Ok(vec![]);
630        }
631        let header_node = &self.catalog_data[0..node_size as usize];
632        let first_leaf = BigEndian::read_u32(&header_node[24..28]);
633
634        let mut results = Vec::new();
635        let mut node_idx = first_leaf;
636
637        while node_idx != 0 {
638            let offset = node_idx as usize * node_size as usize;
639            if offset + node_size as usize > self.catalog_data.len() {
640                break;
641            }
642            let node = &self.catalog_data[offset..offset + node_size as usize];
643
644            // Node descriptor
645            let next_node = BigEndian::read_u32(&node[0..4]);
646            let _kind = node[8] as i8; // should be -1 for leaf
647            let num_records = BigEndian::read_u16(&node[10..12]);
648
649            for i in 0..num_records as usize {
650                // Record offset is stored at end of node, growing backward
651                let offset_pos = node_size as usize - 2 * (i + 1);
652                if offset_pos + 2 > node.len() {
653                    break;
654                }
655                let rec_offset = BigEndian::read_u16(&node[offset_pos..offset_pos + 2]) as usize;
656                if rec_offset + 6 > node.len() {
657                    continue;
658                }
659
660                // Parse catalog key
661                let key_len = node[rec_offset] as usize;
662                if key_len < 6 || rec_offset + 1 + key_len > node.len() {
663                    continue;
664                }
665                let key_data = &node[rec_offset + 1..rec_offset + 1 + key_len];
666                // key_data: reserved(1) + parent_id(4) + name_len(1) + name(N)
667                let rec_parent_id = BigEndian::read_u32(&key_data[1..5]);
668                let name_len = key_data[5] as usize;
669                let name = if name_len > 0 && 6 + name_len <= key_data.len() {
670                    mac_roman_to_utf8(&key_data[6..6 + name_len])
671                } else {
672                    String::new()
673                };
674
675                // Record data follows key (aligned to even boundary)
676                let mut rec_data_offset = rec_offset + 1 + key_len;
677                if !rec_data_offset.is_multiple_of(2) {
678                    rec_data_offset += 1;
679                }
680                if rec_data_offset + 2 > node.len() {
681                    continue;
682                }
683
684                let record_type = node[rec_data_offset] as i8;
685
686                if rec_parent_id != parent_id {
687                    continue;
688                }
689
690                match record_type {
691                    CATALOG_DIR => {
692                        if rec_data_offset + 70 > node.len() {
693                            continue;
694                        }
695                        let dir_id =
696                            BigEndian::read_u32(&node[rec_data_offset + 6..rec_data_offset + 10]);
697                        // Dir record dates: crDate@10, mdDate@14, bkDate@18.
698                        let dir = &node[rec_data_offset..];
699                        let dates = (
700                            BigEndian::read_u32(&dir[10..14]),
701                            BigEndian::read_u32(&dir[14..18]),
702                            BigEndian::read_u32(&dir[18..22]),
703                        );
704                        results.push(CatalogRecord::Directory {
705                            dir_id,
706                            name,
707                            parent_id: rec_parent_id,
708                            dates,
709                        });
710                    }
711                    CATALOG_FILE => {
712                        if rec_data_offset + 102 > node.len() {
713                            continue;
714                        }
715                        let rec = &node[rec_data_offset..];
716                        // Finder Info (FInfo) at offset 4: fdType(4) + fdCreator(4) + fdFlags(2)
717                        let type_code: [u8; 4] = rec[4..8].try_into().unwrap();
718                        let creator_code: [u8; 4] = rec[8..12].try_into().unwrap();
719                        let finder_flags = BigEndian::read_u16(&rec[12..14]);
720                        // File ID (filFlNum) at offset 20
721                        let file_id = BigEndian::read_u32(&rec[20..24]);
722                        // Data fork: logical size at offset 26, first 3 extents at 74
723                        let data_size = BigEndian::read_u32(&rec[26..30]);
724                        let mut data_extents = [HfsExtDescriptor {
725                            start_block: 0,
726                            block_count: 0,
727                        }; 3];
728                        for j in 0..3 {
729                            data_extents[j] = HfsExtDescriptor::parse(&rec[74 + j * 4..78 + j * 4]);
730                        }
731                        // Resource fork: logical size at offset 36, extents at 86
732                        let rsrc_size = BigEndian::read_u32(&rec[36..40]);
733                        let mut rsrc_extents = [HfsExtDescriptor {
734                            start_block: 0,
735                            block_count: 0,
736                        }; 3];
737                        for j in 0..3 {
738                            rsrc_extents[j] = HfsExtDescriptor::parse(&rec[86 + j * 4..90 + j * 4]);
739                        }
740                        // File record dates: crDate@44, mdDate@48, bkDate@52.
741                        let dates = (
742                            BigEndian::read_u32(&rec[44..48]),
743                            BigEndian::read_u32(&rec[48..52]),
744                            BigEndian::read_u32(&rec[52..56]),
745                        );
746                        results.push(CatalogRecord::File {
747                            file_id,
748                            name,
749                            parent_id: rec_parent_id,
750                            data_size,
751                            data_extents,
752                            rsrc_size,
753                            rsrc_extents,
754                            type_code,
755                            creator_code,
756                            finder_flags,
757                            dates,
758                        });
759                    }
760                    _ => {}
761                }
762            }
763
764            node_idx = next_node;
765        }
766
767        Ok(results)
768    }
769
770    /// Find a file record by its file_id (CNID) in the catalog B-tree.
771    /// Returns (data_size, data_extents, rsrc_size, rsrc_extents).
772    fn find_file_by_id(
773        &self,
774        file_id: u32,
775    ) -> Option<(u32, [HfsExtDescriptor; 3], u32, [HfsExtDescriptor; 3])> {
776        if self.catalog_data.len() < 512 {
777            return None;
778        }
779        let node_size = BigEndian::read_u16(&self.catalog_data[32..34]) as u32;
780        if (node_size as usize) < crate::hfs_common::BTREE_MIN_NODE_SIZE
781            || self.catalog_data.len() < node_size as usize
782        {
783            return None;
784        }
785        let header_node = &self.catalog_data[0..node_size as usize];
786        let first_leaf = BigEndian::read_u32(&header_node[24..28]);
787
788        let mut node_idx = first_leaf;
789        while node_idx != 0 {
790            let offset = node_idx as usize * node_size as usize;
791            if offset + node_size as usize > self.catalog_data.len() {
792                break;
793            }
794            let node = &self.catalog_data[offset..offset + node_size as usize];
795            let next_node = BigEndian::read_u32(&node[0..4]);
796            let num_records = BigEndian::read_u16(&node[10..12]);
797
798            for i in 0..num_records as usize {
799                let offset_pos = node_size as usize - 2 * (i + 1);
800                if offset_pos + 2 > node.len() {
801                    break;
802                }
803                let rec_offset = BigEndian::read_u16(&node[offset_pos..offset_pos + 2]) as usize;
804                if rec_offset + 6 > node.len() {
805                    continue;
806                }
807                let key_len = node[rec_offset] as usize;
808                if key_len < 6 || rec_offset + 1 + key_len > node.len() {
809                    continue;
810                }
811                let mut rec_data_offset = rec_offset + 1 + key_len;
812                if !rec_data_offset.is_multiple_of(2) {
813                    rec_data_offset += 1;
814                }
815                if rec_data_offset + 2 > node.len() {
816                    continue;
817                }
818                let record_type = node[rec_data_offset] as i8;
819                if record_type != CATALOG_FILE {
820                    continue;
821                }
822                if rec_data_offset + 102 > node.len() {
823                    continue;
824                }
825                let rec = &node[rec_data_offset..];
826                // File ID (filFlNum) at offset 20
827                let rec_file_id = BigEndian::read_u32(&rec[20..24]);
828                if rec_file_id != file_id {
829                    continue;
830                }
831                // Data fork: logical size at offset 26, extents at 74
832                let data_size = BigEndian::read_u32(&rec[26..30]);
833                let mut data_extents = [HfsExtDescriptor {
834                    start_block: 0,
835                    block_count: 0,
836                }; 3];
837                for j in 0..3 {
838                    data_extents[j] = HfsExtDescriptor::parse(&rec[74 + j * 4..78 + j * 4]);
839                }
840                // Resource fork: logical size at offset 36, extents at 86
841                let rsrc_size = BigEndian::read_u32(&rec[36..40]);
842                let mut rsrc_extents = [HfsExtDescriptor {
843                    start_block: 0,
844                    block_count: 0,
845                }; 3];
846                for j in 0..3 {
847                    rsrc_extents[j] = HfsExtDescriptor::parse(&rec[86 + j * 4..90 + j * 4]);
848                }
849                return Some((data_size, data_extents, rsrc_size, rsrc_extents));
850            }
851            node_idx = next_node;
852        }
853        None
854    }
855
856    /// Read the volume bitmap and return it.
857    fn read_volume_bitmap(&mut self) -> Result<Vec<u8>, FilesystemError> {
858        let bitmap_offset = self.partition_offset + self.mdb.volume_bitmap_block as u64 * 512;
859        let bitmap_size = (self.mdb.total_blocks as u32).div_ceil(8) as usize;
860        self.reader.seek(SeekFrom::Start(bitmap_offset))?;
861        let mut bitmap = vec![0u8; bitmap_size];
862        self.reader.read_exact(&mut bitmap)?;
863        Ok(bitmap)
864    }
865
866    /// Return the byte offset of allocation block `n` from partition start.
867    fn alloc_block_offset(&self, block: u32) -> u64 {
868        self.mdb.first_alloc_block as u64 * 512 + block as u64 * self.mdb.block_size as u64
869    }
870
871    /// Ensure bitmap is loaded.
872    fn ensure_bitmap(&mut self) -> Result<(), FilesystemError> {
873        if self.bitmap.is_none() {
874            let bm = self.read_volume_bitmap()?;
875            self.bitmap = Some(bm);
876        }
877        Ok(())
878    }
879
880    /// Run filesystem integrity check. Loads bitmap if not already cached.
881    pub fn fsck(&mut self) -> Result<disk_core::fsck::FsckResult, FilesystemError> {
882        self.ensure_bitmap()?;
883        // Load extents overflow B-tree if it exists (for files with >3 extents)
884        let extents_data = if self.mdb.extents_file_size > 0 {
885            read_fork_data(
886                &mut self.reader,
887                self.partition_offset,
888                &self.mdb,
889                &self.mdb.extents_file_extents,
890                self.mdb.extents_file_size as u64,
891            )
892            .ok()
893        } else {
894            None
895        };
896        // Read the alternate MDB — located at the sector immediately after
897        // the last allocation block on the volume.
898        let alt_mdb_sector = if let Some(alt_offset) = self.alt_mdb_offset() {
899            let mut alt_buf = [0u8; 512];
900            if self
901                .reader
902                .seek(SeekFrom::Start(alt_offset))
903                .and_then(|_| self.reader.read_exact(&mut alt_buf))
904                .is_ok()
905            {
906                Some(alt_buf)
907            } else {
908                None
909            }
910        } else {
911            None
912        };
913        Ok(crate::hfs_fsck::check_hfs_integrity(
914            &self.mdb,
915            &self.catalog_data,
916            self.bitmap.as_ref().unwrap(),
917            extents_data.as_deref(),
918            alt_mdb_sector.as_ref(),
919            self.partition_size,
920        ))
921    }
922
923    /// Build classic HFS catalog key: key_len(1) + reserved(1) + parent_id(4) + name_len(1) + name(Mac Roman).
924    pub(crate) fn build_catalog_key(parent_id: u32, name: &[u8]) -> Vec<u8> {
925        let key_len = 1 + 4 + 1 + name.len(); // reserved + parent_id + name_len + name
926        let mut key = Vec::with_capacity(1 + key_len);
927        key.push(key_len as u8); // key_len
928        key.push(0); // reserved
929        let mut buf4 = [0u8; 4];
930        BigEndian::write_u32(&mut buf4, parent_id);
931        key.extend_from_slice(&buf4);
932        key.push(name.len() as u8);
933        key.extend_from_slice(name);
934        // Pad to even length if needed (HFS B-tree records are even-aligned)
935        if key.len() % 2 != 0 {
936            key.push(0);
937        }
938        key
939    }
940
941    /// Compare function for HFS catalog keys (key portion only).
942    pub(crate) fn catalog_compare(a: &[u8], b: &[u8]) -> Ordering {
943        // Format: key_len(1) + reserved(1) + parent_id(4) + name_len(1) + name(Mac Roman)
944        if a.len() < 7 || b.len() < 7 {
945            return a.len().cmp(&b.len());
946        }
947        let parent_a = BigEndian::read_u32(&a[2..6]);
948        let parent_b = BigEndian::read_u32(&b[2..6]);
949        let name_len_a = a[6] as usize;
950        let name_len_b = b[6] as usize;
951        let name_a = &a[7..7 + name_len_a.min(a.len() - 7)];
952        let name_b = &b[7..7 + name_len_b.min(b.len() - 7)];
953        hfs_common::compare_hfs_keys(parent_a, name_a, parent_b, name_b)
954    }
955
956    /// Find a catalog record by (parent_id, name).
957    /// Returns Some((node_idx, rec_idx, absolute_offset_in_catalog_data)) if found.
958    ///
959    /// Descends the B-tree index to the single leaf that would hold the key and
960    /// scans only that leaf — O(log n), not a walk of the whole leaf chain. In
961    /// a consistent catalog a key lives in exactly one leaf (the one the search
962    /// path reaches), so this is exact. It matters at scale: `create_file` /
963    /// `create_directory` / `rename` call this for duplicate detection on every
964    /// entry, so a linear walk here made bulk imports (`untar`) O(n^2). The
965    /// index is kept consistent by the incremental inserter
966    /// ([`hfs_common::btree_insert_full`]); on a structurally broken catalog
967    /// (pre-fsck) prefer repairing first — editing assumes a healthy tree.
968    fn find_catalog_record_by_name(
969        &self,
970        parent_id: u32,
971        name: &[u8],
972    ) -> Option<(u32, usize, usize)> {
973        let search_key = Self::build_catalog_key(parent_id, name);
974        let header = BTreeHeader::read(&self.catalog_data);
975        let node_size = header.node_size as usize;
976        if node_size < crate::hfs_common::BTREE_MIN_NODE_SIZE || self.catalog_data.len() < node_size
977        {
978            return None;
979        }
980
981        let (leaf_idx, _) = hfs_common::btree_find_insert_leaf(
982            &self.catalog_data,
983            &header,
984            &search_key,
985            &Self::catalog_compare,
986        );
987
988        let off = leaf_idx as usize * node_size;
989        if off + node_size > self.catalog_data.len() {
990            return None;
991        }
992        let node = &self.catalog_data[off..off + node_size];
993        let num_records = BigEndian::read_u16(&node[10..12]) as usize;
994        for rec_idx in 0..num_records {
995            let (start, end) = hfs_common::btree_record_range(node, node_size, rec_idx);
996            if start >= end || end > node_size {
997                continue;
998            }
999            let rec = &node[start..end];
1000            if rec.len() < 7 {
1001                continue;
1002            }
1003            let key_len = rec[0] as usize;
1004            let key_end = 1 + key_len;
1005            if key_end > rec.len() {
1006                continue;
1007            }
1008            if Self::catalog_compare(&rec[..key_end], &search_key) == Ordering::Equal {
1009                return Some((leaf_idx, rec_idx, off + start));
1010            }
1011        }
1012        None
1013    }
1014
1015    /// Find a thread record by CNID (thread key: parent_id=cnid, name="").
1016    fn find_catalog_record_by_cnid(&self, cnid: u32) -> Option<(u32, usize, usize)> {
1017        self.find_catalog_record_by_name(cnid, &[])
1018    }
1019
1020    /// Update parent folder valence (child count) by delta.
1021    fn update_parent_valence(&mut self, parent_id: u32, delta: i32) -> Result<(), FilesystemError> {
1022        let node_size = BigEndian::read_u16(&self.catalog_data[32..34]) as usize;
1023        let first_leaf = BigEndian::read_u32(&self.catalog_data[24..28]);
1024
1025        // Walk leaves to locate the dir record's valence offset; mutate after
1026        // the borrow ends so we satisfy the &mut self mutation rule.
1027        let val_off = hfs_common::walk_leaf_records(
1028            &self.catalog_data,
1029            first_leaf,
1030            node_size,
1031            |_node_idx, _rec_idx, abs_off, rec| {
1032                if rec.len() < 7 {
1033                    return None;
1034                }
1035                let key_len = rec[0] as usize;
1036                let mut data_off = abs_off + 1 + key_len;
1037                if !data_off.is_multiple_of(2) {
1038                    data_off += 1;
1039                }
1040                let abs_end = abs_off + rec.len();
1041                if data_off + 10 > abs_end {
1042                    return None;
1043                }
1044                if self.catalog_data[data_off] as i8 != CATALOG_DIR {
1045                    return None;
1046                }
1047                // Dir record: type(1) + reserved(1) + flags(2) + valence(2) + dirID(4)
1048                let dir_id = BigEndian::read_u32(&self.catalog_data[data_off + 6..data_off + 10]);
1049                if dir_id == parent_id {
1050                    Some(data_off + 4)
1051                } else {
1052                    None
1053                }
1054            },
1055        );
1056        let val_off = val_off.ok_or_else(|| {
1057            FilesystemError::NotFound(format!(
1058                "parent directory CNID {} not found in catalog",
1059                parent_id
1060            ))
1061        })?;
1062        let old_val = BigEndian::read_u16(&self.catalog_data[val_off..val_off + 2]);
1063        let new_val = (old_val as i32 + delta).max(0) as u16;
1064        BigEndian::write_u16(&mut self.catalog_data[val_off..val_off + 2], new_val);
1065        Ok(())
1066    }
1067
1068    /// Insert a catalog record into the B-tree, handling splits and growth.
1069    ///
1070    /// Delegates to the shared incremental inserter
1071    /// ([`hfs_common::btree_insert_full`]) — the same find-leaf /
1072    /// split-with-insert / grow-root-or-insert-into-parent machinery that HFS+
1073    /// and the streamed defrag builder use. It maintains the index nodes
1074    /// incrementally (O(log n) per insert) rather than rebuilding the entire
1075    /// index after every leaf split.
1076    ///
1077    /// The previous per-split `rebuild_index_nodes` approach was O(n) per split
1078    /// (O(n^2) over a bulk import like `untar`) and, worse, corrupted the tree
1079    /// once it grew past the header node's 2048-bit allocation bitmap: the
1080    /// rebuild's free-all-index-nodes pass was capped at that bitmap, so index
1081    /// nodes living in map-node-covered segments leaked. That drained the free
1082    /// nodes until a rebuild ran out mid-flight and left the index with broken
1083    /// sibling links — surfacing as the spurious "disk full: no free B-tree
1084    /// nodes" / `IndexSiblingLinkBroken` failure at ~7.4k catalog records.
1085    fn insert_catalog_record(&mut self, key_record: &[u8]) -> Result<(), FilesystemError>
1086    where
1087        R: Write,
1088    {
1089        // Out of nodes: grow the catalog file by a clump and retry once, as
1090        // Mac OS does (F-016). The failed insert left the tree untouched.
1091        let mut grown = false;
1092        loop {
1093            match hfs_common::btree_insert_full(
1094                &mut self.catalog_data,
1095                key_record,
1096                &hfs_common::BTreeKeyFormat::CLASSIC_CATALOG,
1097                &Self::catalog_compare,
1098            ) {
1099                Ok(()) => return Ok(()),
1100                Err(FilesystemError::DiskFull(_)) if !grown => {
1101                    self.grow_btree(HfsTree::Catalog)?;
1102                    grown = true;
1103                }
1104                Err(e) => return Err(e),
1105            }
1106        }
1107    }
1108
1109    /// Grow the catalog or extents-overflow file by a clump of nodes: extend
1110    /// its last extent when the blocks after it are free, else take a fresh
1111    /// inline extent slot; publish the nodes (and a map node when the bitmap
1112    /// runs out) and the new file size in the MDB (F-016).
1113    fn grow_btree(&mut self, tree: HfsTree) -> Result<(), FilesystemError>
1114    where
1115        R: Write,
1116    {
1117        let block_size = self.mdb.block_size;
1118        if tree == HfsTree::Extents {
1119            self.ensure_extents_overflow()?;
1120        }
1121        let (node_size, total_nodes, existing_map) = {
1122            let buf = self.tree_buf(tree)?;
1123            let h = BTreeHeader::read(buf);
1124            let segs = hfs_common::btree_bitmap_segments(buf, h.node_size as usize);
1125            (
1126                h.node_size as usize,
1127                h.total_nodes,
1128                (segs.len() as u32).saturating_sub(1),
1129            )
1130        };
1131        if node_size < 256 || block_size == 0 {
1132            return Err(FilesystemError::InvalidData(format!(
1133                "{} B-tree has node size {node_size}",
1134                tree.label()
1135            )));
1136        }
1137        // drXTClpSiz / drCTClpSiz: the clump Mac OS itself extends the file by.
1138        let clump_off = if tree == HfsTree::Catalog { 78 } else { 74 };
1139        let clump = BigEndian::read_u32(&self.mdb.raw_sector[clump_off..clump_off + 4]) as usize;
1140        let grow_nodes = (clump.div_ceil(node_size).max(1) as u32).max(8);
1141        let header_cap = ((node_size - 256) * 8) as u32;
1142        let per_map = (hfs_common::map_node_bitmap_bytes(node_size) * 8) as u32;
1143        let need_map = total_nodes + grow_nodes > header_cap + existing_map * per_map;
1144        let add_slots = grow_nodes + u32::from(need_map);
1145        let grow_blocks = btree_grow_blocks(total_nodes, add_slots, node_size, block_size);
1146        let mut add_slots = add_slots;
1147        if grow_blocks > 0 {
1148            let mut inline = match tree {
1149                HfsTree::Catalog => self.mdb.catalog_file_extents,
1150                HfsTree::Extents => self.mdb.extents_file_extents,
1151            };
1152            // Every extent of the file, inline first, then the overflow records.
1153            let mut all: Vec<HfsExtDescriptor> = inline
1154                .iter()
1155                .copied()
1156                .filter(|e| e.block_count > 0)
1157                .collect();
1158            let inline_blocks: u32 = all.iter().map(|e| e.block_count as u32).sum();
1159            if tree == HfsTree::Catalog && all.len() == 3 {
1160                self.ensure_extents_overflow()?;
1161                if let Some(x) = self.extents_overflow_data.as_ref() {
1162                    all.extend(collect_fork_overflow_extents(
1163                        x,
1164                        CNID_CATALOG,
1165                        0x00,
1166                        inline_blocks,
1167                    ));
1168                }
1169            }
1170            let tail = all
1171                .last()
1172                .map(|e| e.start_block as u32 + e.block_count as u32);
1173            match tail {
1174                Some(t) if self.allocate_blocks_at(t, grow_blocks)? => {
1175                    all.last_mut().unwrap().block_count += grow_blocks as u16;
1176                }
1177                _ => {
1178                    // A fresh extent doubles the file, so the inline slots and
1179                    // the overflow records are not spent one clump at a time.
1180                    let want_nodes = (total_nodes + add_slots).max(grow_nodes + 8);
1181                    let more = btree_grow_blocks(
1182                        total_nodes,
1183                        want_nodes - total_nodes,
1184                        node_size,
1185                        block_size,
1186                    )
1187                    .max(grow_blocks);
1188                    add_slots = want_nodes.max(total_nodes + add_slots) - total_nodes;
1189                    if tree == HfsTree::Extents && all.len() >= 3 {
1190                        return Err(FilesystemError::DiskFull(
1191                            "extents-overflow file has no free extent slot to grow into".into(),
1192                        ));
1193                    }
1194                    // No run that long: settle for the clump and grow again later.
1195                    let (start, more) = match self.allocate_blocks(more) {
1196                        Ok(start) => (start, more),
1197                        Err(_) => {
1198                            add_slots = grow_nodes + u32::from(need_map);
1199                            (self.allocate_blocks(grow_blocks)?, grow_blocks)
1200                        }
1201                    };
1202                    all.push(HfsExtDescriptor {
1203                        start_block: start as u16,
1204                        block_count: more as u16,
1205                    });
1206                }
1207            }
1208            inline = [HfsExtDescriptor {
1209                start_block: 0,
1210                block_count: 0,
1211            }; 3];
1212            for (i, e) in all.iter().take(3).enumerate() {
1213                inline[i] = *e;
1214            }
1215            match tree {
1216                HfsTree::Catalog => {
1217                    self.mdb.catalog_file_extents = inline;
1218                    if all.len() > 3 {
1219                        // Re-key the overflow records from the full list.
1220                        let data = self.extents_overflow_data.as_mut().expect("loaded above");
1221                        remove_fork_overflow_records(data, CNID_CATALOG, 0x00)?;
1222                        self.insert_fork_overflow(CNID_CATALOG, 0x00, &all)?;
1223                        self.write_extents_overflow_back()?;
1224                    }
1225                }
1226                HfsTree::Extents => self.mdb.extents_file_extents = inline,
1227            }
1228        }
1229        let buf = self.tree_buf(tree)?;
1230        buf.resize(buf.len() + add_slots as usize * node_size, 0);
1231        if need_map {
1232            // The new map node takes the first new slot; its own bit is still
1233            // inside the bitmap the tree has, and the header or last map node links to it.
1234            let map_idx = total_nodes;
1235            let tail = btree_map_chain_tail(buf, node_size);
1236            hfs_common::init_map_node(buf, node_size, map_idx, tail, 0);
1237            let linker = tail as usize * node_size;
1238            BigEndian::write_u32(&mut buf[linker..linker + 4], map_idx);
1239            hfs_common::btree_bitmap_set(buf, node_size, map_idx);
1240        }
1241        let mut h = BTreeHeader::read(buf);
1242        h.total_nodes += add_slots;
1243        h.free_nodes += add_slots - u32::from(need_map);
1244        h.write(buf);
1245        let added = add_slots * node_size as u32;
1246        match tree {
1247            HfsTree::Catalog => self.mdb.catalog_file_size += added,
1248            HfsTree::Extents => self.mdb.extents_file_size += added,
1249        }
1250        Ok(())
1251    }
1252
1253    /// The in-memory bytes of a B-tree file; the extents tree must be loaded.
1254    fn tree_buf(&mut self, tree: HfsTree) -> Result<&mut Vec<u8>, FilesystemError> {
1255        match tree {
1256            HfsTree::Catalog => Ok(&mut self.catalog_data),
1257            HfsTree::Extents => self.extents_overflow_data.as_mut().ok_or_else(|| {
1258                FilesystemError::Unsupported("volume has no extents-overflow B-tree".into())
1259            }),
1260        }
1261    }
1262
1263    /// Take `count` blocks starting exactly at `start` when every one is free.
1264    fn allocate_blocks_at(&mut self, start: u32, count: u32) -> Result<bool, FilesystemError> {
1265        self.ensure_bitmap()?;
1266        let total = self.mdb.total_blocks as u32;
1267        let bitmap = self.bitmap.as_mut().unwrap();
1268        if start + count > total || (start..start + count).any(|b| bitmap_test_bit_be(bitmap, b)) {
1269            return Ok(false);
1270        }
1271        for b in start..start + count {
1272            bitmap_set_bit_be(bitmap, b);
1273        }
1274        self.mdb.free_blocks = self.mdb.free_blocks.saturating_sub(count as u16);
1275        Ok(true)
1276    }
1277
1278    /// Remove a catalog record from a leaf node.
1279    fn remove_catalog_record(
1280        &mut self,
1281        node_idx: u32,
1282        rec_idx: usize,
1283    ) -> Result<(), FilesystemError> {
1284        let node_size = BigEndian::read_u16(&self.catalog_data[32..34]) as usize;
1285        if node_size < crate::hfs_common::BTREE_MIN_NODE_SIZE {
1286            return Ok(());
1287        }
1288        let offset = node_idx as usize * node_size;
1289        if offset + node_size > self.catalog_data.len() {
1290            return Ok(());
1291        }
1292
1293        let num_before = {
1294            let node = &self.catalog_data[offset..offset + node_size];
1295            BigEndian::read_u16(&node[10..12]) as usize
1296        };
1297
1298        {
1299            let node = &mut self.catalog_data[offset..offset + node_size];
1300            btree_remove_record(node, node_size, rec_idx);
1301        }
1302
1303        // If node is now empty, free it and fix links
1304        let node = &self.catalog_data[offset..offset + node_size];
1305        let num_after = BigEndian::read_u16(&node[10..12]) as usize;
1306        if num_after == 0 && num_before > 0 {
1307            let prev = BigEndian::read_u32(&self.catalog_data[offset + 4..offset + 8]);
1308            let next = BigEndian::read_u32(&self.catalog_data[offset..offset + 4]);
1309
1310            if prev != 0 {
1311                let prev_off = prev as usize * node_size;
1312                if prev_off + 4 <= self.catalog_data.len() {
1313                    BigEndian::write_u32(&mut self.catalog_data[prev_off..prev_off + 4], next);
1314                }
1315            }
1316            if next != 0 {
1317                let next_off = next as usize * node_size;
1318                if next_off + 8 <= self.catalog_data.len() {
1319                    BigEndian::write_u32(&mut self.catalog_data[next_off + 4..next_off + 8], prev);
1320                }
1321            }
1322
1323            let mut h = BTreeHeader::read(&self.catalog_data);
1324            if h.first_leaf_node == node_idx {
1325                h.first_leaf_node = next;
1326            }
1327            if h.last_leaf_node == node_idx {
1328                h.last_leaf_node = prev;
1329            }
1330            btree_free_node(&mut self.catalog_data, node_size, node_idx);
1331            h.free_nodes += 1;
1332            // The last leaf went with it: an empty tree has no root.
1333            if h.root_node == node_idx {
1334                h.root_node = 0;
1335                h.depth = 0;
1336            }
1337            h.write(&mut self.catalog_data);
1338
1339            // Rebuild index nodes to clean up stale separator keys
1340            let header = BTreeHeader::read(&self.catalog_data);
1341            if header.depth > 1 {
1342                let mut dummy_report = disk_core::fsck::RepairReport {
1343                    fixes_applied: Vec::new(),
1344                    fixes_failed: Vec::new(),
1345                    unrepairable_count: 0,
1346                };
1347                crate::hfs_fsck::rebuild_index_nodes(
1348                    &mut self.catalog_data,
1349                    node_size,
1350                    &mut dummy_report,
1351                );
1352            }
1353        } else if rec_idx == 0 && num_after > 0 {
1354            // The leaf's first key changed: Disk First Aid wants the separators
1355            // above it to say so exactly (E_IKey).
1356            btree_refresh_index_keys(
1357                &mut self.catalog_data,
1358                node_size,
1359                node_idx,
1360                &BTreeKeyFormat::CLASSIC_CATALOG,
1361                &Self::catalog_compare,
1362            )?;
1363        }
1364
1365        // Update leaf_records count
1366        let mut h = BTreeHeader::read(&self.catalog_data);
1367        h.leaf_records = h.leaf_records.saturating_sub(1);
1368        h.write(&mut self.catalog_data);
1369        Ok(())
1370    }
1371
1372    /// Capture a snapshot of all mutable in-memory state for rollback.
1373    ///
1374    /// Returns `None` in bulk mode (see [`HfsFilesystem::bulk_mode`]): the
1375    /// clone of the whole catalog is the dominant per-operation cost during a
1376    /// large import, and the caller guarantees batch-level atomicity there.
1377    /// `restore_snapshot(None)` is then a no-op, so the existing
1378    /// `let snap = self.snapshot(); ... restore_snapshot(snap)` call sites need
1379    /// no changes.
1380    fn snapshot(&self) -> Option<(Vec<u8>, Option<Vec<u8>>, HfsMasterDirectoryBlock)> {
1381        if self.bulk_mode {
1382            return None;
1383        }
1384        Some((
1385            self.catalog_data.clone(),
1386            self.bitmap.clone(),
1387            self.mdb.clone(),
1388        ))
1389    }
1390
1391    /// Restore in-memory state from a previously captured snapshot. A `None`
1392    /// snapshot (bulk mode) restores nothing — the caller owns rollback.
1393    fn restore_snapshot(
1394        &mut self,
1395        snap: Option<(Vec<u8>, Option<Vec<u8>>, HfsMasterDirectoryBlock)>,
1396    ) {
1397        let Some((catalog_data, bitmap, mdb)) = snap else {
1398            return;
1399        };
1400        self.catalog_data = catalog_data;
1401        self.bitmap = bitmap;
1402        self.mdb = mdb;
1403    }
1404
1405    pub(crate) fn mdb(&self) -> &HfsMasterDirectoryBlock {
1406        &self.mdb
1407    }
1408
1409    /// Lightweight read-only summary of the volume. Public so GUI callers
1410    /// outside the library crate can populate display fields without going
1411    /// through the full `Filesystem` browse API.
1412    pub fn volume_summary(&self) -> HfsVolumeSummary {
1413        let used_blocks = self.mdb.total_blocks.saturating_sub(self.mdb.free_blocks) as u64;
1414        HfsVolumeSummary {
1415            volume_name: self.mdb.volume_name.clone(),
1416            block_size: self.mdb.block_size,
1417            total_blocks: self.mdb.total_blocks,
1418            free_blocks: self.mdb.free_blocks,
1419            used_bytes: used_blocks * self.mdb.block_size as u64,
1420            file_count: self.mdb.file_count,
1421            folder_count: self.mdb.folder_count,
1422            catalog_file_size: self.mdb.catalog_file_size,
1423            extents_file_size: self.mdb.extents_file_size,
1424        }
1425    }
1426
1427    pub(crate) fn catalog_data(&self) -> &[u8] {
1428        &self.catalog_data
1429    }
1430
1431    #[allow(dead_code)]
1432    pub(crate) fn partition_offset(&self) -> u64 {
1433        self.partition_offset
1434    }
1435
1436    /// Locate a file's data fork on disk by its catalog CNID. Returns the
1437    /// byte offset of the first extent **within the containing image file**
1438    /// (i.e. `partition_offset + first_alloc_block*512 + start_block*block_size`),
1439    /// the data-fork length, and a flag set when the fork is split across
1440    /// more than one extent — in which case callers that need every byte
1441    /// must walk all extents themselves; the offset returned points at the
1442    /// first one only.
1443    ///
1444    /// Resource forks are ignored. Returns `None` if the CNID is not a
1445    /// file in the catalog, or the file has zero data-fork extents
1446    /// allocated (e.g. an empty file).
1447    pub fn locate_file_by_cnid(&self, cnid: u32) -> Option<HfsFileLocation> {
1448        let (data_size, data_extents, _rsrc_size, _rsrc_extents) = self.find_file_by_id(cnid)?;
1449        let first = data_extents.first().copied()?;
1450        if first.block_count == 0 {
1451            return None;
1452        }
1453        let fragmented = data_extents.iter().skip(1).any(|e| e.block_count != 0);
1454        // alloc_block_offset returns the byte offset of `block` *within the
1455        // partition*; we add partition_offset so the caller (and any script
1456        // grepping the image file) gets an absolute file-byte offset.
1457        let offset_in_image =
1458            self.partition_offset + self.alloc_block_offset(first.start_block as u32);
1459        Some(HfsFileLocation {
1460            offset_in_image,
1461            length: data_size as u64,
1462            fragmented,
1463        })
1464    }
1465
1466    /// Read the 1024-byte boot block region (sectors 0..1 of the partition).
1467    pub(crate) fn read_boot_blocks(&mut self) -> Result<[u8; 1024], FilesystemError> {
1468        self.reader.seek(SeekFrom::Start(self.partition_offset))?;
1469        let mut buf = [0u8; 1024];
1470        self.reader.read_exact(&mut buf)?;
1471        Ok(buf)
1472    }
1473
1474    /// Read a fork's content via the given 3-extent descriptor, up to `size` bytes.
1475    /// This does not consult the extents-overflow B-tree.
1476    #[allow(dead_code)]
1477    pub(crate) fn read_fork(
1478        &mut self,
1479        extents: &[HfsExtDescriptor; 3],
1480        size: u64,
1481    ) -> Result<Vec<u8>, FilesystemError> {
1482        read_fork_data(
1483            &mut self.reader,
1484            self.partition_offset,
1485            &self.mdb,
1486            extents,
1487            size,
1488        )
1489    }
1490
1491    /// Lazily load the extents-overflow B-tree fork into memory. No-op if
1492    /// already loaded or if the volume has no extents-overflow file.
1493    fn ensure_extents_overflow(&mut self) -> Result<(), FilesystemError> {
1494        if self.extents_overflow_data.is_some() {
1495            return Ok(());
1496        }
1497        if self.mdb.extents_file_size == 0 {
1498            return Ok(());
1499        }
1500        let data = read_fork_data(
1501            &mut self.reader,
1502            self.partition_offset,
1503            &self.mdb,
1504            &self.mdb.extents_file_extents,
1505            self.mdb.extents_file_size as u64,
1506        )?;
1507        self.extents_overflow_data = Some(data);
1508        Ok(())
1509    }
1510
1511    /// Read a fork's content combining the 3 inline extents with any
1512    /// overflow records in the extents-overflow B-tree. Used for files
1513    /// fragmented past the inline limit.
1514    ///
1515    /// `fork_type`: `0x00` for the data fork, `0xFF` for the resource fork.
1516    /// Stream fork data (inline + extents-overflow) to a writer.
1517    fn write_fork_with_overflow_to(
1518        &mut self,
1519        file_id: u32,
1520        fork_type: u8,
1521        inline: &[HfsExtDescriptor; 3],
1522        size: u64,
1523        writer: &mut dyn std::io::Write,
1524    ) -> Result<u64, FilesystemError> {
1525        let mut written = write_fork_data_to(
1526            &mut self.reader,
1527            self.partition_offset,
1528            &self.mdb,
1529            inline,
1530            writer,
1531            size,
1532        )?;
1533        if written >= size {
1534            return Ok(written);
1535        }
1536        self.ensure_extents_overflow()?;
1537        let Some(ext_data) = self.extents_overflow_data.as_ref() else {
1538            return Err(FilesystemError::InvalidData(format!(
1539                "file {file_id} fork {fork_type:#x}: {size} bytes requested but only {written} \
1540                 bytes in inline extents and no extents-overflow B-tree present",
1541            )));
1542        };
1543        let inline_blocks: u32 = inline.iter().map(|e| e.block_count as u32).sum();
1544        let overflow = collect_fork_overflow_extents(ext_data, file_id, fork_type, inline_blocks);
1545        let block_size = self.mdb.block_size as u64;
1546        let first_alloc_offset = self.partition_offset + self.mdb.first_alloc_block as u64 * 512;
1547        let mut buf = vec![0u8; 64 * 1024];
1548        for ext in overflow {
1549            if written >= size {
1550                break;
1551            }
1552            if ext.block_count == 0 {
1553                continue;
1554            }
1555            let offset = first_alloc_offset + ext.start_block as u64 * block_size;
1556            let extent_len = ext.block_count as u64 * block_size;
1557            let to_emit = extent_len.min(size - written);
1558            self.reader.seek(SeekFrom::Start(offset))?;
1559            let mut left = to_emit;
1560            while left > 0 {
1561                let n = (buf.len() as u64).min(left) as usize;
1562                self.reader.read_exact(&mut buf[..n])?;
1563                writer.write_all(&buf[..n])?;
1564                left -= n as u64;
1565            }
1566            written += to_emit;
1567        }
1568        if written < size {
1569            return Err(FilesystemError::InvalidData(format!(
1570                "file {file_id} fork {fork_type:#x}: extents (inline + overflow) cover \
1571                 {written} of {size} bytes",
1572            )));
1573        }
1574        Ok(written)
1575    }
1576
1577    fn read_fork_with_overflow(
1578        &mut self,
1579        file_id: u32,
1580        fork_type: u8,
1581        inline: &[HfsExtDescriptor; 3],
1582        size: u64,
1583    ) -> Result<Vec<u8>, FilesystemError> {
1584        let mut data = read_fork_data(
1585            &mut self.reader,
1586            self.partition_offset,
1587            &self.mdb,
1588            inline,
1589            size,
1590        )?;
1591        if (data.len() as u64) >= size {
1592            return Ok(data);
1593        }
1594
1595        self.ensure_extents_overflow()?;
1596        let Some(ext_data) = self.extents_overflow_data.as_ref() else {
1597            return Err(FilesystemError::InvalidData(format!(
1598                "file {file_id} fork {fork_type:#x}: {size} bytes requested but only {} bytes \
1599                 in inline extents and no extents-overflow B-tree present",
1600                data.len()
1601            )));
1602        };
1603
1604        let inline_blocks: u32 = inline.iter().map(|e| e.block_count as u32).sum();
1605        let overflow = collect_fork_overflow_extents(ext_data, file_id, fork_type, inline_blocks);
1606
1607        let block_size = self.mdb.block_size as u64;
1608        let first_alloc_offset = self.partition_offset + self.mdb.first_alloc_block as u64 * 512;
1609
1610        for ext in overflow {
1611            if data.len() as u64 >= size {
1612                break;
1613            }
1614            if ext.block_count == 0 {
1615                continue;
1616            }
1617            let offset = first_alloc_offset + ext.start_block as u64 * block_size;
1618            let extent_len = ext.block_count as u64 * block_size;
1619            let to_read = extent_len.min(size - data.len() as u64) as usize;
1620            self.reader.seek(SeekFrom::Start(offset))?;
1621            let mut buf = vec![0u8; to_read];
1622            self.reader.read_exact(&mut buf)?;
1623            data.extend_from_slice(&buf);
1624        }
1625
1626        if (data.len() as u64) < size {
1627            return Err(FilesystemError::InvalidData(format!(
1628                "file {file_id} fork {fork_type:#x}: extents (inline + overflow) cover \
1629                 {} of {size} bytes",
1630                data.len()
1631            )));
1632        }
1633        Ok(data)
1634    }
1635
1636    /// Locate the catalog record-data offset for a file or directory CNID.
1637    ///
1638    /// Directories always have a thread record (HFS spec mandates it), so we
1639    /// look them up via the thread -> key path. File thread records are
1640    /// optional in classic HFS — Finder, CiderPress2, and Inside Macintosh:
1641    /// Files all describe them as optional, and our `create_file` no longer
1642    /// emits them. So if the thread lookup fails, fall back to a leaf scan
1643    /// for a file record whose `filFlNum` (CNID) matches.
1644    ///
1645    /// The returned offset points at the record type byte (offset 0 of the
1646    /// record data).
1647    fn locate_record_data(&self, cnid: u32) -> Result<usize, FilesystemError> {
1648        if let Some((_, _, t_offset)) = self.find_catalog_record_by_cnid(cnid) {
1649            let key_len = self.catalog_data[t_offset] as usize;
1650            let mut rec_data_start = t_offset + 1 + key_len;
1651            if !rec_data_start.is_multiple_of(2) {
1652                rec_data_start += 1;
1653            }
1654            let thread_parent =
1655                BigEndian::read_u32(&self.catalog_data[rec_data_start + 10..rec_data_start + 14]);
1656            let thread_name_len = self.catalog_data[rec_data_start + 14] as usize;
1657            let thread_name = self.catalog_data
1658                [rec_data_start + 15..rec_data_start + 15 + thread_name_len]
1659                .to_vec();
1660
1661            if let Some((_, _, f_offset)) =
1662                self.find_catalog_record_by_name(thread_parent, &thread_name)
1663            {
1664                let fkey_len = self.catalog_data[f_offset] as usize;
1665                let mut frec_start = f_offset + 1 + fkey_len;
1666                if !frec_start.is_multiple_of(2) {
1667                    frec_start += 1;
1668                }
1669                return Ok(frec_start);
1670            }
1671        }
1672
1673        if let Some((_, _, frec_start)) = self.find_file_record_offset_by_cnid(cnid) {
1674            return Ok(frec_start);
1675        }
1676
1677        Err(FilesystemError::NotFound(format!(
1678            "catalog record for CNID {cnid} not found"
1679        )))
1680    }
1681
1682    /// Scan catalog leaves for a file record whose `filFlNum` equals `cnid`.
1683    /// Returns `(node_idx, rec_idx, record_data_offset)` — the third element
1684    /// has the same meaning as the third element of
1685    /// `find_catalog_record_by_name`, except it points at the record DATA
1686    /// (offset 0 = type byte) rather than the key.
1687    ///
1688    /// Used as a fallback for file CNID lookup since classic HFS file thread
1689    /// records are optional (and our `create_file` no longer emits them).
1690    fn find_file_record_offset_by_cnid(&self, cnid: u32) -> Option<(u32, usize, usize)> {
1691        if self.catalog_data.len() < 512 {
1692            return None;
1693        }
1694        let node_size = BigEndian::read_u16(&self.catalog_data[32..34]) as usize;
1695        let first_leaf = BigEndian::read_u32(&self.catalog_data[24..28]);
1696
1697        hfs_common::walk_leaf_records(
1698            &self.catalog_data,
1699            first_leaf,
1700            node_size,
1701            |node_idx, rec_idx, abs_off, rec| {
1702                if rec.len() < 7 {
1703                    return None;
1704                }
1705                let key_len = rec[0] as usize;
1706                if key_len < 6 || 1 + key_len > rec.len() {
1707                    return None;
1708                }
1709                let mut data_rel = 1 + key_len;
1710                if !data_rel.is_multiple_of(2) {
1711                    data_rel += 1;
1712                }
1713                if data_rel + 24 > rec.len() {
1714                    return None;
1715                }
1716                if rec[data_rel] as i8 != CATALOG_FILE {
1717                    return None;
1718                }
1719                // filFlNum at offset 20 of the file record data
1720                let rec_cnid = BigEndian::read_u32(&rec[data_rel + 20..data_rel + 24]);
1721                if rec_cnid == cnid {
1722                    Some((node_idx, rec_idx, abs_off + data_rel))
1723                } else {
1724                    None
1725                }
1726            },
1727        )
1728    }
1729
1730    /// Write the full 16-byte `FInfo` + 16-byte `FXInfo` Finder metadata
1731    /// blocks on a file catalog record. `entry` must be a file.
1732    pub fn set_finder_info(
1733        &mut self,
1734        entry: &FileEntry,
1735        finfo: [u8; 16],
1736        fxinfo: [u8; 16],
1737    ) -> Result<(), FilesystemError> {
1738        if entry.is_directory() {
1739            return Err(FilesystemError::InvalidData(
1740                "set_finder_info: entry is a directory".into(),
1741            ));
1742        }
1743        let snap = self.snapshot();
1744        let result = (|| {
1745            let rec = self.locate_record_data(entry.location as u32)?;
1746            if self.catalog_data[rec] as i8 != CATALOG_FILE {
1747                return Err(FilesystemError::InvalidData("not a file record".into()));
1748            }
1749            self.catalog_data[rec + 4..rec + 20].copy_from_slice(&finfo);
1750            self.catalog_data[rec + 56..rec + 72].copy_from_slice(&fxinfo);
1751            Ok(())
1752        })();
1753        if result.is_err() {
1754            self.restore_snapshot(snap);
1755        }
1756        result
1757    }
1758
1759    /// Write the full 16-byte `DInfo` + 16-byte `DXInfo` Finder metadata
1760    /// blocks on a directory catalog record. `entry` must be a directory.
1761    pub fn set_directory_finder_info(
1762        &mut self,
1763        entry: &FileEntry,
1764        dinfo: [u8; 16],
1765        dxinfo: [u8; 16],
1766    ) -> Result<(), FilesystemError> {
1767        if !entry.is_directory() {
1768            return Err(FilesystemError::InvalidData(
1769                "set_directory_finder_info: entry is not a directory".into(),
1770            ));
1771        }
1772        let snap = self.snapshot();
1773        let result = (|| {
1774            let rec = self.locate_record_data(entry.location as u32)?;
1775            if self.catalog_data[rec] as i8 != CATALOG_DIR {
1776                return Err(FilesystemError::InvalidData(
1777                    "not a directory record".into(),
1778                ));
1779            }
1780            self.catalog_data[rec + 22..rec + 38].copy_from_slice(&dinfo);
1781            self.catalog_data[rec + 38..rec + 54].copy_from_slice(&dxinfo);
1782            Ok(())
1783        })();
1784        if result.is_err() {
1785            self.restore_snapshot(snap);
1786        }
1787        result
1788    }
1789
1790    /// Set create/modify/backup dates (HFS epoch seconds) on a file or
1791    /// directory catalog record. File record offsets: crDate@44, mdDate@48,
1792    /// bkDate@52. Dir record offsets: crDate@10, mdDate@14, bkDate@18.
1793    pub fn set_dates(
1794        &mut self,
1795        entry: &FileEntry,
1796        create: u32,
1797        modify: u32,
1798        backup: u32,
1799    ) -> Result<(), FilesystemError> {
1800        let snap = self.snapshot();
1801        let result = (|| {
1802            let rec = self.locate_record_data(entry.location as u32)?;
1803            let record_type = self.catalog_data[rec] as i8;
1804            let (cr_off, md_off, bk_off) = match record_type {
1805                CATALOG_FILE => (44, 48, 52),
1806                CATALOG_DIR => (10, 14, 18),
1807                other => {
1808                    return Err(FilesystemError::InvalidData(format!(
1809                        "unexpected catalog record type: {other}"
1810                    )))
1811                }
1812            };
1813            BigEndian::write_u32(
1814                &mut self.catalog_data[rec + cr_off..rec + cr_off + 4],
1815                create,
1816            );
1817            BigEndian::write_u32(
1818                &mut self.catalog_data[rec + md_off..rec + md_off + 4],
1819                modify,
1820            );
1821            BigEndian::write_u32(
1822                &mut self.catalog_data[rec + bk_off..rec + bk_off + 4],
1823                backup,
1824            );
1825            Ok(())
1826        })();
1827        if result.is_err() {
1828            self.restore_snapshot(snap);
1829        }
1830        result
1831    }
1832
1833    /// Set or clear the "locked" bit (0x01) in the catalog file record's
1834    /// flags byte at offset 2. Only meaningful for files.
1835    pub fn set_file_locked(
1836        &mut self,
1837        entry: &FileEntry,
1838        locked: bool,
1839    ) -> Result<(), FilesystemError> {
1840        if entry.is_directory() {
1841            return Err(FilesystemError::InvalidData(
1842                "set_file_locked: entry is a directory".into(),
1843            ));
1844        }
1845        let snap = self.snapshot();
1846        let result = (|| {
1847            let rec = self.locate_record_data(entry.location as u32)?;
1848            if self.catalog_data[rec] as i8 != CATALOG_FILE {
1849                return Err(FilesystemError::InvalidData("not a file record".into()));
1850            }
1851            let flags = self.catalog_data[rec + 2];
1852            self.catalog_data[rec + 2] = if locked { flags | 0x01 } else { flags & !0x01 };
1853            Ok(())
1854        })();
1855        if result.is_err() {
1856            self.restore_snapshot(snap);
1857        }
1858        result
1859    }
1860
1861    /// Set volume-level create / modify / backup dates. The modify date
1862    /// overrides the automatic `hfs_now()` stamp that `sync_metadata`
1863    /// normally applies.
1864    pub fn set_volume_dates(&mut self, create: u32, modify: u32, backup: u32) {
1865        self.pending_volume_dates = Some((create, modify, backup));
1866        self.mdb.create_date = create;
1867        self.mdb.modify_date = modify;
1868        BigEndian::write_u32(&mut self.mdb.raw_sector[2..6], create);
1869        BigEndian::write_u32(&mut self.mdb.raw_sector[6..10], modify);
1870        BigEndian::write_u32(&mut self.mdb.raw_sector[64..68], backup);
1871    }
1872
1873    /// Stage a 1024-byte boot-block region (sectors 0–1) for the volume.
1874    /// The bytes are written verbatim at `sync_metadata` time.
1875    pub fn set_boot_blocks(&mut self, blocks: &[u8; 1024]) {
1876        self.pending_boot_blocks = Some(Box::new(*blocks));
1877    }
1878
1879    /// Write the full 32-byte `drFndrInfo` (8 × u32) on the MDB. Useful when
1880    /// blessed-folder fingerprinting extends beyond `drFndrInfo[0]` (e.g.
1881    /// `drFndrInfo[3]` for the OS folder).
1882    pub fn set_volume_finder_info(&mut self, finder_info: &[u8; 32]) {
1883        for i in 0..8 {
1884            self.mdb.finder_info[i] = BigEndian::read_u32(&finder_info[i * 4..(i + 1) * 4]);
1885        }
1886    }
1887
1888    /// Rename the volume. Updates the MDB `drVN`, the root directory's
1889    /// catalog key (which carries the volume name — Mac OS looks up the
1890    /// root by `(parent=1, drVN)` at mount time), and the root thread
1891    /// record's `thdCName`. Caller must `sync_metadata` to flush. New
1892    /// name must be 1..=27 Mac Roman bytes.
1893    pub fn set_volume_name(&mut self, new_name: &str) -> Result<(), FilesystemError>
1894    where
1895        R: Write,
1896    {
1897        let new_name_raw = utf8_to_mac_roman(new_name)?;
1898        if new_name_raw.is_empty() {
1899            return Err(FilesystemError::InvalidData(
1900                "HFS volume name must not be empty (Mac OS rejects unnamed volumes with -127)"
1901                    .into(),
1902            ));
1903        }
1904        if new_name_raw.len() > 27 {
1905            return Err(FilesystemError::InvalidData(format!(
1906                "HFS volume name '{new_name}' exceeds 27 Mac Roman bytes"
1907            )));
1908        }
1909
1910        let snap = self.snapshot();
1911        let result = (|| -> Result<(), FilesystemError> {
1912            let old_name_raw = self.mdb.volume_name_raw.clone();
1913            if new_name_raw == old_name_raw {
1914                return Ok(());
1915            }
1916
1917            // Locate the root dir record by its current key (parent=1, drVN).
1918            let (node_idx, rec_idx, key_abs) = self
1919                .find_catalog_record_by_name(1, &old_name_raw)
1920                .ok_or_else(|| {
1921                FilesystemError::NotFound("root directory record not found in catalog".into())
1922            })?;
1923
1924            // Extract the existing data portion of the record so we can
1925            // re-emit it under a new key.
1926            let node_size = BigEndian::read_u16(&self.catalog_data[32..34]) as usize;
1927            let node_off = node_idx as usize * node_size;
1928            let node = &self.catalog_data[node_off..node_off + node_size];
1929            let (rec_start, rec_end) = hfs_common::btree_record_range(node, node_size, rec_idx);
1930            // key_abs == node_off + rec_start
1931            debug_assert_eq!(key_abs, node_off + rec_start);
1932            let key_len = node[rec_start] as usize;
1933            let mut data_rel = 1 + key_len;
1934            if !data_rel.is_multiple_of(2) {
1935                data_rel += 1;
1936            }
1937            let data_bytes = node[rec_start + data_rel..rec_end].to_vec();
1938
1939            // Build the new record: new key + (pad to even) + data + (pad).
1940            let new_key = Self::build_catalog_key(1, &new_name_raw);
1941            let mut new_record = new_key;
1942            if !new_record.len().is_multiple_of(2) {
1943                new_record.push(0);
1944            }
1945            new_record.extend_from_slice(&data_bytes);
1946            if !new_record.len().is_multiple_of(2) {
1947                new_record.push(0);
1948            }
1949
1950            self.remove_catalog_record(node_idx, rec_idx)?;
1951            self.insert_catalog_record(&new_record)?;
1952
1953            // Rewrite the root thread record's `thdCName` (Str31 at +14).
1954            // Key stays (parent=2, name="") so the record's footprint is
1955            // unchanged — safe to rewrite in place.
1956            if let Some((_n, _r, t_key_abs)) = self.find_catalog_record_by_cnid(2) {
1957                let t_key_len = self.catalog_data[t_key_abs] as usize;
1958                let mut t_data_off = t_key_abs + 1 + t_key_len;
1959                if !t_data_off.is_multiple_of(2) {
1960                    t_data_off += 1;
1961                }
1962                if t_data_off + 15 + 27 <= self.catalog_data.len() {
1963                    self.catalog_data[t_data_off + 14] = new_name_raw.len() as u8;
1964                    for i in 0..27 {
1965                        self.catalog_data[t_data_off + 15 + i] = 0;
1966                    }
1967                    self.catalog_data[t_data_off + 15..t_data_off + 15 + new_name_raw.len()]
1968                        .copy_from_slice(&new_name_raw);
1969                }
1970            }
1971
1972            // Update the MDB. `drVN` lives at offset 36 (length byte) +
1973            // 37..64 (up to 27 name bytes). `serialize_to_sector` reads
1974            // these from `raw_sector` verbatim, so the change propagates
1975            // on the next `sync_metadata`.
1976            self.mdb.volume_name = mac_roman_to_utf8(&new_name_raw);
1977            self.mdb.volume_name_raw = new_name_raw.clone();
1978            self.mdb.raw_sector[36] = new_name_raw.len() as u8;
1979            for i in 0..27 {
1980                self.mdb.raw_sector[37 + i] = 0;
1981            }
1982            self.mdb.raw_sector[37..37 + new_name_raw.len()].copy_from_slice(&new_name_raw);
1983
1984            Ok(())
1985        })();
1986        if result.is_err() {
1987            self.restore_snapshot(snap);
1988        }
1989        result
1990    }
1991}
1992
1993/// Write helpers — require Read + Write + Seek.
1994impl<R: Read + Write + Seek> HfsFilesystem<R> {
1995    /// Write data to an allocation block.
1996    fn write_block(&mut self, block: u32, data: &[u8]) -> Result<(), FilesystemError> {
1997        let offset = self.partition_offset + self.alloc_block_offset(block);
1998        self.reader.seek(SeekFrom::Start(offset))?;
1999        self.reader.write_all(data)?;
2000        Ok(())
2001    }
2002
2003    /// Write MDB to the primary location (offset+1024).
2004    /// The backup (alternate) MDB is intentionally not updated — its location
2005    /// depends on the exact partition size which may not be known. The primary
2006    /// MDB is authoritative; Mac OS only falls back to the alternate if the
2007    /// primary is corrupt, and the stale alternate remains structurally valid.
2008    fn write_mdb(&mut self) -> Result<(), FilesystemError> {
2009        // Stamp drAtrb with the "volume successfully unmounted" bit (0x0100).
2010        // Without it, classic Mac OS treats the volume as dirty and refuses
2011        // to put it on the desktop until it's scavenged — for a freshly
2012        // built clone that's never been mounted, that produces a disk LIDO
2013        // sees over SCSI but the Finder ignores. This is sync-time because
2014        // by the time we get here every catalog record + bitmap bit + alt
2015        // MDB has been flushed to the writer.
2016        BigEndian::write_u16(&mut self.mdb.raw_sector[10..12], 0x0100);
2017
2018        // Count the root directory's direct children. drNmFls (offset 12)
2019        // and drNmRtDirs (offset 82) hold the number of files / directories
2020        // immediately under CNID 2; the Finder uses these to decide
2021        // whether the volume's root listing is consistent. Real-world
2022        // disks always populate them, so missing values look suspect.
2023        let (root_files, root_dirs) = match self.list_children(2) {
2024            Ok(children) => {
2025                let mut f = 0u16;
2026                let mut d = 0u16;
2027                for c in &children {
2028                    match c {
2029                        CatalogRecord::File { .. } => f = f.saturating_add(1),
2030                        CatalogRecord::Directory { .. } => d = d.saturating_add(1),
2031                    }
2032                }
2033                (f, d)
2034            }
2035            Err(_) => (0, 0),
2036        };
2037        BigEndian::write_u16(&mut self.mdb.raw_sector[12..14], root_files);
2038        BigEndian::write_u16(&mut self.mdb.raw_sector[82..84], root_dirs);
2039
2040        let mdb_bytes = self.mdb.serialize_to_sector();
2041        self.reader
2042            .seek(SeekFrom::Start(self.partition_offset + 1024))?;
2043        self.reader.write_all(&mdb_bytes)?;
2044        // Mirror to the alternate MDB at the sector right after the last
2045        // allocation block. Mac OS rejects volumes whose primary and alt
2046        // MDBs disagree, so this must stay in sync with the primary write.
2047        if let Some(alt_offset) = self.alt_mdb_offset() {
2048            self.reader.seek(SeekFrom::Start(alt_offset))?;
2049            self.reader.write_all(&mdb_bytes)?;
2050        }
2051        Ok(())
2052    }
2053
2054    /// Write catalog B-tree data back to disk through the catalog_file extents.
2055    fn write_catalog(&mut self) -> Result<(), FilesystemError> {
2056        write_hfs_fork_data(
2057            &mut self.reader,
2058            self.partition_offset,
2059            &self.mdb,
2060            &self.mdb.catalog_file_extents,
2061            &self.catalog_data,
2062        )?;
2063        // The part past the inline extents lives where the CNID 4 overflow
2064        // records say (F-016).
2065        let inline_blocks: u32 = self
2066            .mdb
2067            .catalog_file_extents
2068            .iter()
2069            .map(|e| e.block_count as u32)
2070            .sum();
2071        let inline_bytes = inline_blocks as usize * self.mdb.block_size as usize;
2072        if self.catalog_data.len() <= inline_bytes {
2073            return Ok(());
2074        }
2075        self.ensure_extents_overflow()?;
2076        let Some(ext_data) = self.extents_overflow_data.as_ref() else {
2077            return Err(FilesystemError::InvalidData(
2078                "catalog file runs past its inline extents and the volume has no extents-overflow tree".into(),
2079            ));
2080        };
2081        let overflow = collect_fork_overflow_extents(ext_data, CNID_CATALOG, 0x00, inline_blocks);
2082        let first_alloc = self.partition_offset + self.mdb.first_alloc_block as u64 * 512;
2083        let mut written = inline_bytes;
2084        for ext in overflow {
2085            if written >= self.catalog_data.len() {
2086                break;
2087            }
2088            let off = first_alloc + ext.start_block as u64 * self.mdb.block_size as u64;
2089            let len = (ext.block_count as usize * self.mdb.block_size as usize)
2090                .min(self.catalog_data.len() - written);
2091            self.reader.seek(SeekFrom::Start(off))?;
2092            self.reader
2093                .write_all(&self.catalog_data[written..written + len])?;
2094            written += len;
2095        }
2096        if written < self.catalog_data.len() {
2097            return Err(FilesystemError::InvalidData(format!(
2098                "catalog file: extents cover {written} of {} bytes",
2099                self.catalog_data.len()
2100            )));
2101        }
2102        Ok(())
2103    }
2104
2105    /// Write the volume bitmap back to disk.
2106    fn write_volume_bitmap(&mut self) -> Result<(), FilesystemError> {
2107        if let Some(ref bm) = self.bitmap {
2108            let bitmap_offset = self.partition_offset + self.mdb.volume_bitmap_block as u64 * 512;
2109            self.reader.seek(SeekFrom::Start(bitmap_offset))?;
2110            self.reader.write_all(bm)?;
2111        }
2112        Ok(())
2113    }
2114
2115    /// Allocate `count` contiguous blocks from the volume bitmap and return the
2116    /// start block; `allocate_extents` is the fallback when no run is long enough.
2117    fn allocate_blocks(&mut self, count: u32) -> Result<u32, FilesystemError> {
2118        self.ensure_bitmap()?;
2119        let bitmap = self.bitmap.as_mut().unwrap();
2120        let start = bitmap_find_clear_run_be(bitmap, self.mdb.total_blocks as u32, count)
2121            .ok_or_else(|| {
2122                FilesystemError::DiskFull(format!("cannot find {} contiguous free blocks", count))
2123            })?;
2124        for i in 0..count {
2125            bitmap_set_bit_be(bitmap, start + i);
2126        }
2127        // A stale-low drFreeBks (dirty unmount) must not wrap to ~65535.
2128        self.mdb.free_blocks = self
2129            .mdb
2130            .free_blocks
2131            .saturating_sub(count.min(u16::MAX as u32) as u16);
2132        // Advance drAllocPtr (MDB offset 16) past the run so subsequent
2133        // allocation searches start where the last one ended. hformat
2134        // maintains this; without it the field stays at 0 and the
2135        // allocator hint never moves, which diffs against reference
2136        // hformat-built volumes.
2137        let next = (start + count) % self.mdb.total_blocks.max(1) as u32;
2138        BigEndian::write_u16(&mut self.mdb.raw_sector[16..18], next as u16);
2139        Ok(start)
2140    }
2141
2142    /// Allocate `count` blocks as a list of runs: one contiguous run when the
2143    /// volume has it, else first fit over the free runs in address order (F-011).
2144    fn allocate_extents(&mut self, count: u32) -> Result<Vec<HfsExtDescriptor>, FilesystemError> {
2145        if count == 0 {
2146            return Ok(Vec::new());
2147        }
2148        if count > u16::MAX as u32 {
2149            return Err(FilesystemError::InvalidData(
2150                "file too large for classic HFS (>65535 blocks)".into(),
2151            ));
2152        }
2153        if let Ok(start) = self.allocate_blocks(count) {
2154            return Ok(vec![HfsExtDescriptor {
2155                start_block: start as u16,
2156                block_count: count as u16,
2157            }]);
2158        }
2159        self.ensure_bitmap()?;
2160        let total_blocks = self.mdb.total_blocks as u32;
2161        let free = self.mdb.free_blocks as u32;
2162        if free < count {
2163            return Err(FilesystemError::DiskFull(format!(
2164                "{free} blocks free, {count} requested"
2165            )));
2166        }
2167        let bitmap = self.bitmap.as_mut().unwrap();
2168        let mut extents = Vec::new();
2169        let mut remaining = count;
2170        for (start, len) in bitmap_collect_clear_runs_be(bitmap, total_blocks) {
2171            if remaining == 0 {
2172                break;
2173            }
2174            let take = len.min(remaining);
2175            extents.push(HfsExtDescriptor {
2176                start_block: start as u16,
2177                block_count: take as u16,
2178            });
2179            remaining -= take;
2180        }
2181        if remaining > 0 {
2182            return Err(FilesystemError::DiskFull(format!(
2183                "needed {count} blocks, short by {remaining} after gathering every free run"
2184            )));
2185        }
2186        for ext in &extents {
2187            for i in 0..ext.block_count as u32 {
2188                bitmap_set_bit_be(bitmap, ext.start_block as u32 + i);
2189            }
2190        }
2191        self.mdb.free_blocks = self.mdb.free_blocks.saturating_sub(count as u16);
2192        let last = extents.last().unwrap();
2193        let next = (last.start_block as u32 + last.block_count as u32) % total_blocks.max(1);
2194        BigEndian::write_u16(&mut self.mdb.raw_sector[16..18], next as u16);
2195        Ok(extents)
2196    }
2197
2198    /// Free `count` blocks starting at `start`.
2199    fn free_blocks(&mut self, start: u32, count: u32) {
2200        self.ensure_bitmap().ok();
2201        if let Some(ref mut bitmap) = self.bitmap {
2202            for i in 0..count {
2203                bitmap_clear_bit_be(bitmap, start + i);
2204            }
2205        }
2206        self.mdb.free_blocks += count as u16;
2207    }
2208
2209    /// Free all blocks referenced by an extent array.
2210    fn free_extent_blocks(&mut self, extents: &[HfsExtDescriptor; 3]) {
2211        for ext in extents {
2212            if ext.block_count == 0 {
2213                break;
2214            }
2215            self.free_blocks(ext.start_block as u32, ext.block_count as u32);
2216        }
2217    }
2218
2219    /// Free a fork's extents-overflow blocks and drop its records; true if any.
2220    fn free_fork_overflow(&mut self, file_id: u32, fork_type: u8) -> Result<bool, FilesystemError> {
2221        self.ensure_extents_overflow()?;
2222        let Some(data) = self.extents_overflow_data.as_ref() else {
2223            return Ok(false);
2224        };
2225        let extents = collect_fork_overflow_extents(data, file_id, fork_type, 0);
2226        for ext in &extents {
2227            self.free_blocks(ext.start_block as u32, ext.block_count as u32);
2228        }
2229        let data = self.extents_overflow_data.as_mut().expect("loaded above");
2230        Ok(remove_fork_overflow_records(data, file_id, fork_type)? > 0)
2231    }
2232
2233    /// Write the in-memory extents-overflow B-tree back to disk.
2234    fn write_extents_overflow_back(&mut self) -> Result<(), FilesystemError> {
2235        if let Some(data) = self.extents_overflow_data.as_ref() {
2236            if self.mdb.extents_file_size > 0 {
2237                write_extents_overflow_data(
2238                    &mut self.reader,
2239                    self.partition_offset,
2240                    &self.mdb,
2241                    data,
2242                )?;
2243            }
2244        }
2245        Ok(())
2246    }
2247
2248    /// Write file data to freshly allocated blocks; returns the fork's extents
2249    /// in fork order (the first three go inline, the rest into the overflow tree).
2250    fn write_data_to_blocks(
2251        &mut self,
2252        data: &mut dyn std::io::Read,
2253        data_len: u64,
2254    ) -> Result<Vec<HfsExtDescriptor>, FilesystemError> {
2255        if data_len == 0 {
2256            return Ok(Vec::new());
2257        }
2258        let block_size = self.mdb.block_size as u64;
2259        let blocks_needed = data_len.div_ceil(block_size) as u32;
2260        let extents = self.allocate_extents(blocks_needed)?;
2261
2262        let mut buf = vec![0u8; block_size as usize];
2263        let mut remaining = data_len;
2264        for ext in &extents {
2265            for i in 0..ext.block_count as u32 {
2266                let to_read = remaining.min(block_size) as usize;
2267                buf.fill(0);
2268                data.read_exact(&mut buf[..to_read]).map_err(|e| {
2269                    FilesystemError::Io(std::io::Error::new(
2270                        e.kind(),
2271                        format!("reading file data: {e}"),
2272                    ))
2273                })?;
2274                self.write_block(ext.start_block as u32 + i, &buf)?;
2275                remaining -= to_read as u64;
2276            }
2277        }
2278
2279        Ok(extents)
2280    }
2281
2282    /// Total blocks of an extent list; `u16` because that is what filPyLen holds.
2283    fn extents_block_total(extents: &[HfsExtDescriptor]) -> u16 {
2284        extents.iter().map(|e| e.block_count).sum()
2285    }
2286
2287    /// Fill a file record's three inline extent slots at `base` (74 data, 86 rsrc).
2288    fn set_inline_extents(rec: &mut [u8], base: usize, extents: &[HfsExtDescriptor]) {
2289        rec[base..base + 12].fill(0);
2290        for (i, ext) in extents.iter().take(3).enumerate() {
2291            let off = base + i * 4;
2292            BigEndian::write_u16(&mut rec[off..off + 2], ext.start_block);
2293            BigEndian::write_u16(&mut rec[off + 2..off + 4], ext.block_count);
2294        }
2295    }
2296
2297    /// Record the fourth extent on into the extents-overflow tree, three per
2298    /// record keyed by fork block; true if a record was written (F-011).
2299    fn insert_fork_overflow(
2300        &mut self,
2301        file_id: u32,
2302        fork_type: u8,
2303        extents: &[HfsExtDescriptor],
2304    ) -> Result<bool, FilesystemError> {
2305        use crate::hfs_common::compare_classic_extents_keys;
2306        if extents.len() <= 3 {
2307            return Ok(false);
2308        }
2309        self.ensure_extents_overflow()?;
2310        if self.extents_overflow_data.is_none() {
2311            return Err(FilesystemError::Unsupported(
2312                "volume has no extents-overflow B-tree; a fragmented fork cannot be created".into(),
2313            ));
2314        }
2315        let mut fork_block: u32 = extents.iter().take(3).map(|e| e.block_count as u32).sum();
2316        for chunk in extents[3..].chunks(3) {
2317            // Key: keyLen(1) = 7, forkType(1), fileID(4), startBlock(2); 3 extents follow.
2318            let mut rec = vec![7u8, fork_type];
2319            rec.extend_from_slice(&file_id.to_be_bytes());
2320            rec.extend_from_slice(&(fork_block as u16).to_be_bytes());
2321            for i in 0..3 {
2322                let (start, count) = chunk
2323                    .get(i)
2324                    .map_or((0, 0), |e| (e.start_block, e.block_count));
2325                rec.extend_from_slice(&start.to_be_bytes());
2326                rec.extend_from_slice(&count.to_be_bytes());
2327            }
2328            let mut grown = false;
2329            loop {
2330                let data = self.extents_overflow_data.as_mut().expect("loaded above");
2331                match hfs_common::btree_insert_full(
2332                    data,
2333                    &rec,
2334                    &BTreeKeyFormat::CLASSIC_EXTENTS,
2335                    &compare_classic_extents_keys,
2336                ) {
2337                    Ok(()) => break,
2338                    Err(FilesystemError::DiskFull(_)) if !grown => {
2339                        self.grow_btree(HfsTree::Extents)?;
2340                        grown = true;
2341                    }
2342                    Err(e) => return Err(e),
2343                }
2344            }
2345            fork_block += chunk.iter().map(|e| e.block_count as u32).sum::<u32>();
2346        }
2347        Ok(true)
2348    }
2349
2350    /// Build a classic HFS file record (102 bytes).
2351    /// `mtime_secs = Some(secs)` stamps that Unix time as both create and
2352    /// modify dates (a cross-fs copy carrying the source mtime through);
2353    /// `None` stamps `now` (a genuinely new file). The after-create
2354    /// `set_dates` Commander path still overrides both.
2355    #[allow(clippy::too_many_arguments)]
2356    fn build_file_record(
2357        file_id: u32,
2358        data_size: u32,
2359        data_start: u16,
2360        data_blocks: u16,
2361        rsrc_size: u32,
2362        rsrc_start: u16,
2363        rsrc_blocks: u16,
2364        type_code: &[u8; 4],
2365        creator_code: &[u8; 4],
2366        block_size: u32,
2367        mtime_secs: Option<u64>,
2368        finder_flags: u16,
2369    ) -> [u8; 102] {
2370        let mut rec = [0u8; 102];
2371        // Classic HFS stores local wall-clock time, not UTC.
2372        let now = match mtime_secs {
2373            Some(s) => disk_core::times::unix_to_mac_local(s),
2374            None => disk_core::times::mac_local_now(),
2375        };
2376        rec[0] = CATALOG_FILE as u8; // cdrType
2377                                     // rec[1] = reserved
2378                                     // FInfo at offset 4: fdType(4) + fdCreator(4)
2379        rec[4..8].copy_from_slice(type_code);
2380        rec[8..12].copy_from_slice(creator_code);
2381        BigEndian::write_u16(&mut rec[12..14], finder_flags); // fdFlags
2382                                                              // filFlNum at offset 20
2383        BigEndian::write_u32(&mut rec[20..24], file_id);
2384        // filStBlk at offset 24 stays 0: it's the File Manager's in-memory
2385        // "first allocation block" cache, NOT persisted on disk — the real
2386        // fork location lives in filExtRec (offset 74). `fsck_hfs` flags a
2387        // non-zero on-disk filStBlk as "Reserved fields in the catalog record
2388        // have incorrect data" (E_CatalogFlagsNotZero). See `data_start` used
2389        // for the extent record below.
2390        // filLgLen at offset 26 (data fork logical size)
2391        BigEndian::write_u32(&mut rec[26..30], data_size);
2392        // filPyLen at offset 30 (data fork physical size)
2393        BigEndian::write_u32(&mut rec[30..34], data_blocks as u32 * block_size);
2394        // filRStBlk at offset 34 stays 0 for the same reason as filStBlk.
2395        // filRLgLen at offset 36 (rsrc fork logical size)
2396        BigEndian::write_u32(&mut rec[36..40], rsrc_size);
2397        // filRPyLen at offset 40 (rsrc fork physical size)
2398        BigEndian::write_u32(&mut rec[40..44], rsrc_blocks as u32 * block_size);
2399        // filCrDat at offset 44
2400        BigEndian::write_u32(&mut rec[44..48], now);
2401        // filMdDat at offset 48
2402        BigEndian::write_u32(&mut rec[48..52], now);
2403        // Data fork extents at offset 74 (3 × 4 bytes)
2404        if data_blocks > 0 {
2405            BigEndian::write_u16(&mut rec[74..76], data_start);
2406            BigEndian::write_u16(&mut rec[76..78], data_blocks);
2407        }
2408        // Resource fork extents at offset 86 (3 × 4 bytes)
2409        if rsrc_blocks > 0 {
2410            BigEndian::write_u16(&mut rec[86..88], rsrc_start);
2411            BigEndian::write_u16(&mut rec[88..90], rsrc_blocks);
2412        }
2413        rec
2414    }
2415
2416    /// Build a classic HFS directory record (70 bytes). `mtime_secs = Some(secs)`
2417    /// stamps that Unix time into the create and modify dates (a cross-fs
2418    /// copy carrying the source mtime through); `None` stamps `now`. The
2419    /// after-create Commander `set_dates` path still overrides both.
2420    fn build_dir_record_with_dates(dir_id: u32, mtime_secs: Option<u64>) -> [u8; 70] {
2421        let mut rec = [0u8; 70];
2422        // Classic HFS stores local wall-clock time, not UTC.
2423        let now = match mtime_secs {
2424            Some(s) => disk_core::times::unix_to_mac_local(s),
2425            None => disk_core::times::mac_local_now(),
2426        };
2427        rec[0] = CATALOG_DIR as u8; // cdrType
2428                                    // dirFlags at offset 2 (u16) = 0
2429                                    // dirVal at offset 4 (u16) = 0 (child count)
2430                                    // dirDirID at offset 6
2431        BigEndian::write_u32(&mut rec[6..10], dir_id);
2432        // dirCrDat at offset 10
2433        BigEndian::write_u32(&mut rec[10..14], now);
2434        // dirMdDat at offset 14
2435        BigEndian::write_u32(&mut rec[14..18], now);
2436        rec
2437    }
2438
2439    /// Build a thread record for classic HFS.
2440    /// Thread key: (cnid, ""). Thread data: type(1) + reserved(1) + reserved(8) + parentID(4) + name(Pascal string).
2441    pub(crate) fn build_thread_record(
2442        thread_type: i8,
2443        parent_id: u32,
2444        name: &[u8],
2445    ) -> (Vec<u8>, Vec<u8>) {
2446        // Key: (entry_cnid, "")  — built by caller with build_catalog_key(cnid, &[])
2447        // Record data: cdrType(1) + reserved(1) + reserved(8) + thdParID(4) + thdCName(Str31=32)
2448        // Total data = 46 bytes (per hfsutils: fixed Str31 field, not variable-length)
2449        let mut rec = Vec::with_capacity(46);
2450        rec.push(thread_type as u8); // cdrType
2451        rec.push(0); // reserved
2452        rec.extend_from_slice(&[0u8; 8]); // reserved (2 x LongInt)
2453        let mut buf4 = [0u8; 4];
2454        BigEndian::write_u32(&mut buf4, parent_id);
2455        rec.extend_from_slice(&buf4); // thdParID
2456                                      // Str31: 1 byte length + up to 31 bytes name + zero padding = 32 bytes total
2457        let name_len = name.len().min(31);
2458        let mut str31 = [0u8; 32];
2459        str31[0] = name_len as u8;
2460        str31[1..1 + name_len].copy_from_slice(&name[..name_len]);
2461        rec.extend_from_slice(&str31);
2462        // Key for the thread (not built here — caller uses build_catalog_key)
2463        (vec![], rec) // key is unused; caller builds key separately
2464    }
2465
2466    /// Sync metadata: write catalog + bitmap + MDB + flush.
2467    fn do_sync_metadata(&mut self) -> Result<(), FilesystemError> {
2468        if let Some((create, modify, backup)) = self.pending_volume_dates.take() {
2469            self.mdb.create_date = create;
2470            self.mdb.modify_date = modify;
2471            BigEndian::write_u32(&mut self.mdb.raw_sector[2..6], create);
2472            BigEndian::write_u32(&mut self.mdb.raw_sector[6..10], modify);
2473            BigEndian::write_u32(&mut self.mdb.raw_sector[64..68], backup);
2474        } else {
2475            self.mdb.modify_date = disk_core::times::mac_local_now();
2476        }
2477        if let Some(blocks) = self.pending_boot_blocks.take() {
2478            self.reader.seek(SeekFrom::Start(self.partition_offset))?;
2479            self.reader.write_all(&blocks[..])?;
2480        }
2481        self.write_catalog()?;
2482        self.write_volume_bitmap()?;
2483        self.write_mdb()?;
2484        self.reader.flush()?;
2485        Ok(())
2486    }
2487
2488    /// If the catalog and/or extents-overflow B-trees are blank (e.g. Disk
2489    /// Jockey "Empty HFS" output where the MDB reserves extents for both
2490    /// B-trees but leaves the bytes zeroed), build fresh B-trees and commit
2491    /// them to disk. Subsequent edits can then use the normal insert paths
2492    /// instead of panicking on an empty B-tree header.
2493    fn ensure_catalog_initialized(&mut self) -> Result<(), FilesystemError> {
2494        // Once confirmed live, never re-check: the extents read below hits disk
2495        // and would otherwise repeat on every single edit (e.g. per file in a
2496        // large `untar`).
2497        if self.btrees_initialized {
2498            return Ok(());
2499        }
2500        let catalog_blank = is_catalog_uninitialized(&self.catalog_data);
2501        // Read the extents B-tree bytes so we can detect a blank one.
2502        let extents_data = if self.mdb.extents_file_size > 0 {
2503            read_fork_data(
2504                &mut self.reader,
2505                self.partition_offset,
2506                &self.mdb,
2507                &self.mdb.extents_file_extents,
2508                self.mdb.extents_file_size as u64,
2509            )
2510            .ok()
2511        } else {
2512            None
2513        };
2514        let extents_blank = extents_data
2515            .as_deref()
2516            .map(is_catalog_uninitialized)
2517            .unwrap_or(false);
2518        if !catalog_blank && !extents_blank {
2519            self.btrees_initialized = true;
2520            return Ok(());
2521        }
2522        self.initialize_empty_btrees(catalog_blank, extents_blank)?;
2523        self.btrees_initialized = true;
2524        Ok(())
2525    }
2526
2527    fn initialize_empty_btrees(
2528        &mut self,
2529        init_catalog: bool,
2530        init_extents: bool,
2531    ) -> Result<(), FilesystemError> {
2532        if init_catalog {
2533            let catalog_size = self.catalog_data.len();
2534            if catalog_size < 1024 {
2535                return Err(FilesystemError::InvalidData(format!(
2536                    "cannot initialize empty HFS catalog: region is only {catalog_size} bytes"
2537                )));
2538            }
2539            // Build the empty catalog with the current volume name so the
2540            // root dir record's key carries it (Mac OS looks up the root by
2541            // (parent=1, drVN) and rejects volumes with an empty key).
2542            let vol_name = self.mdb.volume_name_raw.clone();
2543            let node_size = pick_btree_node_size(catalog_size as u64) as usize;
2544            let buf = build_empty_hfs_catalog_with_node_size(
2545                catalog_size,
2546                node_size,
2547                &vol_name,
2548                hfs_common::hfs_now(),
2549            )?;
2550            self.catalog_data = buf;
2551            self.write_catalog()?;
2552        }
2553
2554        if init_extents {
2555            let extents_size = self.mdb.extents_file_size as usize;
2556            if extents_size >= 512 {
2557                let buf = build_empty_hfs_extents_btree(extents_size)?;
2558                write_hfs_fork_data(
2559                    &mut self.reader,
2560                    self.partition_offset,
2561                    &self.mdb,
2562                    &self.mdb.extents_file_extents,
2563                    &buf,
2564                )?;
2565            }
2566        }
2567
2568        // Rebuild the volume bitmap from scratch: only the blocks occupied by
2569        // the two B-trees should be marked allocated. This avoids trusting any
2570        // pre-existing garbage in the on-disk bitmap.
2571        let bitmap_size = (self.mdb.total_blocks as u32).div_ceil(8) as usize;
2572        let mut bitmap = vec![0u8; bitmap_size];
2573        let mut used_blocks = 0u32;
2574        for ext in self.mdb.catalog_file_extents.iter() {
2575            if ext.block_count == 0 {
2576                break;
2577            }
2578            for i in 0..ext.block_count {
2579                let bit = ext.start_block as u32 + i as u32;
2580                if bit < self.mdb.total_blocks as u32 {
2581                    hfs_common::bitmap_set_bit_be(&mut bitmap, bit);
2582                    used_blocks += 1;
2583                }
2584            }
2585        }
2586        for ext in self.mdb.extents_file_extents.iter() {
2587            if ext.block_count == 0 {
2588                break;
2589            }
2590            for i in 0..ext.block_count {
2591                let bit = ext.start_block as u32 + i as u32;
2592                if bit < self.mdb.total_blocks as u32 {
2593                    hfs_common::bitmap_set_bit_be(&mut bitmap, bit);
2594                    used_blocks += 1;
2595                }
2596            }
2597        }
2598        self.bitmap = Some(bitmap);
2599        self.mdb.free_blocks = (self.mdb.total_blocks as u32).saturating_sub(used_blocks) as u16;
2600
2601        // Reset volume counters to reflect a truly-empty volume.
2602        self.mdb.file_count = 0;
2603        self.mdb.folder_count = 0;
2604        if self.mdb.next_catalog_id < 16 {
2605            self.mdb.next_catalog_id = 16;
2606        }
2607        self.mdb.modify_date = disk_core::times::mac_local_now();
2608
2609        self.write_volume_bitmap()?;
2610        self.write_mdb()?;
2611        self.reader.flush()?;
2612        Ok(())
2613    }
2614}
2615
2616/// Classic Mac OS expects HFS B-tree node size = 512 bytes. While the on-disk
2617/// format technically allows larger node sizes, Apple's reference tools and
2618/// CiderPress2 both hard-code 512, and an emulated Quadra produced "error
2619/// type -127" when fed a catalog with 1024-byte nodes. Always use 512 — this
2620/// is a hard mountability constraint, not a sizing convenience, so do *not* be
2621/// tempted to scale it up for large catalogs.
2622///
2623/// The header node's bitmap covers only `(512 - 256) * 8 = 2048` nodes, but
2624/// that is no longer a ceiling: B-tree files larger than 2048 nodes chain MAP
2625/// nodes (NodeType=2) off the header to extend the allocation bitmap (see
2626/// [`hfs_map_nodes_required`] / [`hfs_common::init_map_node`] and the
2627/// segment-aware bitmap helpers). Catalogs of tens of thousands of nodes are
2628/// fully supported at node_size=512; the index is maintained incrementally so
2629/// there is no per-split rebuild to choke on the larger node count.
2630fn pick_btree_node_size(_target_bytes: u64) -> u16 {
2631    512
2632}
2633
2634/// Largest B-tree file size the blank-volume builder will produce. With
2635/// node_size=512, the header bitmap covers 2048 nodes (= 1 MiB) on its own;
2636/// each appended map node extends this by 3952 nodes. We cap at 16 MiB —
2637/// 32768 nodes — which is plenty for any real-world HFS catalog while
2638/// staying well under HFS's 32-bit node-index space.
2639pub const HFS_MAX_BTREE_FILE_SIZE: u32 = 16 * 1024 * 1024;
2640
2641/// Number of map nodes (BTNodeKind=2) needed to extend the node-allocation
2642/// bitmap so it covers `total_nodes` indices, given a node size.
2643fn hfs_map_nodes_required(total_nodes: u32, node_size: usize) -> u32 {
2644    let header_cap = ((node_size - 256) * 8) as u32; // header-node bitmap bits
2645    if total_nodes <= header_cap {
2646        return 0;
2647    }
2648    let per_map = (hfs_common::map_node_bitmap_bytes(node_size) * 8) as u32;
2649    if per_map == 0 {
2650        return 0;
2651    }
2652    let extra = total_nodes - header_cap;
2653    extra.div_ceil(per_map)
2654}
2655
2656fn build_empty_hfs_catalog_with_node_size(
2657    catalog_size: usize,
2658    node_size: usize,
2659    volume_name_raw: &[u8],
2660    now: u32,
2661) -> Result<Vec<u8>, FilesystemError> {
2662    if volume_name_raw.len() > 27 {
2663        return Err(FilesystemError::InvalidData(format!(
2664            "HFS volume name length {} exceeds 27 Mac Roman bytes",
2665            volume_name_raw.len()
2666        )));
2667    }
2668    let total_nodes = (catalog_size / node_size) as u32;
2669    if total_nodes < 2 {
2670        return Err(FilesystemError::InvalidData(format!(
2671            "cannot initialize empty HFS catalog: only {total_nodes} node(s) fit"
2672        )));
2673    }
2674    // Layout: 0 = header, 1 = root leaf, 2..2+map_nodes = map nodes (BTNodeKind=2)
2675    // extending the node bitmap beyond the header-node bitmap's capacity.
2676    let map_nodes = hfs_map_nodes_required(total_nodes, node_size);
2677    let used_nodes = 2 + map_nodes;
2678    if total_nodes < used_nodes {
2679        return Err(FilesystemError::InvalidData(format!(
2680            "cannot initialize empty HFS catalog: {total_nodes} nodes < {used_nodes} required \
2681             (header + leaf + {map_nodes} map nodes)"
2682        )));
2683    }
2684    let mut buf = vec![0u8; catalog_size];
2685
2686    // ---- Node 0: B-tree header node (kind=1, 3 records) ----
2687    // fLink at byte 0..4 points to the first map node (when map_nodes > 0).
2688    if map_nodes > 0 {
2689        BigEndian::write_u32(&mut buf[0..4], 2);
2690    }
2691    buf[8] = hfs_common::BTREE_HEADER_NODE as u8;
2692    buf[9] = 0;
2693    BigEndian::write_u16(&mut buf[10..12], 3);
2694
2695    // BTHeaderRec at offset 14
2696    BigEndian::write_u16(&mut buf[14..16], 1); // depth
2697    BigEndian::write_u32(&mut buf[16..20], 1); // rootNode = 1
2698    BigEndian::write_u32(&mut buf[20..24], 2); // leafRecords
2699    BigEndian::write_u32(&mut buf[24..28], 1); // firstLeafNode
2700    BigEndian::write_u32(&mut buf[28..32], 1); // lastLeafNode
2701    BigEndian::write_u16(&mut buf[32..34], node_size as u16); // nodeSize
2702    BigEndian::write_u16(&mut buf[34..36], 37); // maxKeyLen
2703    BigEndian::write_u32(&mut buf[36..40], total_nodes);
2704    BigEndian::write_u32(&mut buf[40..44], total_nodes - used_nodes); // freeNodes
2705
2706    // Record 1: user data at offset 120 (128 bytes zero)
2707    // Record 2: node-allocation bitmap at offset 248. Mark nodes 0..used_nodes
2708    // as allocated. The header bitmap covers the first 2048 bits (at
2709    // node_size=512); the map-node initialization below claims any indices
2710    // beyond that. Since used_nodes ≤ 2 + map_nodes ≪ 2048 in practice, all
2711    // initial bits land in the header bitmap.
2712    let bitmap_rec_offset = 248usize;
2713    let header_bitmap_len = node_size - 256; // bytes
2714    for bit in 0..used_nodes {
2715        // Mark in header bitmap (MSB-first within each byte).
2716        let local = bit;
2717        let byte_idx = (local / 8) as usize;
2718        let bit_in_byte = 7 - (local % 8) as u8;
2719        if byte_idx < header_bitmap_len {
2720            buf[bitmap_rec_offset + byte_idx] |= 1u8 << bit_in_byte;
2721        }
2722    }
2723
2724    // Offset table for header node (stored bottom-up)
2725    let free_offset = (node_size - 8) as u16;
2726    BigEndian::write_u16(&mut buf[node_size - 2..node_size], 14);
2727    BigEndian::write_u16(&mut buf[node_size - 4..node_size - 2], 120);
2728    BigEndian::write_u16(
2729        &mut buf[node_size - 6..node_size - 4],
2730        bitmap_rec_offset as u16,
2731    );
2732    BigEndian::write_u16(&mut buf[node_size - 8..node_size - 6], free_offset);
2733
2734    // ---- Node 1: Leaf node with root dir record + thread ----
2735    let leaf = node_size;
2736    buf[leaf + 8] = hfs_common::BTREE_LEAF_NODE as u8; // -1
2737    buf[leaf + 9] = 1; // height
2738    BigEndian::write_u16(&mut buf[leaf + 10..leaf + 12], 2); // 2 records
2739
2740    // Record 0: Root directory (key: parent CNID 1, name = volume name).
2741    // Mac OS looks up the root dir via (parent=1, drVN); a record with
2742    // name="" makes it return -127 ("File system internal error").
2743    let name_len = volume_name_raw.len();
2744    let r0_key = leaf + 14;
2745    let key_data_len: u8 = (1 + 4 + 1 + name_len) as u8; // resv + parID + nameLen + name
2746    buf[r0_key] = key_data_len;
2747    buf[r0_key + 1] = 0;
2748    BigEndian::write_u32(&mut buf[r0_key + 2..r0_key + 6], 1);
2749    buf[r0_key + 6] = name_len as u8;
2750    buf[r0_key + 7..r0_key + 7 + name_len].copy_from_slice(volume_name_raw);
2751    // Records start on even offsets within a node; pad an extra byte if the
2752    // key total (1 + key_data_len) is odd.
2753    let r0_key_total = 1 + key_data_len as usize;
2754    let r0_pad = r0_key_total & 1;
2755    let r0_data = r0_key + r0_key_total + r0_pad;
2756    buf[r0_data] = CATALOG_DIR as u8;
2757    BigEndian::write_u32(&mut buf[r0_data + 6..r0_data + 10], 2); // dirDirID = root CNID 2
2758    BigEndian::write_u32(&mut buf[r0_data + 10..r0_data + 14], now); // crDate
2759    BigEndian::write_u32(&mut buf[r0_data + 14..r0_data + 18], now); // mdDate
2760                                                                     // Remainder of the 70-byte CdrDirRec stays zero.
2761
2762    // Record 1: Root directory thread (key: parent CNID 2, empty name).
2763    // Per HFS spec the thread RECORD's *key* uses an empty name; the volume
2764    // name lives in the thread DATA's `thdCName` field at offset 14 of the
2765    // 46-byte thread record.
2766    let r1_key = r0_data + 70;
2767    buf[r1_key] = 6;
2768    buf[r1_key + 1] = 0;
2769    BigEndian::write_u32(&mut buf[r1_key + 2..r1_key + 6], 2);
2770    buf[r1_key + 6] = 0;
2771    buf[r1_key + 7] = 0;
2772    let r1_data = r1_key + 8;
2773    buf[r1_data] = CATALOG_DIR_THREAD as u8;
2774    BigEndian::write_u32(&mut buf[r1_data + 10..r1_data + 14], 1); // thdParID = 1
2775    buf[r1_data + 14] = name_len as u8;
2776    buf[r1_data + 15..r1_data + 15 + name_len].copy_from_slice(volume_name_raw);
2777    // Remaining bytes of the Str31 name stay zero. Thread record = 46 bytes.
2778
2779    let free_rel = (r1_data + 46 - leaf) as u16;
2780
2781    // Leaf offset table (2 records + free space)
2782    BigEndian::write_u16(&mut buf[leaf + node_size - 2..leaf + node_size], 14);
2783    BigEndian::write_u16(
2784        &mut buf[leaf + node_size - 4..leaf + node_size - 2],
2785        (r1_key - leaf) as u16,
2786    );
2787    BigEndian::write_u16(
2788        &mut buf[leaf + node_size - 6..leaf + node_size - 4],
2789        free_rel,
2790    );
2791
2792    // ---- Map nodes (BTNodeKind=2) at indices 2..2+map_nodes ----
2793    for i in 0..map_nodes {
2794        let node_idx = 2 + i;
2795        let next_link = if i + 1 < map_nodes { node_idx + 1 } else { 0 };
2796        let prev_link = if i == 0 { 0 } else { node_idx - 1 };
2797        hfs_common::init_map_node(&mut buf, node_size, node_idx, prev_link, next_link);
2798    }
2799
2800    Ok(buf)
2801}
2802
2803/// Build a fresh classic HFS extents-overflow B-tree containing only the
2804/// header node (no leaf records). This is the minimal valid shape for a
2805/// volume with no files that spill beyond three extents per fork.
2806fn build_empty_hfs_extents_btree(size: usize) -> Result<Vec<u8>, FilesystemError> {
2807    build_empty_hfs_extents_btree_with_node_size(size, 512)
2808}
2809
2810fn build_empty_hfs_extents_btree_with_node_size(
2811    size: usize,
2812    node_size: usize,
2813) -> Result<Vec<u8>, FilesystemError> {
2814    let total_nodes = (size / node_size) as u32;
2815    if total_nodes < 1 {
2816        return Err(FilesystemError::InvalidData(format!(
2817            "cannot initialize empty HFS extents B-tree: region is only {size} bytes"
2818        )));
2819    }
2820    let map_nodes = hfs_map_nodes_required(total_nodes, node_size);
2821    let used_nodes = 1 + map_nodes;
2822    if total_nodes < used_nodes {
2823        return Err(FilesystemError::InvalidData(format!(
2824            "cannot initialize empty HFS extents B-tree: {total_nodes} nodes < {used_nodes} \
2825             required (header + {map_nodes} map nodes)"
2826        )));
2827    }
2828    let mut buf = vec![0u8; size];
2829
2830    // Header descriptor. fLink -> first map node when one exists.
2831    if map_nodes > 0 {
2832        BigEndian::write_u32(&mut buf[0..4], 1);
2833    }
2834    buf[8] = hfs_common::BTREE_HEADER_NODE as u8;
2835    buf[9] = 0;
2836    BigEndian::write_u16(&mut buf[10..12], 3);
2837
2838    // BTHeaderRec: depth=0, root=0, no leaves.
2839    BigEndian::write_u16(&mut buf[14..16], 0); // depth
2840    BigEndian::write_u32(&mut buf[16..20], 0); // rootNode
2841    BigEndian::write_u32(&mut buf[20..24], 0); // leafRecords
2842    BigEndian::write_u32(&mut buf[24..28], 0); // firstLeafNode
2843    BigEndian::write_u32(&mut buf[28..32], 0); // lastLeafNode
2844    BigEndian::write_u16(&mut buf[32..34], node_size as u16); // nodeSize
2845    BigEndian::write_u16(&mut buf[34..36], 7); // maxKeyLen for extents key (keyLen+forkType+fileID+startBlock)
2846    BigEndian::write_u32(&mut buf[36..40], total_nodes);
2847    BigEndian::write_u32(&mut buf[40..44], total_nodes - used_nodes);
2848
2849    // Record 1: user data (128 bytes, zero) at offset 120
2850    // Record 2: node bitmap at offset 248; mark node 0 (header) and nodes
2851    // 1..1+map_nodes (map nodes) as allocated.
2852    let bitmap_rec_offset = 248usize;
2853    let header_bitmap_len = node_size - 256;
2854    for bit in 0..used_nodes {
2855        let byte_idx = (bit / 8) as usize;
2856        let bit_in_byte = 7 - (bit % 8) as u8;
2857        if byte_idx < header_bitmap_len {
2858            buf[bitmap_rec_offset + byte_idx] |= 1u8 << bit_in_byte;
2859        }
2860    }
2861
2862    let free_offset = (node_size - 8) as u16;
2863    BigEndian::write_u16(&mut buf[node_size - 2..node_size], 14);
2864    BigEndian::write_u16(&mut buf[node_size - 4..node_size - 2], 120);
2865    BigEndian::write_u16(
2866        &mut buf[node_size - 6..node_size - 4],
2867        bitmap_rec_offset as u16,
2868    );
2869    BigEndian::write_u16(&mut buf[node_size - 8..node_size - 6], free_offset);
2870
2871    // Map nodes at indices 1..1+map_nodes
2872    for i in 0..map_nodes {
2873        let node_idx = 1 + i;
2874        let next_link = if i + 1 < map_nodes { node_idx + 1 } else { 0 };
2875        let prev_link = if i == 0 { 0 } else { node_idx - 1 };
2876        hfs_common::init_map_node(&mut buf, node_size, node_idx, prev_link, next_link);
2877    }
2878
2879    Ok(buf)
2880}
2881
2882/// Build a brand-new, empty classic-HFS partition image with the given total
2883/// size and allocation block size. The returned `Vec<u8>` is a standalone
2884/// partition blob (no APM wrapper) containing:
2885///
2886/// - sectors 0..1: zeroed boot blocks
2887/// - sector 2: primary MDB (Master Directory Block)
2888/// - starting at sector 3: volume bitmap
2889/// - aligned up to `drAlBlSt`: allocation blocks
2890/// - blocks 0..4: extents-overflow B-tree (header-only)
2891/// - blocks 4..8: catalog B-tree (header + leaf with root dir + root thread)
2892/// - blocks 8..: free
2893/// - next-to-last sector: alternate MDB (mirror of primary)
2894/// - last sector: reserved (zero) — required by HFS spec
2895///
2896/// The volume's `drFndrInfo`, dates and boot blocks are left at defaults;
2897/// callers (e.g. Step 6 of the expand-block-size plan) overwrite those
2898/// afterward. `drNxtCNID` starts at 16, matching real-world HFS volumes.
2899///
2900/// `block_size` must be a non-zero multiple of 512 and small enough that
2901/// `total_blocks` fits in a `u16` (<= 65535).
2902pub fn create_blank_hfs(
2903    target_size_bytes: u64,
2904    block_size: u32,
2905    volume_name: &str,
2906) -> Result<Vec<u8>, FilesystemError> {
2907    create_blank_hfs_sized(target_size_bytes, block_size, volume_name, 0, 0)
2908}
2909
2910/// Default B-tree sizes that loosely follow `hformat` (hfsutils).
2911/// The classic clump-size convention is `4 × block_size`; hformat scales
2912/// the Catalog file with volume size so steady-state file activity
2913/// rarely triggers an extension (each extension fragments the metadata).
2914/// Returns `(catalog_bytes, extents_bytes)` — both clump-aligned, with a
2915/// 24-block floor. Catalog ≈ 0.5 % of the volume; Extents ≈ half that.
2916///
2917/// Inputs are taken in bytes (`target_volume_bytes`) so callers don't
2918/// need to repeat the `first_alloc_block` math; `block_size` must be a
2919/// non-zero multiple of 512 (the create_blank_hfs precondition).
2920pub fn default_btree_sizes(target_volume_bytes: u64, block_size: u32) -> (u32, u32) {
2921    if block_size == 0 {
2922        return (0, 0);
2923    }
2924    let clump = 4u64 * block_size as u64;
2925    let floor = 24u64 * block_size as u64;
2926    let raw = target_volume_bytes / 200; // 0.5 %
2927    let catalog = raw.max(floor).div_ceil(clump) * clump;
2928    let extents = (raw / 2).max(floor).div_ceil(clump) * clump;
2929    (
2930        catalog.min(u32::MAX as u64) as u32,
2931        extents.min(u32::MAX as u64) as u32,
2932    )
2933}
2934
2935/// Choose catalog + extents-overflow B-tree sizes (in bytes) for a freshly
2936/// built HFS **clone target** of `target_size` at `block_size`, given the
2937/// source volume's B-tree sizes.
2938///
2939/// Returns the *larger* of the source-derived estimate (1.5× the source
2940/// catalog, 1× the source extents) and the volume-scaled blank-format default
2941/// ([`default_btree_sizes`]), clamped to [`HFS_MAX_BTREE_FILE_SIZE`].
2942///
2943/// Using the blank default as a floor is the important part: a densely-packed
2944/// source catalog reports a small byte size, but the target re-inserts every
2945/// record through the byte-based leaf split, which can pack *less* densely and
2946/// need more nodes than the source used. A catalog pre-sized to only 1.5× the
2947/// source then runs out of nodes mid-clone ("no free B-tree nodes"), because
2948/// the B-tree file can't grow. Flooring at the same size a blank volume of
2949/// `target_size` would get (≈0.5 % of the volume) gives ample headroom while
2950/// staying well under the 16 MiB ceiling for any classic-HFS-sized volume.
2951pub fn clone_target_btree_sizes(
2952    target_size: u64,
2953    block_size: u32,
2954    source_catalog: u32,
2955    source_extents: u32,
2956) -> (u32, u32) {
2957    let (default_cat, default_ext) = default_btree_sizes(target_size, block_size);
2958    let catalog = source_catalog
2959        .saturating_mul(3)
2960        .saturating_div(2)
2961        .max(default_cat)
2962        .min(HFS_MAX_BTREE_FILE_SIZE);
2963    let extents = source_extents.max(default_ext).min(HFS_MAX_BTREE_FILE_SIZE);
2964    (catalog, extents)
2965}
2966
2967/// Pick the smallest 512-byte-multiple allocation block size that keeps a
2968/// volume of `volume_bytes` within HFS's 65535-block (`drNmAlBlks`) ceiling.
2969pub fn pick_block_size(volume_bytes: u64) -> u32 {
2970    let mut bs: u32 = 512;
2971    while volume_bytes / bs as u64 > 65535 {
2972        bs = bs.saturating_mul(2);
2973        if bs == 0 {
2974            return 1 << 16;
2975        }
2976    }
2977    bs
2978}
2979
2980/// Variant of [`create_blank_hfs`] that lets the caller request minimum
2981/// extents-overflow and catalog B-tree sizes (in bytes). Each is rounded up
2982/// to a whole allocation block with a 4-block floor; zero means the
2983/// volume-scaled [`default_btree_sizes`]. Used by the expand-block-size
2984/// pipeline so the new volume's catalog can hold every record from a
2985/// fragmented source.
2986pub fn create_blank_hfs_sized(
2987    target_size_bytes: u64,
2988    block_size: u32,
2989    volume_name: &str,
2990    min_extents_bytes: u32,
2991    min_catalog_bytes: u32,
2992) -> Result<Vec<u8>, FilesystemError> {
2993    create_blank_hfs_sized_at(
2994        target_size_bytes,
2995        block_size,
2996        volume_name,
2997        min_extents_bytes,
2998        min_catalog_bytes,
2999        hfs_common::hfs_now(),
3000    )
3001}
3002
3003/// [`create_blank_hfs_sized`] with the volume dates fixed at `now`.
3004pub(crate) fn create_blank_hfs_sized_at(
3005    target_size_bytes: u64,
3006    block_size: u32,
3007    volume_name: &str,
3008    min_extents_bytes: u32,
3009    min_catalog_bytes: u32,
3010    now: u32,
3011) -> Result<Vec<u8>, FilesystemError> {
3012    let (front, mdb, image_size) = build_blank_hfs_front(
3013        target_size_bytes,
3014        block_size,
3015        volume_name,
3016        min_extents_bytes,
3017        min_catalog_bytes,
3018        now,
3019    )?;
3020    let mut img = vec![0u8; image_size as usize];
3021    img[..front.len()].copy_from_slice(&front);
3022    let alt_off = image_size as usize - 1024;
3023    img[alt_off..alt_off + 512].copy_from_slice(&mdb);
3024    Ok(img)
3025}
3026
3027/// Streaming variant of [`create_blank_hfs_sized`]: writes the metadata regions
3028/// (boot blocks, MDB, bitmap, B-trees, alternate MDB) at `at_offset` in `target`
3029/// and leaves the free middle untouched. Returns the volume's image size, which
3030/// is `target_size_bytes` rounded down to what a whole number of allocation
3031/// blocks covers.
3032///
3033/// The caller sizes the backing file (`File::set_len`) so the untouched middle
3034/// reads back as zeros — the same contract as `fs_unix::efs::write_blank_efs`.
3035pub fn write_blank_hfs_into<W: Write + Seek>(
3036    target: &mut W,
3037    at_offset: u64,
3038    target_size_bytes: u64,
3039    block_size: u32,
3040    volume_name: &str,
3041) -> Result<u64, FilesystemError> {
3042    write_blank_hfs_into_at(
3043        target,
3044        at_offset,
3045        target_size_bytes,
3046        block_size,
3047        volume_name,
3048        hfs_common::hfs_now(),
3049    )
3050}
3051
3052/// [`write_blank_hfs_into`] with the volume dates fixed at `now`.
3053pub(crate) fn write_blank_hfs_into_at<W: Write + Seek>(
3054    target: &mut W,
3055    at_offset: u64,
3056    target_size_bytes: u64,
3057    block_size: u32,
3058    volume_name: &str,
3059    now: u32,
3060) -> Result<u64, FilesystemError> {
3061    let (front, mdb, image_size) =
3062        build_blank_hfs_front(target_size_bytes, block_size, volume_name, 0, 0, now)?;
3063    target.seek(SeekFrom::Start(at_offset))?;
3064    target.write_all(&front)?;
3065    target.seek(SeekFrom::Start(at_offset + image_size - 1024))?;
3066    target.write_all(&mdb)?;
3067    target.write_all(&[0u8; 512])?;
3068    Ok(image_size)
3069}
3070
3071/// Build a blank HFS volume's front region (boot blocks, MDB, bitmap, both
3072/// B-trees) plus the 512-byte MDB the tail mirrors and the total image size.
3073/// Shared by the in-memory and streaming constructors above, so a multi-hundred-
3074/// MB blank never has to materialize its free space in RAM.
3075fn build_blank_hfs_front(
3076    target_size_bytes: u64,
3077    block_size: u32,
3078    volume_name: &str,
3079    min_extents_bytes: u32,
3080    min_catalog_bytes: u32,
3081    now: u32,
3082) -> Result<(Vec<u8>, [u8; 512], u64), FilesystemError> {
3083    if block_size == 0 || !block_size.is_multiple_of(512) {
3084        return Err(FilesystemError::InvalidData(format!(
3085            "HFS block_size must be a non-zero multiple of 512 (got {block_size})"
3086        )));
3087    }
3088
3089    let name_raw = utf8_to_mac_roman(volume_name)?;
3090    if name_raw.len() > 27 {
3091        return Err(FilesystemError::InvalidData(format!(
3092            "HFS volume name '{volume_name}' exceeds 27 Mac Roman bytes"
3093        )));
3094    }
3095
3096    // Iteratively determine first_alloc_block. Bitmap starts at sector 3; its
3097    // size depends on total_blocks, which depends on first_alloc_block, so
3098    // grow the reserved region until it stops changing.
3099    let mut first_alloc_block: u16 = 3;
3100    let total_blocks: u32;
3101    loop {
3102        let pre_alloc = first_alloc_block as u64 * 512;
3103        if target_size_bytes <= pre_alloc + 512 {
3104            return Err(FilesystemError::InvalidData(format!(
3105                "HFS volume too small ({target_size_bytes} bytes) for block_size {block_size}"
3106            )));
3107        }
3108        let usable = target_size_bytes - pre_alloc - 1024; // minus alt MDB + reserved last sector
3109        let tb = usable / block_size as u64;
3110        if tb == 0 {
3111            return Err(FilesystemError::InvalidData(format!(
3112                "HFS volume too small for block_size {block_size}"
3113            )));
3114        }
3115        if tb > 65535 {
3116            return Err(FilesystemError::InvalidData(format!(
3117                "HFS would have {tb} allocation blocks at block_size {block_size}, exceeding u16 ceiling 65535"
3118            )));
3119        }
3120        let tb = tb as u32;
3121        let bitmap_bytes = (tb as u64).div_ceil(8);
3122        let bitmap_sectors = bitmap_bytes.div_ceil(512) as u16;
3123        let needed = 3u16 + bitmap_sectors;
3124        if needed <= first_alloc_block {
3125            total_blocks = tb;
3126            break;
3127        }
3128        first_alloc_block = needed;
3129    }
3130
3131    // B-trees consume contiguous allocation blocks at the start of the
3132    // partition; rb-cli cannot grow a classic catalog later, so a zero request
3133    // takes the volume-scaled hformat default rather than a 4-block stub (R-060).
3134    let (default_catalog, default_extents) = default_btree_sizes(target_size_bytes, block_size);
3135    let blocks_for = |bytes: u32, default: u32| -> u16 {
3136        let bytes = if bytes == 0 { default } else { bytes };
3137        let n = (bytes as u64).div_ceil(block_size as u64).max(4);
3138        n.min(u16::MAX as u64) as u16
3139    };
3140    let extents_blocks = blocks_for(min_extents_bytes, default_extents);
3141    let catalog_blocks = blocks_for(min_catalog_bytes, default_catalog);
3142    let extents_start: u16 = 0;
3143    let catalog_start: u16 = extents_blocks;
3144    let btree_blocks: u16 = extents_blocks + catalog_blocks;
3145
3146    if total_blocks < btree_blocks as u32 {
3147        return Err(FilesystemError::InvalidData(format!(
3148            "HFS volume too small: {total_blocks} allocation blocks is below the minimum of {btree_blocks} needed for B-trees"
3149        )));
3150    }
3151
3152    let extents_size = extents_blocks as u32 * block_size;
3153    let catalog_size = catalog_blocks as u32 * block_size;
3154
3155    // Scale node_size up from 512 when the B-tree file would have more nodes
3156    // than the header-node bitmap can address. This avoids needing dedicated
3157    // "map nodes" while still being spec-compliant.
3158    let extents_node_size = pick_btree_node_size(extents_size as u64) as usize;
3159    let catalog_node_size = pick_btree_node_size(catalog_size as u64) as usize;
3160
3161    // Per HFS spec: alt MDB lives at the *next-to-last* sector of the volume,
3162    // and the very last sector is reserved (zero). Reserve two trailing sectors.
3163    let image_size =
3164        first_alloc_block as u64 * 512 + total_blocks as u64 * block_size as u64 + 1024;
3165    // Everything the format touches at the head is contiguous: the reserved
3166    // sectors (boot blocks, MDB, bitmap) then the B-trees in the first
3167    // allocation blocks. The rest of the volume is free space.
3168    let front_size = first_alloc_block as usize * 512 + btree_blocks as usize * block_size as usize;
3169    let mut front = vec![0u8; front_size];
3170
3171    // Volume bitmap at sector 3: mark the first btree_blocks allocation
3172    // blocks as used (the rest remain free).
3173    let bitmap_off = 3 * 512;
3174    for b in 0..btree_blocks as u32 {
3175        let byte_idx = (b / 8) as usize;
3176        let bit_idx = 7 - (b % 8) as u8;
3177        front[bitmap_off + byte_idx] |= 1 << bit_idx;
3178    }
3179
3180    // Extents B-tree (header-only; no leaf records)
3181    let extents_bytes =
3182        build_empty_hfs_extents_btree_with_node_size(extents_size as usize, extents_node_size)?;
3183    let extents_off = first_alloc_block as u64 * 512 + extents_start as u64 * block_size as u64;
3184    front[extents_off as usize..extents_off as usize + extents_bytes.len()]
3185        .copy_from_slice(&extents_bytes);
3186
3187    // Catalog B-tree with root directory (CNID 2) + its thread record.
3188    // The volume name lives in the root dir record's KEY (parent=1) and
3189    // in the root thread record's `thdCName` data field. Mac OS looks
3190    // these up by drVN at mount time.
3191    let catalog_bytes = build_empty_hfs_catalog_with_node_size(
3192        catalog_size as usize,
3193        catalog_node_size,
3194        &name_raw,
3195        now,
3196    )?;
3197    let catalog_off = first_alloc_block as u64 * 512 + catalog_start as u64 * block_size as u64;
3198    front[catalog_off as usize..catalog_off as usize + catalog_bytes.len()]
3199        .copy_from_slice(&catalog_bytes);
3200
3201    // Primary MDB at sector 2
3202    let mdb = build_blank_mdb(
3203        now,
3204        &name_raw,
3205        total_blocks as u16,
3206        block_size,
3207        first_alloc_block,
3208        catalog_start,
3209        catalog_blocks,
3210        catalog_size,
3211        extents_start,
3212        extents_blocks,
3213        extents_size,
3214        btree_blocks,
3215    );
3216    front[1024..1024 + 512].copy_from_slice(&mdb);
3217
3218    // The alternate MDB goes at the next-to-last sector (= immediately after the
3219    // last allocation block); the final sector stays zero (reserved by Apple).
3220    // Both constructors place it from the returned copy.
3221    Ok((front, mdb, image_size))
3222}
3223
3224#[allow(clippy::too_many_arguments)]
3225fn build_blank_mdb(
3226    now: u32,
3227    name_raw: &[u8],
3228    total_blocks: u16,
3229    block_size: u32,
3230    first_alloc_block: u16,
3231    catalog_start: u16,
3232    catalog_blocks: u16,
3233    catalog_size: u32,
3234    extents_start: u16,
3235    extents_blocks: u16,
3236    extents_size: u32,
3237    allocated_blocks: u16,
3238) -> [u8; 512] {
3239    let mut mdb = [0u8; 512];
3240    BigEndian::write_u16(&mut mdb[0..2], HFS_SIGNATURE);
3241    BigEndian::write_u32(&mut mdb[2..6], now); // drCrDate
3242    BigEndian::write_u32(&mut mdb[6..10], now); // drLsMod
3243                                                // drAtrb: kHFSVolumeUnmountedMask (0x0100). Without this bit, classic
3244                                                // Mac OS treats the volume as needing repair on first mount and runs
3245                                                // Disk First Aid — manifests as a slow startup on a real Mac.
3246    BigEndian::write_u16(&mut mdb[10..12], 0x0100);
3247    BigEndian::write_u16(&mut mdb[14..16], 3); // drVBMSt = bitmap sector
3248    BigEndian::write_u16(&mut mdb[18..20], total_blocks); // drNmAlBlks
3249    BigEndian::write_u32(&mut mdb[20..24], block_size); // drAlBlkSiz
3250    BigEndian::write_u32(&mut mdb[24..28], 4 * block_size); // drClpSiz
3251    BigEndian::write_u16(&mut mdb[28..30], first_alloc_block); // drAlBlSt
3252    BigEndian::write_u32(&mut mdb[30..34], 16); // drNxtCNID
3253    BigEndian::write_u16(&mut mdb[34..36], total_blocks - allocated_blocks); // drFreeBks
3254    mdb[36] = name_raw.len() as u8;
3255    mdb[37..37 + name_raw.len()].copy_from_slice(name_raw);
3256    BigEndian::write_u32(&mut mdb[74..78], extents_size); // drXTClpSiz
3257    BigEndian::write_u32(&mut mdb[78..82], catalog_size); // drCTClpSiz
3258                                                          // drFilCnt (84..88) and drDirCnt (88..92) both 0: no files and no
3259                                                          // non-root directories on a fresh volume. (HFS drDirCnt excludes the
3260                                                          // root directory — see hfs_fsck.rs.)
3261    BigEndian::write_u32(&mut mdb[130..134], extents_size); // drXTFlSize
3262    BigEndian::write_u16(&mut mdb[134..136], extents_start);
3263    BigEndian::write_u16(&mut mdb[136..138], extents_blocks);
3264    BigEndian::write_u32(&mut mdb[146..150], catalog_size); // drCTFlSize
3265    BigEndian::write_u16(&mut mdb[150..152], catalog_start);
3266    BigEndian::write_u16(&mut mdb[152..154], catalog_blocks);
3267    mdb
3268}
3269
3270impl<R: Read + Seek + Send> Filesystem for HfsFilesystem<R> {
3271    fn root(&mut self) -> Result<FileEntry, FilesystemError> {
3272        Ok(FileEntry {
3273            name: "/".into(),
3274            path: "/".into(),
3275            entry_type: EntryType::Directory,
3276            size: 0,
3277            location: 2, // HFS root directory CNID
3278            modified: None,
3279            modified_unix: None,
3280            type_code: None,
3281            creator_code: None,
3282            symlink_target: None,
3283            special_type: None,
3284            mode: None,
3285            uid: None,
3286            gid: None,
3287            resource_fork_size: None,
3288            aux_type: None,
3289            link_target_cnid: None,
3290            amiga_protection: None,
3291            amiga_comment: None,
3292            amiga_date: None,
3293            dos_attributes: None,
3294            finder_flags: None,
3295            prodos_file_type: None,
3296            mac_dates: None,
3297            owner_label: None,
3298            native_attrs: None,
3299            qnx2_attrs: None,
3300        })
3301    }
3302
3303    fn list_directory(&mut self, entry: &FileEntry) -> Result<Vec<FileEntry>, FilesystemError> {
3304        let parent_id = entry.location as u32;
3305        let children = self.list_children(parent_id)?;
3306
3307        let mut entries = Vec::new();
3308        for child in children {
3309            match child {
3310                CatalogRecord::Directory {
3311                    dir_id,
3312                    name,
3313                    dates,
3314                    ..
3315                } => {
3316                    let path = if entry.path == "/" {
3317                        format!("/{name}")
3318                    } else {
3319                        format!("{}/{name}", entry.path)
3320                    };
3321                    let mut fe = FileEntry::new_directory(name, path, dir_id as u64);
3322                    fe.modified = hfs_common::format_mac_date(dates.1);
3323                    fe.modified_unix = disk_core::times::mac_local_to_unix(dates.1);
3324                    fe.mac_dates = Some(dates);
3325                    entries.push(fe);
3326                }
3327                CatalogRecord::File {
3328                    file_id,
3329                    name,
3330                    data_size,
3331                    rsrc_size,
3332                    rsrc_extents,
3333                    type_code,
3334                    creator_code,
3335                    finder_flags,
3336                    dates,
3337                    ..
3338                } => {
3339                    let path = if entry.path == "/" {
3340                        format!("/{name}")
3341                    } else {
3342                        format!("{}/{name}", entry.path)
3343                    };
3344                    let mut fe = FileEntry::new_file(name, path, data_size as u64, file_id as u64);
3345                    fe.type_code = Some(type_code);
3346                    fe.creator_code = Some(creator_code);
3347                    fe.finder_flags = Some(finder_flags);
3348                    fe.modified = hfs_common::format_mac_date(dates.1);
3349                    fe.modified_unix = disk_core::times::mac_local_to_unix(dates.1);
3350                    fe.mac_dates = Some(dates);
3351                    if rsrc_size > 0 {
3352                        fe.resource_fork_size = Some(rsrc_size as u64);
3353                    }
3354                    // Classic-Mac alias: fdFlags bit 0x8000 set. Read the
3355                    // resource fork (small for aliases, typically <4KB) and
3356                    // parse the `alis` resource to surface the target.
3357                    if finder_flags & crate::mac_alias::IS_ALIAS_FLAG != 0 && rsrc_size > 0 {
3358                        if let Ok(rsrc) = read_fork_data(
3359                            &mut self.reader,
3360                            self.partition_offset,
3361                            &self.mdb,
3362                            &rsrc_extents,
3363                            rsrc_size as u64,
3364                        ) {
3365                            if let Some(target) = crate::mac_alias::resolve_alias_target(&rsrc) {
3366                                fe.symlink_target = Some(target);
3367                            }
3368                        }
3369                    }
3370                    entries.push(fe);
3371                }
3372            }
3373        }
3374
3375        entries.sort_by_key(|a| a.name.to_lowercase());
3376        Ok(entries)
3377    }
3378
3379    fn read_file(
3380        &mut self,
3381        entry: &FileEntry,
3382        max_bytes: usize,
3383    ) -> Result<Vec<u8>, FilesystemError> {
3384        let file_id = entry.location as u32;
3385        let (data_size, extents, _rsrc_size, _rsrc_extents) =
3386            self.find_file_by_id(file_id).ok_or_else(|| {
3387                FilesystemError::NotFound(format!("file id {file_id} not found in catalog"))
3388            })?;
3389
3390        let mut data = self.read_fork_with_overflow(file_id, 0x00, &extents, data_size as u64)?;
3391        data.truncate(max_bytes);
3392        Ok(data)
3393    }
3394
3395    fn write_file_to(
3396        &mut self,
3397        entry: &FileEntry,
3398        writer: &mut dyn std::io::Write,
3399    ) -> Result<u64, FilesystemError> {
3400        if entry.is_directory() {
3401            return Err(FilesystemError::NotADirectory(entry.path.clone()));
3402        }
3403        let file_id = entry.location as u32;
3404        let (data_size, extents, _rsrc_size, _rsrc_extents) =
3405            self.find_file_by_id(file_id).ok_or_else(|| {
3406                FilesystemError::NotFound(format!("file id {file_id} not found in catalog"))
3407            })?;
3408        self.write_fork_with_overflow_to(file_id, 0x00, &extents, data_size as u64, writer)
3409    }
3410
3411    fn volume_label(&self) -> Option<&str> {
3412        if self.mdb.volume_name.is_empty() {
3413            None
3414        } else {
3415            Some(&self.mdb.volume_name)
3416        }
3417    }
3418
3419    fn fs_type(&self) -> &str {
3420        "HFS"
3421    }
3422
3423    fn validate_name(&self, name: &str) -> Result<(), FilesystemError> {
3424        validate_hfs_create_name(name).map(|_| ())
3425    }
3426
3427    fn uses_colon_paths(&self) -> bool {
3428        true
3429    }
3430
3431    fn total_size(&self) -> u64 {
3432        self.mdb.total_blocks as u64 * self.mdb.block_size as u64
3433    }
3434
3435    fn used_size(&self) -> u64 {
3436        (self.mdb.total_blocks - self.mdb.free_blocks) as u64 * self.mdb.block_size as u64
3437    }
3438
3439    /// HFS allocates fork storage in fixed-size allocation blocks
3440    /// (`drAlBlkSiz`), so a fork's real on-disk footprint is its length
3441    /// rounded up to this unit. Exposing it lets the copy preflight and
3442    /// `du` model destination consumption exactly instead of falling back
3443    /// to a 512-byte floor.
3444    fn allocation_unit(&self) -> Option<u64> {
3445        if self.mdb.block_size == 0 {
3446            None
3447        } else {
3448            Some(self.mdb.block_size as u64)
3449        }
3450    }
3451
3452    fn last_data_byte(&mut self) -> Result<u64, FilesystemError> {
3453        let bitmap = self.read_volume_bitmap()?;
3454        let last_block = find_last_set_bit(&bitmap, self.mdb.total_blocks as u32);
3455        // The alternate MDB and the sector after it follow the allocation area (R-078).
3456        match last_block {
3457            Some(block) => Ok(self.alloc_block_offset(block + 1) + 1024),
3458            None => Ok(self.alloc_block_offset(self.mdb.total_blocks as u32) + 1024),
3459        }
3460    }
3461
3462    fn write_resource_fork_to(
3463        &mut self,
3464        entry: &FileEntry,
3465        writer: &mut dyn std::io::Write,
3466    ) -> Result<u64, FilesystemError> {
3467        let file_id = entry.location as u32;
3468        let (_data_size, _data_ext, rsrc_size, rsrc_extents) =
3469            self.find_file_by_id(file_id).ok_or_else(|| {
3470                FilesystemError::NotFound(format!("file id {file_id} not found in catalog"))
3471            })?;
3472        if rsrc_size == 0 {
3473            return Ok(0);
3474        }
3475        let data = self.read_fork_with_overflow(file_id, 0xFF, &rsrc_extents, rsrc_size as u64)?;
3476        writer.write_all(&data)?;
3477        Ok(data.len() as u64)
3478    }
3479
3480    fn resource_fork_size(&mut self, entry: &FileEntry) -> u64 {
3481        let file_id = entry.location as u32;
3482        self.find_file_by_id(file_id)
3483            .map(|(_ds, _de, rs, _re)| rs as u64)
3484            .unwrap_or(0)
3485    }
3486
3487    fn fragmentation_stats(
3488        &mut self,
3489    ) -> Option<Result<disk_core::filesystem::FragmentationStats, FilesystemError>> {
3490        Some(Ok(hfs_fragmentation_stats(&self.catalog_data)))
3491    }
3492
3493    fn blessed_system_folder(&mut self) -> Option<(u64, String)> {
3494        let cnid = self.mdb.finder_info[0];
3495        if cnid == 0 {
3496            return None;
3497        }
3498        // Look up folder name via thread record
3499        if let Some((_node, _rec, offset)) = self.find_catalog_record_by_cnid(cnid) {
3500            let key_len = self.catalog_data[offset] as usize;
3501            let mut rec_data_start = offset + 1 + key_len;
3502            if !rec_data_start.is_multiple_of(2) {
3503                rec_data_start += 1;
3504            }
3505            // Thread record: type(1) + reserved(1) + reserved(8) + parentID(4) + name(Pascal)
3506            if rec_data_start + 15 <= self.catalog_data.len() {
3507                let name_len = self.catalog_data[rec_data_start + 14] as usize;
3508                if rec_data_start + 15 + name_len <= self.catalog_data.len() {
3509                    let name = mac_roman_to_utf8(
3510                        &self.catalog_data[rec_data_start + 15..rec_data_start + 15 + name_len],
3511                    );
3512                    return Some((cnid as u64, name));
3513                }
3514            }
3515        }
3516        Some((cnid as u64, format!("CNID {}", cnid)))
3517    }
3518
3519    fn fsck(&mut self) -> Option<Result<disk_core::fsck::FsckResult, FilesystemError>> {
3520        Some(self.fsck())
3521    }
3522}
3523
3524/// Classic HFS thread record type for directories. (File threads exist in
3525/// the spec but are optional and we don't emit them — see `create_file`.)
3526const CATALOG_DIR_THREAD: i8 = 3;
3527
3528impl<R: Read + Write + Seek + Send> EditableFilesystem for HfsFilesystem<R> {
3529    fn as_filesystem(&self) -> &dyn disk_core::filesystem::Filesystem {
3530        self
3531    }
3532    fn as_filesystem_mut(&mut self) -> &mut dyn disk_core::filesystem::Filesystem {
3533        self
3534    }
3535    fn begin_bulk(&mut self) {
3536        self.bulk_mode = true;
3537    }
3538
3539    fn end_bulk(&mut self) {
3540        self.bulk_mode = false;
3541    }
3542
3543    fn create_file(
3544        &mut self,
3545        parent: &FileEntry,
3546        name: &str,
3547        data: &mut dyn std::io::Read,
3548        data_len: u64,
3549        options: &CreateFileOptions,
3550    ) -> Result<FileEntry, FilesystemError> {
3551        self.ensure_catalog_initialized()?;
3552        let snap = self.snapshot();
3553        let extents_before = self.extents_overflow_data.clone();
3554        let result = (|| {
3555            let parent_id = parent.location as u32;
3556
3557            let name_bytes = validate_hfs_create_name(name)?;
3558
3559            // Check for duplicates
3560            if self
3561                .find_catalog_record_by_name(parent_id, &name_bytes)
3562                .is_some()
3563            {
3564                return Err(FilesystemError::AlreadyExists(name.into()));
3565            }
3566
3567            // Assign CNID
3568            let file_id = self.mdb.next_catalog_id;
3569            self.mdb.next_catalog_id += 1;
3570
3571            // Determine type/creator: prefer caller-supplied (e.g. from an
3572            // imported AppleDouble), fill any missing or blank half from the
3573            // extension dictionary so a partial FInfo isn't thrown away.
3574            let (type_code, creator_code) = hfs_common::resolve_create_type_creator(
3575                name,
3576                options.os_type,
3577                options.os_creator,
3578                options.type_code.as_deref(),
3579                options.creator_code.as_deref(),
3580            );
3581
3582            // Allocate blocks and write data
3583            let data_extents = self.write_data_to_blocks(data, data_len)?;
3584
3585            // Handle resource fork
3586            let (rsrc_extents, rsrc_size) = if let Some(ref rsrc_src) = options.resource_fork {
3587                match rsrc_src {
3588                    disk_core::filesystem::ResourceForkSource::Data(rsrc_data) => {
3589                        let mut cursor = std::io::Cursor::new(rsrc_data);
3590                        let ext = self.write_data_to_blocks(&mut cursor, rsrc_data.len() as u64)?;
3591                        (ext, rsrc_data.len() as u32)
3592                    }
3593                    disk_core::filesystem::ResourceForkSource::File(path) => {
3594                        let mut f = std::fs::File::open(path)?;
3595                        let len = f.metadata()?.len();
3596                        let ext = self.write_data_to_blocks(&mut f, len)?;
3597                        (ext, len as u32)
3598                    }
3599                }
3600            } else {
3601                (Vec::new(), 0)
3602            };
3603
3604            // Build file record. A cross-fs copy passes the source mtime
3605            // through `options.unix_times`; a genuinely new file leaves it None
3606            // and takes `now`. Commander's `PreservedDates.mac` path still
3607            // overrides both via a follow-up `set_dates` when present.
3608            let mtime = options.unix_times.map(|t| t.mtime_or_now());
3609            let mut file_rec = Self::build_file_record(
3610                file_id,
3611                data_len as u32,
3612                data_extents.first().map_or(0, |e| e.start_block),
3613                Self::extents_block_total(&data_extents),
3614                rsrc_size,
3615                rsrc_extents.first().map_or(0, |e| e.start_block),
3616                Self::extents_block_total(&rsrc_extents),
3617                &type_code,
3618                &creator_code,
3619                self.mdb.block_size,
3620                mtime,
3621                options.finder_flags.unwrap_or(0),
3622            );
3623            Self::set_inline_extents(&mut file_rec, 74, &data_extents);
3624            Self::set_inline_extents(&mut file_rec, 86, &rsrc_extents);
3625
3626            // Build key + record for catalog insertion
3627            let key = Self::build_catalog_key(parent_id, &name_bytes);
3628            let mut key_record = key;
3629            key_record.extend_from_slice(&file_rec);
3630
3631            self.insert_catalog_record(&key_record)?;
3632
3633            // No file thread record: classic HFS file threads are optional
3634            // (Inside Macintosh: Files), and Finder / CiderPress2 don't emit
3635            // them. Skipping them roughly halves catalog size for file-heavy
3636            // volumes (map nodes now lift the old 2048-node header-bitmap limit,
3637            // so this is a size/compat choice, not a hard cap workaround). File
3638            // CNID lookups fall back to a leaf scan in `locate_record_data` /
3639            // `find_file_record_offset_by_cnid`.
3640
3641            // Update parent valence
3642            self.update_parent_valence(parent_id, 1)?;
3643
3644            // Update MDB counts
3645            self.mdb.file_count += 1;
3646
3647            let mut overflow_changed = self.insert_fork_overflow(file_id, 0x00, &data_extents)?;
3648            overflow_changed |= self.insert_fork_overflow(file_id, 0xFF, &rsrc_extents)?;
3649            if overflow_changed {
3650                self.write_extents_overflow_back()?;
3651            }
3652
3653            let path = if parent.path == "/" {
3654                format!("/{name}")
3655            } else {
3656                format!("{}/{name}", parent.path)
3657            };
3658            let mut fe = FileEntry::new_file(name.to_string(), path, data_len, file_id as u64);
3659            if type_code != [0; 4] {
3660                fe.type_code = Some(type_code);
3661                fe.creator_code = Some(creator_code);
3662            }
3663            fe.finder_flags = options.finder_flags;
3664            if rsrc_size > 0 {
3665                fe.resource_fork_size = Some(rsrc_size as u64);
3666            }
3667            let stamped = mtime.unwrap_or_else(disk_core::times::now);
3668            fe.modified_unix = Some(stamped);
3669            fe.mac_dates = Some((
3670                disk_core::times::unix_to_mac_local(stamped),
3671                disk_core::times::unix_to_mac_local(stamped),
3672                0,
3673            ));
3674            Ok(fe)
3675        })();
3676        if result.is_err() {
3677            self.extents_overflow_data = extents_before;
3678            self.restore_snapshot(snap);
3679        }
3680        result
3681    }
3682
3683    fn create_directory(
3684        &mut self,
3685        parent: &FileEntry,
3686        name: &str,
3687        options: &CreateDirectoryOptions,
3688    ) -> Result<FileEntry, FilesystemError> {
3689        self.ensure_catalog_initialized()?;
3690        let snap = self.snapshot();
3691        let result = (|| {
3692            let parent_id = parent.location as u32;
3693
3694            let name_bytes = validate_hfs_create_name(name)?;
3695
3696            // Check duplicates
3697            if self
3698                .find_catalog_record_by_name(parent_id, &name_bytes)
3699                .is_some()
3700            {
3701                return Err(FilesystemError::AlreadyExists(name.into()));
3702            }
3703
3704            // Assign CNID
3705            let folder_id = self.mdb.next_catalog_id;
3706            self.mdb.next_catalog_id += 1;
3707
3708            // Build folder record — see build_file_record's comment on how
3709            // options.unix_times threads a source mtime through.
3710            let mtime = options.unix_times.map(|t| t.mtime_or_now());
3711            let folder_rec = Self::build_dir_record_with_dates(folder_id, mtime);
3712
3713            // Build key + record
3714            let key = Self::build_catalog_key(parent_id, &name_bytes);
3715            let mut key_record = key;
3716            key_record.extend_from_slice(&folder_rec);
3717
3718            self.insert_catalog_record(&key_record)?;
3719
3720            // Thread record
3721            let (_, thread_data) =
3722                Self::build_thread_record(CATALOG_DIR_THREAD, parent_id, &name_bytes);
3723            let thread_key = Self::build_catalog_key(folder_id, &[]);
3724            let mut thread_record = thread_key;
3725            thread_record.extend_from_slice(&thread_data);
3726            self.insert_catalog_record(&thread_record)?;
3727
3728            // Update parent valence
3729            self.update_parent_valence(parent_id, 1)?;
3730
3731            // Update MDB
3732            self.mdb.folder_count += 1;
3733
3734            let path = if parent.path == "/" {
3735                format!("/{name}")
3736            } else {
3737                format!("{}/{name}", parent.path)
3738            };
3739            let mut fe = FileEntry::new_directory(name.to_string(), path, folder_id as u64);
3740            let stamped = mtime.unwrap_or_else(disk_core::times::now);
3741            fe.modified_unix = Some(stamped);
3742            fe.mac_dates = Some((
3743                disk_core::times::unix_to_mac_local(stamped),
3744                disk_core::times::unix_to_mac_local(stamped),
3745                0,
3746            ));
3747            Ok(fe)
3748        })();
3749        if result.is_err() {
3750            self.restore_snapshot(snap);
3751        }
3752        result
3753    }
3754
3755    fn delete_entry(
3756        &mut self,
3757        parent: &FileEntry,
3758        entry: &FileEntry,
3759    ) -> Result<(), FilesystemError> {
3760        let snap = self.snapshot();
3761        let extents_before = self.extents_overflow_data.clone();
3762        let result = (|| {
3763            let parent_id = parent.location as u32;
3764            let cnid = entry.location as u32;
3765
3766            // Check directory is empty
3767            if entry.is_directory() {
3768                let children = self.list_children(cnid)?;
3769                if !children.is_empty() {
3770                    return Err(FilesystemError::InvalidData(
3771                        "cannot delete non-empty directory".into(),
3772                    ));
3773                }
3774            }
3775
3776            // Find the entry record
3777            let name_bytes = utf8_to_mac_roman(&entry.name)?;
3778            let (node_idx, rec_idx, _offset) = self
3779                .find_catalog_record_by_name(parent_id, &name_bytes)
3780                .ok_or_else(|| {
3781                    FilesystemError::NotFound(format!(
3782                        "entry '{}' not found in catalog",
3783                        entry.name
3784                    ))
3785                })?;
3786
3787            // If it's a file, free its fork blocks
3788            let mut overflow_changed = false;
3789            if !entry.is_directory() {
3790                if let Some((_, data_extents, _, rsrc_extents)) = self.find_file_by_id(cnid) {
3791                    self.free_extent_blocks(&data_extents);
3792                    self.free_extent_blocks(&rsrc_extents);
3793                    // A fork fragmented past three extents keeps the rest in the overflow tree.
3794                    overflow_changed |= self.free_fork_overflow(cnid, 0x00)?;
3795                    overflow_changed |= self.free_fork_overflow(cnid, 0xFF)?;
3796                }
3797            }
3798
3799            self.remove_catalog_record(node_idx, rec_idx)?;
3800
3801            // Find and remove the thread record
3802            if let Some((t_node, t_rec, _)) = self.find_catalog_record_by_cnid(cnid) {
3803                self.remove_catalog_record(t_node, t_rec)?;
3804            }
3805
3806            // Update parent valence
3807            self.update_parent_valence(parent_id, -1)?;
3808
3809            // Update MDB counts
3810            if entry.is_directory() {
3811                self.mdb.folder_count = self.mdb.folder_count.saturating_sub(1);
3812            } else {
3813                self.mdb.file_count = self.mdb.file_count.saturating_sub(1);
3814            }
3815
3816            if overflow_changed {
3817                self.write_extents_overflow_back()?;
3818            }
3819
3820            Ok(())
3821        })();
3822        if result.is_err() {
3823            self.extents_overflow_data = extents_before;
3824            self.restore_snapshot(snap);
3825        }
3826        result
3827    }
3828
3829    fn rename(
3830        &mut self,
3831        parent: &FileEntry,
3832        entry: &FileEntry,
3833        new_name: &str,
3834    ) -> Result<(), FilesystemError> {
3835        if new_name == entry.name {
3836            return Ok(());
3837        }
3838        let parent_id = parent.location as u32;
3839        let cnid = entry.location as u32;
3840        let new_name_raw = validate_hfs_create_name(new_name)?;
3841        let old_name_raw = utf8_to_mac_roman(&entry.name)?;
3842
3843        let snap = self.snapshot();
3844        let result = (|| -> Result<(), FilesystemError> {
3845            // Locate the entry's catalog record by its current (parent, name) key.
3846            let (node_idx, rec_idx, _key_abs) = self
3847                .find_catalog_record_by_name(parent_id, &old_name_raw)
3848                .ok_or_else(|| {
3849                    FilesystemError::NotFound(format!(
3850                        "entry '{}' not found in catalog",
3851                        entry.name
3852                    ))
3853                })?;
3854
3855            // Reject a collision only with a DIFFERENT record. HFS folds case, so
3856            // a case-only rename resolves back to this same record (allowed).
3857            if let Some((n2, r2, _)) = self.find_catalog_record_by_name(parent_id, &new_name_raw) {
3858                if (n2, r2) != (node_idx, rec_idx) {
3859                    return Err(FilesystemError::AlreadyExists(new_name.to_string()));
3860                }
3861            }
3862
3863            // Extract the record's data body to re-emit it verbatim under the new
3864            // key (the CNID and all fork/Finder data are unchanged).
3865            let node_size = BigEndian::read_u16(&self.catalog_data[32..34]) as usize;
3866            let node_off = node_idx as usize * node_size;
3867            let node = &self.catalog_data[node_off..node_off + node_size];
3868            let (rec_start, rec_end) = hfs_common::btree_record_range(node, node_size, rec_idx);
3869            let key_len = node[rec_start] as usize;
3870            let mut data_rel = 1 + key_len;
3871            if !data_rel.is_multiple_of(2) {
3872                data_rel += 1;
3873            }
3874            let data_bytes = node[rec_start + data_rel..rec_end].to_vec();
3875
3876            let mut new_record = Self::build_catalog_key(parent_id, &new_name_raw);
3877            if !new_record.len().is_multiple_of(2) {
3878                new_record.push(0);
3879            }
3880            new_record.extend_from_slice(&data_bytes);
3881            if !new_record.len().is_multiple_of(2) {
3882                new_record.push(0);
3883            }
3884
3885            self.remove_catalog_record(node_idx, rec_idx)?;
3886            self.insert_catalog_record(&new_record)?;
3887
3888            // Rewrite the thread record's `thdCName` (fixed Str31 at data +14, so
3889            // the record footprint is unchanged — safe to patch in place).
3890            // Directories always have a thread; files may not.
3891            if let Some((_n, _r, t_key_abs)) = self.find_catalog_record_by_cnid(cnid) {
3892                let t_key_len = self.catalog_data[t_key_abs] as usize;
3893                let mut t_data_off = t_key_abs + 1 + t_key_len;
3894                if !t_data_off.is_multiple_of(2) {
3895                    t_data_off += 1;
3896                }
3897                if t_data_off + 15 + 31 <= self.catalog_data.len() {
3898                    self.catalog_data[t_data_off + 14] = new_name_raw.len() as u8;
3899                    for i in 0..31 {
3900                        self.catalog_data[t_data_off + 15 + i] = 0;
3901                    }
3902                    self.catalog_data[t_data_off + 15..t_data_off + 15 + new_name_raw.len()]
3903                        .copy_from_slice(&new_name_raw);
3904                }
3905            }
3906            // A same-parent rename leaves parent valence and MDB counts unchanged.
3907            Ok(())
3908        })();
3909        if result.is_err() {
3910            self.restore_snapshot(snap);
3911        }
3912        result
3913    }
3914
3915    fn set_type_creator(
3916        &mut self,
3917        entry: &FileEntry,
3918        type_code: &str,
3919        creator_code: &str,
3920    ) -> Result<(), FilesystemError> {
3921        let snap = self.snapshot();
3922        let result = (|| {
3923            let frec_start = self.locate_record_data(entry.location as u32)?;
3924            if self.catalog_data[frec_start] as i8 != CATALOG_FILE {
3925                return Err(FilesystemError::InvalidData(
3926                    "set_type_creator: not a file record".into(),
3927                ));
3928            }
3929
3930            // Write type at FInfo+0 (rec offset 4), creator at FInfo+4 (rec offset 8)
3931            let tc = hfs_common::encode_fourcc(type_code);
3932            let cc = hfs_common::encode_fourcc(creator_code);
3933            self.catalog_data[frec_start + 4..frec_start + 8].copy_from_slice(&tc);
3934            self.catalog_data[frec_start + 8..frec_start + 12].copy_from_slice(&cc);
3935
3936            Ok(())
3937        })();
3938        if result.is_err() {
3939            self.restore_snapshot(snap);
3940        }
3941        result
3942    }
3943
3944    fn write_resource_fork(
3945        &mut self,
3946        entry: &FileEntry,
3947        data: &mut dyn std::io::Read,
3948        len: u64,
3949    ) -> Result<(), FilesystemError> {
3950        let snap = self.snapshot();
3951        let extents_before = self.extents_overflow_data.clone();
3952        let result = (|| {
3953            let cnid = entry.location as u32;
3954
3955            // Free existing resource fork blocks, overflow records included
3956            let mut overflow_changed = false;
3957            if let Some((_, _, _, rsrc_extents)) = self.find_file_by_id(cnid) {
3958                self.free_extent_blocks(&rsrc_extents);
3959                overflow_changed = self.free_fork_overflow(cnid, 0xFF)?;
3960            }
3961
3962            // Allocate and write new resource fork
3963            let rsrc_extents = self.write_data_to_blocks(data, len)?;
3964
3965            let frec_start = self.locate_record_data(cnid)?;
3966            if self.catalog_data[frec_start] as i8 != CATALOG_FILE {
3967                return Err(FilesystemError::InvalidData(
3968                    "write_resource_fork: not a file record".into(),
3969                ));
3970            }
3971
3972            // Update rsrc fork fields in file record. filRStBlk (offset 34)
3973            // stays 0 — it's the File Manager's in-memory cache, not persisted;
3974            // a non-zero on-disk value makes fsck_hfs report "Reserved fields
3975            // in the catalog record have incorrect data". The real location is
3976            // the rsrc extent record at offset 86 (written below).
3977            self.catalog_data[frec_start + 34..frec_start + 36].fill(0);
3978            // filRLgLen at offset 36
3979            BigEndian::write_u32(
3980                &mut self.catalog_data[frec_start + 36..frec_start + 40],
3981                len as u32,
3982            );
3983            // filRPyLen at offset 40
3984            BigEndian::write_u32(
3985                &mut self.catalog_data[frec_start + 40..frec_start + 44],
3986                Self::extents_block_total(&rsrc_extents) as u32 * self.mdb.block_size,
3987            );
3988            // Rsrc extents at offset 86; a fourth extent on goes to the overflow tree.
3989            Self::set_inline_extents(
3990                &mut self.catalog_data[frec_start..frec_start + 102],
3991                86,
3992                &rsrc_extents,
3993            );
3994            overflow_changed |= self.insert_fork_overflow(cnid, 0xFF, &rsrc_extents)?;
3995
3996            if overflow_changed {
3997                self.write_extents_overflow_back()?;
3998            }
3999
4000            Ok(())
4001        })();
4002        if result.is_err() {
4003            self.extents_overflow_data = extents_before;
4004            self.restore_snapshot(snap);
4005        }
4006        result
4007    }
4008
4009    fn repair(&mut self) -> Result<disk_core::fsck::RepairReport, FilesystemError> {
4010        self.ensure_bitmap()?;
4011        let mut extents_data = if self.mdb.extents_file_size > 0 {
4012            read_fork_data(
4013                &mut self.reader,
4014                self.partition_offset,
4015                &self.mdb,
4016                &self.mdb.extents_file_extents,
4017                self.mdb.extents_file_size as u64,
4018            )
4019            .ok()
4020        } else {
4021            None
4022        };
4023
4024        let report = crate::hfs_fsck::repair_hfs(
4025            &mut self.mdb,
4026            &mut self.catalog_data,
4027            self.bitmap.as_mut().unwrap(),
4028            extents_data.as_mut(),
4029        );
4030
4031        // Write back repaired extents overflow B-tree if modified
4032        if let Some(ref ext_data) = extents_data {
4033            if self.mdb.extents_file_size > 0 {
4034                write_extents_overflow_data(
4035                    &mut self.reader,
4036                    self.partition_offset,
4037                    &self.mdb,
4038                    ext_data,
4039                )?;
4040            }
4041        }
4042
4043        self.do_sync_metadata()?;
4044        Ok(report)
4045    }
4046
4047    fn sync_metadata(&mut self) -> Result<(), FilesystemError> {
4048        self.do_sync_metadata()
4049    }
4050
4051    fn free_space(&mut self) -> Result<u64, FilesystemError> {
4052        Ok(self.mdb.free_blocks as u64 * self.mdb.block_size as u64)
4053    }
4054
4055    fn set_blessed_folder(&mut self, entry: &FileEntry) -> Result<(), FilesystemError> {
4056        let snap = self.snapshot();
4057        let result = (|| {
4058            if !entry.is_directory() {
4059                return Err(FilesystemError::InvalidData(
4060                    "can only bless a directory".into(),
4061                ));
4062            }
4063            self.mdb.finder_info[0] = entry.location as u32;
4064            Ok(())
4065        })();
4066        if result.is_err() {
4067            self.restore_snapshot(snap);
4068        }
4069        result
4070    }
4071
4072    fn write_boot_blocks(&mut self, blocks: &[u8; 1024]) -> Result<(), FilesystemError> {
4073        HfsFilesystem::set_boot_blocks(self, blocks);
4074        Ok(())
4075    }
4076
4077    fn set_volume_name(&mut self, new_name: &str) -> Result<(), FilesystemError> {
4078        HfsFilesystem::set_volume_name(self, new_name)
4079    }
4080
4081    fn set_finder_info(
4082        &mut self,
4083        entry: &FileEntry,
4084        finfo: [u8; 16],
4085        fxinfo: [u8; 16],
4086    ) -> Result<(), FilesystemError> {
4087        HfsFilesystem::set_finder_info(self, entry, finfo, fxinfo)
4088    }
4089
4090    fn set_dates(
4091        &mut self,
4092        entry: &FileEntry,
4093        create: u32,
4094        modify: u32,
4095        backup: u32,
4096    ) -> Result<(), FilesystemError> {
4097        HfsFilesystem::set_dates(self, entry, create, modify, backup)
4098    }
4099}
4100
4101/// Read fork data from the 3-extent descriptor array in the MDB.
4102/// Walk the extents-overflow B-tree leaf chain and collect every extent
4103/// record matching `(file_id, fork_type)` whose key startBlock is at or after
4104/// `min_start_block`. Returned in startBlock order (records appear in key
4105/// order on disk; we sort defensively in case of corruption).
4106fn collect_fork_overflow_extents(
4107    extents_data: &[u8],
4108    file_id: u32,
4109    fork_type: u8,
4110    min_start_block: u32,
4111) -> Vec<HfsExtDescriptor> {
4112    use crate::hfs_common::{btree_record_range, BTreeHeader, BTREE_LEAF_NODE};
4113    use std::collections::HashSet;
4114
4115    let mut out: Vec<(u16, HfsExtDescriptor)> = Vec::new();
4116    if extents_data.len() < 512 {
4117        return Vec::new();
4118    }
4119    let header = BTreeHeader::read(extents_data);
4120    let node_size = header.node_size as usize;
4121    if node_size < crate::hfs_common::BTREE_MIN_NODE_SIZE || extents_data.len() < node_size {
4122        return Vec::new();
4123    }
4124
4125    let mut node_idx = header.first_leaf_node;
4126    let mut visited: HashSet<u32> = HashSet::new();
4127    while node_idx != 0 {
4128        if !visited.insert(node_idx) {
4129            break;
4130        }
4131        let off = node_idx as usize * node_size;
4132        if off + node_size > extents_data.len() {
4133            break;
4134        }
4135        let node = &extents_data[off..off + node_size];
4136        let flink = BigEndian::read_u32(&node[0..4]);
4137        let kind = node[8] as i8;
4138        if kind != BTREE_LEAF_NODE {
4139            break;
4140        }
4141        let num_records = BigEndian::read_u16(&node[10..12]) as usize;
4142        for i in 0..num_records {
4143            let (rec_start, _) = btree_record_range(node, node_size, i);
4144            if rec_start + 8 > node_size {
4145                continue;
4146            }
4147            let key_len = node[rec_start] as usize;
4148            if key_len < 7 || rec_start + 1 + key_len > node_size {
4149                continue;
4150            }
4151            let rec_fork_type = node[rec_start + 1];
4152            let rec_file_id = BigEndian::read_u32(&node[rec_start + 2..rec_start + 6]);
4153            let rec_start_block = BigEndian::read_u16(&node[rec_start + 6..rec_start + 8]);
4154            if rec_fork_type != fork_type || rec_file_id != file_id {
4155                continue;
4156            }
4157            if (rec_start_block as u32) < min_start_block {
4158                continue;
4159            }
4160            let mut data_off = rec_start + 1 + key_len;
4161            if !data_off.is_multiple_of(2) {
4162                data_off += 1;
4163            }
4164            if data_off + 12 > node_size {
4165                continue;
4166            }
4167            for j in 0..3 {
4168                let ext = HfsExtDescriptor::parse(&node[data_off + j * 4..data_off + j * 4 + 4]);
4169                if ext.block_count > 0 {
4170                    out.push((rec_start_block, ext));
4171                }
4172            }
4173        }
4174        node_idx = flink;
4175    }
4176
4177    out.sort_by_key(|(sb, _)| *sb);
4178    out.into_iter().map(|(_, e)| e).collect()
4179}
4180
4181fn read_fork_data<R: Read + Seek>(
4182    reader: &mut R,
4183    partition_offset: u64,
4184    mdb: &HfsMasterDirectoryBlock,
4185    extents: &[HfsExtDescriptor; 3],
4186    size: u64,
4187) -> Result<Vec<u8>, FilesystemError> {
4188    let mut data = Vec::with_capacity(size as usize);
4189    let first_alloc_offset = partition_offset + mdb.first_alloc_block as u64 * 512;
4190
4191    for ext in extents {
4192        if ext.block_count == 0 {
4193            break;
4194        }
4195        let offset = first_alloc_offset + ext.start_block as u64 * mdb.block_size as u64;
4196        let len = ext.block_count as u64 * mdb.block_size as u64;
4197        reader.seek(SeekFrom::Start(offset))?;
4198        let read_len = len.min(size - data.len() as u64) as usize;
4199        let mut buf = vec![0u8; read_len];
4200        reader.read_exact(&mut buf)?;
4201        data.extend_from_slice(&buf);
4202        if data.len() as u64 >= size {
4203            break;
4204        }
4205    }
4206
4207    data.truncate(size as usize);
4208    Ok(data)
4209}
4210
4211/// Remove every extents-overflow record of `(file_id, fork_type)`, freeing a
4212/// leaf that empties; returns the number of records removed.
4213fn remove_fork_overflow_records(
4214    extents_data: &mut [u8],
4215    file_id: u32,
4216    fork_type: u8,
4217) -> Result<u32, FilesystemError> {
4218    use crate::hfs_common::{
4219        btree_remove_record, btree_retire_empty_leaf, compare_classic_extents_keys,
4220        walk_leaf_records, BTreeHeader,
4221    };
4222    let kf = BTreeKeyFormat::CLASSIC_EXTENTS;
4223    if extents_data.len() < 512 {
4224        return Ok(0);
4225    }
4226    let header = BTreeHeader::read(extents_data);
4227    let node_size = header.node_size as usize;
4228    if node_size < crate::hfs_common::BTREE_MIN_NODE_SIZE || extents_data.len() < node_size {
4229        return Ok(0);
4230    }
4231    let mut victims: Vec<(u32, usize)> = Vec::new();
4232    walk_leaf_records::<(), _>(
4233        extents_data,
4234        header.first_leaf_node,
4235        node_size,
4236        |node_idx, rec_idx, _off, rec| {
4237            // Key: keyLen(1) = 7, forkType(1), fileID(4), startBlock(2).
4238            if rec.len() >= 8
4239                && rec[0] >= 7
4240                && rec[1] == fork_type
4241                && BigEndian::read_u32(&rec[2..6]) == file_id
4242            {
4243                victims.push((node_idx, rec_idx));
4244            }
4245            None
4246        },
4247    );
4248    if victims.is_empty() {
4249        return Ok(0);
4250    }
4251    // Highest record index first per node, so earlier indices stay valid.
4252    victims.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
4253    for &(node_idx, rec_idx) in &victims {
4254        let off = node_idx as usize * node_size;
4255        btree_remove_record(&mut extents_data[off..off + node_size], node_size, rec_idx);
4256    }
4257    let mut h = BTreeHeader::read(extents_data);
4258    h.leaf_records = h.leaf_records.saturating_sub(victims.len() as u32);
4259    h.write(extents_data);
4260    let mut nodes: Vec<u32> = victims.iter().map(|v| v.0).collect();
4261    nodes.dedup();
4262    for node_idx in nodes {
4263        let off = node_idx as usize * node_size;
4264        if BigEndian::read_u16(&extents_data[off + 10..off + 12]) == 0 {
4265            // An emptied leaf leaves the chain, the node map and the index.
4266            btree_retire_empty_leaf(
4267                extents_data,
4268                node_size,
4269                node_idx,
4270                &kf,
4271                &compare_classic_extents_keys,
4272            )?;
4273        } else if victims.iter().any(|&(n, r)| n == node_idx && r == 0) {
4274            // The leaf's first key changed: the separators above must match it.
4275            btree_refresh_index_keys(
4276                extents_data,
4277                node_size,
4278                node_idx,
4279                &kf,
4280                &compare_classic_extents_keys,
4281            )?;
4282        }
4283    }
4284    Ok(victims.len() as u32)
4285}
4286
4287/// Write the extents-overflow B-tree back over the extents file's blocks.
4288fn write_extents_overflow_data<W: std::io::Write + Seek>(
4289    writer: &mut W,
4290    partition_offset: u64,
4291    mdb: &HfsMasterDirectoryBlock,
4292    data: &[u8],
4293) -> Result<(), FilesystemError> {
4294    let alloc_start = partition_offset + mdb.first_alloc_block as u64 * 512;
4295    let mut data_start = 0usize;
4296    for extent in mdb.extents_file_extents.iter() {
4297        if extent.block_count == 0 {
4298            break;
4299        }
4300        let block_off = alloc_start + extent.start_block as u64 * mdb.block_size as u64;
4301        let byte_len = extent.block_count as usize * mdb.block_size as usize;
4302        let data_end = (data_start + byte_len).min(data.len());
4303        if data_start < data_end {
4304            writer.seek(std::io::SeekFrom::Start(block_off))?;
4305            writer.write_all(&data[data_start..data_end])?;
4306        }
4307        data_start += byte_len;
4308    }
4309    Ok(())
4310}
4311
4312/// Stream fork data through inline extents to a writer. Returns bytes written.
4313fn write_fork_data_to<R: Read + Seek>(
4314    reader: &mut R,
4315    partition_offset: u64,
4316    mdb: &HfsMasterDirectoryBlock,
4317    extents: &[HfsExtDescriptor; 3],
4318    writer: &mut dyn std::io::Write,
4319    size: u64,
4320) -> Result<u64, FilesystemError> {
4321    let first_alloc_offset = partition_offset + mdb.first_alloc_block as u64 * 512;
4322    let mut written: u64 = 0;
4323    let mut buf = vec![0u8; 64 * 1024];
4324
4325    for ext in extents {
4326        if ext.block_count == 0 || written >= size {
4327            break;
4328        }
4329        let offset = first_alloc_offset + ext.start_block as u64 * mdb.block_size as u64;
4330        let extent_len = ext.block_count as u64 * mdb.block_size as u64;
4331        let to_emit = extent_len.min(size - written);
4332        reader.seek(SeekFrom::Start(offset))?;
4333        let mut left = to_emit;
4334        while left > 0 {
4335            let n = (buf.len() as u64).min(left) as usize;
4336            reader.read_exact(&mut buf[..n])?;
4337            writer.write_all(&buf[..n])?;
4338            left -= n as u64;
4339        }
4340        written += to_emit;
4341    }
4342    Ok(written)
4343}
4344
4345/// Write fork data to disk through extent descriptors.
4346fn write_hfs_fork_data<R: Write + Seek>(
4347    writer: &mut R,
4348    partition_offset: u64,
4349    mdb: &HfsMasterDirectoryBlock,
4350    extents: &[HfsExtDescriptor; 3],
4351    data: &[u8],
4352) -> Result<(), FilesystemError> {
4353    let first_alloc_offset = partition_offset + mdb.first_alloc_block as u64 * 512;
4354    let mut written = 0usize;
4355    for ext in extents {
4356        if ext.block_count == 0 || written >= data.len() {
4357            break;
4358        }
4359        let offset = first_alloc_offset + ext.start_block as u64 * mdb.block_size as u64;
4360        let extent_len = ext.block_count as u64 * mdb.block_size as u64;
4361        let to_write = extent_len.min((data.len() - written) as u64) as usize;
4362        writer.seek(SeekFrom::Start(offset))?;
4363        writer.write_all(&data[written..written + to_write])?;
4364        written += to_write;
4365    }
4366    Ok(())
4367}
4368
4369/// Find the index of the last set bit in a bitmap (MSB-first).
4370fn find_last_set_bit(bitmap: &[u8], max_bits: u32) -> Option<u32> {
4371    let mut last = None;
4372    for bit in 0..max_bits {
4373        let byte_idx = bit as usize / 8;
4374        let bit_idx = 7 - (bit % 8); // MSB-first
4375        if byte_idx < bitmap.len() && (bitmap[byte_idx] >> bit_idx) & 1 == 1 {
4376            last = Some(bit);
4377        }
4378    }
4379    last
4380}
4381
4382/// Compact reader for classic HFS: layout-preserving image with zeros for free blocks.
4383///
4384/// Outputs the full pre-allocation region (boot blocks + MDB + volume bitmap,
4385/// `first_alloc_block * 512` bytes) followed by all allocation blocks in order,
4386/// with free blocks replaced by zeros. This preserves the original block layout:
4387/// allocation block N is always at byte offset `first_alloc_block*512 + N*block_size`,
4388/// enabling correct filesystem browsing and reliable restore.
4389pub struct CompactHfsReader<R: Read + Seek> {
4390    reader: R,
4391    partition_offset: u64,
4392    mdb: HfsMasterDirectoryBlock,
4393    bitmap: Vec<u8>,
4394    /// Current phase: 0=pre-alloc region, 1=allocation blocks, 2=done.
4395    phase: u8,
4396    /// Byte position within the pre-alloc region (phase 0).
4397    pre_alloc_pos: u64,
4398    /// Current allocation block index (phase 1).
4399    current_block: u32,
4400    /// Byte position within the current allocation block (phase 1).
4401    block_pos: u64,
4402    #[allow(dead_code)]
4403    original_size: u64,
4404    #[allow(dead_code)]
4405    allocated_blocks: u32,
4406}
4407
4408impl<R: Read + Seek> CompactHfsReader<R> {
4409    pub fn new(
4410        mut reader: R,
4411        partition_offset: u64,
4412    ) -> Result<(Self, CompactResult), FilesystemError> {
4413        // Read MDB
4414        log::debug!(
4415            "[HFS compact] seeking to MDB at offset {}",
4416            partition_offset + 1024
4417        );
4418        reader.seek(SeekFrom::Start(partition_offset + 1024))?;
4419        let mut mdb_buf = [0u8; 162];
4420        reader.read_exact(&mut mdb_buf)?;
4421        let mdb = HfsMasterDirectoryBlock::parse(&mdb_buf).map_err(|e| {
4422            log::debug!("[HFS compact] MDB parse failed: {e}");
4423            e
4424        })?;
4425        log::debug!(
4426            "[HFS compact] MDB ok: block_size={}, total_blocks={}, first_alloc_block={}, volume_bitmap_block={}",
4427            mdb.block_size, mdb.total_blocks, mdb.first_alloc_block, mdb.volume_bitmap_block,
4428        );
4429
4430        if mdb.has_embedded_hfs_plus() {
4431            log::debug!("[HFS compact] has embedded HFS+ — refusing to compact HFS wrapper");
4432            return Err(FilesystemError::Unsupported(
4433                "cannot compact HFS wrapper with embedded HFS+".into(),
4434            ));
4435        }
4436
4437        // Read volume bitmap
4438        let bitmap_offset = partition_offset + mdb.volume_bitmap_block as u64 * 512;
4439        log::debug!("[HFS compact] reading bitmap at offset {bitmap_offset}");
4440        let bitmap_size = (mdb.total_blocks as u32).div_ceil(8) as usize;
4441        reader.seek(SeekFrom::Start(bitmap_offset))?;
4442        let mut bitmap = vec![0u8; bitmap_size];
4443        reader.read_exact(&mut bitmap)?;
4444        log::debug!("[HFS compact] bitmap read: {bitmap_size} bytes");
4445
4446        // Count allocated blocks
4447        let mut allocated = 0u32;
4448        for bit in 0..mdb.total_blocks as u32 {
4449            let byte_idx = bit as usize / 8;
4450            let bit_idx = 7 - (bit % 8);
4451            if byte_idx < bitmap.len() && (bitmap[byte_idx] >> bit_idx) & 1 == 1 {
4452                allocated += 1;
4453            }
4454        }
4455        log::debug!(
4456            "[HFS compact] allocated={} / {} total blocks ({} free)",
4457            allocated,
4458            mdb.total_blocks,
4459            mdb.total_blocks as u32 - allocated,
4460        );
4461
4462        // Full partition data: pre-alloc region + all allocation blocks.
4463        let pre_alloc_size = mdb.first_alloc_block as u64 * 512;
4464        let original_size = pre_alloc_size + mdb.total_blocks as u64 * mdb.block_size as u64;
4465        // Layout-preserving: compacted_size == original_size.
4466        // Free allocation blocks are zeroed, so they compress extremely well.
4467        let compacted_size = original_size;
4468        // data_size: pre-alloc is always read from disk; only allocated alloc blocks are read.
4469        let data_size = pre_alloc_size + allocated as u64 * mdb.block_size as u64;
4470        log::debug!(
4471            "[HFS compact] pre_alloc_size={}, data_size={}, compacted_size={} original_size={} (layout-preserving; free blocks -> zeros)",
4472            pre_alloc_size, data_size, compacted_size, original_size
4473        );
4474
4475        let result = CompactResult {
4476            original_size,
4477            compacted_size,
4478            data_size,
4479            clusters_used: allocated,
4480        };
4481
4482        Ok((
4483            CompactHfsReader {
4484                reader,
4485                partition_offset,
4486                mdb,
4487                bitmap,
4488                phase: 0,
4489                pre_alloc_pos: 0,
4490                current_block: 0,
4491                block_pos: 0,
4492                original_size,
4493                allocated_blocks: allocated,
4494            },
4495            result,
4496        ))
4497    }
4498
4499    fn is_block_allocated(&self, block: u32) -> bool {
4500        let byte_idx = block as usize / 8;
4501        let bit_idx = 7 - (block % 8);
4502        byte_idx < self.bitmap.len() && (self.bitmap[byte_idx] >> bit_idx) & 1 == 1
4503    }
4504}
4505
4506impl<R: Read + Seek> Read for CompactHfsReader<R> {
4507    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
4508        if self.phase > 1 {
4509            return Ok(0);
4510        }
4511
4512        if self.phase == 0 {
4513            // Phase 0: pre-alloc region (boot blocks, MDB, volume bitmap).
4514            // Covers sectors 0..first_alloc_block — always preserved verbatim.
4515            let pre_alloc_size = self.mdb.first_alloc_block as u64 * 512;
4516            let remaining = pre_alloc_size - self.pre_alloc_pos;
4517            if remaining == 0 {
4518                self.phase = 1;
4519                return self.read(buf);
4520            }
4521            let to_read = (remaining as usize).min(buf.len());
4522            let offset = self.partition_offset + self.pre_alloc_pos;
4523            self.reader
4524                .seek(SeekFrom::Start(offset))
4525                .map_err(disk_core::io_other)?;
4526            let n = self.reader.read(&mut buf[..to_read])?;
4527            self.pre_alloc_pos += n as u64;
4528            return Ok(n);
4529        }
4530
4531        // Phase 1: allocation blocks 0..total_blocks.
4532        // Allocated blocks -> real data; free blocks -> zeros.
4533        let total_blocks = self.mdb.total_blocks as u32;
4534        if self.current_block >= total_blocks {
4535            self.phase = 2;
4536            return Ok(0);
4537        }
4538
4539        let block_size = self.mdb.block_size as u64;
4540
4541        // Coalesce the longest run starting at current_block whose
4542        // allocation state matches. Each per-block seek + read on a
4543        // slow source media (SD card) costs milliseconds; this turns
4544        // O(blocks) syscalls into O(extents).
4545        let allocated = self.is_block_allocated(self.current_block);
4546        let mut run_end = self.current_block + 1;
4547        while run_end < total_blocks && self.is_block_allocated(run_end) == allocated {
4548            run_end += 1;
4549        }
4550        let bytes_in_run = (run_end - self.current_block) as u64 * block_size - self.block_pos;
4551        let to_read = (bytes_in_run as usize).min(buf.len());
4552
4553        let n = if allocated {
4554            let offset = self.partition_offset
4555                + self.mdb.first_alloc_block as u64 * 512
4556                + self.current_block as u64 * block_size
4557                + self.block_pos;
4558            self.reader
4559                .seek(SeekFrom::Start(offset))
4560                .map_err(disk_core::io_other)?;
4561            self.reader.read(&mut buf[..to_read])?
4562        } else {
4563            // Free run — emit zeros so free space compresses to nothing.
4564            buf[..to_read].fill(0);
4565            to_read
4566        };
4567
4568        // Advance cursor (current_block + block_pos) by `n` bytes.
4569        let mut remaining_in_n = n as u64;
4570        if self.block_pos > 0 {
4571            let want = block_size - self.block_pos;
4572            let take = remaining_in_n.min(want);
4573            self.block_pos += take;
4574            remaining_in_n -= take;
4575            if self.block_pos >= block_size {
4576                self.block_pos = 0;
4577                self.current_block += 1;
4578            }
4579        }
4580        let whole = (remaining_in_n / block_size) as u32;
4581        self.current_block += whole;
4582        remaining_in_n -= whole as u64 * block_size;
4583        if remaining_in_n > 0 {
4584            self.block_pos = remaining_in_n;
4585        }
4586
4587        Ok(n)
4588    }
4589}
4590
4591// --- Resize and validation functions ---
4592
4593/// Resize an HFS filesystem in place.
4594/// Probe a classic HFS volume and return the maximum size (bytes) it can be
4595/// grown to in-place, given its allocation block size and Volume Bitmap
4596/// capacity. Returns `None` if the partition doesn't start with a valid HFS
4597/// MDB (e.g. it's HFS+, HFSX, or something else).
4598///
4599/// The ceiling is `min(65535 blocks, VBM bit capacity) * block_size + overhead`.
4600/// Growing beyond this requires reformatting with a larger block size.
4601/// Grow a classic HFS volume to fill its partition (R-058). Mac OS reads the
4602/// alternate MDB two sectors before the partition end and expects the
4603/// allocation area to end there, so a smaller volume fails Disk First Aid
4604/// however its headers are placed. Returns whether the volume was grown.
4605pub fn fit_hfs_volume_to_partition(
4606    device: &mut (impl Read + Write + Seek),
4607    partition_offset: u64,
4608    partition_len: u64,
4609    log: &mut impl FnMut(&str),
4610) -> anyhow::Result<bool> {
4611    device.seek(SeekFrom::Start(partition_offset + 1024))?;
4612    let mut sector = [0u8; 512];
4613    device.read_exact(&mut sector)?;
4614    if BigEndian::read_u16(&sector[0..2]) != HFS_SIGNATURE {
4615        return Ok(false);
4616    }
4617    // A wrapper's embedded HFS+ volume keeps its own headers; leave it alone.
4618    if BigEndian::read_u16(&sector[0x7C..0x7E]) == 0x482B {
4619        return Ok(false);
4620    }
4621    let total_blocks = BigEndian::read_u16(&sector[0x12..0x14]) as u64;
4622    let block_size = BigEndian::read_u32(&sector[0x14..0x18]) as u64;
4623    let first_alloc = BigEndian::read_u16(&sector[0x1C..0x1E]) as u64;
4624    let volume_len = first_alloc * 512 + total_blocks * block_size + 1024;
4625    if block_size == 0 || volume_len >= partition_len {
4626        return Ok(false);
4627    }
4628    let growable = hfs_max_growable_size(device, partition_offset).unwrap_or(0);
4629    if growable < partition_len {
4630        log(&format!(
4631            "Warning: the HFS volume ({volume_len} bytes) does not fill its {partition_len}-byte \
4632             partition and its volume bitmap cannot address more blocks; Disk First Aid will \
4633             report an invalid allocation block start. Format the volume at the partition size"
4634        ));
4635        return Ok(false);
4636    }
4637    resize_hfs_in_place(device, partition_offset, partition_len, log)?;
4638    log(&format!(
4639        "HFS volume grown from {volume_len} bytes to fill its {partition_len}-byte partition"
4640    ));
4641    Ok(true)
4642}
4643
4644pub fn hfs_max_growable_size(
4645    device: &mut (impl Read + Seek),
4646    partition_offset: u64,
4647) -> Option<u64> {
4648    device.seek(SeekFrom::Start(partition_offset + 1024)).ok()?;
4649    let mut sector = [0u8; 512];
4650    device.read_exact(&mut sector).ok()?;
4651
4652    let sig = BigEndian::read_u16(&sector[0..2]);
4653    if sig != HFS_SIGNATURE {
4654        return None;
4655    }
4656
4657    let block_size = BigEndian::read_u32(&sector[20..24]) as u64;
4658    let vbm_start = BigEndian::read_u16(&sector[14..16]) as u64;
4659    let first_alloc = BigEndian::read_u16(&sector[28..30]) as u64;
4660
4661    if block_size == 0 || first_alloc <= vbm_start {
4662        return None;
4663    }
4664
4665    let vbm_capacity = (first_alloc - vbm_start) * 512 * 8;
4666    let max_blocks = vbm_capacity.min(u16::MAX as u64);
4667    // Plus the alternate MDB and the trailing sector the allocation area stops short of.
4668    let overhead = first_alloc * 512 + 1024;
4669
4670    Some(max_blocks * block_size + overhead)
4671}
4672
4673pub fn resize_hfs_in_place(
4674    device: &mut (impl Read + Write + Seek),
4675    partition_offset: u64,
4676    new_size_bytes: u64,
4677    log: &mut impl FnMut(&str),
4678) -> anyhow::Result<()> {
4679    // Read MDB sector (512-byte aligned I/O for raw device compatibility)
4680    // The MDB is at partition_offset + 1024 and is 162 bytes, but we read/write
4681    // a full 512-byte sector to satisfy raw device alignment requirements.
4682    device.seek(SeekFrom::Start(partition_offset + 1024))?;
4683    let mut sector = [0u8; 512];
4684    device.read_exact(&mut sector)?;
4685
4686    let sig = BigEndian::read_u16(&sector[0..2]);
4687    if sig != HFS_SIGNATURE {
4688        log("HFS resize: not an HFS volume, skipping");
4689        return Ok(());
4690    }
4691
4692    let block_size = BigEndian::read_u32(&sector[20..24]);
4693    let old_total = BigEndian::read_u16(&sector[18..20]);
4694    let free_blocks = BigEndian::read_u16(&sector[34..36]);
4695    let vbm_start = BigEndian::read_u16(&sector[14..16]);
4696    let first_alloc = BigEndian::read_u16(&sector[28..30]);
4697    let used_blocks = old_total - free_blocks;
4698
4699    // Volume Bitmap lives in sectors [vbm_start, first_alloc) and holds 1 bit
4700    // per allocation block. If new_total exceeds that bit capacity, the OS
4701    // would walk allocation blocks not represented in the VBM.
4702    let vbm_capacity_blocks = if first_alloc > vbm_start {
4703        (first_alloc - vbm_start) as u64 * 512 * 8
4704    } else {
4705        0
4706    };
4707
4708    // The alternate MDB and the unused sector after it stay outside the
4709    // allocation area, as the formatter lays them out (R-059).
4710    let overhead = first_alloc as u64 * 512 + 1024;
4711    if new_size_bytes <= overhead {
4712        anyhow::bail!(
4713            "HFS resize: new size {} bytes <= overhead {} bytes",
4714            new_size_bytes,
4715            overhead
4716        );
4717    }
4718    let new_total_u64 = (new_size_bytes - overhead) / block_size as u64;
4719
4720    // drNmAlBlks is a u16 — 65535 allocation blocks is the hard HFS ceiling.
4721    // Silently truncating here would write a bogus block count to the MDB.
4722    if new_total_u64 > u16::MAX as u64 {
4723        let max_bytes = u16::MAX as u64 * block_size as u64 + overhead;
4724        anyhow::bail!(
4725            "HFS resize: requested size {} MB needs {} allocation blocks at {}-byte blocks, \
4726             but HFS caps at 65535 blocks (max {} MB for this volume). \
4727             Growing further would require reformatting with a larger allocation block size.",
4728            new_size_bytes / 1024 / 1024,
4729            new_total_u64,
4730            block_size,
4731            max_bytes / 1024 / 1024,
4732        );
4733    }
4734    if new_total_u64 > vbm_capacity_blocks {
4735        anyhow::bail!(
4736            "HFS resize: requested {} allocation blocks exceeds the Volume Bitmap capacity \
4737             ({} blocks) for this volume. The VBM lives in fixed sectors [{}..{}) and cannot \
4738             be grown without reformatting: the most this volume can grow to is {} bytes. \
4739             Mac OS wants a classic HFS volume to fill its partition, so format one at the \
4740             partition size instead (`new volume hfs --size`, then `new hd --fill`).",
4741            new_total_u64,
4742            vbm_capacity_blocks,
4743            vbm_start,
4744            first_alloc,
4745            vbm_capacity_blocks.min(u16::MAX as u64) * block_size as u64 + overhead,
4746        );
4747    }
4748    let new_total = new_total_u64 as u16;
4749
4750    // A count says nothing about position: a volume with free blocks near the
4751    // front still has data at its tail, and cutting there loses it.
4752    let vbm_len = (first_alloc - vbm_start) as usize * 512;
4753    let mut vbm = vec![0u8; vbm_len];
4754    device.seek(SeekFrom::Start(partition_offset + vbm_start as u64 * 512))?;
4755    device.read_exact(&mut vbm)?;
4756    let highest_used = find_last_set_bit(&vbm, old_total as u32).map_or(0, |b| b + 1);
4757    if (new_total as u32) < highest_used {
4758        anyhow::bail!(
4759            "HFS resize: new size {} blocks ends before the last allocated block ({}); \
4760             {} of {} blocks are in use",
4761            new_total,
4762            highest_used,
4763            used_blocks,
4764            old_total
4765        );
4766    }
4767    // Blocks the volume gains, or gives up, read as free rather than as
4768    // whatever the bits past the old count held.
4769    for b in old_total.min(new_total) as u32..old_total.max(new_total) as u32 {
4770        bitmap_clear_bit_be(&mut vbm, b);
4771    }
4772    device.seek(SeekFrom::Start(partition_offset + vbm_start as u64 * 512))?;
4773    device.write_all(&vbm)?;
4774
4775    let new_free = new_total - used_blocks;
4776
4777    log(&format!(
4778        "HFS resize: {} -> {} blocks ({} free)",
4779        old_total, new_total, new_free
4780    ));
4781
4782    // Update MDB fields in the sector buffer
4783    BigEndian::write_u16(&mut sector[18..20], new_total);
4784    BigEndian::write_u16(&mut sector[34..36], new_free);
4785
4786    // Write primary MDB sector at offset + 1024
4787    device.seek(SeekFrom::Start(partition_offset + 1024))?;
4788    device.write_all(&sector)?;
4789
4790    // Write backup MDB sector at offset + new_size - 1024
4791    if new_size_bytes > 1024 {
4792        device.seek(SeekFrom::Start(partition_offset + new_size_bytes - 1024))?;
4793        device.write_all(&sector)?;
4794    }
4795
4796    device.flush()?;
4797    Ok(())
4798}
4799
4800/// Validate HFS filesystem integrity.
4801pub fn validate_hfs_integrity(
4802    device: &mut (impl Read + Seek),
4803    partition_offset: u64,
4804    log: &mut impl FnMut(&str),
4805) -> anyhow::Result<()> {
4806    // Read MDB sector (512-byte aligned I/O for raw device compatibility)
4807    device.seek(SeekFrom::Start(partition_offset + 1024))?;
4808    let mut sector = [0u8; 512];
4809    device.read_exact(&mut sector)?;
4810
4811    let sig = BigEndian::read_u16(&sector[0..2]);
4812    if sig != HFS_SIGNATURE {
4813        log("HFS validate: not an HFS volume, skipping");
4814        return Ok(());
4815    }
4816
4817    let block_size = BigEndian::read_u32(&sector[20..24]);
4818    let total_blocks = BigEndian::read_u16(&sector[18..20]);
4819
4820    // Basic sanity checks
4821    if !block_size.is_power_of_two() || block_size < 512 {
4822        anyhow::bail!("HFS validate: invalid block size {block_size}");
4823    }
4824    if total_blocks == 0 {
4825        anyhow::bail!("HFS validate: zero total blocks");
4826    }
4827
4828    log(&format!(
4829        "HFS validate: OK ({total_blocks} blocks, {} block size)",
4830        block_size
4831    ));
4832    Ok(())
4833}
4834
4835#[cfg(test)]
4836#[allow(clippy::identity_op)] // `1 * 1024 * 1024` reads as "1 MB" in size tables
4837mod tests {
4838    use super::*;
4839    use std::io::Cursor;
4840
4841    /// The streaming and in-memory blank formatters must agree byte for byte —
4842    /// the streaming one only skips writing the free middle, which reads back
4843    /// as zeros from a `set_len`'d file.
4844    #[test]
4845    fn write_blank_hfs_into_matches_create_blank_hfs() {
4846        let (size, bs) = (8 * 1024 * 1024u64, 4096u32);
4847        let now = 0xB0A1_2345;
4848        let mem = create_blank_hfs_sized_at(size, bs, "StreamEq", 0, 0, now).unwrap();
4849
4850        let mut sink = Cursor::new(vec![0u8; 0]);
4851        let written = write_blank_hfs_into_at(&mut sink, 0, size, bs, "StreamEq", now).unwrap();
4852        let mut streamed = sink.into_inner();
4853        streamed.resize(written as usize, 0);
4854
4855        assert_eq!(written as usize, mem.len());
4856        assert_eq!(streamed, mem);
4857    }
4858
4859    /// The same at a non-zero offset, which is how the Mac CD-ROM builder lays
4860    /// the volume inside its `Apple_HFS` partition.
4861    #[test]
4862    fn write_blank_hfs_into_honours_the_offset() {
4863        let (size, bs) = (4 * 1024 * 1024u64, 2048u32);
4864        let now = 0xB0A1_2345;
4865        let mem = create_blank_hfs_sized_at(size, bs, "Offset", 0, 0, now).unwrap();
4866
4867        let offset = 64 * 512u64;
4868        let mut sink = Cursor::new(vec![0u8; 0]);
4869        let written = write_blank_hfs_into_at(&mut sink, offset, size, bs, "Offset", now).unwrap();
4870        let mut streamed = sink.into_inner();
4871        streamed.resize((offset + written) as usize, 0);
4872
4873        assert!(streamed[..offset as usize].iter().all(|&b| b == 0));
4874        assert_eq!(&streamed[offset as usize..], &mem[..]);
4875    }
4876
4877    #[test]
4878    fn test_mac_roman_to_utf8_ascii() {
4879        let data = b"Hello World";
4880        assert_eq!(mac_roman_to_utf8(data), "Hello World");
4881    }
4882
4883    #[test]
4884    fn test_mac_roman_to_utf8_special() {
4885        // 0x80 = Ä, 0x81 = Å, 0x87 = á
4886        let data = [0x80, 0x81, 0x87];
4887        assert_eq!(mac_roman_to_utf8(&data), "ÄÅá");
4888    }
4889
4890    #[test]
4891    fn test_decode_mac_filename_utf8() {
4892        // "HeapFixer" + UTF-8 ™ (E2 84 A2) + " 1.01" — a re-packed archive
4893        // whose name is already UTF-8. Must NOT be re-decoded as Mac Roman.
4894        let mut data = b"HeapFixer".to_vec();
4895        data.extend_from_slice(&[0xE2, 0x84, 0xA2]);
4896        data.extend_from_slice(b" 1.01");
4897        assert_eq!(decode_mac_filename(&data), "HeapFixer™ 1.01");
4898    }
4899
4900    #[test]
4901    fn test_decode_mac_filename_macroman() {
4902        // Genuine Mac Roman names: 0xC4 = ƒ, 0xA5 = •, 0xA8 = ®. These are lone
4903        // high bytes (invalid standalone UTF-8) so they take the Mac Roman path.
4904        assert_eq!(decode_mac_filename(&[0xC4]), "ƒ");
4905        assert_eq!(decode_mac_filename(b"Cache\xA5Flow"), "Cache•Flow");
4906        assert_eq!(decode_mac_filename(b"Virex\xA8 3.51"), "Virex® 3.51");
4907    }
4908
4909    #[test]
4910    fn test_decode_mac_filename_ascii() {
4911        assert_eq!(decode_mac_filename(b"ScriptEdit"), "ScriptEdit");
4912    }
4913
4914    #[test]
4915    fn test_format_ostype() {
4916        // High Mac Roman byte in a creator code must render as its glyph, not �.
4917        assert_eq!(format_ostype(&[b'S', 0xA5, b'L', b' ']), "S•L");
4918        assert_eq!(format_ostype(b"APPL"), "APPL");
4919        // Trailing NUL/space padding is trimmed.
4920        assert_eq!(format_ostype(&[b'L', b's', 0x00, 0x00]), "Ls");
4921    }
4922
4923    #[test]
4924    fn test_mdb_parse() {
4925        let mut data = [0u8; 162];
4926        // Signature
4927        BigEndian::write_u16(&mut data[0..2], HFS_SIGNATURE);
4928        // total_blocks
4929        BigEndian::write_u16(&mut data[18..20], 1000);
4930        // block_size
4931        BigEndian::write_u32(&mut data[20..24], 4096);
4932        // free_blocks
4933        BigEndian::write_u16(&mut data[34..36], 200);
4934        // volume name: Pascal string "TestVol"
4935        data[36] = 7;
4936        data[37..44].copy_from_slice(b"TestVol");
4937
4938        let mdb = HfsMasterDirectoryBlock::parse(&data).unwrap();
4939        assert_eq!(mdb.signature, HFS_SIGNATURE);
4940        assert_eq!(mdb.total_blocks, 1000);
4941        assert_eq!(mdb.block_size, 4096);
4942        assert_eq!(mdb.free_blocks, 200);
4943        assert_eq!(mdb.volume_name, "TestVol");
4944        assert!(!mdb.has_embedded_hfs_plus());
4945    }
4946
4947    #[test]
4948    fn test_mdb_detect_embedded_hfs_plus() {
4949        let mut data = [0u8; 162];
4950        BigEndian::write_u16(&mut data[0..2], HFS_SIGNATURE);
4951        BigEndian::write_u16(&mut data[18..20], 1000);
4952        BigEndian::write_u32(&mut data[20..24], 4096);
4953        // Embedded HFS+ signature
4954        BigEndian::write_u16(&mut data[124..126], HFS_PLUS_EMBEDDED_SIGNATURE);
4955        BigEndian::write_u16(&mut data[126..128], 10); // start
4956        BigEndian::write_u16(&mut data[128..130], 500); // count
4957
4958        let mdb = HfsMasterDirectoryBlock::parse(&data).unwrap();
4959        assert!(mdb.has_embedded_hfs_plus());
4960    }
4961
4962    #[test]
4963    fn test_find_last_set_bit() {
4964        let bitmap = [0b10100000, 0b00001000]; // bits 0, 2, 12
4965        assert_eq!(find_last_set_bit(&bitmap, 16), Some(12));
4966
4967        let bitmap_empty = [0u8; 2];
4968        assert_eq!(find_last_set_bit(&bitmap_empty, 16), None);
4969
4970        let bitmap_first = [0b10000000]; // bit 0
4971        assert_eq!(find_last_set_bit(&bitmap_first, 8), Some(0));
4972    }
4973
4974    #[test]
4975    fn test_utf8_to_mac_roman_roundtrip() {
4976        let text = "Hello World";
4977        let mac = utf8_to_mac_roman(text).unwrap();
4978        assert_eq!(mac, b"Hello World");
4979        assert_eq!(mac_roman_to_utf8(&mac), text);
4980    }
4981
4982    #[test]
4983    fn test_utf8_to_mac_roman_special() {
4984        let text = "Ä"; // U+00C4 => Mac Roman 0x80
4985        let mac = utf8_to_mac_roman(text).unwrap();
4986        assert_eq!(mac, &[0x80]);
4987        assert_eq!(mac_roman_to_utf8(&mac), text);
4988    }
4989
4990    #[test]
4991    fn test_utf8_to_mac_roman_unencodable() {
4992        let text = "\u{4E2D}"; // Chinese character — not in Mac Roman
4993        assert!(utf8_to_mac_roman(text).is_err());
4994    }
4995
4996    /// A node size under 512 (a Disk Copy 6 data fork read without `bcem`) used to panic.
4997    #[test]
4998    fn tiny_catalog_node_size_does_not_panic() {
4999        use disk_core::filesystem::Filesystem;
5000        let clean = create_blank_hfs(1474560, 512, "Tiny").unwrap();
5001        let mdb = {
5002            let fs = HfsFilesystem::open(Cursor::new(clean.clone()), 0).unwrap();
5003            fs.mdb.clone()
5004        };
5005        let cat_off = mdb.first_alloc_block as usize * 512
5006            + mdb.catalog_file_extents[0].start_block as usize * mdb.block_size as usize;
5007        for bad in [1u16, 3, 9, 14, 17, 27, 28, 511] {
5008            let mut img = clean.clone();
5009            img[cat_off + 32..cat_off + 34].copy_from_slice(&bad.to_be_bytes());
5010            let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
5011            let root = fs.root().unwrap();
5012            let listed = fs.list_directory(&root);
5013            assert!(
5014                listed.map(|v| v.is_empty()).unwrap_or(true),
5015                "node size {bad}: expected an empty listing or an error"
5016            );
5017        }
5018    }
5019
5020    /// H13: the shrink guard compared block counts, so a volume with free
5021    /// space at its front and one file at its tail could be cut through.
5022    /// H10: classic HFS keeps wall-clock local time, but stamps went out as
5023    /// UTC and came back shifted by the zone offset on every read.
5024    #[test]
5025    fn classic_hfs_stamps_are_local_time_and_round_trip_to_utc() {
5026        use disk_core::filesystem::{CreateFileOptions, EditableFilesystem, Filesystem};
5027        use disk_core::times::{local_utc_offset_secs, unix_to_mac_epoch, UnixTimes};
5028        const T: u64 = 1_700_000_000; // 2023-11-14 22:13:20 UTC
5029        let mut img = create_blank_hfs(4 * 1024 * 1024, 4096, "Zone").unwrap();
5030        let mut fs = HfsFilesystem::open(Cursor::new(&mut img), 0).unwrap();
5031        let root = fs.root().unwrap();
5032        let opts = CreateFileOptions {
5033            unix_times: Some(UnixTimes::mtime_only(T)),
5034            ..Default::default()
5035        };
5036        fs.create_file(&root, "stamp", &mut &b"x"[..], 1, &opts)
5037            .unwrap();
5038        fs.sync_metadata().unwrap();
5039        let entry = fs
5040            .list_directory(&root)
5041            .unwrap()
5042            .into_iter()
5043            .find(|e| e.name == "stamp")
5044            .unwrap();
5045        assert_eq!(entry.modified_unix, Some(T), "UTC must round-trip");
5046        let raw = entry.mac_dates.expect("raw Mac dates").1;
5047        let expected = (T as i64 + local_utc_offset_secs()) as u64;
5048        assert_eq!(
5049            raw,
5050            unix_to_mac_epoch(expected),
5051            "on disk it is the local wall clock"
5052        );
5053    }
5054
5055    /// H7: the alternate MDB was mirrored to the sector after the last
5056    /// allocation block; Mac OS and Disk First Aid read the partition's
5057    /// next-to-last sector, which differs whenever the blocks do not reach it.
5058    #[test]
5059    fn the_alternate_mdb_follows_the_partition_end_when_the_length_is_known() {
5060        use disk_core::filesystem::{CreateFileOptions, EditableFilesystem, Filesystem};
5061        const VOLUME: u64 = 8 * 1024 * 1024;
5062        const PARTITION: u64 = VOLUME + 64 * 1024;
5063        let mut img = create_blank_hfs(VOLUME, 4096, "Slack").unwrap();
5064        img.resize(PARTITION as usize, 0);
5065        {
5066            let mut fs =
5067                HfsFilesystem::open_sized(Cursor::new(&mut img), 0, Some(PARTITION)).unwrap();
5068            let root = fs.root().unwrap();
5069            fs.create_file(&root, "a", &mut &b"x"[..], 1, &CreateFileOptions::default())
5070                .unwrap();
5071            fs.sync_metadata().unwrap();
5072        }
5073        let primary = &img[1024..1536];
5074        let alt = &img[PARTITION as usize - 1024..PARTITION as usize - 512];
5075        assert_eq!(
5076            &alt[0..2],
5077            b"BD",
5078            "alternate MDB missing at the partition end"
5079        );
5080        assert_eq!(primary, alt, "the mirror must equal the primary MDB");
5081    }
5082
5083    /// H14: an imported file's Finder flags (invisible, bundle, custom icon)
5084    /// had no way into create_file and came out zero.
5085    #[test]
5086    fn create_file_writes_the_finder_flags_it_is_given() {
5087        use disk_core::filesystem::{CreateFileOptions, EditableFilesystem, Filesystem};
5088        let mut img = create_blank_hfs(4 * 1024 * 1024, 4096, "Flags").unwrap();
5089        let mut fs = HfsFilesystem::open(Cursor::new(&mut img), 0).unwrap();
5090        let root = fs.root().unwrap();
5091        let opts = CreateFileOptions {
5092            os_type: Some(*b"APPL"),
5093            os_creator: Some(*b"ttxt"),
5094            finder_flags: Some(0x2000),
5095            ..Default::default()
5096        };
5097        fs.create_file(&root, "app", &mut &b"x"[..], 1, &opts)
5098            .unwrap();
5099        fs.sync_metadata().unwrap();
5100        let entry = fs
5101            .list_directory(&root)
5102            .unwrap()
5103            .into_iter()
5104            .find(|e| e.name == "app")
5105            .unwrap();
5106        assert_eq!(entry.finder_flags, Some(0x2000));
5107    }
5108
5109    #[test]
5110    fn shrink_is_refused_when_data_sits_past_the_new_end() {
5111        use disk_core::filesystem::{CreateFileOptions, EditableFilesystem, Filesystem};
5112        const SIZE: u64 = 8 * 1024 * 1024;
5113        let mut img = create_blank_hfs(SIZE, 4096, "Tail").unwrap();
5114        {
5115            let mut fs = HfsFilesystem::open(Cursor::new(&mut img), 0).unwrap();
5116            let root = fs.root().unwrap();
5117            fs.create_file(&root, "a", &mut &b"x"[..], 1, &CreateFileOptions::default())
5118                .unwrap();
5119            fs.sync_metadata().unwrap();
5120        }
5121        let total = BigEndian::read_u16(&img[1024 + 18..1024 + 20]) as usize;
5122        let vbm_start = BigEndian::read_u16(&img[1024 + 14..1024 + 16]) as usize;
5123        // Mark the last allocation block used without touching the counts.
5124        let tail = total - 1;
5125        let byte = vbm_start * 512 + tail / 8;
5126        img[byte] |= 0x80 >> (tail % 8);
5127        let err = resize_hfs_in_place(&mut Cursor::new(&mut img), 0, SIZE / 2, &mut |_| {})
5128            .expect_err("a tail block in use must refuse the shrink");
5129        assert!(format!("{err}").contains("last allocated block"), "{err}");
5130        img[byte] &= !(0x80 >> (tail % 8));
5131        resize_hfs_in_place(&mut Cursor::new(&mut img), 0, SIZE / 2, &mut |_| {})
5132            .expect("with the tail free the shrink goes through");
5133    }
5134
5135    /// R-078: the minimum size left no room for the alternate MDB, so a shrink to it cut the last block.
5136    #[test]
5137    fn last_data_byte_is_a_size_the_volume_shrinks_to() {
5138        use disk_core::filesystem::Filesystem;
5139        const SIZE: u64 = 8 * 1024 * 1024;
5140        let mut img = create_blank_hfs(SIZE, 4096, "Tail").unwrap();
5141        let total = BigEndian::read_u16(&img[1024 + 18..1024 + 20]) as usize;
5142        let vbm_start = BigEndian::read_u16(&img[1024 + 14..1024 + 16]) as usize;
5143        let free = BigEndian::read_u16(&img[1024 + 34..1024 + 36]);
5144        // Mark a block halfway through used, as the last allocated one.
5145        let last = total / 2;
5146        img[vbm_start * 512 + last / 8] |= 0x80 >> (last % 8);
5147        BigEndian::write_u16(&mut img[1024 + 34..1024 + 36], free - 1);
5148        let floor = {
5149            let mut fs = HfsFilesystem::open(Cursor::new(&mut img), 0).unwrap();
5150            fs.last_data_byte().unwrap()
5151        };
5152        resize_hfs_in_place(&mut Cursor::new(&mut img), 0, floor, &mut |_| {})
5153            .expect("a shrink to last_data_byte must keep the last allocated block");
5154        let new_total = BigEndian::read_u16(&img[1024 + 18..1024 + 20]) as usize;
5155        assert_eq!(
5156            new_total,
5157            last + 1,
5158            "the floor holds exactly the used blocks"
5159        );
5160    }
5161
5162    #[test]
5163    fn test_resize_hfs_rejects_u16_overflow() {
5164        // Build a minimal HFS MDB with 8192-byte blocks. The u16 ceiling at this
5165        // block size is 65535 * 8192 = ~512 MB, so a 2047 MB resize request must
5166        // bail (not silently truncate via `as u16`).
5167        let mut sector = [0u8; 512];
5168        BigEndian::write_u16(&mut sector[0..2], HFS_SIGNATURE);
5169        BigEndian::write_u16(&mut sector[14..16], 3); // vbm_st
5170        BigEndian::write_u16(&mut sector[18..20], 64110); // drNmAlBlks
5171        BigEndian::write_u32(&mut sector[20..24], 8192); // drAlBlkSiz
5172        BigEndian::write_u16(&mut sector[28..30], 19); // drAlBlSt
5173        BigEndian::write_u16(&mut sector[34..36], 31423); // drFreeBks
5174
5175        // resize_hfs_in_place reads only the MDB at offset 1024 and bails on the
5176        // u16 block-count check *before* it seeks to the (would-be) backup MDB
5177        // near the end, so the image just needs to hold the MDB sector — no
5178        // multi-hundred-MB pad. (This also lets the test run on 32-bit targets,
5179        // which couldn't allocate the old 512 MB buffer.)
5180        let mut img = vec![0u8; 2048];
5181        img[1024..1024 + 512].copy_from_slice(&sector);
5182
5183        let mut cursor = Cursor::new(img);
5184        let res = resize_hfs_in_place(
5185            &mut cursor,
5186            0,
5187            2047 * 1024 * 1024, // would need ~262016 blocks, overflows u16
5188            &mut |_| {},
5189        );
5190        let err = res.expect_err("resize should have bailed on u16 overflow");
5191        let msg = format!("{err}");
5192        assert!(
5193            msg.contains("65535") || msg.contains("u16") || msg.contains("Volume Bitmap"),
5194            "unexpected error: {msg}"
5195        );
5196    }
5197
5198    #[test]
5199    fn test_mdb_serialize_roundtrip() {
5200        let mut data = [0u8; 512];
5201        BigEndian::write_u16(&mut data[0..2], HFS_SIGNATURE);
5202        BigEndian::write_u16(&mut data[18..20], 100);
5203        BigEndian::write_u32(&mut data[20..24], 4096);
5204        BigEndian::write_u16(&mut data[34..36], 50);
5205        BigEndian::write_u32(&mut data[30..34], 10); // next_catalog_id
5206        BigEndian::write_u32(&mut data[92..96], 42); // finder_info[0]
5207        data[36] = 4;
5208        data[37..41].copy_from_slice(b"Test");
5209
5210        let mdb = HfsMasterDirectoryBlock::parse(&data).unwrap();
5211        let serialized = mdb.serialize_to_sector();
5212        assert_eq!(BigEndian::read_u16(&serialized[18..20]), 100);
5213        assert_eq!(BigEndian::read_u32(&serialized[30..34]), 10);
5214        assert_eq!(BigEndian::read_u32(&serialized[92..96]), 42);
5215    }
5216
5217    /// Create a minimal valid in-memory classic HFS image for testing.
5218    /// Layout: 128 blocks × 4096 bytes = 512 KB
5219    /// - Sectors 0-1: Boot blocks (1024 bytes)
5220    /// - Sector 2: MDB (1024 bytes)
5221    /// - Sectors 3-4: Volume bitmap (1024 bytes, covers 128 blocks = 16 bytes needed)
5222    /// - first_alloc_block = 5 (sectors), allocation starts at byte 2560
5223    /// - Block 0-3: catalog B-tree (4 blocks = 16 KB)
5224    ///   - Node 0: header node (4096 bytes)
5225    ///   - Node 1: leaf node with root folder + thread
5226    ///   - Nodes 2-3: free
5227    /// - Blocks 4+: free for user data
5228    fn make_editable_hfs_image() -> Vec<u8> {
5229        let block_size = 4096u32;
5230        let total_blocks = 128u16;
5231        let first_alloc_block = 5u16; // start at sector 5 (byte 2560)
5232        let alloc_start = first_alloc_block as usize * 512;
5233        let image_size = alloc_start + total_blocks as usize * block_size as usize;
5234        let mut img = vec![0u8; image_size];
5235
5236        let catalog_start = 0u16; // first 8 allocation blocks
5237        let catalog_blocks = 8u16;
5238        let catalog_size = catalog_blocks as u32 * block_size;
5239
5240        // Volume bitmap at sector 3 (byte 1536)
5241        let bitmap_sector = 3u16;
5242        let bitmap_off = bitmap_sector as usize * 512;
5243        // Mark blocks 0-7 as allocated (catalog spans 8 blocks)
5244        img[bitmap_off] = 0b11111111;
5245
5246        // Build catalog B-tree
5247        let node_size = 4096usize;
5248        let catalog_offset = alloc_start; // block 0 of allocation area
5249
5250        // Node 0: Header node
5251        let hdr_off = catalog_offset;
5252        img[hdr_off + 8] = 1; // kind = header node
5253        BigEndian::write_u16(&mut img[hdr_off + 10..hdr_off + 12], 3); // 3 records
5254
5255        // B-tree header record (record 0, at offset 14)
5256        let hr = hdr_off + 14;
5257        BigEndian::write_u16(&mut img[hr..hr + 2], 1); // depth = 1
5258        BigEndian::write_u32(&mut img[hr + 2..hr + 6], 1); // root_node = 1
5259        BigEndian::write_u32(&mut img[hr + 6..hr + 10], 2); // leaf_records = 2
5260        BigEndian::write_u32(&mut img[hr + 10..hr + 14], 1); // first_leaf_node = 1
5261        BigEndian::write_u32(&mut img[hr + 14..hr + 18], 1); // last_leaf_node = 1
5262        BigEndian::write_u16(&mut img[hr + 18..hr + 20], node_size as u16); // node_size
5263        BigEndian::write_u16(&mut img[hr + 20..hr + 22], 37); // max_key_len
5264        BigEndian::write_u32(&mut img[hr + 22..hr + 26], 8); // total_nodes = 8
5265        BigEndian::write_u32(&mut img[hr + 26..hr + 30], 6); // free_nodes = 6
5266
5267        // Record offset table for header node
5268        let ot = hdr_off + node_size;
5269        BigEndian::write_u16(&mut img[ot - 2..ot], 14); // record 0
5270        BigEndian::write_u16(&mut img[ot - 4..ot - 2], 142); // record 1 (user data)
5271        BigEndian::write_u16(&mut img[ot - 6..ot - 4], 270); // record 2 (bitmap)
5272        BigEndian::write_u16(&mut img[ot - 8..ot - 6], 526); // free space
5273
5274        // Node bitmap (record 2): mark nodes 0 and 1 as allocated
5275        img[hdr_off + 270] = 0b11000000;
5276
5277        // Node 1: Leaf node with root folder + thread
5278        let leaf_off = catalog_offset + node_size;
5279        img[leaf_off + 8] = 0xFF; // kind = -1 (leaf)
5280        img[leaf_off + 9] = 1; // height = 1
5281        BigEndian::write_u16(&mut img[leaf_off + 10..leaf_off + 12], 2); // 2 records
5282
5283        // Record 0: Root folder record (CNID 2)
5284        // Key: key_len(1) + reserved(1) + parent_id(4, =1) + name_len(1) + name(0) = 7 bytes
5285        // Pad to even: 8 bytes total
5286        let r0_off = leaf_off + 14;
5287        img[r0_off] = 6; // key_len = 6 (reserved + parent_id + name_len)
5288        img[r0_off + 1] = 0; // reserved
5289        BigEndian::write_u32(&mut img[r0_off + 2..r0_off + 6], 1); // parent_id = 1
5290        img[r0_off + 6] = 0; // name_len = 0
5291                             // Pad to even
5292        img[r0_off + 7] = 0;
5293        // Record data at offset 8 (even-aligned)
5294        let r0_data = r0_off + 8;
5295        img[r0_data] = CATALOG_DIR as u8; // type = directory
5296                                          // dir record: type(1) + reserved(1) + flags(2) + valence(2) + dirID(4) + ...
5297        BigEndian::write_u32(&mut img[r0_data + 6..r0_data + 10], 2); // dirID = 2
5298                                                                      // Dir record = 70 bytes
5299
5300        // Record 1: Thread record for root (CNID 2)
5301        let r1_off = r0_data + 70;
5302        img[r1_off] = 6; // key_len = 6
5303        img[r1_off + 1] = 0; // reserved
5304        BigEndian::write_u32(&mut img[r1_off + 2..r1_off + 6], 2); // parent_id = 2 (CNID)
5305        img[r1_off + 6] = 0; // name_len = 0
5306        img[r1_off + 7] = 0; // padding
5307        let r1_data = r1_off + 8;
5308        img[r1_data] = CATALOG_DIR_THREAD as u8; // type = dir thread
5309        img[r1_data + 1] = 0; // reserved
5310                              // reserved(8) at offset 2-9
5311        BigEndian::write_u32(&mut img[r1_data + 10..r1_data + 14], 1); // parentID = 1
5312                                                                       // Str31 name field: 32 bytes (1 length byte + 31 bytes name/padding)
5313        img[r1_data + 14] = 0; // name_len = 0 (rest of 32 bytes already zero)
5314                               // Thread data total = 2 (type+rsv) + 8 (rsv) + 4 (parentID) + 32 (Str31) = 46 bytes
5315
5316        // Record offset table for leaf node
5317        let lot = leaf_off + node_size;
5318        BigEndian::write_u16(&mut img[lot - 2..lot], 14); // record 0
5319        let r1_rel = (r1_off - leaf_off) as u16;
5320        BigEndian::write_u16(&mut img[lot - 4..lot - 2], r1_rel); // record 1
5321        let free_rel = (r1_data + 46 - leaf_off) as u16;
5322        BigEndian::write_u16(&mut img[lot - 6..lot - 4], free_rel); // free space
5323
5324        // MDB at byte 1024
5325        let mdb_off = 1024;
5326        let mut mdb = [0u8; 512];
5327        BigEndian::write_u16(&mut mdb[0..2], HFS_SIGNATURE);
5328        BigEndian::write_u32(&mut mdb[2..6], hfs_common::hfs_now()); // create date
5329        BigEndian::write_u32(&mut mdb[6..10], hfs_common::hfs_now()); // modify date
5330        BigEndian::write_u16(&mut mdb[14..16], bitmap_sector); // volume bitmap block
5331        BigEndian::write_u16(&mut mdb[18..20], total_blocks);
5332        BigEndian::write_u32(&mut mdb[20..24], block_size);
5333        BigEndian::write_u16(&mut mdb[28..30], first_alloc_block);
5334        BigEndian::write_u32(&mut mdb[30..34], 16); // next_catalog_id
5335        BigEndian::write_u16(&mut mdb[34..36], total_blocks - catalog_blocks); // free blocks
5336                                                                               // Volume name: "TestVol"
5337        mdb[36] = 7;
5338        mdb[37..44].copy_from_slice(b"TestVol");
5339        BigEndian::write_u32(&mut mdb[84..88], 0); // file_count
5340        BigEndian::write_u32(&mut mdb[88..92], 0); // folder_count (drDirCnt excludes root)
5341                                                   // Catalog file size
5342        BigEndian::write_u32(&mut mdb[146..150], catalog_size);
5343        // Catalog extents
5344        BigEndian::write_u16(&mut mdb[150..152], catalog_start);
5345        BigEndian::write_u16(&mut mdb[152..154], catalog_blocks);
5346
5347        img[mdb_off..mdb_off + 512].copy_from_slice(&mdb);
5348
5349        img
5350    }
5351
5352    /// Create an "uninitialized" HFS image mimicking Disk Jockey's Empty HFS
5353    /// output: MDB present with catalog/extents extents reserved, but the
5354    /// catalog and extents-overflow regions are all zeros and the volume
5355    /// bitmap is blank.
5356    fn make_uninitialized_hfs_image() -> Vec<u8> {
5357        let block_size = 512u32;
5358        let total_blocks = 256u16;
5359        let first_alloc_block = 6u16;
5360        let alloc_start = first_alloc_block as usize * 512;
5361        let image_size = alloc_start + total_blocks as usize * block_size as usize;
5362        let mut img = vec![0u8; image_size];
5363
5364        // Extents B-tree: blocks 0..3 (4 × 512 = 2048 bytes)
5365        let extents_start = 0u16;
5366        let extents_blocks = 4u16;
5367        // Catalog B-tree: blocks 4..7 (4 × 512 = 2048 bytes)
5368        let catalog_start = 4u16;
5369        let catalog_blocks = 4u16;
5370
5371        // MDB at byte 1024, bitmap at sector 3 (byte 1536), both left blank
5372        // for the catalog and extents regions.
5373        let mdb_off = 1024usize;
5374        let mut mdb = [0u8; 512];
5375        BigEndian::write_u16(&mut mdb[0..2], HFS_SIGNATURE);
5376        BigEndian::write_u32(&mut mdb[2..6], hfs_common::hfs_now());
5377        BigEndian::write_u32(&mut mdb[6..10], hfs_common::hfs_now());
5378        BigEndian::write_u16(&mut mdb[14..16], 3); // vbm_block sector
5379        BigEndian::write_u16(&mut mdb[18..20], total_blocks);
5380        BigEndian::write_u32(&mut mdb[20..24], block_size);
5381        BigEndian::write_u16(&mut mdb[28..30], first_alloc_block);
5382        BigEndian::write_u32(&mut mdb[30..34], 16);
5383        BigEndian::write_u16(
5384            &mut mdb[34..36],
5385            total_blocks - extents_blocks - catalog_blocks,
5386        );
5387        // Volume name "Blank"
5388        mdb[36] = 5;
5389        mdb[37..42].copy_from_slice(b"Blank");
5390        // Extents file
5391        BigEndian::write_u32(&mut mdb[130..134], extents_blocks as u32 * block_size);
5392        BigEndian::write_u16(&mut mdb[134..136], extents_start);
5393        BigEndian::write_u16(&mut mdb[136..138], extents_blocks);
5394        // Catalog file
5395        BigEndian::write_u32(&mut mdb[146..150], catalog_blocks as u32 * block_size);
5396        BigEndian::write_u16(&mut mdb[150..152], catalog_start);
5397        BigEndian::write_u16(&mut mdb[152..154], catalog_blocks);
5398
5399        img[mdb_off..mdb_off + 512].copy_from_slice(&mdb);
5400        img
5401    }
5402
5403    #[test]
5404    fn test_hfs_empty_volume_auto_initialize_and_create_file() {
5405        // Blank catalog + extents regions should be auto-initialized on the
5406        // first mutating call instead of panicking in btree_find_insert_leaf.
5407        let img = make_uninitialized_hfs_image();
5408        let cursor = Cursor::new(img);
5409        let mut fs = HfsFilesystem::open(cursor, 0).unwrap();
5410        assert_eq!(fs.fs_type(), "HFS");
5411
5412        // list_directory on a blank catalog returns an empty list (no panic).
5413        let root = fs.root().unwrap();
5414        let entries = fs.list_directory(&root).unwrap();
5415        assert!(entries.is_empty());
5416
5417        // create_file triggers the auto-init path.
5418        let data = b"auto-init test";
5419        let mut cursor = Cursor::new(data.as_slice());
5420        let fe = fs
5421            .create_file(
5422                &root,
5423                "hello.txt",
5424                &mut cursor,
5425                data.len() as u64,
5426                &CreateFileOptions::default(),
5427            )
5428            .unwrap();
5429        assert_eq!(fe.name, "hello.txt");
5430        fs.sync_metadata().unwrap();
5431
5432        // The new file should be listed and readable.
5433        let entries = fs.list_directory(&root).unwrap();
5434        assert_eq!(entries.len(), 1);
5435        assert_eq!(entries[0].name, "hello.txt");
5436        let read_back = fs.read_file(&fe, 1024).unwrap();
5437        assert_eq!(&read_back, data);
5438
5439        // fsck should report no errors or warnings on the initialized volume.
5440        let result = fs.fsck().unwrap();
5441        let err_msgs: Vec<_> = result.errors.iter().map(|e| e.message.clone()).collect();
5442        let warn_msgs: Vec<_> = result.warnings.iter().map(|w| w.message.clone()).collect();
5443        assert!(
5444            result.errors.is_empty(),
5445            "unexpected fsck errors: {err_msgs:?}"
5446        );
5447        assert!(
5448            result.warnings.is_empty(),
5449            "unexpected fsck warnings: {warn_msgs:?}"
5450        );
5451    }
5452
5453    #[test]
5454    fn test_hfs_editable_open_and_free_space() {
5455        let img = make_editable_hfs_image();
5456        let cursor = Cursor::new(img);
5457        let mut fs = HfsFilesystem::open(cursor, 0).unwrap();
5458        assert_eq!(fs.fs_type(), "HFS");
5459        assert_eq!(fs.volume_label(), Some("TestVol"));
5460        let free = fs.free_space().unwrap();
5461        // 128 total - 8 catalog blocks = 120 free × 4096
5462        assert_eq!(free, 120 * 4096);
5463    }
5464
5465    #[test]
5466    fn test_hfs_editable_create_file_and_read() {
5467        let img = make_editable_hfs_image();
5468        let cursor = Cursor::new(img);
5469        let mut fs = HfsFilesystem::open(cursor, 0).unwrap();
5470
5471        let root = fs.root().unwrap();
5472        let test_data = b"Hello, HFS World!";
5473        let mut data_reader = Cursor::new(test_data.as_slice());
5474        let options = CreateFileOptions::default();
5475        let fe = fs
5476            .create_file(
5477                &root,
5478                "test.txt",
5479                &mut data_reader,
5480                test_data.len() as u64,
5481                &options,
5482            )
5483            .unwrap();
5484
5485        assert_eq!(fe.name, "test.txt");
5486        assert_eq!(fe.size, test_data.len() as u64);
5487
5488        // Verify listing
5489        let entries = fs.list_directory(&root).unwrap();
5490        assert!(entries.iter().any(|e| e.name == "test.txt"));
5491
5492        // Read back
5493        let read_back = fs.read_file(&fe, 1024).unwrap();
5494        assert_eq!(&read_back, test_data);
5495    }
5496
5497    /// `filStBlk` (offset 24) and `filRStBlk` (offset 34) of an HFS file record
5498    /// are the File Manager's in-memory "first allocation block" cache and MUST
5499    /// be zero on disk — the real fork location lives in the extent record at
5500    /// offset 74 / 86. A non-zero on-disk value makes Apple's `fsck_hfs` (incl.
5501    /// Mac OS X 10.4 Tiger, which fully supports HFS Standard) report
5502    /// "Reserved fields in the catalog record have incorrect data"
5503    /// (E_CatalogFlagsNotZero). Validated fsck_hfs-clean + mountable on Tiger.
5504    #[test]
5505    fn test_hfs_file_record_first_alloc_block_fields_are_zero() {
5506        use crate::hfs_common::walk_leaf_records;
5507        use disk_core::filesystem::ResourceForkSource;
5508
5509        let img = make_editable_hfs_image();
5510        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
5511        let root = fs.root().unwrap();
5512
5513        // A file with BOTH a data fork and a resource fork, so both filStBlk
5514        // and filRStBlk are exercised.
5515        let data = b"data fork contents that span a block";
5516        let options = CreateFileOptions {
5517            resource_fork: Some(ResourceForkSource::Data(b"resource fork bytes".to_vec())),
5518            ..Default::default()
5519        };
5520        fs.create_file(
5521            &root,
5522            "forked.bin",
5523            &mut Cursor::new(data.as_slice()),
5524            data.len() as u64,
5525            &options,
5526        )
5527        .unwrap();
5528        fs.sync_metadata().unwrap();
5529
5530        let node_size = BigEndian::read_u16(&fs.catalog_data[32..34]) as usize;
5531        let first_leaf = BigEndian::read_u32(&fs.catalog_data[24..28]);
5532        let found = walk_leaf_records::<bool, _>(
5533            &fs.catalog_data,
5534            first_leaf,
5535            node_size,
5536            |_n, _i, _off, rec| {
5537                let klen = rec[0] as usize;
5538                let mut ds = 1 + klen;
5539                if !ds.is_multiple_of(2) {
5540                    ds += 1;
5541                }
5542                let body = &rec[ds..];
5543                if body.first() != Some(&2) {
5544                    return None; // not a file record
5545                }
5546                let fil_st_blk = BigEndian::read_u16(&body[24..26]);
5547                let fil_rst_blk = BigEndian::read_u16(&body[34..36]);
5548                let data_ext_start = BigEndian::read_u16(&body[74..76]);
5549                let rsrc_ext_start = BigEndian::read_u16(&body[86..88]);
5550                assert_eq!(fil_st_blk, 0, "filStBlk (offset 24) must be 0 on disk");
5551                assert_eq!(fil_rst_blk, 0, "filRStBlk (offset 34) must be 0 on disk");
5552                // The real fork locations are still recorded in the extents.
5553                assert_ne!(data_ext_start, 0, "data fork extent should be allocated");
5554                assert_ne!(rsrc_ext_start, 0, "rsrc fork extent should be allocated");
5555                Some(true)
5556            },
5557        );
5558        assert_eq!(found, Some(true), "file record not found in catalog");
5559    }
5560
5561    /// Catalog dates surface on `FileEntry::mac_dates` (and the modify value on
5562    /// `modified`), and an in-place `set_dates` round-trips through sync +
5563    /// reopen. Exercises the M4 read path (catalog decode) plus the write path
5564    /// (`set_dates`, the setter `StagedEdit::SetDates` dispatches to).
5565    #[test]
5566    fn test_hfs_catalog_dates_round_trip() {
5567        let img = make_editable_hfs_image();
5568        let mut buf = img.clone();
5569
5570        // create_file stamps create/modify to "now" (non-zero); backup is 0.
5571        {
5572            let mut fs = HfsFilesystem::open(Cursor::new(&mut buf), 0).unwrap();
5573            let root = fs.root().unwrap();
5574            let data = b"x";
5575            let fe = fs
5576                .create_file(
5577                    &root,
5578                    "dated.txt",
5579                    &mut Cursor::new(data.as_slice()),
5580                    1,
5581                    &CreateFileOptions::default(),
5582                )
5583                .unwrap();
5584            fs.sync_metadata().unwrap();
5585
5586            let listed = fs.list_directory(&root).unwrap();
5587            let f = listed.iter().find(|e| e.name == "dated.txt").unwrap();
5588            let (c, m, b) = f.mac_dates.expect("HFS file should carry mac_dates");
5589            assert!(c != 0 && m != 0, "create/modify stamped by create_file");
5590            assert_eq!(b, 0, "backup date defaults to 0");
5591
5592            // Stage specific dates via the in-place setter and persist.
5593            let target = (0x10000000u32, 0x20000000u32, 0x30000000u32);
5594            fs.set_dates(&fe, target.0, target.1, target.2).unwrap();
5595            fs.sync_metadata().unwrap();
5596        }
5597
5598        // Reopen and confirm the dates decoded back, including the formatted
5599        // string surfaced in `modified`.
5600        let mut fs = HfsFilesystem::open(Cursor::new(&mut buf), 0).unwrap();
5601        let root = fs.root().unwrap();
5602        let listed = fs.list_directory(&root).unwrap();
5603        let f = listed.iter().find(|e| e.name == "dated.txt").unwrap();
5604        assert_eq!(f.mac_dates, Some((0x10000000, 0x20000000, 0x30000000)));
5605        assert_eq!(f.modified, hfs_common::format_mac_date(0x20000000));
5606    }
5607
5608    #[test]
5609    fn test_hfs_editable_create_directory() {
5610        let img = make_editable_hfs_image();
5611        let cursor = Cursor::new(img);
5612        let mut fs = HfsFilesystem::open(cursor, 0).unwrap();
5613
5614        let root = fs.root().unwrap();
5615        let options = CreateDirectoryOptions::default();
5616        let dir = fs.create_directory(&root, "NewDir", &options).unwrap();
5617
5618        assert_eq!(dir.name, "NewDir");
5619        assert!(dir.is_directory());
5620
5621        let entries = fs.list_directory(&root).unwrap();
5622        assert!(entries
5623            .iter()
5624            .any(|e| e.name == "NewDir" && e.is_directory()));
5625    }
5626
5627    #[test]
5628    fn test_hfs_editable_rename_file() {
5629        let img = make_editable_hfs_image();
5630        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
5631        let root = fs.root().unwrap();
5632
5633        let data = b"keep my bytes across a rename";
5634        let mut r = Cursor::new(data.as_slice());
5635        let fe = fs
5636            .create_file(
5637                &root,
5638                "Before",
5639                &mut r,
5640                data.len() as u64,
5641                &CreateFileOptions::default(),
5642            )
5643            .unwrap();
5644        let cnid = fe.location;
5645
5646        fs.rename(&root, &fe, "After Doc").unwrap();
5647
5648        let entries = fs.list_directory(&root).unwrap();
5649        assert!(entries.iter().any(|e| e.name == "After Doc"), "{entries:?}");
5650        assert!(!entries.iter().any(|e| e.name == "Before"), "{entries:?}");
5651        let renamed = entries.iter().find(|e| e.name == "After Doc").unwrap();
5652        // Identity (CNID) preserved and content intact (the catalog re-key keeps
5653        // the fork data untouched).
5654        assert_eq!(renamed.location, cnid);
5655        assert_eq!(&fs.read_file(renamed, 1024).unwrap(), data);
5656
5657        // Renaming a directory keeps it browsable (its thread record name is
5658        // updated in place).
5659        fs.create_directory(&root, "OldDir", &CreateDirectoryOptions::default())
5660            .unwrap();
5661        let old_dir = fs
5662            .list_directory(&root)
5663            .unwrap()
5664            .into_iter()
5665            .find(|e| e.name == "OldDir")
5666            .unwrap();
5667        fs.rename(&root, &old_dir, "NewDir").unwrap();
5668        let new_dir = fs
5669            .list_directory(&root)
5670            .unwrap()
5671            .into_iter()
5672            .find(|e| e.name == "NewDir")
5673            .expect("renamed dir present");
5674        assert!(fs.list_directory(&new_dir).is_ok());
5675
5676        // A collision with a different entry is rejected.
5677        let after = fs
5678            .list_directory(&root)
5679            .unwrap()
5680            .into_iter()
5681            .find(|e| e.name == "After Doc")
5682            .unwrap();
5683        assert!(matches!(
5684            fs.rename(&root, &after, "NewDir"),
5685            Err(FilesystemError::AlreadyExists(_))
5686        ));
5687
5688        fs.sync_metadata().unwrap();
5689    }
5690
5691    #[test]
5692    fn test_hfs_editable_delete_file() {
5693        let img = make_editable_hfs_image();
5694        let cursor = Cursor::new(img);
5695        let mut fs = HfsFilesystem::open(cursor, 0).unwrap();
5696
5697        let root = fs.root().unwrap();
5698        let test_data = b"delete me";
5699        let mut data_reader = Cursor::new(test_data.as_slice());
5700        let options = CreateFileOptions::default();
5701        let fe = fs
5702            .create_file(
5703                &root,
5704                "gone.txt",
5705                &mut data_reader,
5706                test_data.len() as u64,
5707                &options,
5708            )
5709            .unwrap();
5710
5711        let free_before = fs.free_space().unwrap();
5712        fs.delete_entry(&root, &fe).unwrap();
5713
5714        let entries = fs.list_directory(&root).unwrap();
5715        assert!(!entries.iter().any(|e| e.name == "gone.txt"));
5716
5717        let free_after = fs.free_space().unwrap();
5718        assert!(free_after > free_before);
5719    }
5720
5721    #[test]
5722    fn test_hfs_editable_duplicate_name_rejected() {
5723        let img = make_editable_hfs_image();
5724        let cursor = Cursor::new(img);
5725        let mut fs = HfsFilesystem::open(cursor, 0).unwrap();
5726
5727        let root = fs.root().unwrap();
5728        let test_data = b"first";
5729        let mut r1 = Cursor::new(test_data.as_slice());
5730        let options = CreateFileOptions::default();
5731        fs.create_file(&root, "dup.txt", &mut r1, 5, &options)
5732            .unwrap();
5733
5734        let mut r2 = Cursor::new(test_data.as_slice());
5735        let result = fs.create_file(&root, "dup.txt", &mut r2, 5, &options);
5736        assert!(matches!(result, Err(FilesystemError::AlreadyExists(_))));
5737    }
5738
5739    #[test]
5740    fn test_hfs_editable_name_too_long() {
5741        let img = make_editable_hfs_image();
5742        let cursor = Cursor::new(img);
5743        let mut fs = HfsFilesystem::open(cursor, 0).unwrap();
5744
5745        let root = fs.root().unwrap();
5746        let long_name = "a".repeat(32); // 32 bytes > 31 limit
5747        let test_data = b"x";
5748        let mut r = Cursor::new(test_data.as_slice());
5749        let options = CreateFileOptions::default();
5750        let result = fs.create_file(&root, &long_name, &mut r, 1, &options);
5751        assert!(matches!(result, Err(FilesystemError::InvalidData(_))));
5752    }
5753
5754    #[test]
5755    fn test_hfs_editable_blessed_folder() {
5756        let img = make_editable_hfs_image();
5757        let cursor = Cursor::new(img);
5758        let mut fs = HfsFilesystem::open(cursor, 0).unwrap();
5759
5760        // Initially no blessed folder
5761        assert!(fs.blessed_system_folder().is_none());
5762
5763        // Create a system folder
5764        let root = fs.root().unwrap();
5765        let options = CreateDirectoryOptions::default();
5766        let sys_dir = fs.create_directory(&root, "System", &options).unwrap();
5767
5768        // Bless it
5769        fs.set_blessed_folder(&sys_dir).unwrap();
5770
5771        // Verify
5772        let blessed = fs.blessed_system_folder();
5773        assert!(blessed.is_some());
5774        let (cnid, name) = blessed.unwrap();
5775        assert_eq!(cnid, sys_dir.location);
5776        assert_eq!(name, "System");
5777    }
5778
5779    #[test]
5780    fn test_hfs_editable_type_creator_auto_detect() {
5781        let img = make_editable_hfs_image();
5782        let cursor = Cursor::new(img);
5783        let mut fs = HfsFilesystem::open(cursor, 0).unwrap();
5784
5785        let root = fs.root().unwrap();
5786        let test_data = b"text content";
5787        let mut data_reader = Cursor::new(test_data.as_slice());
5788        let options = CreateFileOptions::default();
5789        let fe = fs
5790            .create_file(
5791                &root,
5792                "readme.txt",
5793                &mut data_reader,
5794                test_data.len() as u64,
5795                &options,
5796            )
5797            .unwrap();
5798
5799        assert!(fe.type_code.is_some());
5800        assert_eq!(fe.type_code, Some(*b"TEXT"));
5801    }
5802
5803    /// Helper: run fsck on an HfsFilesystem and panic if any errors are found.
5804    fn assert_fsck_clean<R: Read + Seek>(fs: &mut HfsFilesystem<R>) {
5805        let result = fs.fsck().unwrap();
5806        if !result.is_clean() {
5807            let msgs: Vec<_> = result
5808                .errors
5809                .iter()
5810                .map(|e| format!("[{}] {}", e.code, e.message))
5811                .collect();
5812            panic!("fsck found {} error(s):\n{}", msgs.len(), msgs.join("\n"));
5813        }
5814    }
5815
5816    #[test]
5817    fn test_fsck_clean_fresh_image() {
5818        let img = make_editable_hfs_image();
5819        let cursor = Cursor::new(img);
5820        let mut fs = HfsFilesystem::open(cursor, 0).unwrap();
5821
5822        let result = fs.fsck().unwrap();
5823        let err_msgs: Vec<&str> = result.errors.iter().map(|e| e.message.as_str()).collect();
5824        assert!(
5825            result.is_clean(),
5826            "fresh image should be clean: {:?}",
5827            err_msgs
5828        );
5829        assert_eq!(result.stats.directories_checked, 1); // root only
5830        assert_eq!(result.stats.files_checked, 0);
5831    }
5832
5833    #[test]
5834    fn test_fsck_clean_after_create_file() {
5835        let img = make_editable_hfs_image();
5836        let cursor = Cursor::new(img);
5837        let mut fs = HfsFilesystem::open(cursor, 0).unwrap();
5838
5839        let root = fs.root().unwrap();
5840        let test_data = b"Hello, HFS World!";
5841        let mut data_reader = Cursor::new(test_data.as_slice());
5842        let options = CreateFileOptions::default();
5843        fs.create_file(
5844            &root,
5845            "test.txt",
5846            &mut data_reader,
5847            test_data.len() as u64,
5848            &options,
5849        )
5850        .unwrap();
5851
5852        assert_fsck_clean(&mut fs);
5853    }
5854
5855    #[test]
5856    fn test_fsck_clean_after_create_directory() {
5857        let img = make_editable_hfs_image();
5858        let cursor = Cursor::new(img);
5859        let mut fs = HfsFilesystem::open(cursor, 0).unwrap();
5860
5861        let root = fs.root().unwrap();
5862        let options = CreateDirectoryOptions::default();
5863        fs.create_directory(&root, "NewDir", &options).unwrap();
5864
5865        assert_fsck_clean(&mut fs);
5866    }
5867
5868    #[test]
5869    fn test_fsck_clean_after_delete_file() {
5870        let img = make_editable_hfs_image();
5871        let cursor = Cursor::new(img);
5872        let mut fs = HfsFilesystem::open(cursor, 0).unwrap();
5873
5874        let root = fs.root().unwrap();
5875        let test_data = b"delete me";
5876        let mut data_reader = Cursor::new(test_data.as_slice());
5877        let options = CreateFileOptions::default();
5878        let fe = fs
5879            .create_file(
5880                &root,
5881                "gone.txt",
5882                &mut data_reader,
5883                test_data.len() as u64,
5884                &options,
5885            )
5886            .unwrap();
5887
5888        assert_fsck_clean(&mut fs);
5889        fs.delete_entry(&root, &fe).unwrap();
5890        assert_fsck_clean(&mut fs);
5891    }
5892
5893    /// Random-order bulk inserts must pack the catalog densely *and* stay
5894    /// fsck-clean. The greedy "pack-left-full" split used to split off only the
5895    /// overflow on a middle insert, leaving a trail of ~1-record leaves: a
5896    /// random import packed ~1.6 records/node, roughly half what classic Mac OS
5897    /// writes (a real MacPack disk sits near 3.0). The B*-style sibling rotation
5898    /// (redistribute into a neighbour before splitting, at both the leaf and the
5899    /// index level) lifts random-order packing to ~3.0 records/node while
5900    /// leaving sequential growth fully packed (4 records/leaf).
5901    #[test]
5902    fn test_hfs_random_insert_packs_densely_and_fsck_clean() {
5903        use crate::hfs_common::BTreeHeader;
5904        type Fs = HfsFilesystem<Cursor<Vec<u8>>>;
5905        let block_size = 32 * 1024u32;
5906        let img = create_blank_hfs_sized(64 * 1024 * 1024, block_size, "Pack", 0, 16 * 1024 * 1024)
5907            .unwrap();
5908        let mut fs = Fs::open(Cursor::new(img), 0).unwrap();
5909        let root_id = fs.root().unwrap().location as u32;
5910
5911        // Pseudo-random insertion order (multiplicative hash), de-duplicated.
5912        let n = 16000u32;
5913        let mut seen = std::collections::HashSet::new();
5914        let order: Vec<u32> = (0..n)
5915            .map(|i| i.wrapping_mul(2654435761) % n)
5916            .filter(|x| seen.insert(*x))
5917            .collect();
5918        let count = order.len() as u32;
5919        fs.begin_bulk();
5920        for &i in &order {
5921            let name = format!("f{:05}", i);
5922            let mut kr = Fs::build_catalog_key(root_id, name.as_bytes());
5923            kr.extend_from_slice(&Fs::build_file_record(
5924                16 + i,
5925                0,
5926                0,
5927                0,
5928                0,
5929                0,
5930                0,
5931                &[0u8; 4],
5932                &[0u8; 4],
5933                block_size,
5934                None,
5935                0,
5936            ));
5937            fs.insert_catalog_record(&kr).unwrap();
5938        }
5939        fs.end_bulk();
5940        fs.mdb.file_count = count;
5941        fs.mdb.next_catalog_id = 16 + n;
5942        fs.update_parent_valence(root_id, count as i32).unwrap();
5943
5944        // Density: well above the ~1.6 the old split produced, and now close to
5945        // the ~3.2 of a fully-packed sequential build thanks to sibling rotation.
5946        let h = BTreeHeader::read(fs.catalog_data());
5947        let used = h.total_nodes - h.free_nodes;
5948        let rec_per_node = count as f64 / used as f64;
5949        assert!(
5950            rec_per_node > 2.8,
5951            "random-order packing too sparse: {count} records in {used} nodes ({rec_per_node:.2}/node)"
5952        );
5953
5954        let result = fs.fsck().unwrap();
5955        assert!(
5956            result.errors.is_empty(),
5957            "fsck errors after random import: {:?}",
5958            result
5959                .errors
5960                .iter()
5961                .take(8)
5962                .map(|e| &e.message)
5963                .collect::<Vec<_>>()
5964        );
5965    }
5966
5967    /// Bulk mode (`begin_bulk` / `end_bulk`) skips the per-operation catalog
5968    /// snapshot for speed — the dominant per-file cost on a large import once
5969    /// the duplicate check is index-based. A successful batch must still leave
5970    /// a fsck-clean volume identical to the per-op path (on the success path no
5971    /// rollback ever happens, so the only difference is the skipped clone).
5972    /// This is the path `tar_import` drives.
5973    #[test]
5974    fn test_hfs_bulk_mode_import_fsck_clean() {
5975        let block_size = 32 * 1024u32;
5976        let img = create_blank_hfs_sized(
5977            64 * 1024 * 1024,
5978            block_size,
5979            "BulkImport",
5980            0,
5981            8 * 1024 * 1024,
5982        )
5983        .expect("create blank volume");
5984        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
5985        let root = fs.root().unwrap();
5986        let root_id = root.location as u32;
5987        let options = CreateFileOptions::default();
5988
5989        fs.begin_bulk();
5990        for i in 0..9000 {
5991            let name = format!("f{:05}.txt", i);
5992            let mut empty = Cursor::new(Vec::new());
5993            fs.create_file(&root, &name, &mut empty, 0, &options)
5994                .unwrap_or_else(|e| panic!("bulk create_file #{i}: {e}"));
5995        }
5996        fs.end_bulk();
5997
5998        assert_eq!(fs.mdb.file_count, 9000);
5999        let result = fs.fsck().unwrap();
6000        assert!(
6001            result.errors.is_empty(),
6002            "bulk-mode import left fsck errors: {:?}",
6003            result
6004                .errors
6005                .iter()
6006                .take(8)
6007                .map(|e| &e.message)
6008                .collect::<Vec<_>>()
6009        );
6010        // Records inserted under skipped snapshots are still correct/findable.
6011        assert!(fs
6012            .find_catalog_record_by_name(root_id, b"f04500.txt")
6013            .is_some());
6014        assert_eq!(fs.list_directory(&root).unwrap().len(), 9000);
6015
6016        // After end_bulk, normal snapshotting resumes: a duplicate create is
6017        // rejected and rolls back cleanly, leaving the volume fsck-clean.
6018        let mut empty = Cursor::new(Vec::new());
6019        assert!(fs
6020            .create_file(&root, "f00000.txt", &mut empty, 0, &options)
6021            .is_err());
6022        assert_fsck_clean(&mut fs);
6023    }
6024
6025    /// Regression for the classic-HFS catalog B-tree scaling bug
6026    /// (PROMPT-hfs-catalog-btree-scaling): importing many files used to die at
6027    /// ~7.4k records with "disk full: no free B-tree nodes" and fsck reported
6028    /// widespread `IndexSiblingLinkBroken`. Two writer defects combined — the
6029    /// per-split index rebuild leaked index nodes past the 2048-node header
6030    /// bitmap (exhausting free nodes), and the incremental index split never
6031    /// maintained fLink/bLink sibling links. Drive the B-tree well past that
6032    /// boundary on a production-shaped volume (node_size 512) and assert it is
6033    /// fsck-clean.
6034    ///
6035    /// Uses the low-level inserter directly so the test stays fast (the
6036    /// higher-level `create_file` does an O(n) duplicate scan per call); the
6037    /// end-to-end path is covered by
6038    /// `test_hfs_create_file_multilevel_index_fsck_clean`.
6039    #[test]
6040    fn test_hfs_catalog_scales_past_header_bitmap_fsck_clean() {
6041        use crate::hfs_common::BTreeHeader;
6042        type Fs = HfsFilesystem<Cursor<Vec<u8>>>;
6043        let block_size = 32 * 1024u32;
6044        // Empty files cost no data blocks, so a 64 MiB volume with a generous
6045        // 10 MiB catalog (20,480 nodes @ 512) holds the records and forces the
6046        // catalog past the header bitmap's 2048-node window into map nodes.
6047        let img = create_blank_hfs_sized(
6048            64 * 1024 * 1024,
6049            block_size,
6050            "Scale20k",
6051            0,
6052            10 * 1024 * 1024,
6053        )
6054        .expect("create blank volume");
6055        let mut fs = Fs::open(Cursor::new(img), 0).unwrap();
6056        let root = fs.root().unwrap();
6057        let root_id = root.location as u32;
6058
6059        let n: u32 = 22_000;
6060        for i in 0..n {
6061            let name = format!("f{:05}.txt", i);
6062            let mut key_record = Fs::build_catalog_key(root_id, name.as_bytes());
6063            let file_rec = Fs::build_file_record(
6064                16 + i,
6065                0,
6066                0,
6067                0,
6068                0,
6069                0,
6070                0,
6071                &[0u8; 4],
6072                &[0u8; 4],
6073                block_size,
6074                None,
6075                0,
6076            );
6077            key_record.extend_from_slice(&file_rec);
6078            fs.insert_catalog_record(&key_record)
6079                .unwrap_or_else(|e| panic!("insert #{i} ({name}): {e}"));
6080        }
6081
6082        // Mirror the MDB bookkeeping the create path maintains so fsck's
6083        // cross-checks (file_count, next_catalog_id, root valence) pass.
6084        fs.mdb.file_count = n;
6085        fs.mdb.next_catalog_id = 16 + n;
6086        fs.update_parent_valence(root_id, n as i32).unwrap();
6087
6088        // Confirm the catalog really grew a multi-level index beyond the single
6089        // header bitmap — otherwise the test wouldn't exercise the bug at all.
6090        let header = BTreeHeader::read(fs.catalog_data());
6091        assert!(
6092            header.depth >= 3,
6093            "expected a multi-level index, depth = {}",
6094            header.depth
6095        );
6096        let used = header.total_nodes - header.free_nodes;
6097        assert!(
6098            used > 2048,
6099            "expected > 2048 catalog nodes in use, got {used}"
6100        );
6101
6102        let result = fs.fsck().unwrap();
6103        assert!(
6104            result.errors.is_empty(),
6105            "fsck found {} errors after {n}-record import: {:?}",
6106            result.errors.len(),
6107            result
6108                .errors
6109                .iter()
6110                .take(8)
6111                .map(|e| &e.message)
6112                .collect::<Vec<_>>()
6113        );
6114
6115        // After all those splits, records must still be findable by descending
6116        // the index (locates the correct leaf), including across the old
6117        // failure point (~7386) and the map-node boundary.
6118        for i in [0u32, 1, 7386, 12345, n - 1] {
6119            let name = format!("f{:05}.txt", i);
6120            assert!(
6121                fs.find_catalog_record_by_name(root_id, name.as_bytes())
6122                    .is_some(),
6123                "record {name} not findable after bulk insert"
6124            );
6125        }
6126    }
6127
6128    /// Per-`put`-shaped regression (PROMPT-hfs-catalog-incremental-put-packing):
6129    /// 20k records inserted ONE AT A TIME in non-sequential (shuffled) key
6130    /// order, spread across many parent directories — exactly the shape of a
6131    /// `rb-cli put`-driven image build — must pack the catalog nearly as tightly
6132    /// as a sequential bulk import and stay fsck-clean to the volume's real
6133    /// limit. Before the B*-style sibling rotation (leaf + index), a shuffled
6134    /// per-record build left leaves at the classic ~69% B-tree occupancy and the
6135    /// index far worse, exhausting the node budget ~2x sooner ("disk full: no
6136    /// free B-tree nodes") and leaving `IndexSiblingLinkBroken` at the ceiling.
6137    #[test]
6138    fn test_hfs_incremental_shuffled_multidir_packs_dense_fsck_clean() {
6139        use crate::hfs_common::BTreeHeader;
6140        type Fs = HfsFilesystem<Cursor<Vec<u8>>>;
6141        let block_size = 32 * 1024u32;
6142        // ~6 MiB catalog (12,288 nodes @ 512). With the rotation fix 20k records
6143        // pack into well under that; the pre-fix ~1.6 rec/node packing would have
6144        // needed far more leaf nodes alone and exhausted it.
6145        let img = create_blank_hfs_sized(
6146            128 * 1024 * 1024,
6147            block_size,
6148            "PutShuffle",
6149            0,
6150            6 * 1024 * 1024,
6151        )
6152        .expect("create blank volume");
6153        let mut fs = Fs::open(Cursor::new(img), 0).unwrap();
6154
6155        // Real directories so threads / valence / counts stay fsck-correct.
6156        const DIRS: u32 = 200;
6157        let root = fs.root().unwrap();
6158        let mut dir_ids = Vec::with_capacity(DIRS as usize);
6159        for d in 0..DIRS {
6160            let name = format!("dir{d:03}");
6161            let de = fs
6162                .create_directory(&root, &name, &Default::default())
6163                .unwrap();
6164            dir_ids.push(de.location as u32);
6165        }
6166        let base_cnid = fs.mdb.next_catalog_id;
6167
6168        // 20k files in shuffled key order (multiplicative hash is a bijection
6169        // mod 2^32, so names are unique and land mid-leaf, not appended),
6170        // sprayed across the directories.
6171        let n: u32 = 20_000;
6172        let mut per_dir = vec![0i32; DIRS as usize];
6173        for i in 0..n {
6174            let hash = i.wrapping_mul(2_654_435_761);
6175            let d = (hash % DIRS) as usize;
6176            let name = format!("f{hash:08x}");
6177            let mut kr = Fs::build_catalog_key(dir_ids[d], name.as_bytes());
6178            kr.extend_from_slice(&Fs::build_file_record(
6179                base_cnid + i,
6180                0,
6181                0,
6182                0,
6183                0,
6184                0,
6185                0,
6186                &[0u8; 4],
6187                &[0u8; 4],
6188                block_size,
6189                None,
6190                0,
6191            ));
6192            fs.insert_catalog_record(&kr)
6193                .unwrap_or_else(|e| panic!("insert #{i} into dir{d}: {e}"));
6194            per_dir[d] += 1;
6195        }
6196        // Mirror the bookkeeping the create path would have maintained so fsck's
6197        // cross-checks (file_count, next_catalog_id, per-dir valence) pass.
6198        fs.mdb.file_count += n;
6199        fs.mdb.next_catalog_id = base_cnid + n;
6200        for (d, &cnt) in per_dir.iter().enumerate() {
6201            fs.update_parent_valence(dir_ids[d], cnt).unwrap();
6202        }
6203
6204        // Density: the whole catalog (leaves + index) averages well above the
6205        // pre-fix ~1.6 rec/node — proof the incremental build packed tight and
6206        // didn't run away into a sparse, over-deep tree.
6207        let h = BTreeHeader::read(fs.catalog_data());
6208        let used = h.total_nodes - h.free_nodes;
6209        let rec_per_node = h.leaf_records as f64 / used as f64;
6210        assert!(
6211            rec_per_node > 2.6,
6212            "shuffled multi-dir packing too sparse: {} records in {used} nodes ({rec_per_node:.2}/node)",
6213            h.leaf_records
6214        );
6215
6216        // No IndexSiblingLinkBroken or other structural damage at the ceiling.
6217        let result = fs.fsck().unwrap();
6218        assert!(
6219            result.errors.is_empty(),
6220            "fsck found {} errors after 20k shuffled multi-dir put: {:?}",
6221            result.errors.len(),
6222            result
6223                .errors
6224                .iter()
6225                .take(8)
6226                .map(|e| &e.message)
6227                .collect::<Vec<_>>()
6228        );
6229
6230        // Records remain findable by index descent across the keyspace.
6231        for i in [0u32, 7, 9_999, n - 1] {
6232            let hash = i.wrapping_mul(2_654_435_761);
6233            let parent = dir_ids[(hash % DIRS) as usize];
6234            let name = format!("f{hash:08x}");
6235            assert!(
6236                fs.find_catalog_record_by_name(parent, name.as_bytes())
6237                    .is_some(),
6238                "record {name} not findable after shuffled multi-dir insert"
6239            );
6240        }
6241    }
6242
6243    /// The delete / fsck-repair path rebuilds the catalog index wholesale via
6244    /// `rebuild_index_nodes`. Its free-all-index-nodes pass used to be capped
6245    /// at the 2048-bit header bitmap, leaking every index node beyond it; once
6246    /// a catalog crosses that line a rebuild must still round-trip fsck-clean.
6247    #[test]
6248    fn test_rebuild_index_nodes_past_header_bitmap_fsck_clean() {
6249        use crate::hfs_common::BTreeHeader;
6250        type Fs = HfsFilesystem<Cursor<Vec<u8>>>;
6251        let block_size = 32 * 1024u32;
6252        let img =
6253            create_blank_hfs_sized(64 * 1024 * 1024, block_size, "Rebuild", 0, 10 * 1024 * 1024)
6254                .expect("create blank volume");
6255        let mut fs = Fs::open(Cursor::new(img), 0).unwrap();
6256        let root_id = fs.root().unwrap().location as u32;
6257
6258        let n: u32 = 12_000;
6259        for i in 0..n {
6260            let name = format!("f{:05}.txt", i);
6261            let mut key_record = Fs::build_catalog_key(root_id, name.as_bytes());
6262            let file_rec = Fs::build_file_record(
6263                16 + i,
6264                0,
6265                0,
6266                0,
6267                0,
6268                0,
6269                0,
6270                &[0u8; 4],
6271                &[0u8; 4],
6272                block_size,
6273                None,
6274                0,
6275            );
6276            key_record.extend_from_slice(&file_rec);
6277            fs.insert_catalog_record(&key_record).unwrap();
6278        }
6279        fs.mdb.file_count = n;
6280        fs.mdb.next_catalog_id = 16 + n;
6281        fs.update_parent_valence(root_id, n as i32).unwrap();
6282
6283        let node_size = BTreeHeader::read(fs.catalog_data()).node_size as usize;
6284        let used_before = {
6285            let h = BTreeHeader::read(fs.catalog_data());
6286            h.total_nodes - h.free_nodes
6287        };
6288        assert!(
6289            used_before > 2048,
6290            "test needs > 2048 nodes, got {used_before}"
6291        );
6292
6293        // Force a full index rebuild and require it to succeed without leaking.
6294        let mut report = disk_core::fsck::RepairReport {
6295            fixes_applied: Vec::new(),
6296            fixes_failed: Vec::new(),
6297            unrepairable_count: 0,
6298        };
6299        crate::hfs_fsck::rebuild_index_nodes(&mut fs.catalog_data, node_size, &mut report);
6300        assert!(
6301            report.fixes_failed.is_empty(),
6302            "rebuild reported failures: {:?}",
6303            report.fixes_failed
6304        );
6305
6306        let result = fs.fsck().unwrap();
6307        assert!(
6308            result.errors.is_empty(),
6309            "fsck after rebuild: {:?}",
6310            result
6311                .errors
6312                .iter()
6313                .take(8)
6314                .map(|e| &e.message)
6315                .collect::<Vec<_>>()
6316        );
6317    }
6318
6319    /// End-to-end guard through the real `create_file` API: enough files to
6320    /// grow a multi-level catalog index must stay fsck-clean. ~2000 files at
6321    /// node_size 512 yields ~500 leaves and tens of index nodes — enough to
6322    /// split index nodes, the exact spot the incremental inserter used to
6323    /// leave sibling links broken.
6324    #[test]
6325    fn test_hfs_create_file_multilevel_index_fsck_clean() {
6326        use crate::hfs_common::BTreeHeader;
6327        let block_size = 32 * 1024u32;
6328        let img =
6329            create_blank_hfs_sized(32 * 1024 * 1024, block_size, "MultiIdx", 0, 4 * 1024 * 1024)
6330                .expect("create blank volume");
6331        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
6332        let root = fs.root().unwrap();
6333        let options = CreateFileOptions::default();
6334        for i in 0..2000 {
6335            let name = format!("f{:05}.txt", i);
6336            let mut empty = Cursor::new(Vec::new());
6337            fs.create_file(&root, &name, &mut empty, 0, &options)
6338                .unwrap_or_else(|e| panic!("create_file #{i}: {e}"));
6339        }
6340        let header = BTreeHeader::read(fs.catalog_data());
6341        assert!(
6342            header.depth >= 2,
6343            "expected an index level, depth = {}",
6344            header.depth
6345        );
6346        let result = fs.fsck().unwrap();
6347        assert!(
6348            result.errors.is_empty(),
6349            "fsck errors: {:?}",
6350            result
6351                .errors
6352                .iter()
6353                .take(8)
6354                .map(|e| &e.message)
6355                .collect::<Vec<_>>()
6356        );
6357        assert_eq!(fs.mdb.file_count, 2000);
6358    }
6359
6360    /// Lock in lossless round-tripping of filenames carrying raw control bytes
6361    /// (e.g. 0x7F / DEL, as in the Macintosh Garden "IPNetRouter<DEL><DEL>"
6362    /// title that triggered the related JSON-escaping bug downstream). Mac
6363    /// Roman maps bytes < 0x80 straight through, so create then list must
6364    /// preserve them byte-for-byte.
6365    #[test]
6366    fn deleting_a_leafs_first_record_refreshes_the_separators() {
6367        // fsck_hfs E_IKey: a separator must be its child's first key, not the
6368        // key that record had before a delete took it.
6369        use crate::hfs_common::{
6370            btree_record_range, btree_stale_index_keys, BTreeHeader, BTreeKeyFormat,
6371        };
6372        let block_size = 32 * 1024u32;
6373        let img = create_blank_hfs_sized(32 * 1024 * 1024, block_size, "IKey", 0, 4 * 1024 * 1024)
6374            .expect("create blank volume");
6375        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
6376        let root = fs.root().unwrap();
6377        let options = CreateFileOptions::default();
6378        for i in 0..2000 {
6379            let mut empty = Cursor::new(Vec::new());
6380            fs.create_file(&root, &format!("f{i:05}.txt"), &mut empty, 0, &options)
6381                .unwrap();
6382        }
6383        let node_size = BigEndian::read_u16(&fs.catalog_data()[32..34]) as usize;
6384        let header = BTreeHeader::read(fs.catalog_data());
6385        assert!(
6386            header.depth >= 2,
6387            "need an index level, depth {}",
6388            header.depth
6389        );
6390
6391        let first = header.first_leaf_node as usize;
6392        let second =
6393            BigEndian::read_u32(&fs.catalog_data()[first * node_size..first * node_size + 4])
6394                as usize;
6395        let name = {
6396            let node = &fs.catalog_data()[second * node_size..(second + 1) * node_size];
6397            let (s, e) = btree_record_range(node, node_size, 0);
6398            let rec = &node[s..e];
6399            let name_len = rec[6] as usize;
6400            String::from_utf8_lossy(&rec[7..7 + name_len]).into_owned()
6401        };
6402        assert!(
6403            name.starts_with('f'),
6404            "expected a file record first, got {name:?}"
6405        );
6406        let victim = fs
6407            .list_directory(&root)
6408            .unwrap()
6409            .into_iter()
6410            .find(|e| e.name == name)
6411            .expect("victim listed");
6412        fs.delete_entry(&root, &victim).unwrap();
6413
6414        let stale = btree_stale_index_keys(
6415            fs.catalog_data(),
6416            node_size,
6417            &BTreeKeyFormat::CLASSIC_CATALOG,
6418            &HfsFilesystem::<Cursor<Vec<u8>>>::catalog_compare,
6419        );
6420        assert!(stale.is_empty(), "separators still stale: {stale:?}");
6421        let result = fs.fsck().unwrap();
6422        assert!(
6423            result.errors.is_empty(),
6424            "{:?}",
6425            result
6426                .errors
6427                .iter()
6428                .map(|e| &e.message)
6429                .take(4)
6430                .collect::<Vec<_>>()
6431        );
6432    }
6433
6434    #[test]
6435    fn test_hfs_control_char_filename_roundtrip() {
6436        let img = make_editable_hfs_image();
6437        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
6438        let root = fs.root().unwrap();
6439        let options = CreateFileOptions::default();
6440        let name = "IPNetRouter\u{7f}\u{7f}_154_68k";
6441        let mut data = Cursor::new(b"x".to_vec());
6442        fs.create_file(&root, name, &mut data, 1, &options)
6443            .expect("create_file with raw 0x7F bytes in the name");
6444        let entries = fs.list_directory(&root).unwrap();
6445        assert!(
6446            entries.iter().any(|e| e.name == name),
6447            "0x7F-laden name not preserved; got {:?}",
6448            entries.iter().map(|e| &e.name).collect::<Vec<_>>()
6449        );
6450        assert_fsck_clean(&mut fs);
6451    }
6452
6453    /// A null byte in a catalog name is valid on classic HFS (only the colon is
6454    /// forbidden) — a leading null is an old Finder trick to sort a file to the
6455    /// top. Real Apple disks carry such names (3 on the MacPack boot volume), so
6456    /// fsck must treat them as an informational *warning*, not an error.
6457    #[test]
6458    fn test_hfs_null_byte_name_is_warning_not_error() {
6459        let img = make_editable_hfs_image();
6460        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
6461        let root = fs.root().unwrap();
6462        let options = CreateFileOptions::default();
6463        let mut data = Cursor::new(b"x".to_vec());
6464        fs.create_file(&root, "\u{0}Sorts First", &mut data, 1, &options)
6465            .expect("create_file with a leading null byte in the name");
6466        let result = fs.fsck().unwrap();
6467        assert!(
6468            result.errors.is_empty(),
6469            "a null-byte name must not be a fsck error: {:?}",
6470            result.errors.iter().map(|e| &e.message).collect::<Vec<_>>()
6471        );
6472        assert!(
6473            result
6474                .warnings
6475                .iter()
6476                .any(|w| w.code == "UnusualCatalogName"),
6477            "expected an UnusualCatalogName warning for the null-byte name"
6478        );
6479    }
6480
6481    #[test]
6482    fn test_fsck_stress_many_files() {
6483        let img = make_editable_hfs_image();
6484        let cursor = Cursor::new(img);
6485        let mut fs = HfsFilesystem::open(cursor, 0).unwrap();
6486
6487        let root = fs.root().unwrap();
6488        let options = CreateFileOptions::default();
6489
6490        // Create 30+ files to trigger B-tree splits
6491        for i in 0..35 {
6492            let name = format!("file_{:03}.txt", i);
6493            let data = format!("content of file {}", i);
6494            let mut reader = Cursor::new(data.as_bytes().to_vec());
6495            fs.create_file(&root, &name, &mut reader, data.len() as u64, &options)
6496                .unwrap();
6497
6498            // Verify fsck passes after each creation
6499            assert_fsck_clean(&mut fs);
6500        }
6501
6502        let result = fs.fsck().unwrap();
6503        assert!(result.is_clean());
6504        assert_eq!(result.stats.files_checked, 35);
6505        assert_eq!(result.stats.directories_checked, 1); // just root
6506    }
6507
6508    #[test]
6509    fn test_fsck_clean_after_blessed_folder() {
6510        let img = make_editable_hfs_image();
6511        let cursor = Cursor::new(img);
6512        let mut fs = HfsFilesystem::open(cursor, 0).unwrap();
6513
6514        let root = fs.root().unwrap();
6515        let options = CreateDirectoryOptions::default();
6516        let sys_dir = fs.create_directory(&root, "System", &options).unwrap();
6517        fs.set_blessed_folder(&sys_dir).unwrap();
6518
6519        assert_fsck_clean(&mut fs);
6520    }
6521
6522    /// Verify that rebuilt index records have proper even-alignment padding
6523    /// between the key and the node pointer, as required by Mac OS's B-tree
6524    /// traversal code. Without padding, Mac OS reads misaligned node pointers
6525    /// and crashes during volume mount.
6526    #[test]
6527    fn unerased_free_nodes_warn_once_and_repair_zeroes_them() {
6528        // Classic Mac OS never erased freed nodes (issue #89's System 7.5.5 volume had 696).
6529        use crate::hfs_common::{btree_bitmap_test, BTreeHeader};
6530
6531        let mut fs = HfsFilesystem::open(Cursor::new(make_editable_hfs_image()), 0).unwrap();
6532        let node_size = BTreeHeader::read(&fs.catalog_data).node_size as usize;
6533        let free: Vec<usize> = (1..fs.catalog_data.len() / node_size)
6534            .filter(|&i| !btree_bitmap_test(&fs.catalog_data, node_size, i as u32))
6535            .take(3)
6536            .collect();
6537        assert_eq!(free.len(), 3, "blank catalog has free nodes");
6538        for &i in &free {
6539            fs.catalog_data[i * node_size + 20] = 0xA5;
6540        }
6541
6542        let result = fs.fsck().unwrap();
6543        assert!(
6544            result.errors.is_empty(),
6545            "not an error: {:?}",
6546            result.errors
6547        );
6548        let warns: Vec<_> = result
6549            .warnings
6550            .iter()
6551            .filter(|w| w.code == "FreeNodeNotErased")
6552            .collect();
6553        assert_eq!(warns.len(), 1, "one summary per tree, not one per node");
6554        assert!(
6555            warns[0].message.contains("3 unused node(s)"),
6556            "{}",
6557            warns[0].message
6558        );
6559        assert!(result.repairable);
6560
6561        let report = fs.repair().unwrap();
6562        assert!(
6563            report
6564                .fixes_applied
6565                .iter()
6566                .any(|f| f == "Erased 3 unused catalog B-tree node(s)"),
6567            "{:?}",
6568            report.fixes_applied
6569        );
6570        let after = fs.fsck().unwrap();
6571        assert!(after.warnings.iter().all(|w| w.code != "FreeNodeNotErased"));
6572        assert!(after.errors.is_empty(), "{:?}", after.errors);
6573    }
6574
6575    #[test]
6576    fn test_repair_index_records_are_even_aligned() {
6577        use crate::hfs_common::{btree_record_range, BTreeHeader};
6578
6579        let img = make_editable_hfs_image();
6580        let cursor = Cursor::new(img);
6581        let mut fs = HfsFilesystem::open(cursor, 0).unwrap();
6582        let root = fs.root().unwrap();
6583        let options = CreateFileOptions::default();
6584
6585        // Create files with varying name lengths (even and odd) to exercise
6586        // both padded and unpadded key paths in index records
6587        for i in 0..40 {
6588            let name = if i % 2 == 0 {
6589                format!("f{:03}.txt", i) // 8 chars -> key_len=14 (even) -> needs pad
6590            } else {
6591                format!("fi{:03}.txt", i) // 9 chars -> key_len=15 (odd) -> no pad
6592            };
6593            let data = format!("data{}", i);
6594            let mut reader = Cursor::new(data.as_bytes().to_vec());
6595            fs.create_file(&root, &name, &mut reader, data.len() as u64, &options)
6596                .unwrap();
6597        }
6598
6599        // Run repair (which rebuilds index nodes)
6600        let report = fs.repair().unwrap();
6601        assert!(
6602            report.fixes_failed.is_empty(),
6603            "repair failures: {:?}",
6604            report.fixes_failed
6605        );
6606
6607        // Verify every index record has correct alignment
6608        let cat = &fs.catalog_data;
6609        let header = BTreeHeader::read(cat);
6610        let node_size = header.node_size as usize;
6611        let max_nodes = cat.len() / node_size;
6612
6613        let mut index_recs_checked = 0u32;
6614        for n in 1..max_nodes {
6615            let off = n * node_size;
6616            if off + node_size > cat.len() {
6617                break;
6618            }
6619            let kind = cat[off + 8] as i8;
6620            if kind != 0 {
6621                continue;
6622            }
6623            let num_recs = BigEndian::read_u16(&cat[off + 10..off + 12]) as usize;
6624            for r in 0..num_recs {
6625                let (rec_start, rec_end) =
6626                    btree_record_range(&cat[off..off + node_size], node_size, r);
6627                if rec_start >= rec_end || rec_end > node_size {
6628                    continue;
6629                }
6630                let key_len = cat[off + rec_start] as usize;
6631                let rec_len = rec_end - rec_start;
6632
6633                // Mac OS forces all catalog index keys to length 0x25 (37),
6634                // making every record exactly 42 bytes.
6635                assert_eq!(
6636                    key_len, 0x25,
6637                    "Index node {} rec {}: key_len={} but expected 0x25",
6638                    n, r, key_len
6639                );
6640                assert_eq!(
6641                    rec_len, 42,
6642                    "Index node {} rec {}: rec_len={} but expected 42",
6643                    n, r, rec_len
6644                );
6645                index_recs_checked += 1;
6646            }
6647        }
6648        assert!(
6649            index_recs_checked > 0,
6650            "no index records found — test needs more files"
6651        );
6652
6653        // Verify that a post-repair integrity check finds no issues
6654        let result = fs.fsck().unwrap();
6655        let error_msgs: Vec<&str> = result.errors.iter().map(|e| e.message.as_str()).collect();
6656        assert!(
6657            result.errors.is_empty(),
6658            "post-repair check found errors: {:?}",
6659            error_msgs
6660        );
6661    }
6662
6663    /// Blank-volume builder round-trip: open, fsck-clean, create a file,
6664    /// re-open, read back. Runs across the four block sizes called out in
6665    /// docs/hfs_expand_block_size.md.
6666    #[test]
6667    fn test_create_blank_hfs_roundtrip() {
6668        // (total_size, block_size, label) — sizes chosen to stay well clear
6669        // of the u16 block-count ceiling for each block size.
6670        let cases: &[(u64, u32, &str)] = &[
6671            (1 * 1024 * 1024, 512, "1MB @ 512"),
6672            (8 * 1024 * 1024, 4096, "8MB @ 4K"),
6673            (64 * 1024 * 1024, 16384, "64MB @ 16K"),
6674            (256 * 1024 * 1024, 32768, "256MB @ 32K"),
6675        ];
6676
6677        for (total_size, block_size, label) in cases {
6678            let img = create_blank_hfs(*total_size, *block_size, "Fresh")
6679                .unwrap_or_else(|e| panic!("{label}: create_blank_hfs failed: {e}"));
6680
6681            // Primary MDB at sector 2 (byte offset 1024). drAtrb at MDB+10
6682            // must include kHFSVolumeUnmountedMask (0x0100) so Mac OS does
6683            // not flag the volume for repair on first mount.
6684            let atrb = BigEndian::read_u16(&img[1024 + 10..1024 + 12]);
6685            assert_eq!(
6686                atrb & 0x0100,
6687                0x0100,
6688                "{label}: drAtrb missing kHFSVolumeUnmountedMask (got 0x{atrb:04x})"
6689            );
6690            // Alt MDB at next-to-last sector mirrors the primary.
6691            let alt_off = img.len() - 1024;
6692            let alt_atrb = BigEndian::read_u16(&img[alt_off + 10..alt_off + 12]);
6693            assert_eq!(
6694                alt_atrb, atrb,
6695                "{label}: alt MDB drAtrb (0x{alt_atrb:04x}) does not mirror primary (0x{atrb:04x})"
6696            );
6697
6698            // The image MUST be exactly the requested size (rounded down by
6699            // whole allocation blocks, plus MDB sectors). We don't enforce
6700            // exact equality — the builder consumes remaining bytes for the
6701            // alt MDB — but it must never exceed the requested size.
6702            assert!(
6703                (img.len() as u64) <= *total_size,
6704                "{label}: image {} exceeds requested {}",
6705                img.len(),
6706                total_size
6707            );
6708
6709            let mut fs = HfsFilesystem::open(Cursor::new(img.clone()), 0)
6710                .unwrap_or_else(|e| panic!("{label}: open failed: {e}"));
6711            assert_eq!(fs.mdb().block_size, *block_size, "{label}: block_size");
6712            assert_eq!(fs.mdb().volume_name, "Fresh", "{label}: volume_name");
6713
6714            let root = fs.root().unwrap();
6715            let entries = fs.list_directory(&root).unwrap();
6716            assert!(
6717                entries.is_empty(),
6718                "{label}: root not empty on fresh volume"
6719            );
6720
6721            let result = fs.fsck().unwrap();
6722            assert!(
6723                result.errors.is_empty(),
6724                "{label}: fsck errors: {:?}",
6725                result.errors.iter().map(|e| &e.message).collect::<Vec<_>>()
6726            );
6727
6728            // create_file + sync + re-open + read-back
6729            let mut backing = img;
6730            {
6731                let mut fs = HfsFilesystem::open(Cursor::new(&mut backing), 0).unwrap();
6732                let root = fs.root().unwrap();
6733                let payload = b"contents".to_vec();
6734                let mut src = payload.as_slice();
6735                fs.create_file(
6736                    &root,
6737                    "hello",
6738                    &mut src,
6739                    payload.len() as u64,
6740                    &CreateFileOptions::default(),
6741                )
6742                .unwrap_or_else(|e| panic!("{label}: create_file failed: {e}"));
6743                fs.sync_metadata()
6744                    .unwrap_or_else(|e| panic!("{label}: sync_metadata failed: {e}"));
6745            }
6746            let mut fs = HfsFilesystem::open(Cursor::new(&mut backing), 0).unwrap();
6747            let root = fs.root().unwrap();
6748            let entries = fs.list_directory(&root).unwrap();
6749            assert_eq!(entries.len(), 1, "{label}: expected 1 child after create");
6750            assert_eq!(entries[0].name, "hello", "{label}: child name");
6751            let data = fs.read_file(&entries[0], 64).unwrap();
6752            assert_eq!(data, b"contents", "{label}: file content round-trip");
6753
6754            let result = fs.fsck().unwrap();
6755            assert!(
6756                result.errors.is_empty(),
6757                "{label}: post-create fsck errors: {:?}",
6758                result.errors.iter().map(|e| &e.message).collect::<Vec<_>>()
6759            );
6760        }
6761    }
6762
6763    /// A 2 GB - 1 byte volume at 32 KiB block size sits right under the
6764    /// u16 block-count ceiling (65535 blocks). Verify the builder accepts
6765    /// it and produces a fsck-clean image.
6766    ///
6767    /// Gated to 64-bit: create_blank_hfs materializes the whole ~2 GiB image
6768    /// as a Vec, which a 32-bit address space can't allocate (this was the
6769    /// "memory allocation of 2147461632 bytes failed" abort on the i686 CI leg).
6770    #[test]
6771    #[cfg(target_pointer_width = "64")]
6772    fn test_create_blank_hfs_near_2gb() {
6773        let total = 2u64 * 1024 * 1024 * 1024 - 1;
6774        let img = create_blank_hfs(total, 32768, "Near2GB")
6775            .expect("create_blank_hfs near 2GB should succeed");
6776        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
6777        assert_eq!(fs.mdb().block_size, 32768);
6778        assert!(fs.mdb().total_blocks >= 65000);
6779        let result = fs.fsck().unwrap();
6780        assert!(
6781            result.errors.is_empty(),
6782            "fsck errors: {:?}",
6783            result.errors.iter().map(|e| &e.message).collect::<Vec<_>>()
6784        );
6785    }
6786
6787    /// F-016: a catalog formatted with 32 nodes takes a thousand files by
6788    /// growing in clumps; the grown file survives a reopen, and every judge
6789    /// sees a clean tree before and after the files go again.
6790    #[test]
6791    fn catalog_grows_when_its_nodes_run_out() {
6792        let img = create_blank_hfs_sized(16 * 1024 * 1024, 4096, "Grow", 0, 16384).unwrap();
6793        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
6794        let size0 = fs.mdb.catalog_file_size;
6795        assert_eq!(size0, 16384);
6796        let root = fs.root().unwrap();
6797        for i in 0..1000 {
6798            let name = format!("f{i:04}.txt");
6799            fs.create_file(
6800                &root,
6801                &name,
6802                &mut &b"x"[..],
6803                1,
6804                &CreateFileOptions::default(),
6805            )
6806            .unwrap_or_else(|e| panic!("creating {name}: {e}"));
6807        }
6808        assert!(fs.mdb.catalog_file_size > size0, "the catalog never grew");
6809        // Files sit right behind the catalog, so the growth had to take fresh
6810        // extents: three inline, the rest as CNID 4 records in the overflow tree.
6811        let inline: u32 = fs
6812            .mdb
6813            .catalog_file_extents
6814            .iter()
6815            .map(|e| e.block_count as u32)
6816            .sum();
6817        let spilled: u32 = collect_fork_overflow_extents(
6818            fs.extents_overflow_data
6819                .as_ref()
6820                .expect("overflow tree loaded"),
6821            CNID_CATALOG,
6822            0x00,
6823            inline,
6824        )
6825        .iter()
6826        .map(|e| e.block_count as u32)
6827        .sum();
6828        assert!(spilled > 0, "no CNID 4 overflow records");
6829        assert_eq!(
6830            (inline + spilled) * 4096,
6831            fs.mdb.catalog_file_size,
6832            "extents cover the file"
6833        );
6834        fs.sync_metadata().unwrap();
6835        let report = fs.fsck().unwrap();
6836        assert!(report.errors.is_empty(), "{:?}", report.errors);
6837
6838        let img = fs.reader.get_ref().clone();
6839        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
6840        let root = fs.root().unwrap();
6841        let kids = fs.list_directory(&root).unwrap();
6842        assert_eq!(kids.len(), 1000);
6843        for e in &kids {
6844            fs.delete_entry(&root, e).unwrap();
6845        }
6846        fs.create_file(
6847            &root,
6848            "last.txt",
6849            &mut &b"y"[..],
6850            1,
6851            &CreateFileOptions::default(),
6852        )
6853        .unwrap();
6854        fs.sync_metadata().unwrap();
6855        let report = fs.fsck().unwrap();
6856        assert!(report.errors.is_empty(), "{:?}", report.errors);
6857    }
6858
6859    /// F-016: the extents-overflow tree grows too, once a fork spread over
6860    /// enough holes needs more records than its four nodes hold.
6861    #[test]
6862    fn extents_tree_grows_when_its_nodes_run_out() {
6863        let img = create_blank_hfs_sized(4 * 1024 * 1024, 512, "Frag", 2048, 0).unwrap();
6864        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
6865        let xt0 = fs.mdb.extents_file_size;
6866        assert_eq!(xt0, 2048);
6867        let root = fs.root().unwrap();
6868        let block = vec![0x5Au8; 512];
6869        for i in 0..400 {
6870            fs.create_file(
6871                &root,
6872                &format!("h{i:03}"),
6873                &mut Cursor::new(&block),
6874                512,
6875                &CreateFileOptions::default(),
6876            )
6877            .unwrap();
6878        }
6879        for e in fs.list_directory(&root).unwrap() {
6880            if e.name[1..].parse::<u32>().unwrap() % 2 == 1 {
6881                fs.delete_entry(&root, &e).unwrap();
6882            }
6883        }
6884        // The free tail goes to one file but for 64 blocks the trees may grow
6885        // into; a 200-block fork then spreads over the one-block holes.
6886        let tail = (fs.mdb.free_blocks - 264) as usize * 512;
6887        fs.create_file(
6888            &root,
6889            "tail",
6890            &mut Cursor::new(vec![0u8; tail]),
6891            tail as u64,
6892            &CreateFileOptions::default(),
6893        )
6894        .unwrap();
6895        let data: Vec<u8> = (0..200 * 512).map(|i| (i / 512) as u8).collect();
6896        let entry = fs
6897            .create_file(
6898                &root,
6899                "spread",
6900                &mut Cursor::new(&data),
6901                data.len() as u64,
6902                &CreateFileOptions::default(),
6903            )
6904            .unwrap();
6905        assert!(
6906            fs.mdb.extents_file_size > xt0,
6907            "the extents tree never grew"
6908        );
6909        assert_eq!(fs.read_file(&entry, usize::MAX).unwrap(), data);
6910        fs.sync_metadata().unwrap();
6911        let report = fs.fsck().unwrap();
6912        assert!(report.errors.is_empty(), "{:?}", report.errors);
6913
6914        let img = fs.reader.get_ref().clone();
6915        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
6916        let root = fs.root().unwrap();
6917        let entry = fs
6918            .list_directory(&root)
6919            .unwrap()
6920            .into_iter()
6921            .find(|e| e.name == "spread")
6922            .unwrap();
6923        assert_eq!(fs.read_file(&entry, usize::MAX).unwrap(), data);
6924        fs.delete_entry(&root, &entry).unwrap();
6925        fs.sync_metadata().unwrap();
6926        let report = fs.fsck().unwrap();
6927        assert!(report.errors.is_empty(), "{:?}", report.errors);
6928    }
6929
6930    /// F-016: past the 2048 nodes the header node's bitmap addresses, a grow
6931    /// links in a map node; 9000 files at 512-byte blocks get there.
6932    #[test]
6933    fn catalog_growth_adds_a_map_node_past_the_header_bitmap() {
6934        let img = create_blank_hfs_sized(24 * 1024 * 1024, 512, "Map", 0, 16384).unwrap();
6935        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
6936        let root = fs.root().unwrap();
6937        for i in 0..9000 {
6938            let name = format!("f{i:04}");
6939            fs.create_file(
6940                &root,
6941                &name,
6942                &mut &b"x"[..],
6943                1,
6944                &CreateFileOptions::default(),
6945            )
6946            .unwrap_or_else(|e| panic!("creating {name}: {e}"));
6947        }
6948        let h = BTreeHeader::read(&fs.catalog_data);
6949        assert!(h.total_nodes > 2048, "{} nodes", h.total_nodes);
6950        assert!(
6951            hfs_common::btree_bitmap_segments(&fs.catalog_data, 512).len() > 1,
6952            "no map node"
6953        );
6954        fs.sync_metadata().unwrap();
6955        let report = fs.fsck().unwrap();
6956        assert!(report.errors.is_empty(), "{:?}", report.errors);
6957        let img = fs.reader.get_ref().clone();
6958        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
6959        let root = fs.root().unwrap();
6960        assert_eq!(fs.list_directory(&root).unwrap().len(), 9000);
6961    }
6962
6963    /// A thousand files in, one more in and out, the thousand out, one in:
6964    /// the catalog must stay clean through every split, merge and retire.
6965    #[test]
6966    fn thousand_file_churn_keeps_the_catalog_clean() {
6967        let img = create_blank_hfs(64 * 1024 * 1024, 4096, "Churn").unwrap();
6968        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
6969        let root = fs.root().unwrap();
6970        let clean = |fs: &mut HfsFilesystem<Cursor<Vec<u8>>>, stage: &str| {
6971            fs.sync_metadata().unwrap();
6972            let r = fs.fsck().unwrap();
6973            assert!(r.errors.is_empty(), "{stage}: {:?}", r.errors);
6974        };
6975        let put = |fs: &mut HfsFilesystem<Cursor<Vec<u8>>>, name: &str, data: &[u8]| {
6976            fs.create_file(
6977                &root,
6978                name,
6979                &mut Cursor::new(data),
6980                data.len() as u64,
6981                &CreateFileOptions::default(),
6982            )
6983            .unwrap_or_else(|e| panic!("creating {name}: {e}"))
6984        };
6985        let free0 = fs.mdb.free_blocks;
6986        for i in 0..1000 {
6987            put(
6988                &mut fs,
6989                &format!("f{i:04}.txt"),
6990                format!("file {i:04}").as_bytes(),
6991            );
6992        }
6993        clean(&mut fs, "after 1000 creates");
6994        let extra = put(&mut fs, "extra.bin", &[7u8; 5000]);
6995        clean(&mut fs, "after one more");
6996        fs.delete_entry(&root, &extra).unwrap();
6997        clean(&mut fs, "after deleting it");
6998        for e in fs.list_directory(&root).unwrap() {
6999            fs.delete_entry(&root, &e).unwrap();
7000        }
7001        assert_eq!(fs.mdb.free_blocks, free0, "every block came back");
7002        clean(&mut fs, "after deleting the 1000");
7003        put(&mut fs, "last.bin", &[9u8; 100]);
7004        clean(&mut fs, "after the last create");
7005        let names: Vec<String> = fs
7006            .list_directory(&root)
7007            .unwrap()
7008            .into_iter()
7009            .map(|e| e.name)
7010            .collect();
7011        assert_eq!(names, ["last.bin"]);
7012    }
7013
7014    /// R-058: with the partition length known, fsck reports a classic HFS
7015    /// volume whose allocation area stops short of the partition's alternate MDB.
7016    #[test]
7017    fn fsck_reports_a_volume_that_does_not_fill_its_partition() {
7018        let img = create_blank_hfs(4 * 1024 * 1024, 2048, "Short").unwrap();
7019        let exact = img.len() as u64;
7020        let mut fs = HfsFilesystem::open_sized(Cursor::new(img.clone()), 0, Some(exact)).unwrap();
7021        assert!(fs.partition_shortfall().is_none());
7022        let report = fs.fsck().unwrap();
7023        assert!(report.errors.is_empty(), "{:?}", report.errors);
7024
7025        // A remainder shorter than one allocation block is what Mac OS's own
7026        // formatter leaves behind; fsck_hfs accepts it, and so does this check.
7027        let mut fs =
7028            HfsFilesystem::open_sized(Cursor::new(img.clone()), 0, Some(exact + 512)).unwrap();
7029        assert_eq!(fs.partition_shortfall(), Some(512));
7030        let report = fs.fsck().unwrap();
7031        assert!(report.errors.is_empty(), "{:?}", report.errors);
7032
7033        let mut fs =
7034            HfsFilesystem::open_sized(Cursor::new(img), 0, Some(exact + 1024 * 1024)).unwrap();
7035        assert_eq!(fs.partition_shortfall(), Some(1024 * 1024));
7036        let report = fs.fsck().unwrap();
7037        let codes: Vec<&str> = report.errors.iter().map(|e| e.code.as_str()).collect();
7038        assert_eq!(codes, ["AllocationAreaEnd"], "{:?}", report.errors);
7039        assert!(report.errors[0].message.contains("resize --size"));
7040    }
7041
7042    /// F-011: a fork no single free run can hold spreads over the holes, its
7043    /// fourth extent on lands in the overflow tree, and both forks read back.
7044    #[test]
7045    fn create_file_spreads_a_fork_over_free_runs() {
7046        let img = create_blank_hfs(8 * 1024 * 1024, 4096, "Runs").unwrap();
7047        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
7048        let root = fs.root().unwrap();
7049        let block = vec![0xA5u8; 4096];
7050        for i in 0..40 {
7051            fs.create_file(
7052                &root,
7053                &format!("f{i:02}"),
7054                &mut Cursor::new(block.as_slice()),
7055                4096,
7056                &CreateFileOptions::default(),
7057            )
7058            .unwrap();
7059        }
7060        // Every other one-block file goes: 20 one-block holes.
7061        for e in fs.list_directory(&root).unwrap() {
7062            if e.name[1..].parse::<u32>().unwrap() % 2 == 1 {
7063                fs.delete_entry(&root, &e).unwrap();
7064            }
7065        }
7066        // The contiguous tail goes to one file, so only the holes are left.
7067        let tail = (fs.mdb.free_blocks - 20) as usize;
7068        let tail_data = vec![0x5Au8; tail * 4096];
7069        fs.create_file(
7070            &root,
7071            "tail",
7072            &mut Cursor::new(tail_data.as_slice()),
7073            tail_data.len() as u64,
7074            &CreateFileOptions::default(),
7075        )
7076        .unwrap();
7077        assert_eq!(fs.mdb.free_blocks, 20);
7078        let free_before = fs.mdb.free_blocks;
7079
7080        // Seven blocks of data over seven holes: three inline, four in overflow.
7081        let data: Vec<u8> = (0..7 * 4096).map(|i| (i / 4096) as u8 + 1).collect();
7082        let rsrc: Vec<u8> = (0..5 * 4096).map(|i| 0x80 | (i / 4096) as u8).collect();
7083        let opts = CreateFileOptions {
7084            resource_fork: Some(disk_core::filesystem::ResourceForkSource::Data(
7085                rsrc.clone(),
7086            )),
7087            ..Default::default()
7088        };
7089        let entry = fs
7090            .create_file(
7091                &root,
7092                "frag",
7093                &mut Cursor::new(data.as_slice()),
7094                data.len() as u64,
7095                &opts,
7096            )
7097            .unwrap();
7098        let cnid = entry.location as u32;
7099        assert_eq!(fs.mdb.free_blocks, free_before - 12);
7100        let (dsize, dext, rsize, rext) = fs.find_file_by_id(cnid).unwrap();
7101        assert_eq!((dsize as usize, rsize as usize), (data.len(), rsrc.len()));
7102        assert!(dext.iter().all(|e| e.block_count == 1) && rext.iter().all(|e| e.block_count == 1));
7103        let tree = fs
7104            .extents_overflow_data
7105            .as_ref()
7106            .expect("overflow tree loaded");
7107        assert_eq!(collect_fork_overflow_extents(tree, cnid, 0x00, 0).len(), 4);
7108        assert_eq!(collect_fork_overflow_extents(tree, cnid, 0xFF, 0).len(), 2);
7109        assert_eq!(fs.read_file(&entry, usize::MAX).unwrap(), data);
7110        let mut got = Vec::new();
7111        fs.write_resource_fork_to(&entry, &mut got).unwrap();
7112        assert_eq!(got, rsrc);
7113
7114        // setrsrc over it: the old runs come back, the new fork spreads again.
7115        let rsrc2: Vec<u8> = (0..4 * 4096).map(|i| 0xC0 | (i / 4096) as u8).collect();
7116        fs.write_resource_fork(
7117            &entry,
7118            &mut Cursor::new(rsrc2.as_slice()),
7119            rsrc2.len() as u64,
7120        )
7121        .unwrap();
7122        assert_eq!(fs.mdb.free_blocks, free_before - 11);
7123        let tree = fs.extents_overflow_data.as_ref().unwrap();
7124        assert_eq!(collect_fork_overflow_extents(tree, cnid, 0xFF, 0).len(), 1);
7125        let mut got = Vec::new();
7126        fs.write_resource_fork_to(&entry, &mut got).unwrap();
7127        assert_eq!(got, rsrc2);
7128
7129        fs.sync_metadata().unwrap();
7130        let report = fs.fsck().unwrap();
7131        assert!(report.errors.is_empty(), "{:?}", report.errors);
7132
7133        // Reopen from disk: the overflow records were written back.
7134        let img = fs.reader.get_ref().clone();
7135        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
7136        let root = fs.root().unwrap();
7137        let entry = fs
7138            .list_directory(&root)
7139            .unwrap()
7140            .into_iter()
7141            .find(|e| e.name == "frag")
7142            .unwrap();
7143        assert_eq!(fs.read_file(&entry, usize::MAX).unwrap(), data);
7144        fs.delete_entry(&root, &entry).unwrap();
7145        assert_eq!(fs.mdb.free_blocks, free_before);
7146        let tree = fs.extents_overflow_data.as_ref().unwrap();
7147        assert!(collect_fork_overflow_extents(tree, cnid, 0x00, 0).is_empty());
7148        fs.sync_metadata().unwrap();
7149        let report = fs.fsck().unwrap();
7150        assert!(report.errors.is_empty(), "{:?}", report.errors);
7151    }
7152
7153    /// A fork fragmented past its three inline extents keeps the rest in the
7154    /// extents-overflow tree; delete used to leak those blocks and records.
7155    #[test]
7156    fn delete_frees_overflow_extents_and_their_records() {
7157        use crate::hfs_common::{
7158            bitmap_test_bit_be, btree_alloc_node, BTreeHeader, BTREE_LEAF_NODE,
7159        };
7160        // A formatted volume: the hand-built test image has no extents file.
7161        let img = create_blank_hfs(8 * 1024 * 1024, 4096, "Overflow").unwrap();
7162        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
7163        assert!(fs.mdb.extents_file_size > 0);
7164        let root = fs.root().unwrap();
7165        let free_at_start = fs.mdb.free_blocks;
7166        let data = b"inline extent contents";
7167        fs.create_file(
7168            &root,
7169            "frag.bin",
7170            &mut Cursor::new(data.as_slice()),
7171            data.len() as u64,
7172            &CreateFileOptions::default(),
7173        )
7174        .unwrap();
7175        let entry = fs
7176            .list_directory(&root)
7177            .unwrap()
7178            .into_iter()
7179            .find(|e| e.name == "frag.bin")
7180            .unwrap();
7181        let cnid = entry.location as u32;
7182
7183        // Give the data fork a fourth extent: two blocks described only by an
7184        // overflow record, in a leaf the blank tree does not have yet.
7185        let start = fs.allocate_blocks(2).unwrap();
7186        fs.ensure_extents_overflow().unwrap();
7187        {
7188            let tree = fs.extents_overflow_data.as_mut().expect("extents tree");
7189            let mut h = BTreeHeader::read(tree);
7190            let node_size = h.node_size as usize;
7191            let leaf = btree_alloc_node(tree, node_size, h.total_nodes).unwrap();
7192            h.root_node = leaf;
7193            h.first_leaf_node = leaf;
7194            h.last_leaf_node = leaf;
7195            h.depth = 1;
7196            h.leaf_records = 1;
7197            h.free_nodes -= 1;
7198            h.write(tree);
7199            let off = leaf as usize * node_size;
7200            let node = &mut tree[off..off + node_size];
7201            node[8] = BTREE_LEAF_NODE as u8;
7202            node[9] = 1;
7203            BigEndian::write_u16(&mut node[10..12], 1);
7204            // Key: keyLen 7, data fork, this file, from its second block.
7205            node[14] = 7;
7206            node[15] = 0x00;
7207            BigEndian::write_u32(&mut node[16..20], cnid);
7208            BigEndian::write_u16(&mut node[20..22], 1);
7209            BigEndian::write_u16(&mut node[22..24], start as u16);
7210            BigEndian::write_u16(&mut node[24..26], 2);
7211            BigEndian::write_u16(&mut node[node_size - 2..], 14);
7212            BigEndian::write_u16(&mut node[node_size - 4..node_size - 2], 34);
7213        }
7214        let tree = fs.extents_overflow_data.as_ref().unwrap();
7215        assert_eq!(collect_fork_overflow_extents(tree, cnid, 0x00, 0).len(), 1);
7216
7217        fs.delete_entry(&root, &entry).unwrap();
7218
7219        let tree = fs.extents_overflow_data.as_ref().unwrap();
7220        assert!(collect_fork_overflow_extents(tree, cnid, 0x00, 0).is_empty());
7221        let h = BTreeHeader::read(tree);
7222        assert_eq!((h.leaf_records, h.root_node, h.depth), (0, 0, 0));
7223        // Apple's fsck rejects a lone empty leaf; the retired tree has none.
7224        assert!(
7225            crate::hfs_common::btree_lonely_empty_leaves(tree, h.node_size as usize).is_empty()
7226        );
7227        let bitmap = fs.bitmap.as_ref().unwrap();
7228        assert!(!bitmap_test_bit_be(bitmap, start) && !bitmap_test_bit_be(bitmap, start + 1));
7229        assert_eq!(fs.mdb.free_blocks, free_at_start, "every block came back");
7230        fs.sync_metadata().unwrap();
7231        let report = fs.fsck().unwrap();
7232        assert!(report.errors.is_empty(), "{:?}", report.errors);
7233    }
7234
7235    /// Synthesize a one-leaf extents-overflow B-tree blob and verify the
7236    /// collector returns the expected (start_block, block_count) sequence
7237    /// for the matching (file_id, fork_type), filtering by start_block.
7238    #[test]
7239    fn test_collect_fork_overflow_extents() {
7240        let node_size = 512usize;
7241        // 3 nodes: 0=header, 1=leaf, 2=unused.
7242        let total_nodes = 3u32;
7243        let mut buf = vec![0u8; node_size * total_nodes as usize];
7244
7245        // Header node.
7246        buf[8] = hfs_common::BTREE_HEADER_NODE as u8;
7247        BigEndian::write_u16(&mut buf[10..12], 3); // numRecords
7248        BigEndian::write_u16(&mut buf[14..16], 1); // depth
7249        BigEndian::write_u32(&mut buf[16..20], 1); // rootNode
7250        BigEndian::write_u32(&mut buf[20..24], 1); // leafRecords (approx)
7251        BigEndian::write_u32(&mut buf[24..28], 1); // firstLeafNode
7252        BigEndian::write_u32(&mut buf[28..32], 1); // lastLeafNode
7253        BigEndian::write_u16(&mut buf[32..34], node_size as u16);
7254        BigEndian::write_u16(&mut buf[34..36], 7);
7255        BigEndian::write_u32(&mut buf[36..40], total_nodes);
7256        BigEndian::write_u32(&mut buf[40..44], total_nodes - 2);
7257        // Mark nodes 0 and 1 allocated in the header-node bitmap (rec at 248).
7258        buf[248] = 0b11000000;
7259
7260        // Leaf node at index 1.
7261        let leaf_off = node_size;
7262        let leaf = &mut buf[leaf_off..leaf_off + node_size];
7263        leaf[8] = hfs_common::BTREE_LEAF_NODE as u8;
7264        BigEndian::write_u16(&mut leaf[10..12], 1); // one record
7265
7266        // Record: key_len(1)=7, fork_type(1)=0x00, file_id(4)=0x100, start_block(2)=3
7267        let rec_off = 14usize;
7268        leaf[rec_off] = 7;
7269        leaf[rec_off + 1] = 0x00;
7270        BigEndian::write_u32(&mut leaf[rec_off + 2..rec_off + 6], 0x100);
7271        BigEndian::write_u16(&mut leaf[rec_off + 6..rec_off + 8], 3);
7272        // 3 extent descriptors (4 bytes each) immediately after key (already even).
7273        let data_off = rec_off + 8;
7274        BigEndian::write_u16(&mut leaf[data_off..data_off + 2], 50); // start
7275        BigEndian::write_u16(&mut leaf[data_off + 2..data_off + 4], 2); // count
7276        BigEndian::write_u16(&mut leaf[data_off + 4..data_off + 6], 60);
7277        BigEndian::write_u16(&mut leaf[data_off + 6..data_off + 8], 1);
7278        BigEndian::write_u16(&mut leaf[data_off + 8..data_off + 10], 0); // unused
7279        BigEndian::write_u16(&mut leaf[data_off + 10..data_off + 12], 0);
7280
7281        // Record offset table: rec0 starts at 14, free at 14+8+12=34.
7282        let nr = 1usize;
7283        BigEndian::write_u16(&mut leaf[node_size - 2..], 14);
7284        BigEndian::write_u16(&mut leaf[node_size - 4..node_size - 2], 34);
7285        let _ = nr;
7286
7287        // Match: same file, data fork, start_block >= 3.
7288        let got = collect_fork_overflow_extents(&buf, 0x100, 0x00, 3);
7289        assert_eq!(got.len(), 2, "expected 2 non-empty extents, got {got:?}");
7290        assert_eq!((got[0].start_block, got[0].block_count), (50, 2));
7291        assert_eq!((got[1].start_block, got[1].block_count), (60, 1));
7292
7293        // Filter by min_start_block: skip record whose key startBlock < 4.
7294        let none = collect_fork_overflow_extents(&buf, 0x100, 0x00, 4);
7295        assert!(
7296            none.is_empty(),
7297            "expected empty when filtered, got {none:?}"
7298        );
7299
7300        // Wrong fork_type returns nothing.
7301        let rsrc = collect_fork_overflow_extents(&buf, 0x100, 0xFF, 0);
7302        assert!(rsrc.is_empty());
7303
7304        // Wrong file_id returns nothing.
7305        let other = collect_fork_overflow_extents(&buf, 0x101, 0x00, 0);
7306        assert!(other.is_empty());
7307    }
7308
7309    /// `create_blank_hfs_sized` with non-default catalog/extents sizes
7310    /// produces an fsck-clean image that opens with the requested B-tree
7311    /// sizes (rounded up to the next allocation block).
7312    #[test]
7313    fn test_create_blank_hfs_sized_custom_btree_sizes() {
7314        let block_size = 32 * 1024u32;
7315        // Ask for 256 KiB catalog and 64 KiB extents-overflow.
7316        let img =
7317            create_blank_hfs_sized(64 * 1024 * 1024, block_size, "Sized", 64 * 1024, 256 * 1024)
7318                .expect("create_blank_hfs_sized");
7319        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).expect("open");
7320        // 64 KiB / 32 KiB = 2 blocks -> bumped to 4 (default floor).
7321        assert_eq!(fs.mdb().extents_file_size, 4 * block_size);
7322        // 256 KiB / 32 KiB = 8 blocks.
7323        assert_eq!(fs.mdb().catalog_file_size, 8 * block_size);
7324        let result = fs.fsck().expect("fsck");
7325        assert!(
7326            result.errors.is_empty(),
7327            "unexpected fsck errors: {:?}",
7328            result.errors.iter().map(|e| &e.message).collect::<Vec<_>>()
7329        );
7330    }
7331
7332    /// `pick_btree_node_size` always returns 512 — classic Mac OS rejects
7333    /// larger node sizes (Quadra ROM produces error -127). Map nodes are
7334    /// the long-term answer for >1 MiB B-tree files; until they exist we
7335    /// just pin the node size and rely on callers to clamp file sizes.
7336    #[test]
7337    fn test_pick_btree_node_size_thresholds() {
7338        assert_eq!(pick_btree_node_size(512 * 2048), 512);
7339        assert_eq!(pick_btree_node_size(512 * 2049), 512);
7340        assert_eq!(pick_btree_node_size(1024 * 6144), 512);
7341        assert_eq!(pick_btree_node_size(u32::MAX as u64), 512);
7342    }
7343
7344    /// Even when the caller asks for a B-tree size above the 1 MiB header-
7345    /// bitmap budget, the produced volume still uses node_size=512. The
7346    /// caller is responsible for clamping; the builder keeps Mac-OS-friendly
7347    /// nodes and produces a fsck-clean image.
7348    #[test]
7349    fn test_create_blank_hfs_sized_large_catalog() {
7350        let block_size = 32 * 1024u32;
7351        let img = create_blank_hfs_sized(
7352            128 * 1024 * 1024,
7353            block_size,
7354            "Big",
7355            0,
7356            HFS_MAX_BTREE_FILE_SIZE,
7357        )
7358        .expect("create_blank_hfs_sized big catalog");
7359        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).expect("open big");
7360        let cat = fs.catalog_data();
7361        let node_size = BigEndian::read_u16(&cat[32..34]);
7362        assert_eq!(
7363            node_size, 512,
7364            "node_size must stay at 512 for Mac OS compat"
7365        );
7366        let result = fs.fsck().expect("fsck big");
7367        assert!(
7368            result.errors.is_empty(),
7369            "unexpected fsck errors: {:?}",
7370            result.errors.iter().map(|e| &e.message).collect::<Vec<_>>()
7371        );
7372    }
7373
7374    /// Catalog larger than the header bitmap's 2048-node capacity must
7375    /// allocate map nodes (BTNodeKind=2). Verify the chain is set up
7376    /// correctly, fsck is clean, and we can allocate nodes beyond bit 2048.
7377    #[test]
7378    fn test_create_blank_hfs_with_map_nodes() {
7379        // Request a 2 MiB catalog (4096 nodes at node_size=512). This needs
7380        // ceil((4096-2048)/3952) = 1 map node.
7381        let img =
7382            create_blank_hfs_sized(64 * 1024 * 1024, 32 * 1024, "MapNodes", 0, 2 * 1024 * 1024)
7383                .expect("create_blank_hfs_sized 2 MiB cat");
7384        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).expect("open");
7385        let cat = fs.catalog_data().to_vec();
7386
7387        // Header.fLink must point to the first map node (node 2).
7388        let header_flink = BigEndian::read_u32(&cat[0..4]);
7389        assert_eq!(header_flink, 2, "header.fLink should point to map node 2");
7390
7391        // Node 2 must be a map node (kind=2) with one record and fLink=0
7392        // (only one map node needed for 4096-node capacity).
7393        let map = &cat[1024..1536];
7394        assert_eq!(map[8] as i8, 2, "node 2 should be a map node");
7395        assert_eq!(BigEndian::read_u16(&map[10..12]), 1);
7396        assert_eq!(
7397            BigEndian::read_u32(&map[0..4]),
7398            0,
7399            "single map node, fLink=0"
7400        );
7401
7402        // Total nodes 4096, used 3 (header, leaf, map). free = 4093.
7403        let header = hfs_common::BTreeHeader::read(&cat);
7404        assert_eq!(header.total_nodes, 4096);
7405        assert_eq!(header.free_nodes, 4093);
7406
7407        // Walk the segment iterator — should yield 2 segments covering
7408        // 256 + 494 = 750 bytes = 6000 bits ≥ 4096 nodes.
7409        let segs = hfs_common::btree_bitmap_segments(&cat, 512);
7410        assert_eq!(segs.len(), 2);
7411        assert_eq!(segs[0].base_bit, 0);
7412        assert_eq!(segs[1].base_bit, 2048);
7413        assert!(
7414            (segs[0].len + segs[1].len) * 8 >= 4096,
7415            "bitmap chain must address all 4096 nodes"
7416        );
7417
7418        // fsck should be clean.
7419        let result = fs.fsck().expect("fsck");
7420        assert!(
7421            result.errors.is_empty(),
7422            "unexpected fsck errors: {:?}",
7423            result.errors.iter().map(|e| &e.message).collect::<Vec<_>>()
7424        );
7425    }
7426
7427    /// Block sizes that aren't positive multiples of 512 must be rejected.
7428    #[test]
7429    fn test_create_blank_hfs_rejects_bad_block_size() {
7430        assert!(create_blank_hfs(1 << 20, 0, "X").is_err());
7431        assert!(create_blank_hfs(1 << 20, 500, "X").is_err());
7432        assert!(create_blank_hfs(1 << 20, 513, "X").is_err());
7433    }
7434
7435    // --- Step 4: Finder-metadata setter round-trip tests ---
7436
7437    /// Create a file on the test image and return (fs, file_entry).
7438    fn make_fs_with_file() -> (HfsFilesystem<Cursor<Vec<u8>>>, FileEntry) {
7439        let img = make_editable_hfs_image();
7440        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
7441        let root = fs.root().unwrap();
7442        let data = b"hello";
7443        let mut r = Cursor::new(data.as_slice());
7444        let fe = fs
7445            .create_file(
7446                &root,
7447                "thing.bin",
7448                &mut r,
7449                data.len() as u64,
7450                &CreateFileOptions::default(),
7451            )
7452            .unwrap();
7453        (fs, fe)
7454    }
7455
7456    #[test]
7457    fn test_hfs_set_finder_info_round_trip() {
7458        let (mut fs, fe) = make_fs_with_file();
7459        let mut finfo = [0u8; 16];
7460        finfo[0..4].copy_from_slice(b"APPL");
7461        finfo[4..8].copy_from_slice(b"MACS");
7462        BigEndian::write_u16(&mut finfo[8..10], 0x4000); // fdFlags kHasBeenInited
7463        BigEndian::write_i16(&mut finfo[10..12], 100); // fdLocation.v
7464        BigEndian::write_i16(&mut finfo[12..14], 200); // fdLocation.h
7465        BigEndian::write_u16(&mut finfo[14..16], 0x1234); // fdFldr
7466        let mut fxinfo = [0u8; 16];
7467        BigEndian::write_u16(&mut fxinfo[0..2], 0xABCD); // fdIconID
7468        fxinfo[8] = 0x55; // fdScript
7469        BigEndian::write_u32(&mut fxinfo[12..16], 0xDEADBEEF); // fdPutAway
7470
7471        fs.set_finder_info(&fe, finfo, fxinfo).unwrap();
7472
7473        let rec = fs.locate_record_data(fe.location as u32).unwrap();
7474        assert_eq!(&fs.catalog_data[rec + 4..rec + 20], &finfo);
7475        assert_eq!(&fs.catalog_data[rec + 56..rec + 72], &fxinfo);
7476
7477        // The read path must surface fdFlags on FileEntry so get-binhex / cp
7478        // preserve them (docs/bug_binhex_finder_flags.md).
7479        let root = fs.root().unwrap();
7480        let listed = fs.list_directory(&root).unwrap();
7481        let entry = listed.iter().find(|e| e.name == "thing.bin").unwrap();
7482        assert_eq!(entry.finder_flags, Some(0x4000));
7483    }
7484
7485    /// Regression for the dropped-Finder-flags bug: a file with hasBundle
7486    /// (0x2000) set must report `finder_flags == Some(0x2000)` on read, which
7487    /// is the value get-binhex now encodes into the .hqx header.
7488    #[test]
7489    fn test_hfs_read_surfaces_has_bundle_flag() {
7490        let (mut fs, fe) = make_fs_with_file();
7491        let mut finfo = [0u8; 16];
7492        finfo[0..4].copy_from_slice(b"APPL");
7493        finfo[4..8].copy_from_slice(b"Po.P");
7494        BigEndian::write_u16(&mut finfo[8..10], 0x2000); // fdFlags hasBundle
7495        fs.set_finder_info(&fe, finfo, [0u8; 16]).unwrap();
7496
7497        let root = fs.root().unwrap();
7498        let listed = fs.list_directory(&root).unwrap();
7499        let entry = listed.iter().find(|e| e.name == "thing.bin").unwrap();
7500        assert_eq!(entry.finder_flags, Some(0x2000));
7501    }
7502
7503    #[test]
7504    fn test_hfs_set_finder_info_rejects_directory() {
7505        let img = make_editable_hfs_image();
7506        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
7507        let root = fs.root().unwrap();
7508        let dir = fs
7509            .create_directory(&root, "Dir", &CreateDirectoryOptions::default())
7510            .unwrap();
7511        let err = fs.set_finder_info(&dir, [0u8; 16], [0u8; 16]).unwrap_err();
7512        assert!(matches!(err, FilesystemError::InvalidData(_)));
7513    }
7514
7515    #[test]
7516    fn test_hfs_set_directory_finder_info_round_trip() {
7517        let img = make_editable_hfs_image();
7518        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
7519        let root = fs.root().unwrap();
7520        let dir = fs
7521            .create_directory(&root, "Apps", &CreateDirectoryOptions::default())
7522            .unwrap();
7523
7524        let mut dinfo = [0u8; 16];
7525        BigEndian::write_i16(&mut dinfo[0..2], 10); // frRect top
7526        BigEndian::write_i16(&mut dinfo[2..4], 20); // frRect left
7527        BigEndian::write_i16(&mut dinfo[4..6], 300); // frRect bottom
7528        BigEndian::write_i16(&mut dinfo[6..8], 400); // frRect right
7529        BigEndian::write_u16(&mut dinfo[8..10], 0x0100); // frFlags
7530        let mut dxinfo = [0u8; 16];
7531        BigEndian::write_i16(&mut dxinfo[0..2], -5); // frScroll.v
7532        BigEndian::write_i16(&mut dxinfo[2..4], -7); // frScroll.h
7533        BigEndian::write_u32(&mut dxinfo[4..8], 0xCAFEBABE); // frOpenChain
7534
7535        fs.set_directory_finder_info(&dir, dinfo, dxinfo).unwrap();
7536
7537        let rec = fs.locate_record_data(dir.location as u32).unwrap();
7538        assert_eq!(&fs.catalog_data[rec + 22..rec + 38], &dinfo);
7539        assert_eq!(&fs.catalog_data[rec + 38..rec + 54], &dxinfo);
7540    }
7541
7542    #[test]
7543    fn test_hfs_set_directory_finder_info_rejects_file() {
7544        let (mut fs, fe) = make_fs_with_file();
7545        let err = fs
7546            .set_directory_finder_info(&fe, [0u8; 16], [0u8; 16])
7547            .unwrap_err();
7548        assert!(matches!(err, FilesystemError::InvalidData(_)));
7549    }
7550
7551    #[test]
7552    fn test_hfs_set_dates_file() {
7553        let (mut fs, fe) = make_fs_with_file();
7554        let create = 0x1000_0001u32;
7555        let modify = 0x2000_0002u32;
7556        let backup = 0x3000_0003u32;
7557        fs.set_dates(&fe, create, modify, backup).unwrap();
7558
7559        let rec = fs.locate_record_data(fe.location as u32).unwrap();
7560        assert_eq!(
7561            BigEndian::read_u32(&fs.catalog_data[rec + 44..rec + 48]),
7562            create
7563        );
7564        assert_eq!(
7565            BigEndian::read_u32(&fs.catalog_data[rec + 48..rec + 52]),
7566            modify
7567        );
7568        assert_eq!(
7569            BigEndian::read_u32(&fs.catalog_data[rec + 52..rec + 56]),
7570            backup
7571        );
7572    }
7573
7574    #[test]
7575    fn test_hfs_set_dates_directory() {
7576        let img = make_editable_hfs_image();
7577        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
7578        let root = fs.root().unwrap();
7579        let dir = fs
7580            .create_directory(&root, "D", &CreateDirectoryOptions::default())
7581            .unwrap();
7582
7583        let create = 0xAAAAu32;
7584        let modify = 0xBBBBu32;
7585        let backup = 0xCCCCu32;
7586        fs.set_dates(&dir, create, modify, backup).unwrap();
7587
7588        let rec = fs.locate_record_data(dir.location as u32).unwrap();
7589        assert_eq!(
7590            BigEndian::read_u32(&fs.catalog_data[rec + 10..rec + 14]),
7591            create
7592        );
7593        assert_eq!(
7594            BigEndian::read_u32(&fs.catalog_data[rec + 14..rec + 18]),
7595            modify
7596        );
7597        assert_eq!(
7598            BigEndian::read_u32(&fs.catalog_data[rec + 18..rec + 22]),
7599            backup
7600        );
7601    }
7602
7603    #[test]
7604    fn test_hfs_set_file_locked_toggle() {
7605        let (mut fs, fe) = make_fs_with_file();
7606        let rec = fs.locate_record_data(fe.location as u32).unwrap();
7607        let initial = fs.catalog_data[rec + 2];
7608        assert_eq!(initial & 0x01, 0);
7609
7610        fs.set_file_locked(&fe, true).unwrap();
7611        let rec = fs.locate_record_data(fe.location as u32).unwrap();
7612        assert_eq!(fs.catalog_data[rec + 2] & 0x01, 0x01);
7613
7614        fs.set_file_locked(&fe, false).unwrap();
7615        let rec = fs.locate_record_data(fe.location as u32).unwrap();
7616        assert_eq!(fs.catalog_data[rec + 2] & 0x01, 0);
7617    }
7618
7619    #[test]
7620    fn test_hfs_set_file_locked_rejects_directory() {
7621        let img = make_editable_hfs_image();
7622        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
7623        let root = fs.root().unwrap();
7624        let dir = fs
7625            .create_directory(&root, "Dir", &CreateDirectoryOptions::default())
7626            .unwrap();
7627        let err = fs.set_file_locked(&dir, true).unwrap_err();
7628        assert!(matches!(err, FilesystemError::InvalidData(_)));
7629    }
7630
7631    #[test]
7632    fn test_hfs_set_volume_dates_persists_through_sync() {
7633        let img = make_editable_hfs_image();
7634        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
7635
7636        let create = 0x4242_4242u32;
7637        let modify = 0x5151_5151u32;
7638        let backup = 0x6060_6060u32;
7639        fs.set_volume_dates(create, modify, backup);
7640        fs.sync_metadata().unwrap();
7641
7642        assert_eq!(fs.mdb.create_date, create);
7643        assert_eq!(fs.mdb.modify_date, modify);
7644        assert_eq!(BigEndian::read_u32(&fs.mdb.raw_sector[64..68]), backup);
7645
7646        // Round-trip through disk: the on-disk MDB must preserve these bytes.
7647        let bytes = fs.reader.get_ref().clone();
7648        let mut fs2 = HfsFilesystem::open(Cursor::new(bytes), 0).unwrap();
7649        assert_eq!(fs2.mdb.create_date, create);
7650        assert_eq!(fs2.mdb.modify_date, modify);
7651        assert_eq!(BigEndian::read_u32(&fs2.mdb.raw_sector[64..68]), backup);
7652
7653        // No pending dates -> sync stamps modify_date to hfs_now (different from `modify`).
7654        fs2.sync_metadata().unwrap();
7655        assert_ne!(fs2.mdb.modify_date, modify);
7656    }
7657
7658    #[test]
7659    fn test_hfs_set_boot_blocks_written_on_sync() {
7660        let img = make_editable_hfs_image();
7661        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
7662        let mut bb = [0u8; 1024];
7663        bb[0] = 0x4C; // 'LK' HFS boot signature
7664        bb[1] = 0x4B;
7665        for (i, slot) in bb.iter_mut().enumerate().skip(2).take(200) {
7666            *slot = (i & 0xFF) as u8;
7667        }
7668        fs.set_boot_blocks(&bb);
7669        fs.sync_metadata().unwrap();
7670
7671        let read_back = fs.read_boot_blocks().unwrap();
7672        assert_eq!(&read_back[..], &bb[..]);
7673    }
7674
7675    #[test]
7676    fn test_hfs_set_volume_finder_info_round_trip() {
7677        let img = make_editable_hfs_image();
7678        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
7679
7680        let mut fi = [0u8; 32];
7681        BigEndian::write_u32(&mut fi[0..4], 100); // blessed folder CNID
7682        BigEndian::write_u32(&mut fi[12..16], 200); // OS folder CNID (drFndrInfo[3])
7683        BigEndian::write_u32(&mut fi[20..24], 300); // drFndrInfo[5]
7684        fs.set_volume_finder_info(&fi);
7685        assert_eq!(fs.mdb.finder_info[0], 100);
7686        assert_eq!(fs.mdb.finder_info[3], 200);
7687        assert_eq!(fs.mdb.finder_info[5], 300);
7688
7689        fs.sync_metadata().unwrap();
7690        let bytes = fs.reader.get_ref().clone();
7691        let fs2 = HfsFilesystem::open(Cursor::new(bytes), 0).unwrap();
7692        assert_eq!(fs2.mdb.finder_info[0], 100);
7693        assert_eq!(fs2.mdb.finder_info[3], 200);
7694        assert_eq!(fs2.mdb.finder_info[5], 300);
7695    }
7696
7697    #[test]
7698    fn test_default_btree_sizes_scale_and_floor() {
7699        // Small volume: both sizes hit the 24-block floor.
7700        let (cat, ext) = default_btree_sizes(1 * 1024 * 1024, 512);
7701        let floor = 24 * 512;
7702        assert_eq!(cat, floor, "small-volume catalog should hit floor");
7703        assert_eq!(ext, floor, "small-volume extents should hit floor");
7704
7705        // 20 MB @ 512 (the hformat reference volume): catalog should be a
7706        // healthy fraction of the volume (well above the 2 KB old default),
7707        // extents ~half. Both clump-aligned (4 * block_size = 2048).
7708        let (cat, ext) = default_btree_sizes(20 * 1024 * 1024, 512);
7709        assert!(cat >= 100 * 1024, "20MB catalog too small: {cat}");
7710        assert_eq!(cat % 2048, 0, "catalog not clump-aligned");
7711        assert_eq!(ext % 2048, 0, "extents not clump-aligned");
7712        assert!(ext <= cat, "extents should not exceed catalog");
7713        assert!(ext * 2 >= cat - 2048, "extents should be ~half catalog");
7714
7715        // Larger volume scales further up.
7716        let (cat_big, _) = default_btree_sizes(200 * 1024 * 1024, 4096);
7717        assert!(cat_big > cat, "larger volume should get a larger catalog");
7718
7719        // A volume formatted with the default sizing must be buildable +
7720        // fsck-clean (the catalog can't exceed available blocks).
7721        let (c, e) = default_btree_sizes(20 * 1024 * 1024, 4096);
7722        let img = create_blank_hfs_sized(20 * 1024 * 1024, 4096, "Ref20", e, c).unwrap();
7723        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
7724        let result = fs.fsck().unwrap();
7725        assert!(
7726            result.errors.is_empty(),
7727            "fsck errors on default-sized volume: {:?}",
7728            result.errors.iter().map(|e| &e.message).collect::<Vec<_>>()
7729        );
7730    }
7731
7732    #[test]
7733    fn test_clone_target_btree_sizes_floors_at_blank_default() {
7734        // Regression: a densely-packed source catalog reports a small byte size
7735        // (here 2 MiB), but 1.5x that (3 MiB) undersized a 2046 MiB expand/HFV
7736        // target and exhausted it mid-clone ("no free B-tree nodes"). The clone
7737        // target must floor at the blank-format default for its own size.
7738        let target = 2046 * 1024 * 1024;
7739        let (default_cat, _) = default_btree_sizes(target, 32768);
7740        let (cat, _ext) = clone_target_btree_sizes(target, 32768, 2 * 1024 * 1024, 1024 * 1024);
7741        assert_eq!(
7742            cat, default_cat,
7743            "large target must floor catalog at the blank default, not 1.5x source"
7744        );
7745        assert!(
7746            cat > 2 * 1024 * 1024 * 3 / 2,
7747            "floor must exceed 1.5x source"
7748        );
7749
7750        // When 1.5x source exceeds the volume default (small target, big
7751        // source catalog), the source-derived value wins.
7752        let small_target = 40 * 1024 * 1024;
7753        let (cat2, _) = clone_target_btree_sizes(small_target, 4096, 4 * 1024 * 1024, 256 * 1024);
7754        assert_eq!(
7755            cat2,
7756            4 * 1024 * 1024 * 3 / 2,
7757            "source-derived value should win"
7758        );
7759
7760        // Never exceeds the HFS B-tree file ceiling.
7761        let (cat3, ext3) = clone_target_btree_sizes(target, 32768, u32::MAX, u32::MAX);
7762        assert!(cat3 <= HFS_MAX_BTREE_FILE_SIZE);
7763        assert!(ext3 <= HFS_MAX_BTREE_FILE_SIZE);
7764    }
7765
7766    #[test]
7767    fn test_hfs_set_volume_name_roundtrip() {
7768        // Build a blank volume, rename, re-open, verify drVN + catalog
7769        // key + root thread record all carry the new name and fsck is clean.
7770        let img = create_blank_hfs(8 * 1024 * 1024, 4096, "OldName").unwrap();
7771        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
7772
7773        fs.set_volume_name("NewName").unwrap();
7774        assert_eq!(fs.mdb.volume_name, "NewName");
7775        fs.sync_metadata().unwrap();
7776
7777        let bytes = fs.reader.get_ref().clone();
7778        let mut fs2 = HfsFilesystem::open(Cursor::new(bytes.clone()), 0).unwrap();
7779        assert_eq!(fs2.mdb.volume_name, "NewName");
7780
7781        // Root dir record key must now be (parent=1, "NewName") — otherwise
7782        // Mac OS rejects the volume with -127 at mount.
7783        let new_raw = b"NewName".to_vec();
7784        assert!(
7785            fs2.find_catalog_record_by_name(1, &new_raw).is_some(),
7786            "root dir record not re-keyed to new name"
7787        );
7788        assert!(
7789            fs2.find_catalog_record_by_name(1, b"OldName").is_none(),
7790            "old root dir key still present"
7791        );
7792
7793        // Root thread record (parent=2, name="") must carry new thdCName.
7794        let (_n, _r, key_abs) = fs2.find_catalog_record_by_cnid(2).unwrap();
7795        let key_len = fs2.catalog_data[key_abs] as usize;
7796        let mut data_off = key_abs + 1 + key_len;
7797        if !data_off.is_multiple_of(2) {
7798            data_off += 1;
7799        }
7800        let thd_len = fs2.catalog_data[data_off + 14] as usize;
7801        assert_eq!(thd_len, new_raw.len(), "thdCName length not updated");
7802        assert_eq!(
7803            &fs2.catalog_data[data_off + 15..data_off + 15 + thd_len],
7804            new_raw.as_slice(),
7805            "thdCName bytes not updated"
7806        );
7807
7808        // MDB drVN at raw_sector +36 should mirror.
7809        let mdb_off = 1024 + 36;
7810        assert_eq!(bytes[mdb_off] as usize, new_raw.len());
7811        assert_eq!(
7812            &bytes[mdb_off + 1..mdb_off + 1 + new_raw.len()],
7813            new_raw.as_slice()
7814        );
7815
7816        let result = fs2.fsck().unwrap();
7817        assert!(
7818            result.errors.is_empty(),
7819            "fsck errors after rename: {:?}",
7820            result.errors.iter().map(|e| &e.message).collect::<Vec<_>>()
7821        );
7822    }
7823
7824    #[test]
7825    fn test_drallocptr_advances_with_allocations() {
7826        // Fresh volume: drAllocPtr should be 0. After a put it should
7827        // advance past the allocated run so subsequent allocations start
7828        // where the last one ended — matches hformat behavior.
7829        let img = create_blank_hfs(4 * 1024 * 1024, 512, "AllocPtr").unwrap();
7830        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
7831        assert_eq!(
7832            BigEndian::read_u16(&fs.mdb.raw_sector[16..18]),
7833            0,
7834            "fresh volume drAllocPtr should be 0"
7835        );
7836
7837        let root = fs.root().unwrap();
7838        let payload = vec![0xABu8; 8 * 1024]; // forces 16 blocks @ 512
7839        let mut src = payload.as_slice();
7840        fs.create_file(
7841            &root,
7842            "ptr",
7843            &mut src,
7844            payload.len() as u64,
7845            &CreateFileOptions::default(),
7846        )
7847        .unwrap();
7848        let after = BigEndian::read_u16(&fs.mdb.raw_sector[16..18]);
7849        assert!(after > 0, "drAllocPtr should advance after allocation");
7850    }
7851
7852    #[test]
7853    fn test_hfs_set_volume_name_rejects_invalid() {
7854        let img = create_blank_hfs(1 * 1024 * 1024, 512, "X").unwrap();
7855        let mut fs = HfsFilesystem::open(Cursor::new(img), 0).unwrap();
7856        assert!(
7857            fs.set_volume_name("").is_err(),
7858            "empty name must be rejected"
7859        );
7860        let too_long = "A".repeat(28);
7861        assert!(
7862            fs.set_volume_name(&too_long).is_err(),
7863            ">27 byte name must be rejected"
7864        );
7865        // Original name must still be intact after rollback.
7866        assert_eq!(fs.mdb.volume_name, "X");
7867    }
7868
7869    #[test]
7870    fn test_hfs_finder_info_setters_survive_fsck() {
7871        // Ensure setters don't produce structural corruption.
7872        let (mut fs, fe) = make_fs_with_file();
7873        fs.set_finder_info(&fe, [0x11; 16], [0x22; 16]).unwrap();
7874        fs.set_dates(&fe, 1, 2, 3).unwrap();
7875        fs.set_file_locked(&fe, true).unwrap();
7876        fs.set_volume_dates(10, 20, 30);
7877        fs.set_volume_finder_info(&[0x33; 32]);
7878        fs.sync_metadata().unwrap();
7879        let result = fs.fsck().unwrap();
7880        assert!(
7881            result.errors.is_empty(),
7882            "fsck errors: {:?}",
7883            result.errors.iter().map(|e| &e.message).collect::<Vec<_>>()
7884        );
7885    }
7886}