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