Skip to main content

fs_mac/
hfs_common.rs

1//! Shared helpers for HFS and HFS+ filesystem editing.
2//!
3//! Provides big-endian bitmap operations, Mac epoch time utilities,
4//! type/creator code lookup, B-tree key comparison, and B-tree node
5//! manipulation (insert, remove, split, grow root).
6
7use byteorder::{BigEndian, ByteOrder};
8use std::cmp::Ordering;
9use std::sync::OnceLock;
10
11use disk_core::filesystem::FilesystemError;
12
13// ---------------------------------------------------------------------------
14// Big-endian bitmap operations (HFS/HFS+ bitmaps are MSB-first)
15// ---------------------------------------------------------------------------
16
17// Moved to `disk-core` (shared with ProDOS); re-exported so existing paths keep working.
18pub use disk_core::bits::{
19    bitmap_clear_bit_be, bitmap_collect_clear_runs_be, bitmap_find_clear_bit_be,
20    bitmap_find_clear_run_be, bitmap_find_set_bit_be, bitmap_set_bit_be, bitmap_test_bit_be,
21};
22
23// ---------------------------------------------------------------------------
24// Mac epoch time
25// ---------------------------------------------------------------------------
26
27/// Seconds between 1904-01-01 00:00:00 UTC and 1970-01-01 00:00:00 UTC.
28const MAC_EPOCH_DELTA: u64 = 2_082_844_800;
29
30/// Returns the current time as seconds since the Mac epoch (1904-01-01).
31pub fn hfs_now() -> u32 {
32    let unix_secs = std::time::SystemTime::now()
33        .duration_since(std::time::UNIX_EPOCH)
34        .unwrap_or_default()
35        .as_secs();
36    // Past 2040-02-06 the field is full; a wrap would stamp 1904.
37    u32::try_from(unix_secs.saturating_add(MAC_EPOCH_DELTA)).unwrap_or(u32::MAX)
38}
39
40/// Format an HFS/HFS+ Mac-epoch timestamp (seconds since 1904-01-01 UTC) as a
41/// human-readable `YYYY-MM-DD HH:MM:SS` string. Returns `None` for a zero
42/// timestamp ("no date set" on disk). Dates before the Unix epoch clamp to
43/// 1970-01-01 — vintage Mac files are 1984+, so this is harmless in practice.
44pub fn format_mac_date(mac_secs: u32) -> Option<String> {
45    if mac_secs == 0 {
46        return None;
47    }
48    let unix = mac_secs as i64 - MAC_EPOCH_DELTA as i64;
49    Some(disk_core::unix_common::inode::format_unix_timestamp(unix))
50}
51
52/// Convert an HFS/HFS+ Mac-epoch timestamp to seconds since the Unix epoch,
53/// for [`disk_core::entry::FileEntry::modified_unix`] and `tar_export`.
54/// Returns `None` for zero ("no date set") or Mac dates that would land
55/// before 1970 (the tar oracle would render those as 1969 either way).
56pub fn mac_date_to_unix(mac_secs: u32) -> Option<u64> {
57    if mac_secs == 0 || (mac_secs as u64) < MAC_EPOCH_DELTA {
58        return None;
59    }
60    Some(mac_secs as u64 - MAC_EPOCH_DELTA)
61}
62
63/// Parse a `YYYY-MM-DD HH:MM:SS` string (interpreted as UTC) into Mac-epoch
64/// seconds. The inverse of [`format_mac_date`]; returns `None` when the string
65/// doesn't parse or falls outside the representable range. An empty string (or
66/// the literal `0`) maps to `0` ("no date set"), so a cleared field round-trips.
67pub fn parse_mac_date(s: &str) -> Option<u32> {
68    parse_mac_date_checked(s).ok()
69}
70
71/// Like [`parse_mac_date`] but returns a short, specific reason on failure
72/// (e.g. "month 13 is out of range", "day 29 is invalid for 1997-02") so the
73/// date editor can tell the user exactly what's wrong. Leap-year / days-per-month
74/// validity is delegated to `chrono`'s `from_ymd_opt`.
75pub fn parse_mac_date_checked(s: &str) -> Result<u32, String> {
76    let s = s.trim();
77    if s.is_empty() || s == "0" {
78        return Ok(0);
79    }
80    let fmt = "expected YYYY-MM-DD HH:MM:SS";
81    let (date, time) = s.split_once(' ').ok_or(fmt)?;
82    let d: Vec<&str> = date.split('-').collect();
83    let t: Vec<&str> = time.split(':').collect();
84    if d.len() != 3 || t.len() != 3 {
85        return Err(fmt.into());
86    }
87    let num = |x: &str, what: &str| -> Result<i64, String> {
88        x.trim()
89            .parse::<i64>()
90            .map_err(|_| format!("'{}' is not a number ({what})", x.trim()))
91    };
92    let (y, mo, day) = (num(d[0], "year")?, num(d[1], "month")?, num(d[2], "day")?);
93    let (h, mi, se) = (
94        num(t[0], "hour")?,
95        num(t[1], "minute")?,
96        num(t[2], "second")?,
97    );
98    if !(1..=12).contains(&mo) {
99        return Err(format!("month {mo} is out of range (1-12)"));
100    }
101    let nd = chrono::NaiveDate::from_ymd_opt(y as i32, mo as u32, day as u32)
102        .ok_or_else(|| format!("day {day} is invalid for {y}-{mo:02}"))?;
103    let nt = chrono::NaiveTime::from_hms_opt(h as u32, mi as u32, se as u32)
104        .ok_or_else(|| format!("time {h:02}:{mi:02}:{se:02} is out of range"))?;
105    let mac = chrono::NaiveDateTime::new(nd, nt).and_utc().timestamp() + MAC_EPOCH_DELTA as i64;
106    if (0..=u32::MAX as i64).contains(&mac) {
107        Ok(mac as u32)
108    } else {
109        Err("date is outside the representable range".into())
110    }
111}
112
113// ---------------------------------------------------------------------------
114// Type/creator lookup from hfs_file_types.json
115// ---------------------------------------------------------------------------
116
117/// Parsed extension-to-type/creator mapping.
118struct ExtensionMap {
119    entries: Vec<(String, [u8; 4], [u8; 4])>, // (ext, type_code, creator_code)
120}
121
122fn load_extension_map() -> &'static ExtensionMap {
123    static MAP: OnceLock<ExtensionMap> = OnceLock::new();
124    MAP.get_or_init(|| {
125        let json_bytes = include_str!("../assets/hfs_file_types.json");
126        let parsed: serde_json::Value = serde_json::from_str(json_bytes).unwrap_or_default();
127        let mut entries = Vec::new();
128        if let Some(exts) = parsed.get("extensions").and_then(|v| v.as_object()) {
129            for (ext, val) in exts {
130                if ext.starts_with('_') {
131                    continue;
132                }
133                if let (Some(tc), Some(cc)) = (
134                    val.get("type").and_then(|v| v.as_str()),
135                    val.get("creator").and_then(|v| v.as_str()),
136                ) {
137                    entries.push((ext.to_lowercase(), encode_fourcc(tc), encode_fourcc(cc)));
138                }
139            }
140        }
141        ExtensionMap { entries }
142    })
143}
144
145/// A known (type, creator) pair with a friendly description, used to populate
146/// the type/creator editor's pulldown.
147#[derive(Debug, Clone)]
148pub struct TypeCreatorEntry {
149    pub type_code: [u8; 4],
150    pub creator_code: [u8; 4],
151    pub description: String,
152}
153
154impl TypeCreatorEntry {
155    pub fn type_str(&self) -> String {
156        String::from_utf8_lossy(&self.type_code).to_string()
157    }
158    pub fn creator_str(&self) -> String {
159        String::from_utf8_lossy(&self.creator_code).to_string()
160    }
161}
162
163fn load_type_creator_entries() -> &'static Vec<TypeCreatorEntry> {
164    static ENTRIES: OnceLock<Vec<TypeCreatorEntry>> = OnceLock::new();
165    ENTRIES.get_or_init(|| {
166        let json_bytes = include_str!("../assets/hfs_file_types.json");
167        let parsed: serde_json::Value = serde_json::from_str(json_bytes).unwrap_or_default();
168
169        // type code -> friendly description (from the "types" block)
170        let mut type_desc: std::collections::HashMap<String, String> =
171            std::collections::HashMap::new();
172        if let Some(types) = parsed.get("types").and_then(|v| v.as_object()) {
173            for (tc, val) in types {
174                if let Some(desc) = val.get("description").and_then(|v| v.as_str()) {
175                    type_desc.insert(tc.clone(), desc.to_string());
176                }
177            }
178        }
179
180        // Curated (type, creator) catalog from the "entries" block — these
181        // win on description because they're hand-written.
182        let mut seen: std::collections::HashSet<(String, String)> =
183            std::collections::HashSet::new();
184        let mut out: Vec<TypeCreatorEntry> = Vec::new();
185        if let Some(arr) = parsed.get("entries").and_then(|v| v.as_array()) {
186            for item in arr {
187                let (Some(tc_str), Some(cc_str), Some(desc)) = (
188                    item.get("type").and_then(|v| v.as_str()),
189                    item.get("creator").and_then(|v| v.as_str()),
190                    item.get("description").and_then(|v| v.as_str()),
191                ) else {
192                    continue;
193                };
194                if !seen.insert((tc_str.to_string(), cc_str.to_string())) {
195                    continue;
196                }
197                out.push(TypeCreatorEntry {
198                    type_code: encode_fourcc(tc_str),
199                    creator_code: encode_fourcc(cc_str),
200                    description: desc.to_string(),
201                });
202            }
203        }
204        // Fall back to extension-derived pairs for anything not already in
205        // the curated catalog (legacy entries, late additions).
206        if let Some(exts) = parsed.get("extensions").and_then(|v| v.as_object()) {
207            for (ext, val) in exts {
208                if ext.starts_with('_') {
209                    continue;
210                }
211                let (Some(tc_str), Some(cc_str)) = (
212                    val.get("type").and_then(|v| v.as_str()),
213                    val.get("creator").and_then(|v| v.as_str()),
214                ) else {
215                    continue;
216                };
217                if !seen.insert((tc_str.to_string(), cc_str.to_string())) {
218                    continue;
219                }
220                let desc = type_desc
221                    .get(tc_str)
222                    .cloned()
223                    .unwrap_or_else(|| format!(".{ext}"));
224                out.push(TypeCreatorEntry {
225                    type_code: encode_fourcc(tc_str),
226                    creator_code: encode_fourcc(cc_str),
227                    description: desc,
228                });
229            }
230        }
231        out.sort_by(|a, b| {
232            a.description
233                .to_lowercase()
234                .cmp(&b.description.to_lowercase())
235                .then_with(|| a.type_code.cmp(&b.type_code))
236                .then_with(|| a.creator_code.cmp(&b.creator_code))
237        });
238        out
239    })
240}
241
242/// All known (type, creator) entries, sorted by friendly description.
243pub fn known_type_creators() -> &'static [TypeCreatorEntry] {
244    load_type_creator_entries().as_slice()
245}
246
247/// Friendly description for a (type, creator) pair, if known.
248pub fn describe_type_creator(type_code: &[u8; 4], creator_code: &[u8; 4]) -> Option<&'static str> {
249    load_type_creator_entries()
250        .iter()
251        .find(|e| &e.type_code == type_code && &e.creator_code == creator_code)
252        .map(|e| e.description.as_str())
253}
254
255/// True for the filesystems whose `create_file` consults the extension
256/// dictionary: HFS, HFS+/HFSX, MFS, and BeOS OFS — the last because early
257/// BeOS typed files with Mac `OSType` pairs, exactly the ones in the
258/// dictionary. ProDOS carries a file type too, but in its own `$XX` space, so
259/// it is deliberately excluded — handing it a Finder OSType like `TEXT` would
260/// be meaningless.
261pub fn uses_hfs_type_dictionary(fs_type: &str) -> bool {
262    fs_type.starts_with("HFS") || fs_type == "MFS" || fs_type == "BeOS OFS"
263}
264
265/// True when an `OSType` carries no information — all four bytes zero, which is
266/// how a classic-Mac volume records "no Finder type set".
267///
268/// An all-*space* code is deliberately not blank: `cp` promises byte-exact
269/// OSType fidelity, and blanking spaces would rewrite bytes a real volume chose.
270/// Empty caller-typed *text* is handled separately in
271/// [`resolve_create_type_creator`].
272pub fn is_blank_ostype(code: &[u8; 4]) -> bool {
273    code == &[0u8; 4]
274}
275
276/// Extension-dictionary lookup keyed by a file *name* rather than a bare
277/// extension. Returns `None` for a name with no extension at all, so a file
278/// literally named `txt` isn't mistaken for a `.txt` document.
279pub fn type_creator_for_filename(name: &str) -> Option<([u8; 4], [u8; 4])> {
280    let ext = name.rsplit('.').next().unwrap_or("");
281    if ext == name {
282        return None; // no '.' in the name
283    }
284    type_creator_for_extension(ext)
285}
286
287/// Resolve the Finder type/creator to stamp on a newly created HFS / HFS+ / MFS
288/// file. Shared by all three `create_file` implementations.
289///
290/// Each half resolves independently, so a partial FInfo (an imported AppleDouble
291/// that carried only a creator, say) keeps what it has and fills the rest.
292/// Precedence per half:
293///
294/// 1. `os_type` / `os_creator` — raw bytes, byte-exact and high-bit safe.
295/// 2. `type_code` / `creator_code` — the lossy caller-typed text form.
296/// 3. The extension dictionary (`assets/hfs_file_types.json`) — the same list
297///    the GUI's type/creator picker offers.
298/// 4. `[0; 4]` when nothing above knows.
299///
300/// A *blank* value at steps 1-2 does not suppress the dictionary. An all-zero
301/// OSType, or empty/whitespace text, means "no type" rather than a real code, so
302/// a type-less file copied onto HFS still picks up a sensible Finder type when
303/// its name tells us one. That is what makes `cp` of an untyped `README.TXT`
304/// land as `TEXT`/`ttxt` instead of staying unopenable in the Finder.
305pub fn resolve_create_type_creator(
306    name: &str,
307    os_type: Option<[u8; 4]>,
308    os_creator: Option<[u8; 4]>,
309    type_code: Option<&str>,
310    creator_code: Option<&str>,
311) -> ([u8; 4], [u8; 4]) {
312    let (dict_t, dict_c) = type_creator_for_filename(name).unwrap_or(([0; 4], [0; 4]));
313    // Emptiness is judged on the trimmed text, but the original is what gets
314    // encoded — `encode_fourcc` re-pads, so a real `"ZIP "` survives, while a
315    // hypothetical leading-space code isn't silently shifted.
316    let resolve = |raw: Option<[u8; 4]>, text: Option<&str>, dict: [u8; 4]| {
317        raw.filter(|c| !is_blank_ostype(c))
318            .or_else(|| text.filter(|s| !s.trim().is_empty()).map(encode_fourcc))
319            .unwrap_or(dict)
320    };
321    (
322        resolve(os_type, type_code, dict_t),
323        resolve(os_creator, creator_code, dict_c),
324    )
325}
326
327/// Look up the type and creator codes for a file extension.
328/// Returns None for unknown extensions.
329pub fn type_creator_for_extension(ext: &str) -> Option<([u8; 4], [u8; 4])> {
330    let lower = ext.to_lowercase();
331    let map = load_extension_map();
332    for (e, tc, cc) in &map.entries {
333        if e == &lower {
334            return Some((*tc, *cc));
335        }
336    }
337    None
338}
339
340/// Encode a 4-character string into a 4-byte array, space-padded on the right.
341pub fn encode_fourcc(s: &str) -> [u8; 4] {
342    let bytes = s.as_bytes();
343    let mut out = [b' '; 4];
344    for (i, &b) in bytes.iter().take(4).enumerate() {
345        out[i] = b;
346    }
347    out
348}
349
350/// Display-only, lossy `OSType` rendering; never use it to reconstruct the bytes.
351pub use disk_core::display::decode_ostype;
352
353// ---------------------------------------------------------------------------
354// B-tree key comparison
355// ---------------------------------------------------------------------------
356
357/// The catalog-name orderings live in the GPL `fs-mac-unicode` crate (derived tables).
358pub use fs_mac_unicode::collate::{compare_hfs_keys, compare_hfsplus_keys};
359
360/// Classic HFS extents-overflow key order: file ID, then fork type, then start
361/// block. Keys are `len(1) forkType(1) fileID(4) startBlock(2)`.
362pub fn compare_classic_extents_keys(a: &[u8], b: &[u8]) -> Ordering {
363    if a.len() < 8 || b.len() < 8 {
364        return a.len().cmp(&b.len());
365    }
366    let file_a = BigEndian::read_u32(&a[2..6]);
367    let file_b = BigEndian::read_u32(&b[2..6]);
368    match file_a.cmp(&file_b) {
369        Ordering::Equal => {}
370        other => return other,
371    }
372    match a[1].cmp(&b[1]) {
373        Ordering::Equal => {}
374        other => return other,
375    }
376    let start_a = BigEndian::read_u16(&a[6..8]);
377    let start_b = BigEndian::read_u16(&b[6..8]);
378    start_a.cmp(&start_b)
379}
380
381// ---------------------------------------------------------------------------
382// B-tree node manipulation
383// ---------------------------------------------------------------------------
384
385/// HFS nodes are 512 and HFS+ 512..=32768; anything smaller would slice past the header record.
386pub const BTREE_MIN_NODE_SIZE: usize = 512;
387
388/// B-tree node kinds.
389pub const BTREE_LEAF_NODE: i8 = -1;
390pub const BTREE_INDEX_NODE: i8 = 0;
391#[allow(dead_code)]
392pub const BTREE_HEADER_NODE: i8 = 1;
393#[allow(dead_code)]
394pub const BTREE_MAP_NODE: i8 = 2;
395
396/// Mac OS forces all catalog B-tree index keys to this length (0x25 = 37 decimal).
397/// Key structure: reserved(1) + parentID(4) + nameLen(1) + name(up to 31) = 37 max.
398pub const HFS_CAT_MAX_KEY_LEN: u8 = 0x25;
399
400/// Normalize a catalog key for use in an index record.
401///
402/// Mac OS expects all catalog index record keys to have key_len = 0x25,
403/// zero-padded beyond the actual key data. This matches the `n_index`
404/// function in hfsutils which forces `ckrKeyLen = 0x25`.
405///
406/// Returns: `[0x25][key_data zero-padded to 37 bytes]` = 38 bytes total.
407pub fn normalize_catalog_index_key(key: &[u8]) -> Vec<u8> {
408    let max = HFS_CAT_MAX_KEY_LEN as usize; // 37
409    let mut result = vec![0u8; 1 + max]; // 38 bytes: key_len + 37 bytes of key data
410    result[0] = HFS_CAT_MAX_KEY_LEN;
411    // Copy actual key data (skip the key_len byte from source)
412    if key.len() > 1 {
413        let copy_len = (key.len() - 1).min(max);
414        result[1..1 + copy_len].copy_from_slice(&key[1..1 + copy_len]);
415    }
416    result
417}
418
419/// `kBTBigKeysMask` — the B-tree's key-length prefix is a 2-byte (`u16`) field.
420/// Set on every HFS+ tree (catalog, extents-overflow, attributes); clear on
421/// classic HFS, whose key length is a single byte.
422pub const KBT_BIG_KEYS_MASK: u32 = 0x0000_0002;
423/// `kBTVariableIndexKeysMask` — index records carry the child's variable-length
424/// key verbatim. Set on the HFS+ catalog and attributes trees; clear on the
425/// extents-overflow tree and classic HFS, whose index keys are padded to a
426/// fixed `max_key_len`.
427pub const KBT_VARIABLE_INDEX_KEYS_MASK: u32 = 0x0000_0004;
428
429/// Describes how a B-tree encodes its keys, so the shared split / grow / rotate
430/// machinery below can serve both the classic-HFS 1-byte-key catalog and the
431/// HFS+ 2-byte ("big key") trees without forking the algorithm.
432///
433/// The classic path threads [`BTreeKeyFormat::CLASSIC_CATALOG`] and is
434/// byte-identical to the pre-descriptor code (the fixed 0x25 index key produced
435/// by [`normalize_catalog_index_key`]); HFS+ threads the matching `HFSPLUS_*`
436/// constant, which is what unlocks variable-length index keys so an HFS+ catalog
437/// can split past a single leaf level without corrupting.
438///
439/// Derived from the BTHeaderRec `attributes` bitfield + `maxKeyLength`.
440#[derive(Clone, Copy, Debug, PartialEq, Eq)]
441pub struct BTreeKeyFormat {
442    /// `kBTBigKeysMask`: key-length prefix is 2 bytes (HFS+) vs 1 byte (classic).
443    pub big_keys: bool,
444    /// `kBTVariableIndexKeysMask`: index records store the child's
445    /// variable-length key verbatim. When clear, index keys are padded to
446    /// `max_key_len`.
447    pub variable_index_keys: bool,
448    /// Maximum key length — the fixed index-key width when `variable_index_keys`
449    /// is clear (classic catalog: 0x25; HFS+ extents-overflow: 10).
450    pub max_key_len: u16,
451}
452
453impl BTreeKeyFormat {
454    /// Classic HFS catalog: 1-byte key length, fixed 0x25-byte index keys.
455    pub const CLASSIC_CATALOG: BTreeKeyFormat = BTreeKeyFormat {
456        big_keys: false,
457        variable_index_keys: false,
458        max_key_len: HFS_CAT_MAX_KEY_LEN as u16,
459    };
460
461    /// HFS+/HFSX catalog: 2-byte key length, variable-length index keys
462    /// (`kHFSPlusCatalogKeyMaximumLength` = 516).
463    pub const HFSPLUS_CATALOG: BTreeKeyFormat = BTreeKeyFormat {
464        big_keys: true,
465        variable_index_keys: true,
466        max_key_len: 516,
467    };
468
469    /// HFS+ extents-overflow: 2-byte key length, fixed 10-byte index keys
470    /// (`kHFSPlusExtentKeyMaximumLength` = 10).
471    pub const HFSPLUS_EXTENTS: BTreeKeyFormat = BTreeKeyFormat {
472        big_keys: true,
473        variable_index_keys: false,
474        max_key_len: 10,
475    };
476
477    /// HFS+ attributes: 2-byte key length, variable-length index keys
478    /// (`kHFSPlusAttrKeyMaximumLength` = 264).
479    pub const HFSPLUS_ATTRIBUTES: BTreeKeyFormat = BTreeKeyFormat {
480        big_keys: true,
481        variable_index_keys: true,
482        max_key_len: 264,
483    };
484
485    /// Classic HFS extents-overflow: 1-byte key length, fixed 7-byte keys.
486    pub const CLASSIC_EXTENTS: BTreeKeyFormat = BTreeKeyFormat {
487        big_keys: false,
488        variable_index_keys: false,
489        max_key_len: 7,
490    };
491
492    /// Build a descriptor from a BTHeaderRec `attributes` bitfield and
493    /// `maxKeyLength`. The `attributes` field on hand-built volumes can be 0
494    /// (no flags) even for HFS+ trees, so callers that know the tree's identity
495    /// should prefer the `HFSPLUS_*` / `CLASSIC_CATALOG` constants; this helper
496    /// is for code paths that only have the raw header bytes.
497    pub fn from_header(attributes: u32, max_key_len: u16) -> Self {
498        BTreeKeyFormat {
499            big_keys: attributes & KBT_BIG_KEYS_MASK != 0,
500            variable_index_keys: attributes & KBT_VARIABLE_INDEX_KEYS_MASK != 0,
501            max_key_len,
502        }
503    }
504
505    /// The BTHeaderRec `attributes` bits implied by this format.
506    pub fn header_attributes(&self) -> u32 {
507        let mut a = 0;
508        if self.big_keys {
509            a |= KBT_BIG_KEYS_MASK;
510        }
511        if self.variable_index_keys {
512            a |= KBT_VARIABLE_INDEX_KEYS_MASK;
513        }
514        a
515    }
516
517    /// Bytes occupied by the key-length prefix (1 classic, 2 big-key).
518    #[inline]
519    pub fn len_prefix(&self) -> usize {
520        if self.big_keys {
521            2
522        } else {
523            1
524        }
525    }
526
527    /// Read the key length stored at the front of `record`.
528    #[inline]
529    pub fn key_len(&self, record: &[u8]) -> usize {
530        if self.big_keys {
531            if record.len() < 2 {
532                0
533            } else {
534                BigEndian::read_u16(&record[0..2]) as usize
535            }
536        } else if record.is_empty() {
537            0
538        } else {
539            record[0] as usize
540        }
541    }
542
543    /// The key portion of a record — the length prefix plus `key_len` key
544    /// bytes. This is exactly the slice an index separator must carry, and what
545    /// the split helpers extract from a leaf/child's first record.
546    pub fn key_portion(&self, record: &[u8]) -> Vec<u8> {
547        let end = (self.len_prefix() + self.key_len(record)).min(record.len());
548        record[..end].to_vec()
549    }
550
551    /// Build the key bytes of an index record (length prefix + key) from a
552    /// child node's first-key portion.
553    ///
554    /// - Classic HFS: the fixed 0x25 normalized key, identical to
555    ///   [`normalize_catalog_index_key`].
556    /// - HFS+ variable-index-key trees (catalog, attributes): the key portion
557    ///   verbatim (2-byte length + key bytes).
558    /// - HFS+ fixed-index-key trees (extents-overflow): the key bytes padded to
559    ///   `max_key_len`, length field forced to `max_key_len`.
560    pub fn make_index_key(&self, first_key: &[u8]) -> Vec<u8> {
561        if !self.big_keys {
562            if self.max_key_len == HFS_CAT_MAX_KEY_LEN as u16 {
563                return normalize_catalog_index_key(first_key);
564            }
565            // Classic extents-overflow: a fixed 7-byte key behind a 1-byte length.
566            let max = self.max_key_len as usize;
567            let mut result = vec![0u8; 1 + max];
568            result[0] = max as u8;
569            let copy = first_key.len().saturating_sub(1).min(max);
570            result[1..1 + copy].copy_from_slice(&first_key[1..1 + copy]);
571            return result;
572        }
573        if self.variable_index_keys {
574            return first_key.to_vec();
575        }
576        let max = self.max_key_len as usize;
577        let copy = self.key_len(first_key).min(max);
578        let mut result = vec![0u8; 2 + max];
579        BigEndian::write_u16(&mut result[0..2], max as u16);
580        if first_key.len() >= 2 + copy {
581            result[2..2 + copy].copy_from_slice(&first_key[2..2 + copy]);
582        }
583        result
584    }
585}
586
587/// Read the B-tree header record from catalog_data (node 0, record 0 at offset 14).
588/// Returns (depth, root_node, leaf_records, first_leaf, last_leaf, node_size,
589///          max_key_len, total_nodes, free_nodes).
590pub struct BTreeHeader {
591    pub depth: u16,
592    pub root_node: u32,
593    pub leaf_records: u32,
594    pub first_leaf_node: u32,
595    pub last_leaf_node: u32,
596    pub node_size: u16,
597    pub total_nodes: u32,
598    pub free_nodes: u32,
599}
600
601impl BTreeHeader {
602    pub fn read(catalog_data: &[u8]) -> Self {
603        // Header record starts at offset 14 in node 0
604        let d = &catalog_data[14..];
605        BTreeHeader {
606            depth: BigEndian::read_u16(&d[0..2]),
607            root_node: BigEndian::read_u32(&d[2..6]),
608            leaf_records: BigEndian::read_u32(&d[6..10]),
609            first_leaf_node: BigEndian::read_u32(&d[10..14]),
610            last_leaf_node: BigEndian::read_u32(&d[14..18]),
611            node_size: BigEndian::read_u16(&d[18..20]),
612            total_nodes: BigEndian::read_u32(&d[22..26]),
613            free_nodes: BigEndian::read_u32(&d[26..30]),
614        }
615    }
616
617    pub fn write(&self, catalog_data: &mut [u8]) {
618        let d = &mut catalog_data[14..];
619        BigEndian::write_u16(&mut d[0..2], self.depth);
620        BigEndian::write_u32(&mut d[2..6], self.root_node);
621        BigEndian::write_u32(&mut d[6..10], self.leaf_records);
622        BigEndian::write_u32(&mut d[10..14], self.first_leaf_node);
623        BigEndian::write_u32(&mut d[14..18], self.last_leaf_node);
624        BigEndian::write_u32(&mut d[22..26], self.total_nodes);
625        BigEndian::write_u32(&mut d[26..30], self.free_nodes);
626    }
627}
628
629/// Calculate free space in a B-tree node (space available for a new record).
630///
631/// Free space = gap between last record's end and first offset-table entry,
632/// minus 2 bytes for the new offset slot that would be needed.
633pub fn btree_node_free_space(node: &[u8], node_size: usize) -> usize {
634    let num_records = BigEndian::read_u16(&node[10..12]) as usize;
635    if num_records == 0 {
636        // Empty node: data starts at 14 (after descriptor), offset table starts at end
637        // Offset table has 1 entry (the "free space offset" at position 0)
638        let data_end = 14;
639        let table_start = node_size - 2 * (num_records + 1);
640        if table_start <= data_end + 2 {
641            return 0;
642        }
643        return table_start - data_end - 2;
644    }
645    // Last record ends at the offset stored in the (num_records) position of offset table
646    let free_offset_pos = node_size - 2 * (num_records + 1);
647    let last_rec_end = BigEndian::read_u16(&node[free_offset_pos..free_offset_pos + 2]) as usize;
648    // Offset table currently occupies: 2*(num_records+1) bytes at end of node
649    // Adding a record would need 2 more bytes for a new offset slot
650    let table_start = free_offset_pos;
651    if table_start <= last_rec_end + 2 {
652        return 0;
653    }
654    table_start - last_rec_end - 2
655}
656
657/// The child pointer an index record ends with; 0 (never a real node) when
658/// a clamped range from a corrupt node is too short to hold one.
659fn index_child_ptr(node: &[u8], end: usize) -> u32 {
660    if end < 4 || end > node.len() {
661        return 0;
662    }
663    BigEndian::read_u32(&node[end - 4..end])
664}
665
666/// Get the offset and data of record `rec_idx` in a node.
667/// Returns (rec_offset, rec_end) — the byte range within the node.
668pub fn btree_record_range(node: &[u8], node_size: usize, rec_idx: usize) -> (usize, usize) {
669    // A corrupt numRecords used to walk the offset table off the node and
670    // panic; an empty, in-bounds range lets fsck report it instead.
671    let node_size = node_size.min(node.len());
672    let (Some(offset_pos), Some(next_pos)) = (
673        node_size.checked_sub(2 * (rec_idx + 1)),
674        node_size.checked_sub(2 * (rec_idx + 2)),
675    ) else {
676        return (0, 0);
677    };
678    let start = (BigEndian::read_u16(&node[offset_pos..offset_pos + 2]) as usize).min(node_size);
679    let end = (BigEndian::read_u16(&node[next_pos..next_pos + 2]) as usize).min(node_size);
680    (start, start.max(end))
681}
682
683/// Visit every record in the catalog's leaf-node chain, skipping non-leaf
684/// nodes and stopping if a cycle is detected. The visitor receives
685/// `(node_idx, rec_idx, abs_rec_offset, rec_bytes)`; returning `Some(T)`
686/// short-circuits the walk and yields that value.
687///
688/// Used by HFS and HFS+ for catalog scans where a B-tree descent isn't
689/// appropriate (linear scans by CNID, valence updates, name lookups in
690/// small catalogs).
691pub fn walk_leaf_records<T, F>(
692    catalog_data: &[u8],
693    first_leaf: u32,
694    node_size: usize,
695    mut visit: F,
696) -> Option<T>
697where
698    F: FnMut(u32, usize, usize, &[u8]) -> Option<T>,
699{
700    if node_size < BTREE_MIN_NODE_SIZE || catalog_data.len() < node_size {
701        return None;
702    }
703    let mut node_idx = first_leaf;
704    let mut visited = std::collections::HashSet::new();
705    while node_idx != 0 {
706        if !visited.insert(node_idx) {
707            return None;
708        }
709        let offset = (node_idx as usize).checked_mul(node_size)?;
710        if offset + node_size > catalog_data.len() {
711            return None;
712        }
713        let node = &catalog_data[offset..offset + node_size];
714        let next = BigEndian::read_u32(&node[0..4]);
715        let kind = node[8] as i8;
716        if kind != BTREE_LEAF_NODE {
717            node_idx = next;
718            continue;
719        }
720        let num_records = BigEndian::read_u16(&node[10..12]) as usize;
721        for i in 0..num_records {
722            let (rec_start, rec_end) = btree_record_range(node, node_size, i);
723            if rec_start >= rec_end || rec_end > node_size {
724                continue;
725            }
726            if let Some(t) = visit(node_idx, i, offset + rec_start, &node[rec_start..rec_end]) {
727                return Some(t);
728            }
729        }
730        node_idx = next;
731    }
732    None
733}
734
735/// Insert a record (key_bytes ++ record_bytes) into a B-tree leaf node at the
736/// correct sorted position.
737///
738/// `compare_fn` takes (existing_key_bytes, new_key_bytes) → Ordering.
739/// Returns the index where the record was inserted.
740/// Returns Err if the node doesn't have enough free space.
741pub fn btree_insert_record<F>(
742    node: &mut [u8],
743    node_size: usize,
744    key_record_bytes: &[u8],
745    compare_fn: &F,
746) -> Result<usize, FilesystemError>
747where
748    F: Fn(&[u8], &[u8]) -> Ordering,
749{
750    let num_records = BigEndian::read_u16(&node[10..12]) as usize;
751    let total_needed = key_record_bytes.len() + 2; // +2 for offset table entry
752
753    let free = btree_node_free_space(node, node_size);
754    if free < total_needed {
755        return Err(FilesystemError::InvalidData("B-tree node full".into()));
756    }
757
758    // Collect all existing records
759    let mut records: Vec<Vec<u8>> = Vec::with_capacity(num_records + 1);
760    for i in 0..num_records {
761        let (rec_start, rec_end) = btree_record_range(node, node_size, i);
762        if rec_start < rec_end && rec_end <= node_size {
763            records.push(node[rec_start..rec_end].to_vec());
764        }
765    }
766
767    // Find insertion position by comparing keys
768    let mut insert_pos = records.len();
769    for (i, rec) in records.iter().enumerate() {
770        if compare_fn(rec, key_record_bytes) == Ordering::Greater {
771            insert_pos = i;
772            break;
773        }
774    }
775
776    // Insert the new record at the sorted position
777    records.insert(insert_pos, key_record_bytes.to_vec());
778
779    // Rebuild the node: write all records sequentially starting at offset 14
780    // This ensures physical data order matches offset table order (monotonically increasing)
781    let mut write_pos = 14usize;
782    for (i, rec) in records.iter().enumerate() {
783        node[write_pos..write_pos + rec.len()].copy_from_slice(rec);
784        let opos = node_size - 2 * (i + 1);
785        BigEndian::write_u16(&mut node[opos..opos + 2], write_pos as u16);
786        write_pos += rec.len();
787    }
788
789    // Write free-space offset
790    let fpos = node_size - 2 * (records.len() + 1);
791    BigEndian::write_u16(&mut node[fpos..fpos + 2], write_pos as u16);
792
793    // Clear any leftover data between write_pos and the offset table
794    let offset_table_start = node_size - 2 * (records.len() + 1);
795    if write_pos < offset_table_start {
796        node[write_pos..offset_table_start].fill(0);
797    }
798
799    // Update num_records in node descriptor
800    BigEndian::write_u16(&mut node[10..12], records.len() as u16);
801
802    Ok(insert_pos)
803}
804
805/// Remove record at `rec_idx` from a B-tree node and compact data.
806///
807/// Rebuilds the node data area sequentially (like `btree_insert_record`) so
808/// that record boundaries in the offset table are contiguous. Without
809/// compaction, `btree_record_range` would report inflated record lengths
810/// because it uses consecutive offset table entries to determine boundaries.
811pub fn btree_remove_record(node: &mut [u8], node_size: usize, rec_idx: usize) {
812    let num_records = BigEndian::read_u16(&node[10..12]) as usize;
813    if rec_idx >= num_records {
814        return;
815    }
816
817    // Collect all records except the one being removed
818    let mut records: Vec<Vec<u8>> = Vec::with_capacity(num_records - 1);
819    for i in 0..num_records {
820        if i == rec_idx {
821            continue;
822        }
823        let (rec_start, rec_end) = btree_record_range(node, node_size, i);
824        if rec_start < rec_end && rec_end <= node_size {
825            records.push(node[rec_start..rec_end].to_vec());
826        }
827    }
828
829    // Rebuild node: write all remaining records sequentially from offset 14
830    let mut write_pos = 14usize;
831    for (i, rec) in records.iter().enumerate() {
832        node[write_pos..write_pos + rec.len()].copy_from_slice(rec);
833        let opos = node_size - 2 * (i + 1);
834        BigEndian::write_u16(&mut node[opos..opos + 2], write_pos as u16);
835        write_pos += rec.len();
836    }
837
838    // Write free-space offset
839    let fpos = node_size - 2 * (records.len() + 1);
840    BigEndian::write_u16(&mut node[fpos..fpos + 2], write_pos as u16);
841
842    // Clear leftover data between write_pos and the offset table
843    let offset_table_start = node_size - 2 * (records.len() + 1);
844    if write_pos < offset_table_start {
845        node[write_pos..offset_table_start].fill(0);
846    }
847
848    // Clear the now-unused last offset slot
849    let clear_pos = node_size - 2 * (num_records + 1);
850    BigEndian::write_u16(&mut node[clear_pos..clear_pos + 2], 0);
851
852    // Update num_records
853    BigEndian::write_u16(&mut node[10..12], records.len() as u16);
854}
855
856// ---------------------------------------------------------------------------
857// B-tree node bitmap (allocation bitmap for nodes)
858// ---------------------------------------------------------------------------
859//
860// The first segment of the node bitmap lives in record 2 of node 0 (the
861// header node). At node_size=512 it covers (512−256) bytes × 8 = 2048 bits,
862// so a B-tree file larger than 2048 nodes needs MAP NODES (BTNodeKind=2)
863// to hold the rest. Map nodes are chained through node descriptors:
864//
865//   header.fLink → mapNode₁ → mapNode₂ → … → 0
866//
867// Each map node carries a single record (record 0) of bitmap bytes covering
868// (node_size − 14 − 4) × 8 = 3952 bits at node_size=512 — i.e. each map node
869// extends the addressable range by 3952 nodes.
870
871/// Header-node bitmap range — first segment of the bitmap chain.
872/// Returns `(byte_offset_in_catalog_data, size_in_bytes)`.
873pub fn btree_node_bitmap_range(catalog_data: &[u8], node_size: usize) -> (usize, usize) {
874    // Node 0 has 3 records: [0]=header, [1]=user data, [2]=bitmap
875    // The bitmap record starts at the offset stored for record 2
876    let offset_pos = node_size - 2 * 3; // record 2 offset
877    let bitmap_start = BigEndian::read_u16(&catalog_data[offset_pos..offset_pos + 2]) as usize;
878    let next_pos = node_size - 2 * 4; // free space offset
879    let bitmap_end = BigEndian::read_u16(&catalog_data[next_pos..next_pos + 2]) as usize;
880    (bitmap_start, bitmap_end.saturating_sub(bitmap_start))
881}
882
883/// Bytes of bitmap stored in a single map node at the given node_size.
884pub fn map_node_bitmap_bytes(node_size: usize) -> usize {
885    // descriptor (14) + record-0 offset u16 + free-space offset u16 = 18.
886    node_size.saturating_sub(18)
887}
888
889/// Number of map nodes (BTNodeKind=2) needed to extend the node-allocation
890/// bitmap so it covers `total_nodes` indices, given a node size. The
891/// header node carries the first `(node_size - 256) * 8` bits; map nodes
892/// in the chain add `map_node_bitmap_bytes * 8` bits each. Used by both
893/// classic HFS catalog/extents builders and the HFS+ defrag B-tree
894/// allocator to decide how many map nodes to materialise up front.
895pub fn map_nodes_required(total_nodes: u32, node_size: usize) -> u32 {
896    let header_cap = ((node_size.saturating_sub(256)) * 8) as u32;
897    if total_nodes <= header_cap {
898        return 0;
899    }
900    let per_map = (map_node_bitmap_bytes(node_size) * 8) as u32;
901    if per_map == 0 {
902        return 0;
903    }
904    (total_nodes - header_cap).div_ceil(per_map)
905}
906
907/// One contiguous span of the node bitmap in `catalog_data`.
908#[derive(Debug, Clone, Copy)]
909pub struct BitmapSegment {
910    /// Byte offset in `catalog_data`.
911    pub byte_off: usize,
912    /// Length in bytes.
913    pub len: usize,
914    /// Index of the first node bit in this segment (i.e. global bit
915    /// `base_bit` lives at byte `byte_off`, bit 7 within that byte).
916    pub base_bit: u32,
917}
918
919/// Walk the B-tree node bitmap from the header node through any map-node
920/// chain and return the byte ranges that make up the full bitmap.
921///
922/// Defensive: bails out if the chain self-loops or points outside the file.
923/// Always returns at least the header-node segment.
924pub fn btree_bitmap_segments(catalog_data: &[u8], node_size: usize) -> Vec<BitmapSegment> {
925    let mut segs = Vec::with_capacity(1);
926    if catalog_data.len() < node_size {
927        return segs;
928    }
929
930    let (hdr_off, hdr_len) = btree_node_bitmap_range(catalog_data, node_size);
931    segs.push(BitmapSegment {
932        byte_off: hdr_off,
933        len: hdr_len,
934        base_bit: 0,
935    });
936    let mut next_bit = (hdr_len as u32) * 8;
937
938    // The header node's fLink is at byte 0..4 of node 0 — when non-zero, it
939    // points to the first map node.
940    let mut next_node = BigEndian::read_u32(&catalog_data[0..4]);
941    let mut visited: std::collections::HashSet<u32> = std::collections::HashSet::with_capacity(8);
942
943    while next_node != 0 {
944        if !visited.insert(next_node) {
945            // Cycle — stop.
946            break;
947        }
948        let off = next_node as usize * node_size;
949        if off + node_size > catalog_data.len() {
950            break;
951        }
952        // Validate kind == 2 (map node) and numRecords >= 1.
953        let kind = catalog_data[off + 8] as i8;
954        let num_records = BigEndian::read_u16(&catalog_data[off + 10..off + 12]);
955        if kind != 2 || num_records < 1 {
956            break;
957        }
958        // Record 0 offset is at end of node minus 2 bytes.
959        let rec0_pos = off + node_size - 2;
960        let rec0_off = BigEndian::read_u16(&catalog_data[rec0_pos..rec0_pos + 2]) as usize;
961        // Free-space offset (next 2 bytes back) gives the end of record 0.
962        let free_pos = off + node_size - 4;
963        let free_off = BigEndian::read_u16(&catalog_data[free_pos..free_pos + 2]) as usize;
964        if rec0_off < 14 || free_off <= rec0_off || free_off > node_size {
965            break;
966        }
967        let len = free_off - rec0_off;
968        segs.push(BitmapSegment {
969            byte_off: off + rec0_off,
970            len,
971            base_bit: next_bit,
972        });
973        next_bit += (len as u32) * 8;
974        // fLink at byte 0..4 of this map node.
975        next_node = BigEndian::read_u32(&catalog_data[off..off + 4]);
976    }
977
978    segs
979}
980
981fn locate_bit_in_segments(segs: &[BitmapSegment], global_bit: u32) -> Option<(usize, u32)> {
982    for (i, seg) in segs.iter().enumerate() {
983        let cap = (seg.len as u32) * 8;
984        if global_bit >= seg.base_bit && global_bit < seg.base_bit + cap {
985            return Some((i, global_bit - seg.base_bit));
986        }
987    }
988    None
989}
990
991/// Test whether the given global node bit is set in the node bitmap chain.
992pub fn btree_bitmap_test(catalog_data: &[u8], node_size: usize, bit: u32) -> bool {
993    let segs = btree_bitmap_segments(catalog_data, node_size);
994    let Some((idx, local)) = locate_bit_in_segments(&segs, bit) else {
995        return false;
996    };
997    let seg = segs[idx];
998    let slice = &catalog_data[seg.byte_off..seg.byte_off + seg.len];
999    bitmap_test_bit_be(slice, local)
1000}
1001
1002/// Allocate a free node from the B-tree node bitmap.
1003/// Sets the bit and returns the node index. Updates nothing else (caller must
1004/// update header's free_nodes).
1005pub fn btree_alloc_node(
1006    catalog_data: &mut [u8],
1007    node_size: usize,
1008    total_nodes: u32,
1009) -> Result<u32, FilesystemError> {
1010    let segs = btree_bitmap_segments(catalog_data, node_size);
1011    for seg in &segs {
1012        let bitmap = &catalog_data[seg.byte_off..seg.byte_off + seg.len];
1013        let cap = (seg.len as u32) * 8;
1014        let upper = total_nodes.saturating_sub(seg.base_bit).min(cap);
1015        for local in 0..upper {
1016            if !bitmap_test_bit_be(bitmap, local) {
1017                let global = seg.base_bit + local;
1018                bitmap_set_bit_be(
1019                    &mut catalog_data[seg.byte_off..seg.byte_off + seg.len],
1020                    local,
1021                );
1022                return Ok(global);
1023            }
1024        }
1025    }
1026    Err(FilesystemError::DiskFull("no free B-tree nodes".into()))
1027}
1028
1029/// Drop the index records pointing at freed leaf `freed`, freeing index nodes
1030/// that empty in turn and collapsing a one-child root; returns nodes freed.
1031pub fn btree_detach_freed_node(data: &mut [u8], node_size: usize, freed: u32) -> u32 {
1032    let mut header = BTreeHeader::read(data);
1033    let max_nodes = (data.len() / node_size) as u32;
1034    let mut freed_count = 0u32;
1035    if header.root_node == freed {
1036        // The last leaf went with it: an empty tree has no root.
1037        header.root_node = 0;
1038        header.depth = 0;
1039        header.write(data);
1040        return 0;
1041    }
1042    let mut target = freed;
1043    loop {
1044        let mut found = None;
1045        'scan: for idx in 1..max_nodes {
1046            if idx == target || !btree_bitmap_test(data, node_size, idx) {
1047                continue;
1048            }
1049            let off = idx as usize * node_size;
1050            let node = &data[off..off + node_size];
1051            if node[8] as i8 != BTREE_INDEX_NODE {
1052                continue;
1053            }
1054            let n = BigEndian::read_u16(&node[10..12]) as usize;
1055            for r in 0..n {
1056                let (_, end) = btree_record_range(node, node_size, r);
1057                if index_child_ptr(node, end) == target {
1058                    found = Some((idx, r));
1059                    break 'scan;
1060                }
1061            }
1062        }
1063        let Some((idx, r)) = found else {
1064            break;
1065        };
1066        let off = idx as usize * node_size;
1067        btree_remove_record(&mut data[off..off + node_size], node_size, r);
1068        if BigEndian::read_u16(&data[off + 10..off + 12]) > 0 {
1069            break;
1070        }
1071        btree_free_node(data, node_size, idx);
1072        freed_count += 1;
1073        if header.root_node == idx {
1074            header.root_node = 0;
1075            header.depth = 0;
1076            header.free_nodes += freed_count;
1077            header.write(data);
1078            return freed_count;
1079        }
1080        target = idx;
1081    }
1082    // A root with a single record only points at the real root.
1083    while header.depth > 1 && header.root_node != 0 {
1084        let off = header.root_node as usize * node_size;
1085        let node = &data[off..off + node_size];
1086        if node[8] as i8 != BTREE_INDEX_NODE || BigEndian::read_u16(&node[10..12]) != 1 {
1087            break;
1088        }
1089        let (_, end) = btree_record_range(node, node_size, 0);
1090        let child = index_child_ptr(node, end);
1091        if child == 0 {
1092            break;
1093        }
1094        btree_free_node(data, node_size, header.root_node);
1095        freed_count += 1;
1096        header.root_node = child;
1097        header.depth -= 1;
1098    }
1099    header.free_nodes += freed_count;
1100    header.write(data);
1101    freed_count
1102}
1103
1104/// The index node and record pointing at `child`, found by scanning: the
1105/// separators cannot be trusted to lead there while one of them is stale.
1106pub fn btree_find_parent(data: &[u8], node_size: usize, child: u32) -> Option<(u32, usize)> {
1107    let max_nodes = (data.len() / node_size) as u32;
1108    for idx in 1..max_nodes {
1109        if idx == child || !btree_bitmap_test(data, node_size, idx) {
1110            continue;
1111        }
1112        let off = idx as usize * node_size;
1113        let node = &data[off..off + node_size];
1114        if node[8] as i8 != BTREE_INDEX_NODE {
1115            continue;
1116        }
1117        let n = BigEndian::read_u16(&node[10..12]) as usize;
1118        for r in 0..n {
1119            let (_, end) = btree_record_range(node, node_size, r);
1120            if index_child_ptr(node, end) == child {
1121                return Some((idx, r));
1122            }
1123        }
1124    }
1125    None
1126}
1127
1128/// `(node, parent)` pairs from the root down to `node`, the shape
1129/// [`btree_insert_into_index`] expects.
1130fn btree_parent_chain(data: &[u8], node_size: usize, node: u32) -> Vec<(u32, u32)> {
1131    let mut chain = Vec::new();
1132    let mut cur = node;
1133    while cur != 0 && chain.len() < 64 {
1134        let parent = btree_find_parent(data, node_size, cur).map_or(0, |(p, _)| p);
1135        chain.push((cur, parent));
1136        cur = parent;
1137    }
1138    chain.reverse();
1139    chain
1140}
1141
1142/// Make every separator above `node_idx` equal its child's first key again
1143/// after that child's first record changed (fsck_hfs "Invalid index key").
1144///
1145/// A separator that merely sorts below the child still finds the records, which
1146/// is why nothing noticed; Disk First Aid insists on equality.
1147pub fn btree_refresh_index_keys<F>(
1148    data: &mut [u8],
1149    node_size: usize,
1150    mut node_idx: u32,
1151    kf: &BTreeKeyFormat,
1152    cmp: &F,
1153) -> Result<(), FilesystemError>
1154where
1155    F: Fn(&[u8], &[u8]) -> Ordering,
1156{
1157    let max_nodes = (data.len() / node_size) as u32;
1158    // Bounded by the tree height; the scan-based parent lookup cannot loop.
1159    for _ in 0..64 {
1160        if node_idx == 0 || node_idx >= max_nodes {
1161            return Ok(());
1162        }
1163        let first_key = {
1164            let off = node_idx as usize * node_size;
1165            let node = &data[off..off + node_size];
1166            if BigEndian::read_u16(&node[10..12]) == 0 {
1167                return Ok(());
1168            }
1169            let (s, e) = btree_record_range(node, node_size, 0);
1170            kf.key_portion(&node[s..e])
1171        };
1172        let Some((parent, rec_idx)) = btree_find_parent(data, node_size, node_idx) else {
1173            return Ok(());
1174        };
1175        let mut wanted = kf.make_index_key(&first_key);
1176        let mut ptr = [0u8; 4];
1177        BigEndian::write_u32(&mut ptr, node_idx);
1178        wanted.extend_from_slice(&ptr);
1179
1180        let parent_off = parent as usize * node_size;
1181        let replaced = {
1182            let pnode = &mut data[parent_off..parent_off + node_size];
1183            let (s, e) = btree_record_range(pnode, node_size, rec_idx);
1184            if pnode[s..e] == wanted[..] {
1185                return Ok(());
1186            }
1187            btree_remove_record(pnode, node_size, rec_idx);
1188            btree_insert_record(pnode, node_size, &wanted, cmp).is_ok()
1189        };
1190        if !replaced {
1191            // A longer key with no room left: the general index insert splits.
1192            let mut header = BTreeHeader::read(data);
1193            let chain = btree_parent_chain(data, node_size, parent);
1194            btree_insert_into_index(
1195                data,
1196                node_size,
1197                parent,
1198                node_idx,
1199                &first_key,
1200                &mut header,
1201                kf,
1202                cmp,
1203                &chain,
1204            )?;
1205            header.write(data);
1206        }
1207        node_idx = parent;
1208    }
1209    Ok(())
1210}
1211
1212/// [`btree_detach_freed_node`], then repair the separators above the parent
1213/// whose first record went with the freed leaf.
1214pub fn btree_detach_freed_node_and_refresh<F>(
1215    data: &mut [u8],
1216    node_size: usize,
1217    freed: u32,
1218    kf: &BTreeKeyFormat,
1219    cmp: &F,
1220) -> Result<u32, FilesystemError>
1221where
1222    F: Fn(&[u8], &[u8]) -> Ordering,
1223{
1224    let parent = btree_find_parent(data, node_size, freed);
1225    let freed_count = btree_detach_freed_node(data, node_size, freed);
1226    if let Some((parent, 0)) = parent {
1227        if btree_bitmap_test(data, node_size, parent) {
1228            btree_refresh_index_keys(data, node_size, parent, kf, cmp)?;
1229        }
1230    }
1231    Ok(freed_count)
1232}
1233
1234/// Every index separator that does not compare equal to its child's first key,
1235/// as `(index node, record, child, how the separator compares)`; what
1236/// fsck_hfs reports as E_IKey.
1237pub fn btree_stale_index_keys<F>(
1238    data: &[u8],
1239    node_size: usize,
1240    kf: &BTreeKeyFormat,
1241    cmp: &F,
1242) -> Vec<(u32, usize, u32, Ordering)>
1243where
1244    F: Fn(&[u8], &[u8]) -> Ordering,
1245{
1246    let mut stale = Vec::new();
1247    if node_size < BTREE_MIN_NODE_SIZE || data.len() < node_size {
1248        return stale;
1249    }
1250    let max_nodes = (data.len() / node_size) as u32;
1251    for idx in 1..max_nodes {
1252        if !btree_bitmap_test(data, node_size, idx) {
1253            continue;
1254        }
1255        let off = idx as usize * node_size;
1256        let node = &data[off..off + node_size];
1257        if node[8] as i8 != BTREE_INDEX_NODE {
1258            continue;
1259        }
1260        let n = BigEndian::read_u16(&node[10..12]) as usize;
1261        for r in 0..n {
1262            let (s, e) = btree_record_range(node, node_size, r);
1263            if e < s + 4 || e > node_size {
1264                continue;
1265            }
1266            let child = index_child_ptr(node, e);
1267            if child == 0 || child >= max_nodes || !btree_bitmap_test(data, node_size, child) {
1268                continue;
1269            }
1270            let coff = child as usize * node_size;
1271            let cnode = &data[coff..coff + node_size];
1272            if BigEndian::read_u16(&cnode[10..12]) == 0 {
1273                continue;
1274            }
1275            let (cs, ce) = btree_record_range(cnode, node_size, 0);
1276            let wanted = kf.make_index_key(&kf.key_portion(&cnode[cs..ce]));
1277            let order = cmp(&node[s..e - 4], &wanted);
1278            if order != Ordering::Equal {
1279                stale.push((idx, r, child, order));
1280            }
1281        }
1282    }
1283    stale
1284}
1285
1286/// Take an emptied leaf out of the tree: unlink it from its siblings and the
1287/// header, free its node, drop its separator and, when it was the last leaf,
1288/// retire the root. Apple's fsck calls a lone empty leaf "Invalid node
1289/// structure"; an empty tree is depth 0 with no root. Returns nodes freed.
1290pub fn btree_retire_empty_leaf<F>(
1291    data: &mut [u8],
1292    node_size: usize,
1293    node_idx: u32,
1294    kf: &BTreeKeyFormat,
1295    cmp: &F,
1296) -> Result<u32, FilesystemError>
1297where
1298    F: Fn(&[u8], &[u8]) -> Ordering,
1299{
1300    let off = node_idx as usize * node_size;
1301    if off + node_size > data.len() || BigEndian::read_u16(&data[off + 10..off + 12]) != 0 {
1302        return Ok(0);
1303    }
1304    let flink = BigEndian::read_u32(&data[off..off + 4]);
1305    let blink = BigEndian::read_u32(&data[off + 4..off + 8]);
1306    if blink != 0 {
1307        let b = blink as usize * node_size;
1308        BigEndian::write_u32(&mut data[b..b + 4], flink);
1309    }
1310    if flink != 0 {
1311        let f = flink as usize * node_size;
1312        BigEndian::write_u32(&mut data[f + 4..f + 8], blink);
1313    }
1314    let mut h = BTreeHeader::read(data);
1315    if h.first_leaf_node == node_idx {
1316        h.first_leaf_node = flink;
1317    }
1318    if h.last_leaf_node == node_idx {
1319        h.last_leaf_node = blink;
1320    }
1321    h.free_nodes += 1;
1322    h.write(data);
1323    btree_free_node(data, node_size, node_idx);
1324    let freed = btree_detach_freed_node_and_refresh(data, node_size, node_idx, kf, cmp)?;
1325    Ok(freed + 1)
1326}
1327
1328/// Leaves with no records and no siblings. Apple retires such a tree instead,
1329/// and fsck_hfs reports one as "Invalid node structure".
1330pub fn btree_lonely_empty_leaves(data: &[u8], node_size: usize) -> Vec<u32> {
1331    let mut lonely = Vec::new();
1332    if node_size < BTREE_MIN_NODE_SIZE || data.len() < node_size {
1333        return lonely;
1334    }
1335    let max_nodes = (data.len() / node_size) as u32;
1336    for idx in 1..max_nodes {
1337        if !btree_bitmap_test(data, node_size, idx) {
1338            continue;
1339        }
1340        let off = idx as usize * node_size;
1341        let node = &data[off..off + node_size];
1342        if node[8] as i8 == BTREE_LEAF_NODE
1343            && BigEndian::read_u16(&node[10..12]) == 0
1344            && BigEndian::read_u32(&node[0..4]) == 0
1345            && BigEndian::read_u32(&node[4..8]) == 0
1346        {
1347            lonely.push(idx);
1348        }
1349    }
1350    lonely
1351}
1352
1353/// Free a node in the B-tree node bitmap: clears the bit and erases the node,
1354/// as fsck_hfs demands of every unused node ("Unused node is not erased").
1355pub fn btree_free_node(catalog_data: &mut [u8], node_size: usize, node_idx: u32) {
1356    let segs = btree_bitmap_segments(catalog_data, node_size);
1357    let Some((idx, local)) = locate_bit_in_segments(&segs, node_idx) else {
1358        return;
1359    };
1360    let seg = segs[idx];
1361    bitmap_clear_bit_be(
1362        &mut catalog_data[seg.byte_off..seg.byte_off + seg.len],
1363        local,
1364    );
1365    let off = node_idx as usize * node_size;
1366    if node_idx != 0 && off + node_size <= catalog_data.len() {
1367        catalog_data[off..off + node_size].fill(0);
1368    }
1369}
1370
1371/// Free nodes that still hold data; Apple erases a node when it frees it and
1372/// fsck_hfs reports one that is not ("Unused node is not erased").
1373pub fn btree_unerased_free_nodes(data: &[u8], node_size: usize) -> Vec<u32> {
1374    let mut dirty = Vec::new();
1375    if node_size < BTREE_MIN_NODE_SIZE || data.len() < node_size {
1376        return dirty;
1377    }
1378    let max_nodes = (data.len() / node_size) as u32;
1379    for idx in 1..max_nodes {
1380        if btree_bitmap_test(data, node_size, idx) {
1381            continue;
1382        }
1383        let off = idx as usize * node_size;
1384        if data[off..off + node_size].iter().any(|&b| b != 0) {
1385            dirty.push(idx);
1386        }
1387    }
1388    dirty
1389}
1390
1391/// Zero every free node that still holds data, as fsck_hfs's repair does; returns how many.
1392pub fn btree_erase_free_nodes(data: &mut [u8], node_size: usize) -> usize {
1393    let dirty = btree_unerased_free_nodes(data, node_size);
1394    for &idx in &dirty {
1395        let off = idx as usize * node_size;
1396        data[off..off + node_size].fill(0);
1397    }
1398    dirty.len()
1399}
1400
1401/// Clear a node's bit in the bitmap. Segment-aware (handles map nodes).
1402pub fn btree_bitmap_clear(catalog_data: &mut [u8], node_size: usize, node_idx: u32) {
1403    let segs = btree_bitmap_segments(catalog_data, node_size);
1404    let Some((idx, local)) = locate_bit_in_segments(&segs, node_idx) else {
1405        return;
1406    };
1407    let seg = segs[idx];
1408    bitmap_clear_bit_be(
1409        &mut catalog_data[seg.byte_off..seg.byte_off + seg.len],
1410        local,
1411    );
1412}
1413
1414/// Set a node's bit in the bitmap (without doing any allocation). Used by
1415/// the blank-volume builder to reserve specific node indices for map nodes.
1416pub fn btree_bitmap_set(catalog_data: &mut [u8], node_size: usize, node_idx: u32) {
1417    let segs = btree_bitmap_segments(catalog_data, node_size);
1418    let Some((idx, local)) = locate_bit_in_segments(&segs, node_idx) else {
1419        return;
1420    };
1421    let seg = segs[idx];
1422    bitmap_set_bit_be(
1423        &mut catalog_data[seg.byte_off..seg.byte_off + seg.len],
1424        local,
1425    );
1426}
1427
1428/// Index of the last node in a B-tree's map-node chain (`header.fLink` →
1429/// mapNode → … → 0), or 0 when the chain is empty (so the caller links the
1430/// first map node via the header node at index 0). Cycle-guarded.
1431pub fn btree_map_chain_tail(buf: &[u8], node_size: usize) -> u32 {
1432    let mut next = BigEndian::read_u32(&buf[0..4]); // header.fLink
1433    let mut last = 0u32;
1434    let mut guard = 0;
1435    while next != 0 && guard < 1_000_000 {
1436        last = next;
1437        let off = next as usize * node_size;
1438        if off + 4 > buf.len() {
1439            break;
1440        }
1441        next = BigEndian::read_u32(&buf[off..off + 4]);
1442        guard += 1;
1443    }
1444    last
1445}
1446
1447/// Allocation blocks a B-tree fork of `total_nodes` needs to gain `add_slots`
1448/// more nodes. Nodes and blocks need not match one-to-one: several 4 KiB nodes
1449/// share an 8 KiB block, and the last block may already have room.
1450pub fn btree_grow_blocks(
1451    total_nodes: u32,
1452    add_slots: u32,
1453    node_size: usize,
1454    block_size: u32,
1455) -> u32 {
1456    let node_bytes = node_size as u64;
1457    let cur_blocks = (total_nodes as u64 * node_bytes).div_ceil(block_size as u64);
1458    let new_blocks = ((total_nodes + add_slots) as u64 * node_bytes).div_ceil(block_size as u64);
1459    (new_blocks - cur_blocks) as u32
1460}
1461
1462/// Initialize a node as a map node (BTNodeKind=2): descriptor + one record
1463/// holding `bitmap_bytes` of zeros + offset table.
1464pub fn init_map_node(
1465    catalog_data: &mut [u8],
1466    node_size: usize,
1467    node_idx: u32,
1468    prev_link: u32,
1469    next_link: u32,
1470) {
1471    let off = node_idx as usize * node_size;
1472    let node = &mut catalog_data[off..off + node_size];
1473    // Zero everything first.
1474    node.fill(0);
1475    // Descriptor: fLink, bLink, kind=2, height=0, numRecords=1, reserved.
1476    BigEndian::write_u32(&mut node[0..4], next_link);
1477    BigEndian::write_u32(&mut node[4..8], prev_link);
1478    node[8] = 2; // BTNodeKind::MapNode
1479    node[9] = 0;
1480    BigEndian::write_u16(&mut node[10..12], 1);
1481    // Record 0 starts at offset 14 and runs to (node_size - 4).
1482    let bitmap_bytes = map_node_bitmap_bytes(node_size) as u16;
1483    let free_off = 14u16 + bitmap_bytes;
1484    // Offset table at the end: [free_space_offset, record0_offset] (last entry first).
1485    BigEndian::write_u16(&mut node[node_size - 2..node_size], 14);
1486    BigEndian::write_u16(&mut node[node_size - 4..node_size - 2], free_off);
1487}
1488
1489// ---------------------------------------------------------------------------
1490// B-tree splitting
1491// ---------------------------------------------------------------------------
1492
1493/// Initialize a new empty node in catalog_data at the given node_idx.
1494/// Sets the node kind and height. Returns a mutable slice to the node.
1495pub(crate) fn init_node(
1496    catalog_data: &mut [u8],
1497    node_size: usize,
1498    node_idx: u32,
1499    kind: i8,
1500    height: u8,
1501) {
1502    let offset = node_idx as usize * node_size;
1503    let node = &mut catalog_data[offset..offset + node_size];
1504    // Zero the node
1505    node.fill(0);
1506    // Set descriptor fields
1507    node[8] = kind as u8; // kind
1508    node[9] = height; // height
1509    BigEndian::write_u16(&mut node[10..12], 0); // num_records = 0
1510                                                // Initialize offset table: record 0 starts at offset 14, free-space offset = 14
1511    BigEndian::write_u16(&mut node[node_size - 2..node_size], 14);
1512}
1513
1514/// Split a full leaf node and insert `new_record` atomically. Distributes
1515/// existing records + `new_record` (sorted via `compare_fn`) across the old
1516/// leaf and one or more new leaves so each side fits within `node_size`.
1517/// Greedy byte-based partitioning handles arbitrary record sizes — including
1518/// HFS+ catalogs full of long-name records that exceed `2 × node_size` and
1519/// would defeat a strict 2-way split.
1520///
1521/// Returns a list of `(new_node_idx, first_key_of_new_node)` — one entry per
1522/// new leaf node allocated; each entry's `first_key_of_new_node` is the
1523/// separator key the caller must install into the parent index node, in
1524/// order. `header` is mutated: `free_nodes` decremented; caller writes back.
1525///
1526/// Most calls produce a single entry (classic 2-way split). N-way splits
1527/// trigger only when the merged record set genuinely exceeds two nodes —
1528/// rare outside HFS+ catalogs holding many max-length filenames.
1529pub fn btree_split_leaf_with_insert<F>(
1530    catalog_data: &mut [u8],
1531    node_size: usize,
1532    node_idx: u32,
1533    header: &mut BTreeHeader,
1534    new_record: &[u8],
1535    kf: &BTreeKeyFormat,
1536    compare_fn: &F,
1537) -> Result<Vec<(u32, Vec<u8>)>, FilesystemError>
1538where
1539    F: Fn(&[u8], &[u8]) -> Ordering,
1540{
1541    let old_offset = node_idx as usize * node_size;
1542    let old_num = BigEndian::read_u16(&catalog_data[old_offset + 10..old_offset + 12]) as usize;
1543    let old_height = catalog_data[old_offset + 9];
1544
1545    // Gather existing records, then merge in new_record at the sorted spot.
1546    let mut records: Vec<Vec<u8>> = Vec::with_capacity(old_num + 1);
1547    for i in 0..old_num {
1548        let (s, e) = btree_record_range(
1549            &catalog_data[old_offset..old_offset + node_size],
1550            node_size,
1551            i,
1552        );
1553        records.push(catalog_data[old_offset + s..old_offset + e].to_vec());
1554    }
1555    let mut insert_pos = records.len();
1556    for (i, rec) in records.iter().enumerate() {
1557        if compare_fn(rec, new_record) == Ordering::Greater {
1558            insert_pos = i;
1559            break;
1560        }
1561    }
1562    records.insert(insert_pos, new_record.to_vec());
1563
1564    // Per-node usage: 14 (descriptor) + sum(rec_lens) + 2 * count + 2 (free
1565    // pointer); `payload` accounts for the fixed overhead. A single record
1566    // larger than the payload is a structural impossibility (HFS/HFS+ key+record
1567    // max is well under node_size) — reject up front rather than loop forever.
1568    let payload = node_size.saturating_sub(16);
1569    for rec in &records {
1570        if rec.len() + 2 > payload {
1571            return Err(FilesystemError::InvalidData(format!(
1572                "btree_split_leaf_with_insert: record of {} bytes exceeds node payload {}",
1573                rec.len(),
1574                payload
1575            )));
1576        }
1577    }
1578
1579    // Greedy "pack-left-full" partition by bytes. `cuts` holds the record index
1580    // at which each chunk after the first begins (chunk count = cuts.len() + 1).
1581    let mut cuts: Vec<usize> = Vec::new();
1582    let mut cur_bytes: usize = 0;
1583    for (i, rec) in records.iter().enumerate() {
1584        let cost = rec.len() + 2;
1585        if i > 0 && cur_bytes + cost > payload {
1586            cuts.push(i);
1587            cur_bytes = 0;
1588        }
1589        cur_bytes += cost;
1590    }
1591    // Caller's contract: "I tried inserting and it failed, allocate me a new
1592    // node." `btree_insert_record`'s free-space check is 2 bytes more
1593    // conservative than the actual packing limit, so the merged set can fit a
1594    // single node by 1–2 bytes. We still must produce ≥ 1 new node — force a
1595    // 2-way split at the midpoint when greedy yielded a single chunk.
1596    if cuts.is_empty() {
1597        cuts.push(
1598            (records.len() / 2)
1599                .max(1)
1600                .min(records.len().saturating_sub(1)),
1601        );
1602    }
1603
1604    // Density tuning. Greedy pack-left is ideal for *sequential* (append)
1605    // inserts — the left node stays full — but for a *random* insert into the
1606    // middle of a full leaf it splits off only the overflow (often a single
1607    // record), leaving a trail of ~1-record leaves; a catalog built that way
1608    // runs ~2x larger than Mac OS writes. When the new record landed in the
1609    // middle and the whole set fits two nodes, re-split at the most balanced
1610    // *valid* point instead, so both leaves keep room to fill (random inserts
1611    // then converge to ~69% full, matching a real Mac volume). Sequential growth
1612    // keeps the dense greedy path.
1613    let is_append = insert_pos == old_num;
1614    if cuts.len() == 1 && !is_append {
1615        let total: usize = records.iter().map(|r| r.len() + 2).sum();
1616        let mut prefix = 0usize;
1617        let mut best_k = cuts[0];
1618        let mut best_diff = usize::MAX;
1619        for k in 1..records.len() {
1620            prefix += records[k - 1].len() + 2;
1621            let right = total - prefix;
1622            if prefix <= payload && right <= payload {
1623                let diff = prefix.abs_diff(total / 2);
1624                if diff < best_diff {
1625                    best_diff = diff;
1626                    best_k = k;
1627                }
1628            }
1629        }
1630        cuts[0] = best_k;
1631    }
1632
1633    // Materialize chunks by draining `records` at the cut points (front to back).
1634    let mut chunks: Vec<Vec<Vec<u8>>> = Vec::with_capacity(cuts.len() + 1);
1635    let mut prev = 0usize;
1636    for &cut in &cuts {
1637        chunks.push(records.drain(0..cut - prev).collect());
1638        prev = cut;
1639    }
1640    chunks.push(records);
1641
1642    // Allocate new nodes (one per extra chunk beyond the first, which reuses old_idx).
1643    let new_count = chunks.len() - 1;
1644    let mut new_indices: Vec<u32> = Vec::with_capacity(new_count);
1645    for _ in 0..new_count {
1646        let idx = btree_alloc_node(catalog_data, node_size, header.total_nodes)?;
1647        header.free_nodes -= 1;
1648        init_node(catalog_data, node_size, idx, BTREE_LEAF_NODE, old_height);
1649        new_indices.push(idx);
1650    }
1651
1652    // Helper to write a chunk into a node at `target_idx`, preserving any
1653    // existing fwd/back links written by `init_node` (caller patches them
1654    // afterwards via the leaf-chain splice).
1655    let write_chunk = |data: &mut [u8], target_idx: u32, chunk: &[Vec<u8>]| {
1656        let off = target_idx as usize * node_size;
1657        let node = &mut data[off..off + node_size];
1658        BigEndian::write_u16(&mut node[10..12], 0);
1659        BigEndian::write_u16(&mut node[node_size - 2..node_size], 14);
1660        let mut write_pos = 14usize;
1661        for (i, rec) in chunk.iter().enumerate() {
1662            node[write_pos..write_pos + rec.len()].copy_from_slice(rec);
1663            let opos = node_size - 2 * (i + 1);
1664            BigEndian::write_u16(&mut node[opos..opos + 2], write_pos as u16);
1665            write_pos += rec.len();
1666        }
1667        let fpos = node_size - 2 * (chunk.len() + 1);
1668        BigEndian::write_u16(&mut node[fpos..fpos + 2], write_pos as u16);
1669        BigEndian::write_u16(&mut node[10..12], chunk.len() as u16);
1670    };
1671
1672    // First chunk goes back into the old node.
1673    write_chunk(catalog_data, node_idx, &chunks[0]);
1674    for (i, idx) in new_indices.iter().enumerate() {
1675        write_chunk(catalog_data, *idx, &chunks[i + 1]);
1676    }
1677
1678    // Splice the chain: old -> new_indices[0] -> new_indices[1] -> ... -> old_next.
1679    let old_next = BigEndian::read_u32(&catalog_data[old_offset..old_offset + 4]);
1680    let mut prev_idx = node_idx;
1681    for &idx in &new_indices {
1682        let prev_off = prev_idx as usize * node_size;
1683        let cur_off = idx as usize * node_size;
1684        BigEndian::write_u32(&mut catalog_data[prev_off..prev_off + 4], idx);
1685        BigEndian::write_u32(&mut catalog_data[cur_off + 4..cur_off + 8], prev_idx);
1686        prev_idx = idx;
1687    }
1688    // Last new node points to old_next, and old_next.back -> last new node.
1689    let last_idx = *new_indices.last().expect("at least one new node");
1690    let last_off = last_idx as usize * node_size;
1691    BigEndian::write_u32(&mut catalog_data[last_off..last_off + 4], old_next);
1692    if old_next != 0 {
1693        let next_offset = old_next as usize * node_size;
1694        BigEndian::write_u32(
1695            &mut catalog_data[next_offset + 4..next_offset + 8],
1696            last_idx,
1697        );
1698    }
1699    if header.last_leaf_node == node_idx {
1700        header.last_leaf_node = last_idx;
1701    }
1702
1703    // Collect separator keys: first record of each new chunk. The key portion
1704    // (length prefix + key bytes) is format-dependent — 1-byte length for
1705    // classic HFS, 2-byte for HFS+ — so read it through `kf` rather than
1706    // assuming `first[0]` is the whole length.
1707    let mut results: Vec<(u32, Vec<u8>)> = Vec::with_capacity(new_count);
1708    for (i, idx) in new_indices.iter().enumerate() {
1709        let split_key = kf.key_portion(&chunks[i + 1][0]);
1710        results.push((*idx, split_key));
1711    }
1712    Ok(results)
1713}
1714
1715/// Overwrite the record area of node `idx` with `recs` (in key order),
1716/// preserving the node descriptor (sibling links, kind, height). Mirrors the
1717/// rebuild step of [`btree_insert_record`] but takes a ready-made record list —
1718/// used by the sibling-rotation path, which rewrites both nodes of a pair at
1719/// once. The caller guarantees `recs` fits within the node payload.
1720fn btree_write_node_records(data: &mut [u8], node_size: usize, idx: u32, recs: &[Vec<u8>]) {
1721    let off = idx as usize * node_size;
1722    let node = &mut data[off..off + node_size];
1723    let mut write_pos = 14usize;
1724    for (i, rec) in recs.iter().enumerate() {
1725        node[write_pos..write_pos + rec.len()].copy_from_slice(rec);
1726        let opos = node_size - 2 * (i + 1);
1727        BigEndian::write_u16(&mut node[opos..opos + 2], write_pos as u16);
1728        write_pos += rec.len();
1729    }
1730    let fpos = node_size - 2 * (recs.len() + 1);
1731    BigEndian::write_u16(&mut node[fpos..fpos + 2], write_pos as u16);
1732    if write_pos < fpos {
1733        node[write_pos..fpos].fill(0);
1734    }
1735    BigEndian::write_u16(&mut node[10..12], recs.len() as u16);
1736}
1737
1738/// True when index node `parent_idx` holds a record whose trailing 4-byte
1739/// child pointer equals `child` — i.e. `child` is a direct child of that index
1740/// node, so the two share `parent_idx` as a parent.
1741fn index_node_has_child(data: &[u8], node_size: usize, parent_idx: u32, child: u32) -> bool {
1742    if parent_idx == 0 {
1743        return false;
1744    }
1745    let off = parent_idx as usize * node_size;
1746    if off + node_size > data.len() {
1747        return false;
1748    }
1749    let num = BigEndian::read_u16(&data[off + 10..off + 12]) as usize;
1750    for i in 0..num {
1751        let (s, e) = btree_record_range(&data[off..off + node_size], node_size, i);
1752        if e < s + 4 || e > node_size {
1753            continue;
1754        }
1755        if BigEndian::read_u32(&data[off + e - 4..off + e]) == child {
1756            return true;
1757        }
1758    }
1759    false
1760}
1761
1762/// In index node `parent_idx`, rewrite (in place) the separator key of the
1763/// record pointing at `child` so it equals `new_first_key` (a record's key
1764/// portion: the `key_len` byte plus key bytes). Catalog index keys are a fixed
1765/// 0x25-length normalized key, so the record length never changes and the
1766/// offset table / sibling records are untouched.
1767///
1768/// Returns `true` only when the separator was found *and* the in-place rewrite
1769/// was valid (the existing key portion is the same length as the normalized
1770/// key). Returns `false` — writing nothing — when `child` isn't a direct child
1771/// of `parent_idx`, or when the existing separator is a different length (a
1772/// real variable-length HFS+ index key, which can't be patched in place). The
1773/// caller uses the `false` result to abandon a rotation and split instead, so a
1774/// non-classic index is never left with a stale separator.
1775#[must_use]
1776fn btree_update_index_separator(
1777    data: &mut [u8],
1778    node_size: usize,
1779    parent_idx: u32,
1780    child: u32,
1781    new_first_key: &[u8],
1782    kf: &BTreeKeyFormat,
1783) -> bool {
1784    let off = parent_idx as usize * node_size;
1785    if off + node_size > data.len() {
1786        return false;
1787    }
1788    let num = BigEndian::read_u16(&data[off + 10..off + 12]) as usize;
1789    for i in 0..num {
1790        let (s, e) = btree_record_range(&data[off..off + node_size], node_size, i);
1791        if e < s + 4 || e > node_size {
1792            continue;
1793        }
1794        if BigEndian::read_u32(&data[off + e - 4..off + e]) == child {
1795            // The replacement separator must occupy the same number of bytes as
1796            // the existing one — only then is an in-place patch valid (the
1797            // offset table and sibling records stay put). Classic HFS index keys
1798            // are a fixed 0x25 length so this always holds; an HFS+ variable key
1799            // matches only when the new first key has the same length as the old
1800            // separator. When it differs we return `false` and the caller
1801            // abandons the rotation and splits instead, so a stale separator is
1802            // never left behind.
1803            let new_key = kf.make_index_key(new_first_key);
1804            let key_bytes = e - s - 4;
1805            if new_key.len() != key_bytes {
1806                return false;
1807            }
1808            data[off + s..off + s + key_bytes].copy_from_slice(&new_key);
1809            return true;
1810        }
1811    }
1812    false
1813}
1814
1815/// Try to absorb `new_record` into a full leaf by redistributing records with
1816/// an adjacent sibling that shares the same parent index node and still has
1817/// room — a B*-tree rotation — instead of splitting.
1818///
1819/// A plain 50/50 leaf split leaves non-sequential inserts (the per-`put`
1820/// workload, where keys land mid-leaf rather than appending) at the classic
1821/// ~69% B-tree occupancy, so a catalog grown one record at a time exhausts its
1822/// node budget ~1.4-2.7x sooner than a bulk/sequential build and can hit the
1823/// file's node ceiling far below the volume's real capacity. Rotating into a
1824/// neighbour before splitting lifts random-insert occupancy to ~88%, so the
1825/// incremental path packs nearly as tightly as the sequential one.
1826///
1827/// Returns `true` when the record was absorbed: no node is allocated and the
1828/// tree shape is unchanged — only the two nodes' records and one parent
1829/// separator key move. Returns `false` when no sibling can take the overflow
1830/// and the caller must fall back to [`btree_split_leaf_with_insert`].
1831///
1832/// Safety of the in-place separator update: we only ever rewrite the separator
1833/// of the pair's *right* node, and only when the pair's *left* node keeps its
1834/// original first key (guarded below). The right node is never the parent's
1835/// first child (a right sibling sorts after `leaf_idx`; a left sibling sharing
1836/// the parent means `leaf_idx` isn't first), so the parent's minimum key — and
1837/// the grandparent invariant that mirrors it — is never disturbed.
1838fn btree_try_rotate_leaf<F>(
1839    data: &mut [u8],
1840    node_size: usize,
1841    leaf_idx: u32,
1842    parent_idx: u32,
1843    new_record: &[u8],
1844    kf: &BTreeKeyFormat,
1845    cmp: &F,
1846) -> bool
1847where
1848    F: Fn(&[u8], &[u8]) -> Ordering,
1849{
1850    let payload = node_size.saturating_sub(16);
1851    let cost = |r: &Vec<u8>| r.len() + 2;
1852    let first_key = |r: &[u8]| -> Vec<u8> { kf.key_portion(r) };
1853    let read_records = |data: &[u8], idx: u32| -> Vec<Vec<u8>> {
1854        let off = idx as usize * node_size;
1855        let num = BigEndian::read_u16(&data[off + 10..off + 12]) as usize;
1856        let mut v = Vec::with_capacity(num);
1857        for i in 0..num {
1858            let (s, e) = btree_record_range(&data[off..off + node_size], node_size, i);
1859            v.push(data[off + s..off + e].to_vec());
1860        }
1861        v
1862    };
1863
1864    let loff = leaf_idx as usize * node_size;
1865    let rsib = BigEndian::read_u32(&data[loff..loff + 4]);
1866    let lsib = BigEndian::read_u32(&data[loff + 4..loff + 8]);
1867    let leaf_recs = read_records(data, leaf_idx);
1868
1869    // Prefer the right sibling, then the left. For each candidate, pool the two
1870    // siblings' records plus `new_record` in key order, then redistribute them
1871    // balanced across the pair so both nodes keep headroom.
1872    for (sib_is_right, sib) in [(true, rsib), (false, lsib)] {
1873        if sib == 0 || !index_node_has_child(data, node_size, parent_idx, sib) {
1874            continue;
1875        }
1876        let sib_recs = read_records(data, sib);
1877        let (left_idx, right_idx, left_recs, right_recs) = if sib_is_right {
1878            (leaf_idx, sib, &leaf_recs, &sib_recs)
1879        } else {
1880            (sib, leaf_idx, &sib_recs, &leaf_recs)
1881        };
1882        let Some(left_first) = left_recs.first() else {
1883            continue;
1884        };
1885        // Build the merged, key-ordered record set across the pair.
1886        let mut pool: Vec<Vec<u8>> = left_recs.iter().chain(right_recs.iter()).cloned().collect();
1887        let mut p = pool.len();
1888        for (i, r) in pool.iter().enumerate() {
1889            if cmp(r, new_record) == Ordering::Greater {
1890                p = i;
1891                break;
1892            }
1893        }
1894        pool.insert(p, new_record.to_vec());
1895        // Skip if the left node's minimum would change — its parent separator
1896        // (which we don't touch) would then be stale. Happens only when the new
1897        // record sorts before the whole pair; rare, and a normal split handles
1898        // it correctly.
1899        if cmp(&pool[0], left_first) != Ordering::Equal {
1900            continue;
1901        }
1902        let total: usize = pool.iter().map(cost).sum();
1903        if total > 2 * payload {
1904            continue; // neighbour is too full to absorb the overflow
1905        }
1906        // Balanced split point: both halves within payload, closest to total/2.
1907        let mut prefix = 0usize;
1908        let mut best_p = 0usize;
1909        let mut best_diff = usize::MAX;
1910        for cut in 1..pool.len() {
1911            prefix += cost(&pool[cut - 1]);
1912            let right = total - prefix;
1913            if prefix <= payload && right <= payload {
1914                let diff = prefix.abs_diff(total / 2);
1915                if diff < best_diff {
1916                    best_diff = diff;
1917                    best_p = cut;
1918                }
1919            }
1920        }
1921        if best_p == 0 {
1922            continue;
1923        }
1924        let left: Vec<Vec<u8>> = pool[..best_p].to_vec();
1925        let right: Vec<Vec<u8>> = pool[best_p..].to_vec();
1926        let right_first = first_key(&right[0]);
1927        // Patch the parent separator first: if it can't be updated in place
1928        // (a variable-length, non-normalized index key — i.e. a real HFS+
1929        // catalog), abandon the rotation untouched and let the caller split,
1930        // rather than leave the moved records behind a stale separator.
1931        if !btree_update_index_separator(data, node_size, parent_idx, right_idx, &right_first, kf) {
1932            continue;
1933        }
1934        btree_write_node_records(data, node_size, left_idx, &left);
1935        btree_write_node_records(data, node_size, right_idx, &right);
1936        return true;
1937    }
1938    false
1939}
1940
1941/// Split a full index node while inserting `new_record` (a normalized index
1942/// record: a 0x25-length key followed by a 4-byte child pointer), append-aware.
1943///
1944/// The previous index split was a fixed 50/50 of the existing records, then the
1945/// new record went into one half. For *sequential* separator inserts — which is
1946/// what every leaf split produces when files arrive in key order (a bulk
1947/// `untar`, or `put`ting a sorted batch) — that froze each non-rightmost index
1948/// node at ~50%, doubling the index node count and adding a whole tree level.
1949/// Packing the left node full on an append (and rebalancing only a genuine
1950/// middle insert), exactly as the leaf split does, keeps the index dense.
1951///
1952/// Returns `(new_node_idx, separator_key)` to install in the grandparent. Index
1953/// records are uniform and small, so a 2-way split always suffices (an overflow
1954/// is at most one record over a node).
1955fn btree_split_index_with_insert<F>(
1956    catalog_data: &mut [u8],
1957    node_size: usize,
1958    node_idx: u32,
1959    header: &mut BTreeHeader,
1960    new_record: &[u8],
1961    kf: &BTreeKeyFormat,
1962    compare_fn: &F,
1963) -> Result<(u32, Vec<u8>), FilesystemError>
1964where
1965    F: Fn(&[u8], &[u8]) -> Ordering,
1966{
1967    let old_offset = node_idx as usize * node_size;
1968    let old_num = BigEndian::read_u16(&catalog_data[old_offset + 10..old_offset + 12]) as usize;
1969    let old_height = catalog_data[old_offset + 9];
1970
1971    // Existing records merged with new_record, in key order.
1972    let mut records: Vec<Vec<u8>> = Vec::with_capacity(old_num + 1);
1973    for i in 0..old_num {
1974        let (s, e) = btree_record_range(
1975            &catalog_data[old_offset..old_offset + node_size],
1976            node_size,
1977            i,
1978        );
1979        records.push(catalog_data[old_offset + s..old_offset + e].to_vec());
1980    }
1981    let mut insert_pos = records.len();
1982    for (i, rec) in records.iter().enumerate() {
1983        if compare_fn(rec, new_record) == Ordering::Greater {
1984            insert_pos = i;
1985            break;
1986        }
1987    }
1988    records.insert(insert_pos, new_record.to_vec());
1989
1990    let payload = node_size.saturating_sub(16);
1991    let cost = |r: &Vec<u8>| r.len() + 2;
1992    let total: usize = records.iter().map(cost).sum();
1993
1994    // Greedy pack-left (dense for appends). `cut` = count kept in the old node.
1995    let mut cut = {
1996        let mut acc = 0usize;
1997        let mut k = 1usize;
1998        for (i, r) in records.iter().enumerate() {
1999            let c = cost(r);
2000            if i > 0 && acc + c > payload {
2001                break;
2002            }
2003            acc += c;
2004            k = i + 1;
2005        }
2006        k.clamp(1, records.len() - 1)
2007    };
2008    // A genuine middle insert rebalances so both halves keep room (matches the
2009    // leaf split's density tuning); a tail append keeps the dense pack-left.
2010    let is_append = insert_pos == old_num;
2011    if !is_append {
2012        let mut prefix = 0usize;
2013        let mut best = cut;
2014        let mut best_diff = usize::MAX;
2015        for k in 1..records.len() {
2016            prefix += cost(&records[k - 1]);
2017            let right = total - prefix;
2018            if prefix <= payload && right <= payload {
2019                let diff = prefix.abs_diff(total / 2);
2020                if diff < best_diff {
2021                    best_diff = diff;
2022                    best = k;
2023                }
2024            }
2025        }
2026        cut = best;
2027    }
2028
2029    let new_idx = btree_alloc_node(catalog_data, node_size, header.total_nodes)?;
2030    header.free_nodes -= 1;
2031    init_node(
2032        catalog_data,
2033        node_size,
2034        new_idx,
2035        BTREE_INDEX_NODE,
2036        old_height,
2037    );
2038
2039    let left: Vec<Vec<u8>> = records[..cut].to_vec();
2040    let right: Vec<Vec<u8>> = records[cut..].to_vec();
2041    btree_write_node_records(catalog_data, node_size, node_idx, &left);
2042    btree_write_node_records(catalog_data, node_size, new_idx, &right);
2043
2044    // Splice the index-level sibling chain: node_idx <-> new_idx <-> old_next.
2045    // `btree_write_node_records` preserved node_idx's old forward link.
2046    let old_next = BigEndian::read_u32(&catalog_data[old_offset..old_offset + 4]);
2047    let new_offset = new_idx as usize * node_size;
2048    BigEndian::write_u32(&mut catalog_data[old_offset..old_offset + 4], new_idx);
2049    BigEndian::write_u32(&mut catalog_data[new_offset + 4..new_offset + 8], node_idx);
2050    BigEndian::write_u32(&mut catalog_data[new_offset..new_offset + 4], old_next);
2051    if old_next != 0 {
2052        let next_off = old_next as usize * node_size;
2053        BigEndian::write_u32(&mut catalog_data[next_off + 4..next_off + 8], new_idx);
2054    }
2055
2056    // Separator = first key portion (length prefix + key bytes) of the new node.
2057    let split_key = kf.key_portion(&right[0]);
2058    Ok((new_idx, split_key))
2059}
2060
2061/// Insert a separator key into a parent index node, pointing to child_node.
2062///
2063/// For index nodes, each record is: key_bytes + child_node_ptr (4 bytes BE).
2064/// If the parent is full, recursively splits and inserts upward.
2065///
2066/// `parent_chain` is the chain of (node_idx, parent_of_node_idx) from root to the
2067/// index node. We need this to find the grandparent when the index node itself splits.
2068#[allow(clippy::too_many_arguments)] // B-tree index insert threads tree state + cmp + parent_chain
2069pub fn btree_insert_into_index<F>(
2070    catalog_data: &mut [u8],
2071    node_size: usize,
2072    index_node_idx: u32,
2073    child_node: u32,
2074    split_key: &[u8],
2075    header: &mut BTreeHeader,
2076    kf: &BTreeKeyFormat,
2077    compare_fn: &F,
2078    parent_chain: &[(u32, u32)], // [(node_idx, parent_idx), ...]
2079) -> Result<(), FilesystemError>
2080where
2081    F: Fn(&[u8], &[u8]) -> Ordering,
2082{
2083    // Build the index record: index key + child_pointer. For classic HFS the
2084    // index key is the fixed 0x25-length normalized key (record always 42
2085    // bytes); for an HFS+ variable-index-key tree it is the child's first key
2086    // verbatim (2-byte length + key bytes). `make_index_key` picks the right
2087    // shape per `kf`. Catalog/attributes keys are even-length, so the trailing
2088    // child pointer always lands on an even offset.
2089    let mut index_record = kf.make_index_key(split_key);
2090    let mut ptr = [0u8; 4];
2091    BigEndian::write_u32(&mut ptr, child_node);
2092    index_record.extend_from_slice(&ptr);
2093
2094    let offset = index_node_idx as usize * node_size;
2095    let node = &mut catalog_data[offset..offset + node_size];
2096
2097    // Try inserting
2098    match btree_insert_record(node, node_size, &index_record, compare_fn) {
2099        Ok(_) => Ok(()),
2100        Err(_) => {
2101            // Index node is full. Before allocating a node, try a B*-style
2102            // rotation into an index sibling that shares this node's parent and
2103            // has room — the same density win applied at the leaf level, so a
2104            // deep or sequential catalog doesn't accumulate half-full index
2105            // nodes (and the extra level they imply). The root has no parent and
2106            // grows instead; `btree_try_rotate_leaf` is record-agnostic and
2107            // updates the parent separator in place, so it serves index nodes
2108            // just as it does leaves.
2109            if let Some(&(_, parent_idx)) = parent_chain
2110                .iter()
2111                .find(|&&(nidx, _)| nidx == index_node_idx)
2112            {
2113                if parent_idx != 0
2114                    && btree_try_rotate_leaf(
2115                        catalog_data,
2116                        node_size,
2117                        index_node_idx,
2118                        parent_idx,
2119                        &index_record,
2120                        kf,
2121                        compare_fn,
2122                    )
2123                {
2124                    return Ok(());
2125                }
2126            }
2127
2128            // No sibling could help — split (append-aware), placing the new
2129            // record in the appropriate half, then install the new node's
2130            // separator in the parent.
2131            let (new_idx, new_split_key) = btree_split_index_with_insert(
2132                catalog_data,
2133                node_size,
2134                index_node_idx,
2135                header,
2136                &index_record,
2137                kf,
2138                compare_fn,
2139            )?;
2140
2141            // Now insert new_split_key into this index node's parent
2142            // Find our parent from the chain
2143            if let Some(&(_, parent_idx)) = parent_chain
2144                .iter()
2145                .find(|&&(nidx, _)| nidx == index_node_idx)
2146            {
2147                if parent_idx == 0 && index_node_idx == header.root_node {
2148                    // The root itself split — grow the tree
2149                    btree_grow_root(
2150                        catalog_data,
2151                        node_size,
2152                        header,
2153                        index_node_idx,
2154                        new_idx,
2155                        &new_split_key,
2156                        kf,
2157                    )?;
2158                } else {
2159                    // Recurse to parent index node
2160                    btree_insert_into_index(
2161                        catalog_data,
2162                        node_size,
2163                        parent_idx,
2164                        new_idx,
2165                        &new_split_key,
2166                        header,
2167                        kf,
2168                        compare_fn,
2169                        parent_chain,
2170                    )?;
2171                }
2172            } else {
2173                // No parent found — this must be root
2174                btree_grow_root(
2175                    catalog_data,
2176                    node_size,
2177                    header,
2178                    index_node_idx,
2179                    new_idx,
2180                    &new_split_key,
2181                    kf,
2182                )?;
2183            }
2184            Ok(())
2185        }
2186    }
2187}
2188
2189/// Grow the B-tree root: create a new root node at height+1 with two children.
2190pub fn btree_grow_root(
2191    catalog_data: &mut [u8],
2192    node_size: usize,
2193    header: &mut BTreeHeader,
2194    old_root: u32,
2195    new_sibling: u32,
2196    split_key: &[u8],
2197    kf: &BTreeKeyFormat,
2198) -> Result<(), FilesystemError> {
2199    let new_root = btree_alloc_node(catalog_data, node_size, header.total_nodes)?;
2200    header.free_nodes -= 1;
2201
2202    let old_root_offset = old_root as usize * node_size;
2203    let old_height = catalog_data[old_root_offset + 9];
2204
2205    init_node(
2206        catalog_data,
2207        node_size,
2208        new_root,
2209        BTREE_INDEX_NODE,
2210        old_height + 1,
2211    );
2212
2213    let new_root_offset = new_root as usize * node_size;
2214
2215    // Build two index records:
2216    // 1. First child (old_root): use the first key of old_root + pointer to old_root
2217    // 2. Second child (new_sibling): split_key + pointer to new_sibling
2218
2219    // Get the first key portion of old_root (length prefix + key bytes, read
2220    // through `kf` so the 1-byte vs 2-byte length is handled correctly).
2221    let (first_rec_start, first_rec_end) = btree_record_range(
2222        &catalog_data[old_root_offset..old_root_offset + node_size],
2223        node_size,
2224        0,
2225    );
2226    let first_key = kf.key_portion(
2227        &catalog_data[old_root_offset + first_rec_start..old_root_offset + first_rec_end],
2228    );
2229
2230    // Record 1: index key for first_key + old_root pointer.
2231    let mut rec1 = kf.make_index_key(&first_key);
2232    let mut ptr1 = [0u8; 4];
2233    BigEndian::write_u32(&mut ptr1, old_root);
2234    rec1.extend_from_slice(&ptr1);
2235
2236    // Record 2: index key for split_key + new_sibling pointer.
2237    let mut rec2 = kf.make_index_key(split_key);
2238    let mut ptr2 = [0u8; 4];
2239    BigEndian::write_u32(&mut ptr2, new_sibling);
2240    rec2.extend_from_slice(&ptr2);
2241
2242    // Write records manually into the new root node (we know it's empty)
2243    let node = &mut catalog_data[new_root_offset..new_root_offset + node_size];
2244    let mut write_pos = 14usize;
2245    // Record 0
2246    node[write_pos..write_pos + rec1.len()].copy_from_slice(&rec1);
2247    BigEndian::write_u16(&mut node[node_size - 2..node_size], 14);
2248    write_pos += rec1.len();
2249    // Record 1
2250    node[write_pos..write_pos + rec2.len()].copy_from_slice(&rec2);
2251    BigEndian::write_u16(&mut node[node_size - 4..node_size - 2], write_pos as u16);
2252    write_pos += rec2.len();
2253    // Free-space offset
2254    BigEndian::write_u16(&mut node[node_size - 6..node_size - 4], write_pos as u16);
2255    BigEndian::write_u16(&mut node[10..12], 2); // num_records = 2
2256
2257    header.root_node = new_root;
2258    header.depth += 1;
2259
2260    Ok(())
2261}
2262
2263// ---------------------------------------------------------------------------
2264// B-tree search helpers
2265// ---------------------------------------------------------------------------
2266
2267/// Walk the B-tree from root to leaf to find a record matching a key.
2268///
2269/// `key_extract_fn` extracts the comparable key portion from a record.
2270/// `compare_fn` compares (extracted_key, search_key) → Ordering.
2271///
2272/// Returns Some((node_idx, rec_idx, rec_data_offset_in_catalog_data))
2273/// or None if not found.
2274///
2275/// Also returns the parent chain for use in insertion: Vec<(node_idx, parent_idx)>.
2276#[allow(dead_code)]
2277#[allow(clippy::type_complexity)] // returns (find_result, parent_chain) tuples — flattening would obscure intent
2278pub fn btree_find_record<K, C>(
2279    catalog_data: &[u8],
2280    header: &BTreeHeader,
2281    search_key: &[u8],
2282    key_extract_fn: &K,
2283    compare_fn: &C,
2284) -> (Option<(u32, usize, usize)>, Vec<(u32, u32)>)
2285where
2286    K: Fn(&[u8]) -> &[u8],
2287    C: Fn(&[u8], &[u8]) -> Ordering,
2288{
2289    let node_size = header.node_size as usize;
2290    let mut parent_chain: Vec<(u32, u32)> = Vec::new();
2291    let mut current_node = header.root_node;
2292    let mut parent_node = 0u32;
2293    let mut visited = std::collections::HashSet::new();
2294
2295    loop {
2296        // Cycle detection: bail if we revisit a node
2297        if !visited.insert(current_node) {
2298            return (None, parent_chain);
2299        }
2300        let offset = current_node as usize * node_size;
2301        if offset + node_size > catalog_data.len() {
2302            return (None, parent_chain);
2303        }
2304        let node = &catalog_data[offset..offset + node_size];
2305        let kind = node[8] as i8;
2306        let num_records = BigEndian::read_u16(&node[10..12]) as usize;
2307
2308        parent_chain.push((current_node, parent_node));
2309
2310        if kind == BTREE_LEAF_NODE {
2311            // Search records for exact match
2312            for i in 0..num_records {
2313                let (start, end) = btree_record_range(node, node_size, i);
2314                if start >= end || end > node_size {
2315                    continue;
2316                }
2317                let rec_bytes = &node[start..end];
2318                let key = key_extract_fn(rec_bytes);
2319                match compare_fn(key, search_key) {
2320                    Ordering::Equal => {
2321                        return (Some((current_node, i, offset + start)), parent_chain);
2322                    }
2323                    Ordering::Greater => return (None, parent_chain),
2324                    _ => {}
2325                }
2326            }
2327            return (None, parent_chain);
2328        } else if kind == BTREE_INDEX_NODE {
2329            // Find the child pointer to descend into
2330            // Index records: key + child_node_ptr(4 bytes at end)
2331            let mut next_child = 0u32;
2332            for i in 0..num_records {
2333                let (start, end) = btree_record_range(node, node_size, i);
2334                if end < start + 4 || end > node_size {
2335                    continue;
2336                }
2337                let rec_bytes = &node[start..end];
2338                let key = key_extract_fn(rec_bytes);
2339                // Child pointer is last 4 bytes of the record
2340                let child_ptr = index_child_ptr(node, end);
2341
2342                match compare_fn(key, search_key) {
2343                    Ordering::Greater => {
2344                        // This key is beyond our search key; use the previous child
2345                        break;
2346                    }
2347                    _ => {
2348                        next_child = child_ptr;
2349                    }
2350                }
2351            }
2352            if next_child == 0 && num_records > 0 {
2353                // Below every separator: the first child, where Mac OS looks.
2354                // Descending the last child scattered records out of key order.
2355                let (_, end) = btree_record_range(node, node_size, 0);
2356                next_child = index_child_ptr(node, end);
2357            }
2358            parent_node = current_node;
2359            current_node = next_child;
2360        } else {
2361            return (None, parent_chain);
2362        }
2363    }
2364}
2365
2366/// Find the leaf node and position where a key should be inserted.
2367/// Returns (leaf_node_idx, parent_chain).
2368pub fn btree_find_insert_leaf<C>(
2369    catalog_data: &[u8],
2370    header: &BTreeHeader,
2371    search_key: &[u8],
2372    compare_fn: &C,
2373) -> (u32, Vec<(u32, u32)>)
2374where
2375    C: Fn(&[u8], &[u8]) -> Ordering,
2376{
2377    let node_size = header.node_size as usize;
2378    let mut parent_chain: Vec<(u32, u32)> = Vec::new();
2379    let mut current_node = header.root_node;
2380    let mut parent_node = 0u32;
2381    let mut visited = std::collections::HashSet::new();
2382
2383    loop {
2384        // Cycle detection: bail to first leaf if we revisit a node
2385        if !visited.insert(current_node) {
2386            return (header.first_leaf_node, parent_chain);
2387        }
2388        let offset = current_node as usize * node_size;
2389        if offset + node_size > catalog_data.len() {
2390            return (current_node, parent_chain);
2391        }
2392        let node = &catalog_data[offset..offset + node_size];
2393        let kind = node[8] as i8;
2394        let num_records = BigEndian::read_u16(&node[10..12]) as usize;
2395
2396        parent_chain.push((current_node, parent_node));
2397
2398        if kind == BTREE_LEAF_NODE {
2399            return (current_node, parent_chain);
2400        }
2401
2402        // Index node: find the right child
2403        let mut next_child = 0u32;
2404        for i in 0..num_records {
2405            let (start, end) = btree_record_range(node, node_size, i);
2406            if end < start + 4 || end > node_size {
2407                continue;
2408            }
2409            // For HFS+ catalog keys, the key is everything up to the last 4 bytes
2410            let key_portion = &node[start..end - 4];
2411            let child_ptr = index_child_ptr(node, end);
2412
2413            if compare_fn(key_portion, search_key) != Ordering::Greater {
2414                next_child = child_ptr;
2415            } else {
2416                break;
2417            }
2418        }
2419        if next_child == 0 && num_records > 0 {
2420            // Same rule as `btree_find_record`: below every separator, first child.
2421            let (_, end) = btree_record_range(node, node_size, 0);
2422            next_child = index_child_ptr(node, end);
2423        }
2424
2425        parent_node = current_node;
2426        current_node = next_child;
2427    }
2428}
2429
2430/// Insert a fully-formed `key + record` pair into a B-tree backed by
2431/// `data` (header lives in node 0 at offset 14, leaves chained from
2432/// `first_leaf_node`). Drives `btree_find_insert_leaf`,
2433/// `btree_insert_record`, and the `btree_split_leaf_with_insert` /
2434/// `btree_grow_root` / `btree_insert_into_index` machinery so callers
2435/// don't have to repeat the (find leaf, try insert, split on full,
2436/// fix up parent or grow root) dance.
2437///
2438/// `cmp` compares two records by their key portion only — it must
2439/// match whatever ordering the tree was built with (case-folding NFD
2440/// for HFS+ catalog, binary for HFSX, case-insensitive Mac-Roman for
2441/// classic HFS, raw byte order for extents-overflow / attributes).
2442///
2443/// On success the header's `leaf_records` is bumped and (for splits)
2444/// `depth` / `root_node` / `last_leaf_node` are updated. Returns
2445/// `Err(DiskFull)` when the B-tree is out of free nodes.
2446///
2447/// Used by the streamed defrag builder (Step 22c) to populate target
2448/// catalog / extents-overflow / attributes B-trees from scratch, and
2449/// matches the algorithm `HfsPlusFilesystem::insert_catalog_record`
2450/// has been using.
2451pub fn btree_insert_full<F>(
2452    data: &mut [u8],
2453    key_record: &[u8],
2454    kf: &BTreeKeyFormat,
2455    cmp: &F,
2456) -> Result<(), disk_core::filesystem::FilesystemError>
2457where
2458    F: Fn(&[u8], &[u8]) -> Ordering,
2459{
2460    let mut header = BTreeHeader::read(data);
2461    let node_size = header.node_size as usize;
2462    if node_size < BTREE_MIN_NODE_SIZE {
2463        return Err(disk_core::filesystem::FilesystemError::InvalidData(
2464            format!("B-tree has invalid node_size {node_size}"),
2465        ));
2466    }
2467
2468    // Bootstrap an empty (depth-0) tree: a fresh extents-overflow / attributes
2469    // B-tree carries only its header node until the first record arrives
2470    // (matching Apple's newfs_hfs, which lays out empty trees as
2471    // depth=0/root=0 with no leaf). Allocate and initialise the root leaf so
2472    // the normal insert path below has somewhere to land.
2473    if header.depth == 0 || header.root_node == 0 {
2474        let leaf = btree_alloc_node(data, node_size, header.total_nodes)?;
2475        let off = leaf as usize * node_size;
2476        let node = &mut data[off..off + node_size];
2477        node.fill(0);
2478        node[8] = 0xFF; // kind = -1 (leaf)
2479        node[9] = 1; // height = 1
2480        BigEndian::write_u16(&mut node[10..12], 0); // numRecords = 0
2481        BigEndian::write_u16(&mut node[node_size - 2..node_size], 14); // free-space offset
2482        header.depth = 1;
2483        header.root_node = leaf;
2484        header.first_leaf_node = leaf;
2485        header.last_leaf_node = leaf;
2486        header.free_nodes = header.free_nodes.saturating_sub(1);
2487        header.write(data);
2488    }
2489
2490    let (leaf_idx, parent_chain) = btree_find_insert_leaf(data, &header, key_record, cmp);
2491
2492    let off = leaf_idx as usize * node_size;
2493    let node = &mut data[off..off + node_size];
2494    match btree_insert_record(node, node_size, key_record, cmp) {
2495        Ok(_) => {
2496            let mut h = BTreeHeader::read(data);
2497            h.leaf_records += 1;
2498            h.write(data);
2499            Ok(())
2500        }
2501        Err(_) => {
2502            let mut h = BTreeHeader::read(data);
2503            // Before allocating a node, try a B*-style rotation into an adjacent
2504            // sibling that shares this leaf's parent and still has room. This
2505            // keeps the catalog densely packed for non-sequential (per-`put`)
2506            // inserts; only when no sibling can help do we split. Needs a parent
2507            // index node, so skip it while the root is still the lone leaf.
2508            if h.depth > 1 {
2509                if let Some(&(_, parent_idx)) =
2510                    parent_chain.iter().find(|&&(nidx, _)| nidx == leaf_idx)
2511                {
2512                    if parent_idx != 0
2513                        && btree_try_rotate_leaf(
2514                            data, node_size, leaf_idx, parent_idx, key_record, kf, cmp,
2515                        )
2516                    {
2517                        h.leaf_records += 1;
2518                        h.write(data);
2519                        return Ok(());
2520                    }
2521                }
2522            }
2523            // A single-record insert into a previously-valid leaf splits at
2524            // most 2-way (one new leaf), and the separator can cascade up,
2525            // splitting one node per level plus a possible new root — at most
2526            // `depth + 1` new nodes total. If the tree can't cover that exact
2527            // worst case, bail *before* touching anything (the
2528            // `btree_insert_record` above failed without mutating the full leaf,
2529            // and a failed rotation leaves the tree unchanged), so a caller that
2530            // grows the fork and retries can't end up with a half-applied split
2531            // — which would duplicate the record. This makes DiskFull from here
2532            // a clean no-op (see the grow-on-full retry in
2533            // `HfsPlusFilesystem::insert_catalog_record` et al.). The bound is
2534            // exact, not padded, so classic HFS (which has no grow path) still
2535            // fills its pre-sized catalog right up to genuine capacity.
2536            if h.free_nodes < h.depth as u32 + 1 {
2537                return Err(disk_core::filesystem::FilesystemError::DiskFull(
2538                    "no free B-tree nodes".into(),
2539                ));
2540            }
2541            let splits = btree_split_leaf_with_insert(
2542                data, node_size, leaf_idx, &mut h, key_record, kf, cmp,
2543            )?;
2544            h.leaf_records += 1;
2545            // First new node's separator goes via either grow_root (depth==1)
2546            // or insert_into_index. Any additional separators (N-way splits
2547            // with N > 2) always go through insert_into_index — the tree's
2548            // root depth is already > 1 once the first separator installed.
2549            let first = &splits[0];
2550            if h.depth == 1 {
2551                btree_grow_root(data, node_size, &mut h, leaf_idx, first.0, &first.1, kf)?;
2552            } else if let Some(&(_, parent_idx)) =
2553                parent_chain.iter().find(|&&(nidx, _)| nidx == leaf_idx)
2554            {
2555                btree_insert_into_index(
2556                    data,
2557                    node_size,
2558                    parent_idx,
2559                    first.0,
2560                    &first.1,
2561                    &mut h,
2562                    kf,
2563                    cmp,
2564                    &parent_chain,
2565                )?;
2566            } else {
2567                btree_grow_root(data, node_size, &mut h, leaf_idx, first.0, &first.1, kf)?;
2568            }
2569            for split in &splits[1..] {
2570                // After the first separator installed, the leaf's parent is
2571                // either the new root (depth grew to ≥ 2) or whatever was in
2572                // `parent_chain`. Re-resolve by walking down from root via the
2573                // separator key.
2574                let header_now = BTreeHeader::read(data);
2575                let (_, fresh_chain) = btree_find_insert_leaf(data, &header_now, &split.1, cmp);
2576                // The leaf the separator key would land in shares a parent
2577                // with `leaf_idx` (the original split source). Use the last
2578                // entry of fresh_chain as the parent — that's the deepest
2579                // index node above any leaf along the search path.
2580                let parent_for_sep = fresh_chain
2581                    .last()
2582                    .map(|&(_, p)| p)
2583                    .unwrap_or(header_now.root_node);
2584                btree_insert_into_index(
2585                    data,
2586                    node_size,
2587                    parent_for_sep,
2588                    split.0,
2589                    &split.1,
2590                    &mut h,
2591                    kf,
2592                    cmp,
2593                    &fresh_chain,
2594                )?;
2595            }
2596            h.write(data);
2597            Ok(())
2598        }
2599    }
2600}
2601
2602// ---------------------------------------------------------------------------
2603// Tests
2604// ---------------------------------------------------------------------------
2605
2606#[cfg(test)]
2607mod tests {
2608    use super::*;
2609
2610    #[test]
2611    fn test_format_mac_date() {
2612        // Zero == "no date set".
2613        assert_eq!(format_mac_date(0), None);
2614        // The Unix epoch in Mac-epoch seconds is MAC_EPOCH_DELTA.
2615        assert_eq!(
2616            format_mac_date(MAC_EPOCH_DELTA as u32).as_deref(),
2617            Some("1970-01-01 00:00:00")
2618        );
2619        // A real vintage Mac timestamp: 2000-01-01 00:00:00 UTC.
2620        assert_eq!(
2621            format_mac_date((MAC_EPOCH_DELTA + 946_684_800) as u32).as_deref(),
2622            Some("2000-01-01 00:00:00")
2623        );
2624    }
2625
2626    #[test]
2627    fn test_parse_mac_date_round_trips() {
2628        // Empty / "0" map to the "no date" sentinel.
2629        assert_eq!(parse_mac_date(""), Some(0));
2630        assert_eq!(parse_mac_date("0"), Some(0));
2631        // Round-trips a real timestamp through format -> parse.
2632        let mac = (MAC_EPOCH_DELTA + 946_684_800) as u32; // 2000-01-01
2633        let s = format_mac_date(mac).unwrap();
2634        assert_eq!(parse_mac_date(&s), Some(mac));
2635        // Garbage is rejected.
2636        assert_eq!(parse_mac_date("not a date"), None);
2637    }
2638
2639    #[test]
2640    fn test_parse_mac_date_checked_specific_errors() {
2641        assert!(parse_mac_date_checked("1997-13-01 11:09:34")
2642            .unwrap_err()
2643            .contains("month 13"));
2644        assert!(parse_mac_date_checked("1997-02-29 11:09:34")
2645            .unwrap_err()
2646            .contains("day 29 is invalid for 1997-02"));
2647        assert!(parse_mac_date_checked("2000-01-01 25:00:00")
2648            .unwrap_err()
2649            .contains("out of range"));
2650        assert!(parse_mac_date_checked("garbage")
2651            .unwrap_err()
2652            .contains("expected YYYY-MM-DD"));
2653        // A leap day in an actual leap year is fine.
2654        assert!(parse_mac_date_checked("2000-02-29 12:00:00").is_ok());
2655    }
2656
2657    // -- Bitmap tests --
2658
2659    #[test]
2660    fn test_bitmap_set_clear_test_be() {
2661        let mut data = [0u8; 2];
2662        // MSB-first: bit 0 is the leftmost bit of byte 0
2663        bitmap_set_bit_be(&mut data, 0);
2664        assert_eq!(data[0], 0b10000000);
2665        assert!(bitmap_test_bit_be(&data, 0));
2666        assert!(!bitmap_test_bit_be(&data, 1));
2667
2668        bitmap_set_bit_be(&mut data, 7);
2669        assert_eq!(data[0], 0b10000001);
2670
2671        bitmap_set_bit_be(&mut data, 8);
2672        assert_eq!(data[1], 0b10000000);
2673
2674        bitmap_clear_bit_be(&mut data, 0);
2675        assert_eq!(data[0], 0b00000001);
2676        assert!(!bitmap_test_bit_be(&data, 0));
2677    }
2678
2679    #[test]
2680    fn test_bitmap_find_clear_bit_be() {
2681        let data = [0b11111110, 0b11111111]; // bit 7 is clear
2682        assert_eq!(bitmap_find_clear_bit_be(&data, 16), Some(7));
2683
2684        let full = [0xFF, 0xFF];
2685        assert_eq!(bitmap_find_clear_bit_be(&full, 16), None);
2686
2687        let empty = [0x00, 0x00];
2688        assert_eq!(bitmap_find_clear_bit_be(&empty, 16), Some(0));
2689    }
2690
2691    #[test]
2692    fn test_bitmap_find_clear_run_be() {
2693        // bit pattern: 11100011 00000000 (BE: bit 0=MSB)
2694        let data = [0b11100011, 0b00000000];
2695        // BE bit indices: 0=1,1=1,2=1,3=0,4=0,5=0,6=1,7=1, 8..15=0
2696        // Bits 3,4,5 are clear (run of 3), bits 6,7 set, bits 8-15 all clear (run of 8).
2697        assert_eq!(bitmap_find_clear_run_be(&data, 16, 3), Some(3));
2698        // Run of 4: bits 3-5 only 3 clear, so run of 4 starts at bit 8.
2699        assert_eq!(bitmap_find_clear_run_be(&data, 16, 4), Some(8));
2700        assert_eq!(bitmap_find_clear_run_be(&data, 16, 8), Some(8));
2701        assert_eq!(bitmap_find_clear_run_be(&data, 16, 9), None);
2702    }
2703
2704    #[test]
2705    fn test_bitmap_be_roundtrip() {
2706        let mut data = [0u8; 4];
2707        for i in 0..32u32 {
2708            bitmap_set_bit_be(&mut data, i);
2709        }
2710        assert_eq!(data, [0xFF; 4]);
2711        for i in 0..32u32 {
2712            assert!(bitmap_test_bit_be(&data, i));
2713            bitmap_clear_bit_be(&mut data, i);
2714        }
2715        assert_eq!(data, [0x00; 4]);
2716    }
2717
2718    #[test]
2719    fn test_bitmap_single_bit() {
2720        let mut data = [0u8; 1];
2721        bitmap_set_bit_be(&mut data, 3);
2722        assert_eq!(data[0], 0b00010000);
2723        assert!(bitmap_test_bit_be(&data, 3));
2724        assert!(!bitmap_test_bit_be(&data, 2));
2725        assert!(!bitmap_test_bit_be(&data, 4));
2726    }
2727
2728    // -- Mac epoch --
2729
2730    #[test]
2731    fn test_hfs_now_sanity() {
2732        let now = hfs_now();
2733        // Should be well past 2024-01-01 in Mac time
2734        // 2024-01-01 00:00:00 UTC = Unix 1704067200 + 2082844800 = 3786912000
2735        assert!(now > 3_786_912_000);
2736    }
2737
2738    // -- Type/creator lookup --
2739
2740    #[test]
2741    fn test_type_creator_for_extension_known() {
2742        let (tc, cc) = type_creator_for_extension("txt").unwrap();
2743        assert_eq!(&tc, b"TEXT");
2744        assert_eq!(&cc, b"ttxt");
2745    }
2746
2747    #[test]
2748    fn test_type_creator_for_extension_case_insensitive() {
2749        let (tc, cc) = type_creator_for_extension("TXT").unwrap();
2750        assert_eq!(&tc, b"TEXT");
2751        assert_eq!(&cc, b"ttxt");
2752    }
2753
2754    #[test]
2755    fn test_uses_hfs_type_dictionary() {
2756        for t in ["HFS", "HFS+", "HFSX", "MFS"] {
2757            assert!(uses_hfs_type_dictionary(t), "{t} should use the dictionary");
2758        }
2759        // ProDOS has a file type, but in its own `$XX` space.
2760        for t in ["ProDOS", "FAT16", "ext4", ""] {
2761            assert!(!uses_hfs_type_dictionary(t), "{t} should not");
2762        }
2763    }
2764
2765    #[test]
2766    fn test_type_creator_for_filename_needs_a_real_extension() {
2767        assert!(type_creator_for_filename("readme.txt").is_some());
2768        // A dotless name has no extension: a file *named* `txt` is not a
2769        // `.txt` document, even though `rsplit('.')` hands back the whole name.
2770        assert!(type_creator_for_filename("txt").is_none());
2771        assert!(type_creator_for_filename("README").is_none());
2772    }
2773
2774    #[test]
2775    fn test_resolve_blank_ostype_falls_through_to_dictionary() {
2776        // The InstallerMaker-of-this-feature: a type-less file (all-zero FInfo,
2777        // how a real Mac volume records "no type") copied onto HFS should pick
2778        // up TEXT/ttxt from its name rather than land unopenable.
2779        let (t, c) =
2780            resolve_create_type_creator("readme.txt", Some([0; 4]), Some([0; 4]), None, None);
2781        assert_eq!(&t, b"TEXT");
2782        assert_eq!(&c, b"ttxt");
2783
2784        // Same for empty / whitespace-only caller text.
2785        let (t, c) = resolve_create_type_creator("pic.gif", None, None, Some(""), Some("   "));
2786        assert_eq!(&t, b"GIFf");
2787        assert_eq!(&c, b"ogle");
2788    }
2789
2790    #[test]
2791    fn test_resolve_real_ostype_wins_over_dictionary() {
2792        // Byte-exact fidelity: a high-bit creator (Prince of Persia's `PoƒP`)
2793        // on a `.txt` name must survive, not be "corrected" to TEXT/ttxt.
2794        let (t, c) = resolve_create_type_creator(
2795            "poi.txt",
2796            Some(*b"APPL"),
2797            Some([0x50, 0x6f, 0xC4, 0x50]),
2798            None,
2799            None,
2800        );
2801        assert_eq!(&t, b"APPL");
2802        assert_eq!(c, [0x50, 0x6f, 0xC4, 0x50]);
2803
2804        // An all-space OSType is a real code, not a blank: `cp` promises
2805        // byte-exact fidelity, so it must not be rewritten from the dictionary.
2806        let (t, _) =
2807            resolve_create_type_creator("spc.txt", Some(*b"    "), Some(*b"    "), None, None);
2808        assert_eq!(&t, b"    ");
2809    }
2810
2811    #[test]
2812    fn test_resolve_fills_only_the_missing_half() {
2813        // A partial FInfo (creator known, type absent) keeps the creator and
2814        // fills the type from the dictionary.
2815        let (t, c) = resolve_create_type_creator("pic.gif", None, Some(*b"MINE"), None, None);
2816        assert_eq!(&t, b"GIFf");
2817        assert_eq!(&c, b"MINE");
2818    }
2819
2820    #[test]
2821    fn test_resolve_unknown_extension_stays_blank() {
2822        // Nothing known and nothing inferable: stay [0;4]. Callers that want a
2823        // generic fallback (the CLI's BINA/????) apply it themselves.
2824        let (t, c) = resolve_create_type_creator("thing.xyz123", None, None, None, None);
2825        assert_eq!(t, [0; 4]);
2826        assert_eq!(c, [0; 4]);
2827    }
2828
2829    #[test]
2830    fn test_resolve_text_type_keeps_trailing_space_codes() {
2831        // `ZIP ` / `PDF ` are real four-byte codes whose fourth byte is a space;
2832        // the blank check must not eat them.
2833        let (t, _) = resolve_create_type_creator("x.unknown", None, None, Some("ZIP "), None);
2834        assert_eq!(&t, b"ZIP ");
2835    }
2836
2837    #[test]
2838    fn test_type_creator_for_extension_unknown() {
2839        assert!(type_creator_for_extension("xyz123").is_none());
2840    }
2841
2842    #[test]
2843    fn test_encode_fourcc() {
2844        assert_eq!(encode_fourcc("TEXT"), *b"TEXT");
2845        assert_eq!(encode_fourcc("AB"), *b"AB  ");
2846        assert_eq!(encode_fourcc("SIT!"), *b"SIT!");
2847    }
2848
2849    #[test]
2850    fn test_encode_fourcc_roundtrip() {
2851        let code = encode_fourcc("JPEG");
2852        assert_eq!(&code, b"JPEG");
2853    }
2854
2855    // -- B-tree node manipulation --
2856
2857    /// Create a minimal B-tree structure for testing: header node + one leaf node.
2858    fn make_test_btree(node_size: usize) -> Vec<u8> {
2859        let mut data = vec![0u8; node_size * 4]; // 4 nodes
2860
2861        // Node 0: header node
2862        data[8] = BTREE_HEADER_NODE as u8; // kind
2863        data[9] = 0; // height
2864        BigEndian::write_u16(&mut data[10..12], 3); // 3 records (header, user, bitmap)
2865
2866        // Header record at offset 14
2867        BigEndian::write_u16(&mut data[14..16], 1); // depth
2868        BigEndian::write_u32(&mut data[16..20], 1); // root_node = 1
2869        BigEndian::write_u32(&mut data[20..24], 0); // leaf_records = 0
2870        BigEndian::write_u32(&mut data[24..28], 1); // first_leaf = 1
2871        BigEndian::write_u32(&mut data[28..32], 1); // last_leaf = 1
2872        BigEndian::write_u16(&mut data[32..34], node_size as u16); // node_size
2873        BigEndian::write_u16(&mut data[34..36], 256); // max_key_len
2874        BigEndian::write_u32(&mut data[36..40], 4); // total_nodes
2875        BigEndian::write_u32(&mut data[40..44], 2); // free_nodes (nodes 2,3 are free)
2876
2877        // Offset table for node 0 (3 records + free-space)
2878        let header_rec_offset = 14u16;
2879        let user_rec_offset = 120u16; // after header record
2880        let bitmap_rec_offset = 128u16; // after user record
2881        let free_offset = 256u16; // after bitmap record
2882
2883        BigEndian::write_u16(&mut data[node_size - 2..node_size], header_rec_offset);
2884        BigEndian::write_u16(&mut data[node_size - 4..node_size - 2], user_rec_offset);
2885        BigEndian::write_u16(&mut data[node_size - 6..node_size - 4], bitmap_rec_offset);
2886        BigEndian::write_u16(&mut data[node_size - 8..node_size - 6], free_offset);
2887
2888        // Bitmap in node 0: nodes 0,1 allocated, 2,3 free
2889        // bit 0 = node 0 (header), bit 1 = node 1 (leaf)
2890        data[bitmap_rec_offset as usize] = 0b11000000; // BE: bits 0,1 set
2891
2892        // Node 1: empty leaf node
2893        let n1 = node_size;
2894        data[n1 + 8] = BTREE_LEAF_NODE as u8; // kind = -1
2895        data[n1 + 9] = 1; // height
2896        BigEndian::write_u16(&mut data[n1 + 10..n1 + 12], 0); // num_records = 0
2897        BigEndian::write_u16(&mut data[n1 + node_size - 2..n1 + node_size], 14); // free-space offset
2898
2899        data
2900    }
2901
2902    #[test]
2903    fn test_btree_node_free_space_empty_leaf() {
2904        let data = make_test_btree(512);
2905        let node = &data[512..1024]; // node 1
2906        let free = btree_node_free_space(node, 512);
2907        // Empty node: data starts at 14, offset table has 1 entry (2 bytes) at end
2908        // Free = 512 - 14 - 2 - 2 = 494
2909        assert_eq!(free, 494);
2910    }
2911
2912    #[test]
2913    fn test_btree_insert_and_remove() {
2914        let mut data = make_test_btree(512);
2915        let node_size = 512;
2916
2917        // Insert a record into node 1 (leaf)
2918        let rec1 = vec![0u8; 20]; // 20-byte record
2919        let rec2 = vec![1u8; 20];
2920        let compare = |a: &[u8], b: &[u8]| a.cmp(b);
2921
2922        let node = &mut data[node_size..node_size * 2];
2923        let pos = btree_insert_record(node, node_size, &rec1, &compare).unwrap();
2924        assert_eq!(pos, 0);
2925        assert_eq!(BigEndian::read_u16(&node[10..12]), 1); // num_records = 1
2926
2927        let pos2 = btree_insert_record(node, node_size, &rec2, &compare).unwrap();
2928        assert_eq!(pos2, 1); // rec2 > rec1
2929        assert_eq!(BigEndian::read_u16(&node[10..12]), 2);
2930
2931        // Remove first record
2932        btree_remove_record(node, node_size, 0);
2933        assert_eq!(BigEndian::read_u16(&node[10..12]), 1);
2934    }
2935
2936    #[test]
2937    fn test_btree_alloc_free_node() {
2938        let mut data = make_test_btree(512);
2939        let node_size = 512;
2940
2941        // Allocate: should get node 2 (first free)
2942        let idx = btree_alloc_node(&mut data, node_size, 4).unwrap();
2943        assert_eq!(idx, 2);
2944
2945        // Allocate again: should get node 3
2946        let idx2 = btree_alloc_node(&mut data, node_size, 4).unwrap();
2947        assert_eq!(idx2, 3);
2948
2949        // No more free nodes
2950        assert!(btree_alloc_node(&mut data, node_size, 4).is_err());
2951
2952        // Free node 2
2953        btree_free_node(&mut data, node_size, 2);
2954        let idx3 = btree_alloc_node(&mut data, node_size, 4).unwrap();
2955        assert_eq!(idx3, 2);
2956    }
2957
2958    #[test]
2959    fn test_btree_header_read_write() {
2960        let mut data = make_test_btree(512);
2961        let header = BTreeHeader::read(&data);
2962        assert_eq!(header.depth, 1);
2963        assert_eq!(header.root_node, 1);
2964        assert_eq!(header.total_nodes, 4);
2965        assert_eq!(header.free_nodes, 2);
2966
2967        // Modify and write back
2968        let mut header = header;
2969        header.leaf_records = 42;
2970        header.write(&mut data);
2971
2972        let header2 = BTreeHeader::read(&data);
2973        assert_eq!(header2.leaf_records, 42);
2974    }
2975
2976    #[test]
2977    fn test_btree_split_leaf_with_insert() {
2978        let mut data = make_test_btree(512);
2979        let node_size = 512;
2980
2981        // Insert 10 fixed-size records (keys 0..10, even indices) into node 1.
2982        // 45-byte records: 10 × 47 = 470 + 14 + 2 = 486, fits one 512-byte node.
2983        // An 11th 45-byte record would push usage to 533 > 512 → forces a split.
2984        let compare = |a: &[u8], b: &[u8]| a.cmp(b);
2985        for i in 0..10u8 {
2986            let mut rec = vec![0u8; 45];
2987            rec[0] = i * 2;
2988            let node = &mut data[node_size..node_size * 2];
2989            btree_insert_record(node, node_size, &rec, &compare).unwrap();
2990        }
2991
2992        let node = &data[node_size..node_size * 2];
2993        assert_eq!(BigEndian::read_u16(&node[10..12]), 10);
2994
2995        // Atomic split + insert a new 45-byte record with key=5 (lands in left half).
2996        let mut new_rec = vec![0u8; 45];
2997        new_rec[0] = 5;
2998        let mut header = BTreeHeader::read(&data);
2999        let splits = btree_split_leaf_with_insert(
3000            &mut data,
3001            node_size,
3002            1,
3003            &mut header,
3004            &new_rec,
3005            &BTreeKeyFormat::CLASSIC_CATALOG,
3006            &compare,
3007        )
3008        .unwrap();
3009        assert_eq!(splits.len(), 1, "30-byte records: 2-way split is enough");
3010        let new_idx = splits[0].0;
3011        assert_eq!(new_idx, 2);
3012
3013        let old_node = &data[node_size..node_size * 2];
3014        let new_node = &data[new_idx as usize * node_size..(new_idx as usize + 1) * node_size];
3015        let old_count = BigEndian::read_u16(&old_node[10..12]);
3016        let new_count = BigEndian::read_u16(&new_node[10..12]);
3017        assert_eq!(old_count + new_count, 11); // 10 existing + 1 inserted
3018    }
3019}