Skip to main content

fs_mac/
hfs.rs

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