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