Skip to main content

hdf5_reader/
btree_v2.rs

1//! HDF5 B-tree Version 2.
2//!
3//! V2 B-trees are used by newer-style groups and datasets for indexed link
4//! storage, attribute storage, and chunked dataset indexing. The header
5//! (`BTHD`) describes the tree parameters. Internal nodes (`BTIN`) and leaf
6//! nodes (`BTLF`) contain the actual records.
7//!
8//! This module provides the header parse, record types, and a traversal
9//! function that collects all records from a tree.
10
11use std::collections::HashSet;
12
13use crate::checksum::jenkins_lookup3;
14use crate::error::{Error, Result};
15use crate::io::Cursor;
16use crate::storage::Storage;
17
18// ---------------------------------------------------------------------------
19// Signatures
20// ---------------------------------------------------------------------------
21
22const BTHD_SIGNATURE: [u8; 4] = *b"BTHD";
23const BTIN_SIGNATURE: [u8; 4] = *b"BTIN";
24const BTLF_SIGNATURE: [u8; 4] = *b"BTLF";
25const MAX_BTREE_V2_DEPTH: u16 = 64;
26
27// ---------------------------------------------------------------------------
28// Header
29// ---------------------------------------------------------------------------
30
31/// Parsed B-tree v2 header.
32#[derive(Debug, Clone)]
33pub struct BTreeV2Header {
34    /// B-tree type (determines the record format).
35    pub btree_type: u8,
36    /// Size in bytes of each B-tree node (both internal and leaf).
37    pub node_size: u32,
38    /// Size in bytes of each record.
39    pub record_size: u16,
40    /// Depth of the tree (0 = root is a leaf).
41    pub depth: u16,
42    /// Percent full at which to split a node.
43    pub split_percent: u8,
44    /// Percent full at which to merge a node.
45    pub merge_percent: u8,
46    /// Address of the root node.
47    pub root_node_address: u64,
48    /// Number of records in the root node.
49    pub num_records_in_root: u16,
50    /// Total number of records in the entire tree.
51    pub total_records: u64,
52}
53
54impl BTreeV2Header {
55    /// Parse a B-tree v2 header at the current cursor position.
56    ///
57    /// Format:
58    /// - Signature: `BTHD` (4 bytes)
59    /// - Version: 0 (1 byte)
60    /// - B-tree type (u8)
61    /// - Node size (u32 LE)
62    /// - Record size (u16 LE)
63    /// - Depth (u16 LE)
64    /// - Split percent (u8)
65    /// - Merge percent (u8)
66    /// - Root node address (`offset_size` bytes)
67    /// - Number of records in root node (u16 LE)
68    /// - Total number of records in tree (`length_size` bytes)
69    /// - Checksum (u32 LE)
70    pub fn parse(cursor: &mut Cursor, offset_size: u8, length_size: u8) -> Result<Self> {
71        let start = cursor.position();
72
73        let sig = cursor.read_bytes(4)?;
74        if sig != BTHD_SIGNATURE {
75            return Err(Error::InvalidBTreeV2Signature { context: "header" });
76        }
77
78        let version = cursor.read_u8()?;
79        if version != 0 {
80            return Err(Error::UnsupportedBTreeVersion(version));
81        }
82
83        let btree_type = cursor.read_u8()?;
84        let node_size = cursor.read_u32_le()?;
85        let record_size = cursor.read_u16_le()?;
86        let depth = cursor.read_u16_le()?;
87        let split_percent = cursor.read_u8()?;
88        let merge_percent = cursor.read_u8()?;
89        let root_node_address = cursor.read_offset(offset_size)?;
90        let num_records_in_root = cursor.read_u16_le()?;
91        let total_records = cursor.read_length(length_size)?;
92
93        // Checksum covers everything from signature through total_records.
94        let checksum_end = cursor.position();
95        let stored_checksum = cursor.read_u32_le()?;
96
97        let computed = jenkins_lookup3(&cursor.data()[start as usize..checksum_end as usize]);
98        if computed != stored_checksum {
99            return Err(Error::ChecksumMismatch {
100                expected: stored_checksum,
101                actual: computed,
102            });
103        }
104
105        Ok(BTreeV2Header {
106            btree_type,
107            node_size,
108            record_size,
109            depth,
110            split_percent,
111            merge_percent,
112            root_node_address,
113            num_records_in_root,
114            total_records,
115        })
116    }
117
118    /// Parse a B-tree v2 header from random-access storage.
119    pub fn parse_at_storage(
120        storage: &dyn Storage,
121        address: u64,
122        offset_size: u8,
123        length_size: u8,
124    ) -> Result<Self> {
125        let header_len = 4
126            + 1
127            + 1
128            + 4
129            + 2
130            + 2
131            + 1
132            + 1
133            + usize::from(offset_size)
134            + 2
135            + usize::from(length_size)
136            + 4;
137        let bytes = storage.read_range(address, header_len)?;
138        let mut cursor = Cursor::new(bytes.as_ref());
139        Self::parse(&mut cursor, offset_size, length_size)
140    }
141}
142
143// ---------------------------------------------------------------------------
144// Records
145// ---------------------------------------------------------------------------
146
147/// A record from a B-tree v2.
148///
149/// The record format depends on the B-tree type field in the header.
150#[derive(Debug, Clone)]
151pub enum BTreeV2Record {
152    /// Type 1: indirectly accessed, non-filtered huge fractal heap object.
153    HugeIndirectNonFiltered {
154        address: u64,
155        length: u64,
156        object_id: u64,
157    },
158    /// Type 2: indirectly accessed, filtered huge fractal heap object.
159    HugeIndirectFiltered {
160        address: u64,
161        filtered_length: u64,
162        filter_mask: u32,
163        memory_length: u64,
164        object_id: u64,
165    },
166    /// Type 3: directly accessed, non-filtered huge fractal heap object.
167    HugeDirectNonFiltered { address: u64, length: u64 },
168    /// Type 4: directly accessed, filtered huge fractal heap object.
169    HugeDirectFiltered {
170        address: u64,
171        filtered_length: u64,
172        filter_mask: u32,
173        memory_length: u64,
174    },
175    /// Type 5: Link name for indexed group (hashed).
176    LinkNameHash { hash: u32, heap_id: Vec<u8> },
177    /// Type 6: Creation order for indexed group.
178    CreationOrder { order: u64, heap_id: Vec<u8> },
179    /// Type 8: Attribute name for indexed group (hashed).
180    AttributeNameHash {
181        hash: u32,
182        flags: u8,
183        creation_order: u32,
184        heap_id: Vec<u8>,
185    },
186    /// Type 9: Attribute creation order.
187    AttributeCreationOrder { order: u32, heap_id: Vec<u8> },
188    /// Type 10: Non-filtered chunked dataset record (v2 chunk index).
189    ChunkedNonFiltered { address: u64, offsets: Vec<u64> },
190    /// Type 11: Filtered chunked dataset record (v2 chunk index).
191    ChunkedFiltered {
192        address: u64,
193        chunk_size: u64,
194        filter_mask: u32,
195        offsets: Vec<u64>,
196    },
197    /// Type 7: shared object-header message stored in the SOHM heap.
198    SharedMessageHeap {
199        hash: u32,
200        reference_count: u32,
201        heap_id: Vec<u8>,
202    },
203    /// Type 7: shared object-header message stored in an object header.
204    SharedMessageObjectHeader {
205        hash: u32,
206        message_type: u16,
207        object_header_index: u16,
208        object_header_address: u64,
209    },
210    /// Unknown/unsupported record type — raw bytes preserved.
211    Unknown { record_type: u8, data: Vec<u8> },
212}
213
214// ---------------------------------------------------------------------------
215// Record parsing
216// ---------------------------------------------------------------------------
217
218/// Parse a single record of the given B-tree type.
219#[allow(clippy::too_many_arguments)]
220fn parse_record(
221    cursor: &mut Cursor,
222    btree_type: u8,
223    record_size: u16,
224    offset_size: u8,
225    length_size: u8,
226    ndims: Option<u32>,
227    chunk_dims: &[u32],
228    heap_id_len: usize,
229) -> Result<BTreeV2Record> {
230    let record_start = cursor.position();
231
232    let record = match btree_type {
233        // Type 1: indirectly accessed, non-filtered huge fractal heap object.
234        1 => BTreeV2Record::HugeIndirectNonFiltered {
235            address: cursor.read_offset(offset_size)?,
236            length: cursor.read_length(length_size)?,
237            object_id: cursor.read_length(length_size)?,
238        },
239
240        // Type 2: indirectly accessed, filtered huge fractal heap object.
241        2 => BTreeV2Record::HugeIndirectFiltered {
242            address: cursor.read_offset(offset_size)?,
243            filtered_length: cursor.read_length(length_size)?,
244            filter_mask: cursor.read_u32_le()?,
245            memory_length: cursor.read_length(length_size)?,
246            object_id: cursor.read_length(length_size)?,
247        },
248
249        // Type 3: directly accessed, non-filtered huge fractal heap object.
250        3 => BTreeV2Record::HugeDirectNonFiltered {
251            address: cursor.read_offset(offset_size)?,
252            length: cursor.read_length(length_size)?,
253        },
254
255        // Type 4: directly accessed, filtered huge fractal heap object.
256        4 => BTreeV2Record::HugeDirectFiltered {
257            address: cursor.read_offset(offset_size)?,
258            filtered_length: cursor.read_length(length_size)?,
259            filter_mask: cursor.read_u32_le()?,
260            memory_length: cursor.read_length(length_size)?,
261        },
262
263        // Type 5: link name hash
264        5 => {
265            let hash = cursor.read_u32_le()?;
266            let heap_id = cursor.read_bytes(heap_id_len)?.to_vec();
267            BTreeV2Record::LinkNameHash { hash, heap_id }
268        }
269
270        // Type 6: creation order
271        6 => {
272            let order = cursor.read_u64_le()?;
273            let heap_id = cursor.read_bytes(heap_id_len)?.to_vec();
274            BTreeV2Record::CreationOrder { order, heap_id }
275        }
276
277        // Type 7: shared object-header messages.
278        7 => {
279            let location = cursor.read_u8()?;
280            cursor.skip(3)?;
281            let hash = cursor.read_u32_le()?;
282            match location {
283                0 => {
284                    let reference_count = cursor.read_u32_le()?;
285                    let heap_id = cursor.read_bytes(8)?.to_vec();
286                    BTreeV2Record::SharedMessageHeap {
287                        hash,
288                        reference_count,
289                        heap_id,
290                    }
291                }
292                1 => {
293                    let _reserved = cursor.read_u8()?;
294                    let message_type = u16::from(cursor.read_u8()?);
295                    let object_header_index = cursor.read_u16_le()?;
296                    let object_header_address = cursor.read_offset(offset_size)?;
297                    BTreeV2Record::SharedMessageObjectHeader {
298                        hash,
299                        message_type,
300                        object_header_index,
301                        object_header_address,
302                    }
303                }
304                other => {
305                    return Err(Error::InvalidData(format!(
306                        "unknown SOHM B-tree record location: {other}"
307                    )));
308                }
309            }
310        }
311
312        // Type 8: attribute name hash
313        8 => {
314            let heap_id = cursor.read_bytes(heap_id_len)?.to_vec();
315            let flags = cursor.read_u8()?;
316            let creation_order = cursor.read_u32_le()?;
317            let hash = cursor.read_u32_le()?;
318            BTreeV2Record::AttributeNameHash {
319                hash,
320                flags,
321                creation_order,
322                heap_id,
323            }
324        }
325
326        // Type 9: attribute creation order
327        9 => {
328            let heap_id = cursor.read_bytes(heap_id_len)?.to_vec();
329            let _flags = cursor.read_u8()?;
330            let order = cursor.read_u32_le()?;
331            BTreeV2Record::AttributeCreationOrder { order, heap_id }
332        }
333
334        // Type 10: non-filtered chunk
335        10 => {
336            // Chunk offsets are encoded as scaled 64-bit values.
337            // The number of offset dimensions is calculated from the record size.
338            // Each offset is 8 bytes in a type-10 record.
339            let offset_bytes_available = record_payload_len(
340                record_size,
341                offset_size as usize,
342                "B-tree v2 type-10 chunk record is shorter than its address",
343            )?;
344            let address = cursor.read_offset(offset_size)?;
345            let num_offsets = offset_bytes_available / 8;
346            let offsets = read_scaled_chunk_offsets(cursor, num_offsets, ndims, chunk_dims)?;
347            BTreeV2Record::ChunkedNonFiltered { address, offsets }
348        }
349
350        // Type 11: filtered chunk
351        11 => {
352            // nbytes (chunk size on disk) is encoded using length_size bytes.
353            let nbytes_size = length_size as usize;
354            let fixed_size = offset_size as usize + nbytes_size + 4; // filter_mask
355            let remaining = record_payload_len(
356                record_size,
357                fixed_size,
358                "B-tree v2 type-11 chunk record is shorter than its fixed fields",
359            )?;
360            let address = cursor.read_offset(offset_size)?;
361            let chunk_size = cursor.read_length(length_size)?;
362            let filter_mask = cursor.read_u32_le()?;
363            let num_offsets = remaining / 8;
364            let offsets = read_scaled_chunk_offsets(cursor, num_offsets, ndims, chunk_dims)?;
365            BTreeV2Record::ChunkedFiltered {
366                address,
367                chunk_size,
368                filter_mask,
369                offsets,
370            }
371        }
372
373        // Unknown type — read raw bytes.
374        _ => {
375            let data = cursor.read_bytes(record_size as usize)?.to_vec();
376            return Ok(BTreeV2Record::Unknown {
377                record_type: btree_type,
378                data,
379            });
380        }
381    };
382
383    // Ensure we consumed no more than record_size bytes, then skip padding.
384    let consumed = cursor.position() - record_start;
385    let record_size = u64::from(record_size);
386    if consumed > record_size {
387        return Err(Error::InvalidData(format!(
388            "B-tree v2 type-{btree_type} record consumed {consumed} bytes but record size is {record_size}"
389        )));
390    }
391    if consumed < record_size {
392        cursor.skip((record_size - consumed) as usize)?;
393    }
394
395    Ok(record)
396}
397
398fn record_payload_len(record_size: u16, fixed_size: usize, error_message: &str) -> Result<usize> {
399    (record_size as usize)
400        .checked_sub(fixed_size)
401        .ok_or_else(|| Error::InvalidData(error_message.into()))
402}
403
404fn read_scaled_chunk_offsets(
405    cursor: &mut Cursor,
406    num_offsets: usize,
407    ndims: Option<u32>,
408    chunk_dims: &[u32],
409) -> Result<Vec<u64>> {
410    if let Some(ndims) = ndims {
411        let ndims = usize::try_from(ndims)
412            .map_err(|_| Error::InvalidData("B-tree v2 chunk rank exceeds usize".into()))?;
413        if num_offsets != ndims {
414            return Err(Error::InvalidData(format!(
415                "B-tree v2 chunk record has {num_offsets} offsets for {ndims} dimensions"
416            )));
417        }
418    }
419
420    if !chunk_dims.is_empty() && num_offsets != chunk_dims.len() {
421        return Err(Error::InvalidData(format!(
422            "B-tree v2 chunk record has {num_offsets} offsets but {} chunk dimensions",
423            chunk_dims.len()
424        )));
425    }
426
427    let mut offsets = Vec::with_capacity(num_offsets);
428    for dim in 0..num_offsets {
429        let scaled = cursor.read_u64_le()?;
430        let chunk_extent = chunk_dims.get(dim).copied().unwrap_or(1);
431        let offset = scaled
432            .checked_mul(u64::from(chunk_extent))
433            .ok_or_else(|| Error::InvalidData("B-tree v2 chunk offset overflows u64".into()))?;
434        offsets.push(offset);
435    }
436    Ok(offsets)
437}
438
439fn record_matches_chunk_bounds(
440    record: &BTreeV2Record,
441    chunk_dims: &[u32],
442    chunk_bounds: Option<(&[u64], &[u64])>,
443) -> bool {
444    let Some((first_chunk, last_chunk)) = chunk_bounds else {
445        return true;
446    };
447
448    let offsets = match record {
449        BTreeV2Record::ChunkedNonFiltered { offsets, .. }
450        | BTreeV2Record::ChunkedFiltered { offsets, .. } => offsets,
451        _ => return true,
452    };
453
454    offsets.iter().enumerate().all(|(dim, offset)| {
455        let chunk_index = *offset / u64::from(chunk_dims[dim]);
456        chunk_index >= first_chunk[dim] && chunk_index <= last_chunk[dim]
457    })
458}
459
460fn record_matches_link_name_hash(record: &BTreeV2Record, target_hash: Option<u32>) -> bool {
461    match target_hash {
462        Some(target_hash) => {
463            matches!(record, BTreeV2Record::LinkNameHash { hash, .. } if *hash == target_hash)
464        }
465        None => true,
466    }
467}
468
469fn record_matches_query(
470    record: &BTreeV2Record,
471    chunk_dims: &[u32],
472    chunk_bounds: Option<(&[u64], &[u64])>,
473    link_name_hash: Option<u32>,
474) -> bool {
475    record_matches_link_name_hash(record, link_name_hash)
476        && record_matches_chunk_bounds(record, chunk_dims, chunk_bounds)
477}
478
479fn link_name_hash(record: &BTreeV2Record) -> Option<u32> {
480    match record {
481        BTreeV2Record::LinkNameHash { hash, .. } => Some(*hash),
482        _ => None,
483    }
484}
485
486fn child_may_match_link_name_hash(
487    records: &[BTreeV2Record],
488    child_index: usize,
489    target_hash: Option<u32>,
490) -> bool {
491    let Some(target_hash) = target_hash else {
492        return true;
493    };
494
495    let lower_matches = child_index == 0
496        || link_name_hash(&records[child_index - 1]).map_or(true, |hash| target_hash >= hash);
497    let upper_matches = child_index == records.len()
498        || link_name_hash(&records[child_index]).map_or(true, |hash| target_hash <= hash);
499    lower_matches && upper_matches
500}
501
502fn validate_btree_v2_depth(depth: u16) -> Result<()> {
503    if depth > MAX_BTREE_V2_DEPTH {
504        return Err(Error::InvalidData(format!(
505            "B-tree v2 depth {depth} exceeds traversal limit {MAX_BTREE_V2_DEPTH}"
506        )));
507    }
508    Ok(())
509}
510
511fn enter_btree_v2_node(visited: &mut HashSet<u64>, address: u64) -> Result<()> {
512    if !visited.insert(address) {
513        return Err(Error::InvalidData(format!(
514            "B-tree v2 traversal revisits node at offset {address:#x}"
515        )));
516    }
517    Ok(())
518}
519
520// ---------------------------------------------------------------------------
521// Node parsing
522// ---------------------------------------------------------------------------
523
524/// Compute the number of bytes needed to represent `max_records` as an
525/// unsigned integer (used for child-node record counts in internal nodes).
526fn num_records_size(max_records: u64) -> usize {
527    if max_records <= 0xFF {
528        1
529    } else if max_records <= 0xFFFF {
530        2
531    } else if max_records <= 0xFFFF_FFFF {
532        4
533    } else {
534        8
535    }
536}
537
538/// Compute the maximum number of records that fit in a leaf node.
539fn max_leaf_records(node_size: u32, record_size: u16) -> u64 {
540    // Leaf node overhead: signature(4) + version(1) + type(1) + checksum(4) = 10
541    let overhead = 10u32;
542    if node_size <= overhead || record_size == 0 {
543        return 0;
544    }
545    ((node_size - overhead) / record_size as u32) as u64
546}
547
548/// Compute the maximum number of records that fit in an internal node.
549/// This depends on the pointer size (offset_size) and the number-of-records
550/// encoding for child nodes, which makes it recursive in principle. We use
551/// an iterative approach.
552fn max_internal_records(
553    node_size: u32,
554    record_size: u16,
555    offset_size: u8,
556    max_child_records: u64,
557) -> u64 {
558    // Internal node overhead: signature(4) + version(1) + type(1) + checksum(4) = 10
559    let overhead = 10u32;
560    if node_size <= overhead || record_size == 0 {
561        return 0;
562    }
563    let available = (node_size - overhead) as u64;
564    // Each entry in an internal node is: record(record_size) + child_pointer(offset_size) + num_records(var)
565    let nrec_size = num_records_size(max_child_records) as u64;
566    let entry_size = record_size as u64 + offset_size as u64 + nrec_size;
567    // There is one more child pointer + num_records than records.
568    // So: n * record_size + (n+1) * (offset_size + nrec_size) <= available
569    // => n * (record_size + offset_size + nrec_size) + offset_size + nrec_size <= available
570    let extra = offset_size as u64 + nrec_size;
571    if available <= extra {
572        return 0;
573    }
574    (available - extra) / entry_size
575}
576
577fn node_checksum_bounds(
578    cursor: &Cursor<'_>,
579    start: u64,
580    node_size: u32,
581    compact_end: u64,
582    context: &'static str,
583) -> Result<usize> {
584    let start = usize::try_from(start)
585        .map_err(|_| Error::InvalidData(format!("B-tree v2 {context} offset is too large")))?;
586    let node_size = usize::try_from(node_size)
587        .map_err(|_| Error::InvalidData(format!("B-tree v2 {context} size is too large")))?;
588    if node_size < 10 {
589        return Err(Error::InvalidData(format!(
590            "B-tree v2 {context} node is too small: {node_size} bytes"
591        )));
592    }
593
594    let checksum_pos = usize::try_from(compact_end)
595        .map_err(|_| Error::InvalidData(format!("B-tree v2 {context} checksum is too large")))?;
596    if checksum_pos < start {
597        return Err(Error::InvalidData(format!(
598            "B-tree v2 {context} payload starts before node"
599        )));
600    }
601    let node_end = start
602        .checked_add(node_size)
603        .ok_or_else(|| Error::InvalidData(format!("B-tree v2 {context} size overflow")))?;
604
605    if node_end > cursor.data().len() {
606        return Err(Error::UnexpectedEof {
607            offset: start as u64,
608            needed: node_size as u64,
609            available: cursor.data().len().saturating_sub(start) as u64,
610        });
611    }
612    let checksum_end = checksum_pos
613        .checked_add(4)
614        .ok_or_else(|| Error::InvalidData(format!("B-tree v2 {context} checksum overflow")))?;
615    if checksum_end > node_end {
616        return Err(Error::InvalidData(format!(
617            "B-tree v2 {context} contents exceed node size"
618        )));
619    }
620
621    Ok(checksum_pos)
622}
623
624fn verify_node_checksum(
625    cursor: &mut Cursor<'_>,
626    start: u64,
627    node_size: u32,
628    compact_end: u64,
629    context: &'static str,
630) -> Result<()> {
631    let checksum_pos = node_checksum_bounds(cursor, start, node_size, compact_end, context)?;
632    cursor.set_position(checksum_pos as u64);
633    let stored_checksum = cursor.read_u32_le()?;
634    let computed = jenkins_lookup3(&cursor.data()[start as usize..checksum_pos]);
635    if computed != stored_checksum {
636        return Err(Error::ChecksumMismatch {
637            expected: stored_checksum,
638            actual: computed,
639        });
640    }
641    Ok(())
642}
643
644/// Parse a leaf node and collect its records.
645#[allow(clippy::too_many_arguments)]
646fn parse_leaf_node(
647    cursor: &mut Cursor,
648    header: &BTreeV2Header,
649    offset_size: u8,
650    length_size: u8,
651    ndims: Option<u32>,
652    chunk_dims: &[u32],
653    chunk_bounds: Option<(&[u64], &[u64])>,
654    link_name_hash: Option<u32>,
655    num_records: u16,
656    heap_id_len: usize,
657    records: &mut Vec<BTreeV2Record>,
658) -> Result<()> {
659    let start = cursor.position();
660
661    let sig = cursor.read_bytes(4)?;
662    if sig != BTLF_SIGNATURE {
663        return Err(Error::InvalidBTreeV2Signature {
664            context: "leaf node",
665        });
666    }
667
668    let version = cursor.read_u8()?;
669    if version != 0 {
670        return Err(Error::UnsupportedBTreeVersion(version));
671    }
672
673    let node_type = cursor.read_u8()?;
674    if node_type != header.btree_type {
675        return Err(Error::InvalidData(format!(
676            "B-tree v2 leaf node type mismatch: header says {}, node says {}",
677            header.btree_type, node_type
678        )));
679    }
680
681    for _ in 0..num_records {
682        let record = parse_record(
683            cursor,
684            header.btree_type,
685            header.record_size,
686            offset_size,
687            length_size,
688            ndims,
689            chunk_dims,
690            heap_id_len,
691        )?;
692        if record_matches_query(&record, chunk_dims, chunk_bounds, link_name_hash) {
693            records.push(record);
694        }
695    }
696
697    verify_node_checksum(
698        cursor,
699        start,
700        header.node_size,
701        cursor.position(),
702        "leaf node",
703    )?;
704
705    Ok(())
706}
707
708/// Parse an internal node, collecting child addresses and recursing.
709#[allow(clippy::too_many_arguments)]
710fn parse_internal_node(
711    data: &[u8],
712    address: u64,
713    header: &BTreeV2Header,
714    offset_size: u8,
715    length_size: u8,
716    ndims: Option<u32>,
717    chunk_dims: &[u32],
718    chunk_bounds: Option<(&[u64], &[u64])>,
719    link_name_hash: Option<u32>,
720    num_records: u16,
721    depth: u16,
722    heap_id_len: usize,
723    visited: &mut HashSet<u64>,
724    records: &mut Vec<BTreeV2Record>,
725) -> Result<()> {
726    if depth == 0 {
727        return Err(Error::InvalidData(
728            "B-tree v2 internal node traversal reached depth zero".into(),
729        ));
730    }
731    enter_btree_v2_node(visited, address)?;
732
733    let mut cursor = Cursor::new(data);
734    cursor.set_position(address);
735
736    let start = cursor.position();
737
738    let sig = cursor.read_bytes(4)?;
739    if sig != BTIN_SIGNATURE {
740        return Err(Error::InvalidBTreeV2Signature {
741            context: "internal node",
742        });
743    }
744
745    let version = cursor.read_u8()?;
746    if version != 0 {
747        return Err(Error::UnsupportedBTreeVersion(version));
748    }
749
750    let node_type = cursor.read_u8()?;
751    if node_type != header.btree_type {
752        return Err(Error::InvalidData(format!(
753            "B-tree v2 internal node type mismatch: header says {}, node says {}",
754            header.btree_type, node_type
755        )));
756    }
757
758    // Compute max records for children to know the encoding size for
759    // child record counts.
760    let max_child_records = if depth == 1 {
761        max_leaf_records(header.node_size, header.record_size)
762    } else {
763        // For deeper trees, compute iteratively.
764        let leaf_max = max_leaf_records(header.node_size, header.record_size);
765        let mut prev_max = leaf_max;
766        for _ in 1..depth {
767            prev_max =
768                max_internal_records(header.node_size, header.record_size, offset_size, prev_max);
769        }
770        prev_max
771    };
772    let nrec_bytes = num_records_size(max_child_records);
773
774    // Read records and child pointers interleaved:
775    // child[0], record[0], child[1], record[1], ..., record[n-1], child[n]
776    // Plus total_records counts for each child.
777    //
778    // Actually per the HDF5 spec the layout is:
779    // record[0], record[1], ..., record[n-1],
780    // child_ptr[0], nrec[0], total[0], child_ptr[1], nrec[1], total[1], ..., child_ptr[n], nrec[n], total[n]
781    //
782    // Records first, then child pointers with their metadata.
783
784    // Read all records.
785    let mut node_records = Vec::with_capacity(num_records as usize);
786    for _ in 0..num_records {
787        let record = parse_record(
788            &mut cursor,
789            header.btree_type,
790            header.record_size,
791            offset_size,
792            length_size,
793            ndims,
794            chunk_dims,
795            heap_id_len,
796        )?;
797        node_records.push(record);
798    }
799
800    // Read child node pointers (num_records + 1 of them).
801    let num_children = num_records as usize + 1;
802
803    // Whether to include a "total records" field for each child.
804    // This is present when depth > 1.
805    let has_total_records = depth > 1;
806    // total_records encoding size for deeper nodes
807    let total_nrec_bytes = if has_total_records {
808        // Total records in a sub-tree — need enough bytes to hold the
809        // maximum total records. We use length_size as an upper bound.
810        length_size as usize
811    } else {
812        0
813    };
814
815    let mut child_addresses = Vec::with_capacity(num_children);
816    let mut child_nrecords = Vec::with_capacity(num_children);
817
818    for _ in 0..num_children {
819        let child_addr = cursor.read_offset(offset_size)?;
820        child_addresses.push(child_addr);
821        let nrec = cursor.read_uvar(nrec_bytes)?;
822        child_nrecords.push(nrec as u16);
823        if has_total_records {
824            // Skip total records count
825            cursor.read_uvar(total_nrec_bytes)?;
826        }
827    }
828
829    let compact_end = cursor.position();
830    verify_node_checksum(
831        &mut cursor,
832        start,
833        header.node_size,
834        compact_end,
835        "internal node",
836    )?;
837
838    // HDF5's v2 B-tree iterator visits child[i], then record[i], then the
839    // final child. Internal-node records are real records, not only separators.
840    let child_depth = depth - 1;
841    for (i, record) in node_records.iter().enumerate() {
842        let child_addr = child_addresses[i];
843        if !Cursor::is_undefined_offset(child_addr, offset_size)
844            && child_may_match_link_name_hash(&node_records, i, link_name_hash)
845        {
846            let child_nrec = child_nrecords[i];
847            if child_depth == 0 {
848                enter_btree_v2_node(visited, child_addr)?;
849                let mut child_cursor = Cursor::new(data);
850                child_cursor.set_position(child_addr);
851                parse_leaf_node(
852                    &mut child_cursor,
853                    header,
854                    offset_size,
855                    length_size,
856                    ndims,
857                    chunk_dims,
858                    chunk_bounds,
859                    link_name_hash,
860                    child_nrec,
861                    heap_id_len,
862                    records,
863                )?;
864            } else {
865                parse_internal_node(
866                    data,
867                    child_addr,
868                    header,
869                    offset_size,
870                    length_size,
871                    ndims,
872                    chunk_dims,
873                    chunk_bounds,
874                    link_name_hash,
875                    child_nrec,
876                    child_depth,
877                    heap_id_len,
878                    visited,
879                    records,
880                )?;
881            }
882        }
883
884        if record_matches_query(record, chunk_dims, chunk_bounds, link_name_hash) {
885            records.push(record.clone());
886        }
887    }
888
889    let final_child_index = node_records.len();
890    let final_child_addr = child_addresses[final_child_index];
891    if !Cursor::is_undefined_offset(final_child_addr, offset_size)
892        && child_may_match_link_name_hash(&node_records, final_child_index, link_name_hash)
893    {
894        let child_nrec = child_nrecords[final_child_index];
895        if child_depth == 0 {
896            enter_btree_v2_node(visited, final_child_addr)?;
897            let mut child_cursor = Cursor::new(data);
898            child_cursor.set_position(final_child_addr);
899            parse_leaf_node(
900                &mut child_cursor,
901                header,
902                offset_size,
903                length_size,
904                ndims,
905                chunk_dims,
906                chunk_bounds,
907                link_name_hash,
908                child_nrec,
909                heap_id_len,
910                records,
911            )?;
912        } else {
913            parse_internal_node(
914                data,
915                final_child_addr,
916                header,
917                offset_size,
918                length_size,
919                ndims,
920                chunk_dims,
921                chunk_bounds,
922                link_name_hash,
923                child_nrec,
924                child_depth,
925                heap_id_len,
926                visited,
927                records,
928            )?;
929        }
930    }
931
932    Ok(())
933}
934
935// ---------------------------------------------------------------------------
936// Public traversal
937// ---------------------------------------------------------------------------
938
939/// Collect all records from a B-tree v2 by traversing from the root.
940///
941/// `header` must be a previously parsed `BTreeV2Header`. `data` is the full
942/// file buffer. `ndims` is needed for chunk index record types (10 and 11).
943pub fn collect_btree_v2_records(
944    data: &[u8],
945    header: &BTreeV2Header,
946    offset_size: u8,
947    length_size: u8,
948    ndims: Option<u32>,
949    chunk_dims: &[u32],
950    chunk_bounds: Option<(&[u64], &[u64])>,
951) -> Result<Vec<BTreeV2Record>> {
952    collect_btree_v2_records_with_link_hash(
953        data,
954        header,
955        offset_size,
956        length_size,
957        ndims,
958        chunk_dims,
959        chunk_bounds,
960        None,
961    )
962}
963
964#[allow(clippy::too_many_arguments)]
965fn collect_btree_v2_records_with_link_hash(
966    data: &[u8],
967    header: &BTreeV2Header,
968    offset_size: u8,
969    length_size: u8,
970    ndims: Option<u32>,
971    chunk_dims: &[u32],
972    chunk_bounds: Option<(&[u64], &[u64])>,
973    link_name_hash: Option<u32>,
974) -> Result<Vec<BTreeV2Record>> {
975    if Cursor::is_undefined_offset(header.root_node_address, offset_size) {
976        return Ok(Vec::new());
977    }
978
979    if header.total_records == 0 || header.num_records_in_root == 0 {
980        return Ok(Vec::new());
981    }
982    validate_btree_v2_depth(header.depth)?;
983
984    // Determine heap_id_len from the record_size and btree_type.
985    let heap_id_len = compute_heap_id_len(header);
986
987    let mut records = Vec::new();
988    let mut visited = HashSet::new();
989
990    if header.depth == 0 {
991        // Root is a leaf node.
992        enter_btree_v2_node(&mut visited, header.root_node_address)?;
993        let mut cursor = Cursor::new(data);
994        cursor.set_position(header.root_node_address);
995        parse_leaf_node(
996            &mut cursor,
997            header,
998            offset_size,
999            length_size,
1000            ndims,
1001            chunk_dims,
1002            chunk_bounds,
1003            link_name_hash,
1004            header.num_records_in_root,
1005            heap_id_len,
1006            &mut records,
1007        )?;
1008    } else {
1009        // Root is an internal node.
1010        parse_internal_node(
1011            data,
1012            header.root_node_address,
1013            header,
1014            offset_size,
1015            length_size,
1016            ndims,
1017            chunk_dims,
1018            chunk_bounds,
1019            link_name_hash,
1020            header.num_records_in_root,
1021            header.depth,
1022            heap_id_len,
1023            &mut visited,
1024            &mut records,
1025        )?;
1026    }
1027
1028    Ok(records)
1029}
1030
1031/// Collect link-name records whose stored hash matches `target_hash`.
1032#[cfg(test)]
1033pub(crate) fn collect_btree_v2_link_name_hash_records(
1034    data: &[u8],
1035    header: &BTreeV2Header,
1036    offset_size: u8,
1037    length_size: u8,
1038    target_hash: u32,
1039) -> Result<Vec<BTreeV2Record>> {
1040    collect_btree_v2_records_with_link_hash(
1041        data,
1042        header,
1043        offset_size,
1044        length_size,
1045        None,
1046        &[],
1047        None,
1048        Some(target_hash),
1049    )
1050}
1051
1052/// Collect all records from a B-tree v2 using random-access storage.
1053pub fn collect_btree_v2_records_storage(
1054    storage: &dyn Storage,
1055    header: &BTreeV2Header,
1056    offset_size: u8,
1057    length_size: u8,
1058    ndims: Option<u32>,
1059    chunk_dims: &[u32],
1060    chunk_bounds: Option<(&[u64], &[u64])>,
1061) -> Result<Vec<BTreeV2Record>> {
1062    collect_btree_v2_records_storage_with_link_hash(
1063        storage,
1064        header,
1065        offset_size,
1066        length_size,
1067        ndims,
1068        chunk_dims,
1069        chunk_bounds,
1070        None,
1071    )
1072}
1073
1074#[allow(clippy::too_many_arguments)]
1075fn collect_btree_v2_records_storage_with_link_hash(
1076    storage: &dyn Storage,
1077    header: &BTreeV2Header,
1078    offset_size: u8,
1079    length_size: u8,
1080    ndims: Option<u32>,
1081    chunk_dims: &[u32],
1082    chunk_bounds: Option<(&[u64], &[u64])>,
1083    link_name_hash: Option<u32>,
1084) -> Result<Vec<BTreeV2Record>> {
1085    if Cursor::is_undefined_offset(header.root_node_address, offset_size) {
1086        return Ok(Vec::new());
1087    }
1088
1089    if header.total_records == 0 || header.num_records_in_root == 0 {
1090        return Ok(Vec::new());
1091    }
1092    validate_btree_v2_depth(header.depth)?;
1093
1094    let heap_id_len = compute_heap_id_len(header);
1095    let mut records = Vec::new();
1096    let mut visited = HashSet::new();
1097
1098    if header.depth == 0 {
1099        enter_btree_v2_node(&mut visited, header.root_node_address)?;
1100        parse_leaf_node_storage(
1101            storage,
1102            header.root_node_address,
1103            header,
1104            offset_size,
1105            length_size,
1106            ndims,
1107            chunk_dims,
1108            chunk_bounds,
1109            link_name_hash,
1110            header.num_records_in_root,
1111            heap_id_len,
1112            &mut records,
1113        )?;
1114    } else {
1115        parse_internal_node_storage(
1116            storage,
1117            header.root_node_address,
1118            header,
1119            offset_size,
1120            length_size,
1121            ndims,
1122            chunk_dims,
1123            chunk_bounds,
1124            link_name_hash,
1125            header.num_records_in_root,
1126            header.depth,
1127            heap_id_len,
1128            &mut visited,
1129            &mut records,
1130        )?;
1131    }
1132
1133    Ok(records)
1134}
1135
1136/// Collect link-name records whose stored hash matches `target_hash`.
1137pub(crate) fn collect_btree_v2_link_name_hash_records_storage(
1138    storage: &dyn Storage,
1139    header: &BTreeV2Header,
1140    offset_size: u8,
1141    length_size: u8,
1142    target_hash: u32,
1143) -> Result<Vec<BTreeV2Record>> {
1144    collect_btree_v2_records_storage_with_link_hash(
1145        storage,
1146        header,
1147        offset_size,
1148        length_size,
1149        None,
1150        &[],
1151        None,
1152        Some(target_hash),
1153    )
1154}
1155
1156#[allow(clippy::too_many_arguments)]
1157fn parse_leaf_node_storage(
1158    storage: &dyn Storage,
1159    address: u64,
1160    header: &BTreeV2Header,
1161    offset_size: u8,
1162    length_size: u8,
1163    ndims: Option<u32>,
1164    chunk_dims: &[u32],
1165    chunk_bounds: Option<(&[u64], &[u64])>,
1166    link_name_hash: Option<u32>,
1167    num_records: u16,
1168    heap_id_len: usize,
1169    records: &mut Vec<BTreeV2Record>,
1170) -> Result<()> {
1171    let node_len = usize::try_from(header.node_size).map_err(|_| {
1172        Error::InvalidData("B-tree v2 node size exceeds platform usize capacity".into())
1173    })?;
1174    let node_bytes = storage.read_range(address, node_len)?;
1175    let mut cursor = Cursor::new(node_bytes.as_ref());
1176    parse_leaf_node(
1177        &mut cursor,
1178        header,
1179        offset_size,
1180        length_size,
1181        ndims,
1182        chunk_dims,
1183        chunk_bounds,
1184        link_name_hash,
1185        num_records,
1186        heap_id_len,
1187        records,
1188    )
1189}
1190
1191#[allow(clippy::too_many_arguments)]
1192fn parse_internal_node_storage(
1193    storage: &dyn Storage,
1194    address: u64,
1195    header: &BTreeV2Header,
1196    offset_size: u8,
1197    length_size: u8,
1198    ndims: Option<u32>,
1199    chunk_dims: &[u32],
1200    chunk_bounds: Option<(&[u64], &[u64])>,
1201    link_name_hash: Option<u32>,
1202    num_records: u16,
1203    depth: u16,
1204    heap_id_len: usize,
1205    visited: &mut HashSet<u64>,
1206    records: &mut Vec<BTreeV2Record>,
1207) -> Result<()> {
1208    if depth == 0 {
1209        return Err(Error::InvalidData(
1210            "B-tree v2 internal node traversal reached depth zero".into(),
1211        ));
1212    }
1213    enter_btree_v2_node(visited, address)?;
1214
1215    let node_len = usize::try_from(header.node_size).map_err(|_| {
1216        Error::InvalidData("B-tree v2 node size exceeds platform usize capacity".into())
1217    })?;
1218    let node_bytes = storage.read_range(address, node_len)?;
1219    let mut cursor = Cursor::new(node_bytes.as_ref());
1220    let start = cursor.position();
1221
1222    let sig = cursor.read_bytes(4)?;
1223    if sig != BTIN_SIGNATURE {
1224        return Err(Error::InvalidBTreeV2Signature {
1225            context: "internal node",
1226        });
1227    }
1228
1229    let version = cursor.read_u8()?;
1230    if version != 0 {
1231        return Err(Error::UnsupportedBTreeVersion(version));
1232    }
1233
1234    let node_type = cursor.read_u8()?;
1235    if node_type != header.btree_type {
1236        return Err(Error::InvalidData(format!(
1237            "B-tree v2 internal node type mismatch: header says {}, node says {}",
1238            header.btree_type, node_type
1239        )));
1240    }
1241
1242    let max_child_records = if depth == 1 {
1243        max_leaf_records(header.node_size, header.record_size)
1244    } else {
1245        let leaf_max = max_leaf_records(header.node_size, header.record_size);
1246        let mut prev_max = leaf_max;
1247        for _ in 1..depth {
1248            prev_max =
1249                max_internal_records(header.node_size, header.record_size, offset_size, prev_max);
1250        }
1251        prev_max
1252    };
1253    let nrec_bytes = num_records_size(max_child_records);
1254
1255    let mut node_records = Vec::with_capacity(num_records as usize);
1256    for _ in 0..num_records {
1257        let record = parse_record(
1258            &mut cursor,
1259            header.btree_type,
1260            header.record_size,
1261            offset_size,
1262            length_size,
1263            ndims,
1264            chunk_dims,
1265            heap_id_len,
1266        )?;
1267        node_records.push(record);
1268    }
1269
1270    let num_children = usize::from(num_records) + 1;
1271    let has_total_records = depth > 1;
1272    let total_nrec_bytes = if has_total_records {
1273        usize::from(length_size)
1274    } else {
1275        0
1276    };
1277
1278    let mut child_addresses = Vec::with_capacity(num_children);
1279    let mut child_nrecords = Vec::with_capacity(num_children);
1280    for _ in 0..num_children {
1281        child_addresses.push(cursor.read_offset(offset_size)?);
1282        child_nrecords.push(cursor.read_uvar(nrec_bytes)? as u16);
1283        if has_total_records {
1284            cursor.read_uvar(total_nrec_bytes)?;
1285        }
1286    }
1287
1288    let compact_end = cursor.position();
1289    verify_node_checksum(
1290        &mut cursor,
1291        start,
1292        header.node_size,
1293        compact_end,
1294        "internal node",
1295    )?;
1296
1297    let child_depth = depth - 1;
1298    for (i, record) in node_records.iter().enumerate() {
1299        let child_addr = child_addresses[i];
1300        if !Cursor::is_undefined_offset(child_addr, offset_size)
1301            && child_may_match_link_name_hash(&node_records, i, link_name_hash)
1302        {
1303            let child_nrec = child_nrecords[i];
1304            if child_depth == 0 {
1305                enter_btree_v2_node(visited, child_addr)?;
1306                parse_leaf_node_storage(
1307                    storage,
1308                    child_addr,
1309                    header,
1310                    offset_size,
1311                    length_size,
1312                    ndims,
1313                    chunk_dims,
1314                    chunk_bounds,
1315                    link_name_hash,
1316                    child_nrec,
1317                    heap_id_len,
1318                    records,
1319                )?;
1320            } else {
1321                parse_internal_node_storage(
1322                    storage,
1323                    child_addr,
1324                    header,
1325                    offset_size,
1326                    length_size,
1327                    ndims,
1328                    chunk_dims,
1329                    chunk_bounds,
1330                    link_name_hash,
1331                    child_nrec,
1332                    child_depth,
1333                    heap_id_len,
1334                    visited,
1335                    records,
1336                )?;
1337            }
1338        }
1339
1340        if record_matches_query(record, chunk_dims, chunk_bounds, link_name_hash) {
1341            records.push(record.clone());
1342        }
1343    }
1344
1345    let final_child_index = node_records.len();
1346    let final_child_addr = child_addresses[final_child_index];
1347    if !Cursor::is_undefined_offset(final_child_addr, offset_size)
1348        && child_may_match_link_name_hash(&node_records, final_child_index, link_name_hash)
1349    {
1350        let child_nrec = child_nrecords[final_child_index];
1351        if child_depth == 0 {
1352            enter_btree_v2_node(visited, final_child_addr)?;
1353            parse_leaf_node_storage(
1354                storage,
1355                final_child_addr,
1356                header,
1357                offset_size,
1358                length_size,
1359                ndims,
1360                chunk_dims,
1361                chunk_bounds,
1362                link_name_hash,
1363                child_nrec,
1364                heap_id_len,
1365                records,
1366            )?;
1367        } else {
1368            parse_internal_node_storage(
1369                storage,
1370                final_child_addr,
1371                header,
1372                offset_size,
1373                length_size,
1374                ndims,
1375                chunk_dims,
1376                chunk_bounds,
1377                link_name_hash,
1378                child_nrec,
1379                child_depth,
1380                heap_id_len,
1381                visited,
1382                records,
1383            )?;
1384        }
1385    }
1386
1387    Ok(())
1388}
1389
1390/// Compute the heap ID length from the record size and tree type.
1391///
1392/// For link/attribute B-trees (types 5, 6, 8, 9), the heap ID occupies
1393/// the bytes not used by the fixed fields. For chunk types (10, 11) or
1394/// unknown types, return 0 (heap_id is not used).
1395fn compute_heap_id_len(header: &BTreeV2Header) -> usize {
1396    let rs = header.record_size as usize;
1397    match header.btree_type {
1398        5 => rs.saturating_sub(4),         // hash(4)
1399        6 => rs.saturating_sub(8),         // order(8)
1400        8 => rs.saturating_sub(4 + 1 + 4), // hash(4) + flags(1) + creation_order(4)
1401        9 => rs.saturating_sub(1 + 4),     // flags(1) + order(4)
1402        _ => 0,
1403    }
1404}
1405
1406#[cfg(test)]
1407mod tests {
1408    use super::*;
1409
1410    /// Build a minimal BTHD with the given parameters.
1411    #[allow(clippy::too_many_arguments)]
1412    fn build_header(
1413        btree_type: u8,
1414        node_size: u32,
1415        record_size: u16,
1416        depth: u16,
1417        root_node_address: u64,
1418        num_records_in_root: u16,
1419        total_records: u64,
1420        offset_size: u8,
1421        length_size: u8,
1422    ) -> Vec<u8> {
1423        let mut buf = Vec::new();
1424        buf.extend_from_slice(b"BTHD");
1425        buf.push(0); // version
1426        buf.push(btree_type);
1427        buf.extend_from_slice(&node_size.to_le_bytes());
1428        buf.extend_from_slice(&record_size.to_le_bytes());
1429        buf.extend_from_slice(&depth.to_le_bytes());
1430        buf.push(75); // split percent
1431        buf.push(40); // merge percent
1432        match offset_size {
1433            4 => buf.extend_from_slice(&(root_node_address as u32).to_le_bytes()),
1434            8 => buf.extend_from_slice(&root_node_address.to_le_bytes()),
1435            _ => panic!("unsupported"),
1436        }
1437        buf.extend_from_slice(&num_records_in_root.to_le_bytes());
1438        match length_size {
1439            4 => buf.extend_from_slice(&(total_records as u32).to_le_bytes()),
1440            8 => buf.extend_from_slice(&total_records.to_le_bytes()),
1441            _ => panic!("unsupported"),
1442        }
1443        // Compute and append checksum.
1444        let checksum = jenkins_lookup3(&buf);
1445        buf.extend_from_slice(&checksum.to_le_bytes());
1446        buf
1447    }
1448
1449    #[test]
1450    fn parse_header() {
1451        let data = build_header(5, 4096, 12, 0, 0x1000, 3, 3, 8, 8);
1452        let mut cursor = Cursor::new(&data);
1453        let hdr = BTreeV2Header::parse(&mut cursor, 8, 8).unwrap();
1454
1455        assert_eq!(hdr.btree_type, 5);
1456        assert_eq!(hdr.node_size, 4096);
1457        assert_eq!(hdr.record_size, 12);
1458        assert_eq!(hdr.depth, 0);
1459        assert_eq!(hdr.split_percent, 75);
1460        assert_eq!(hdr.merge_percent, 40);
1461        assert_eq!(hdr.root_node_address, 0x1000);
1462        assert_eq!(hdr.num_records_in_root, 3);
1463        assert_eq!(hdr.total_records, 3);
1464    }
1465
1466    #[test]
1467    fn bad_signature() {
1468        let mut data = build_header(5, 4096, 12, 0, 0x1000, 0, 0, 8, 8);
1469        data[0] = b'X';
1470        let mut cursor = Cursor::new(&data);
1471        assert!(matches!(
1472            BTreeV2Header::parse(&mut cursor, 8, 8),
1473            Err(Error::InvalidBTreeV2Signature { .. })
1474        ));
1475    }
1476
1477    #[test]
1478    fn bad_checksum() {
1479        let mut data = build_header(5, 4096, 12, 0, 0x1000, 0, 0, 8, 8);
1480        // Corrupt a byte in the middle.
1481        data[6] = 0xFF;
1482        let mut cursor = Cursor::new(&data);
1483        assert!(matches!(
1484            BTreeV2Header::parse(&mut cursor, 8, 8),
1485            Err(Error::ChecksumMismatch { .. })
1486        ));
1487    }
1488
1489    #[test]
1490    fn collect_empty_tree() {
1491        let header = BTreeV2Header {
1492            btree_type: 5,
1493            node_size: 4096,
1494            record_size: 12,
1495            depth: 0,
1496            split_percent: 75,
1497            merge_percent: 40,
1498            root_node_address: u64::MAX,
1499            num_records_in_root: 0,
1500            total_records: 0,
1501        };
1502        let data = vec![0u8; 100];
1503        let records = collect_btree_v2_records(&data, &header, 8, 8, None, &[], None).unwrap();
1504        assert!(records.is_empty());
1505    }
1506
1507    #[test]
1508    fn heap_id_len_uses_record_type_sizes() {
1509        // Type 5: record_size - 4
1510        let h5 = BTreeV2Header {
1511            btree_type: 5,
1512            record_size: 12,
1513            node_size: 0,
1514            depth: 0,
1515            split_percent: 0,
1516            merge_percent: 0,
1517            root_node_address: 0,
1518            num_records_in_root: 0,
1519            total_records: 0,
1520        };
1521        assert_eq!(compute_heap_id_len(&h5), 8);
1522
1523        // Type 8: record_size - 9
1524        let h8 = BTreeV2Header {
1525            btree_type: 8,
1526            record_size: 17,
1527            ..h5
1528        };
1529        assert_eq!(compute_heap_id_len(&h8), 8);
1530
1531        // Type 9: record_size - flags(1) - order(4)
1532        let h9 = BTreeV2Header {
1533            btree_type: 9,
1534            record_size: 13,
1535            ..h5
1536        };
1537        assert_eq!(compute_heap_id_len(&h9), 8);
1538    }
1539
1540    #[test]
1541    fn parse_attribute_name_record_uses_on_disk_field_order() {
1542        let heap_id = [0x00, 0xbe, 0x00, 0x00, 0x00, 0x00, 0x1c, 0x00];
1543        let mut data = heap_id.to_vec();
1544        data.push(0x02);
1545        data.extend_from_slice(&6u32.to_le_bytes());
1546        data.extend_from_slice(&0x0396_dda5u32.to_le_bytes());
1547        let mut cursor = Cursor::new(&data);
1548
1549        let record = parse_record(&mut cursor, 8, data.len() as u16, 8, 8, None, &[], 8).unwrap();
1550        match record {
1551            BTreeV2Record::AttributeNameHash {
1552                hash,
1553                flags,
1554                creation_order,
1555                heap_id: parsed_heap_id,
1556            } => {
1557                assert_eq!(parsed_heap_id, heap_id);
1558                assert_eq!(flags, 0x02);
1559                assert_eq!(creation_order, 6);
1560                assert_eq!(hash, 0x0396_dda5);
1561            }
1562            other => panic!("expected attribute-name record, got {other:?}"),
1563        }
1564    }
1565
1566    #[test]
1567    fn parse_attribute_creation_order_record_uses_on_disk_field_order() {
1568        let heap_id = [0x00, 0x16, 0x00, 0x00, 0x00, 0x00, 0x1c, 0x00];
1569        let mut data = heap_id.to_vec();
1570        data.push(0x02);
1571        data.extend_from_slice(&7u32.to_le_bytes());
1572        let mut cursor = Cursor::new(&data);
1573
1574        let record = parse_record(&mut cursor, 9, data.len() as u16, 8, 8, None, &[], 8).unwrap();
1575        match record {
1576            BTreeV2Record::AttributeCreationOrder {
1577                order,
1578                heap_id: parsed_heap_id,
1579            } => {
1580                assert_eq!(parsed_heap_id, heap_id);
1581                assert_eq!(order, 7);
1582            }
1583            other => panic!("expected attribute-creation-order record, got {other:?}"),
1584        }
1585    }
1586
1587    #[test]
1588    fn max_leaf_records_accounts_for_node_overhead() {
1589        // node_size=4096, record_size=12, overhead=10
1590        // => (4096 - 10) / 12 = 340
1591        assert_eq!(max_leaf_records(4096, 12), 340);
1592    }
1593
1594    #[test]
1595    fn record_count_size_scales_at_integer_boundaries() {
1596        assert_eq!(num_records_size(0), 1);
1597        assert_eq!(num_records_size(255), 1);
1598        assert_eq!(num_records_size(256), 2);
1599        assert_eq!(num_records_size(65535), 2);
1600        assert_eq!(num_records_size(65536), 4);
1601    }
1602
1603    #[test]
1604    fn parse_huge_indirect_record() {
1605        let mut data = Vec::new();
1606        data.extend_from_slice(&0x1234u64.to_le_bytes());
1607        data.extend_from_slice(&99u64.to_le_bytes());
1608        data.extend_from_slice(&7u64.to_le_bytes());
1609        let mut cursor = Cursor::new(&data);
1610
1611        let record = parse_record(&mut cursor, 1, data.len() as u16, 8, 8, None, &[], 0).unwrap();
1612        match record {
1613            BTreeV2Record::HugeIndirectNonFiltered {
1614                address,
1615                length,
1616                object_id,
1617            } => {
1618                assert_eq!(address, 0x1234);
1619                assert_eq!(length, 99);
1620                assert_eq!(object_id, 7);
1621            }
1622            other => panic!("expected huge record, got {:?}", other),
1623        }
1624    }
1625
1626    #[test]
1627    fn parse_shared_heap_record() {
1628        let mut data = Vec::new();
1629        data.push(0);
1630        data.extend_from_slice(&[0, 0, 0]);
1631        data.extend_from_slice(&0xAABB_CCDDu32.to_le_bytes());
1632        data.extend_from_slice(&3u32.to_le_bytes());
1633        data.extend_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]);
1634        let mut cursor = Cursor::new(&data);
1635
1636        let record = parse_record(&mut cursor, 7, data.len() as u16, 8, 8, None, &[], 0).unwrap();
1637        match record {
1638            BTreeV2Record::SharedMessageHeap {
1639                hash,
1640                reference_count,
1641                heap_id,
1642            } => {
1643                assert_eq!(hash, 0xAABB_CCDD);
1644                assert_eq!(reference_count, 3);
1645                assert_eq!(heap_id, vec![1, 2, 3, 4, 5, 6, 7, 8]);
1646            }
1647            other => panic!("expected shared heap record, got {:?}", other),
1648        }
1649    }
1650
1651    #[test]
1652    fn parse_record_rejects_known_record_that_exceeds_record_size() {
1653        let mut data = Vec::new();
1654        data.extend_from_slice(&0x1234u64.to_le_bytes());
1655        data.extend_from_slice(&99u64.to_le_bytes());
1656        data.extend_from_slice(&7u64.to_le_bytes());
1657        let mut cursor = Cursor::new(&data);
1658
1659        let err = parse_record(&mut cursor, 1, 16, 8, 8, None, &[], 0).unwrap_err();
1660        assert!(
1661            matches!(err, Error::InvalidData(message) if message.contains("consumed 24 bytes but record size is 16"))
1662        );
1663    }
1664
1665    #[test]
1666    fn parse_chunk_record_rejects_size_shorter_than_address() {
1667        let mut data = Vec::new();
1668        data.extend_from_slice(&0x1234u64.to_le_bytes());
1669        let mut cursor = Cursor::new(&data);
1670
1671        let err = parse_record(&mut cursor, 10, 4, 8, 8, None, &[], 0).unwrap_err();
1672        assert!(
1673            matches!(err, Error::InvalidData(message) if message.contains("shorter than its address"))
1674        );
1675        assert_eq!(cursor.position(), 0);
1676    }
1677
1678    #[test]
1679    fn parse_filtered_chunk_record_rejects_size_shorter_than_fixed_fields() {
1680        let mut data = Vec::new();
1681        data.extend_from_slice(&0x1234u64.to_le_bytes());
1682        data.extend_from_slice(&99u64.to_le_bytes());
1683        data.extend_from_slice(&0xAu32.to_le_bytes());
1684        let mut cursor = Cursor::new(&data);
1685
1686        let err = parse_record(&mut cursor, 11, 16, 8, 8, None, &[], 0).unwrap_err();
1687        assert!(
1688            matches!(err, Error::InvalidData(message) if message.contains("shorter than its fixed fields"))
1689        );
1690        assert_eq!(cursor.position(), 0);
1691    }
1692
1693    #[test]
1694    fn parse_chunk_record_scales_offsets_by_chunk_dimensions() {
1695        let mut data = Vec::new();
1696        data.extend_from_slice(&0x1234u64.to_le_bytes());
1697        data.extend_from_slice(&2u64.to_le_bytes());
1698        data.extend_from_slice(&1u64.to_le_bytes());
1699        let mut cursor = Cursor::new(&data);
1700
1701        let record = parse_record(
1702            &mut cursor,
1703            10,
1704            data.len() as u16,
1705            8,
1706            8,
1707            Some(2),
1708            &[5, 7],
1709            0,
1710        )
1711        .unwrap();
1712        match record {
1713            BTreeV2Record::ChunkedNonFiltered { address, offsets } => {
1714                assert_eq!(address, 0x1234);
1715                assert_eq!(offsets, vec![10, 7]);
1716            }
1717            other => panic!("expected non-filtered chunk record, got {:?}", other),
1718        }
1719    }
1720
1721    #[test]
1722    fn parse_filtered_chunk_record_scales_offsets_by_chunk_dimensions() {
1723        let mut data = Vec::new();
1724        data.extend_from_slice(&0x1234u64.to_le_bytes());
1725        data.extend_from_slice(&99u64.to_le_bytes());
1726        data.extend_from_slice(&0xAu32.to_le_bytes());
1727        data.extend_from_slice(&3u64.to_le_bytes());
1728        data.extend_from_slice(&4u64.to_le_bytes());
1729        let mut cursor = Cursor::new(&data);
1730
1731        let record = parse_record(
1732            &mut cursor,
1733            11,
1734            data.len() as u16,
1735            8,
1736            8,
1737            Some(2),
1738            &[5, 7],
1739            0,
1740        )
1741        .unwrap();
1742        match record {
1743            BTreeV2Record::ChunkedFiltered {
1744                address,
1745                chunk_size,
1746                filter_mask,
1747                offsets,
1748            } => {
1749                assert_eq!(address, 0x1234);
1750                assert_eq!(chunk_size, 99);
1751                assert_eq!(filter_mask, 0xA);
1752                assert_eq!(offsets, vec![15, 28]);
1753            }
1754            other => panic!("expected filtered chunk record, got {:?}", other),
1755        }
1756    }
1757
1758    #[test]
1759    fn parse_leaf_with_type5_records() {
1760        // Build a leaf node with 2 type-5 records (link name hash).
1761        // record_size = 12 (hash=4 + heap_id=8)
1762        let record_size: u16 = 12;
1763        let node_size: u32 = 4096;
1764
1765        let header = BTreeV2Header {
1766            btree_type: 5,
1767            node_size,
1768            record_size,
1769            depth: 0,
1770            split_percent: 75,
1771            merge_percent: 40,
1772            root_node_address: 0, // will point to our leaf
1773            num_records_in_root: 2,
1774            total_records: 2,
1775        };
1776
1777        // Build the leaf node.
1778        let mut leaf = Vec::new();
1779        leaf.extend_from_slice(b"BTLF"); // signature
1780        leaf.push(0); // version
1781        leaf.push(5); // type
1782
1783        // Record 1: hash=0xAABBCCDD, heap_id=[1,2,3,4,5,6,7,8]
1784        leaf.extend_from_slice(&0xAABBCCDDu32.to_le_bytes());
1785        leaf.extend_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]);
1786
1787        // Record 2: hash=0x11223344, heap_id=[9,10,11,12,13,14,15,16]
1788        leaf.extend_from_slice(&0x11223344u32.to_le_bytes());
1789        leaf.extend_from_slice(&[9, 10, 11, 12, 13, 14, 15, 16]);
1790
1791        // The checksum covers the populated node payload. The node allocation
1792        // may be larger and is padded after the checksum.
1793        let checksum = jenkins_lookup3(&leaf);
1794        leaf.extend_from_slice(&checksum.to_le_bytes());
1795        leaf.resize(node_size as usize, 0);
1796
1797        let mut records = Vec::new();
1798        let mut cursor = Cursor::new(&leaf);
1799        parse_leaf_node(
1800            &mut cursor,
1801            &header,
1802            8,
1803            8,
1804            None,
1805            &[],
1806            None,
1807            None,
1808            2,
1809            8, // heap_id_len
1810            &mut records,
1811        )
1812        .unwrap();
1813
1814        assert_eq!(records.len(), 2);
1815        match &records[0] {
1816            BTreeV2Record::LinkNameHash { hash, heap_id } => {
1817                assert_eq!(*hash, 0xAABBCCDD);
1818                assert_eq!(heap_id, &[1, 2, 3, 4, 5, 6, 7, 8]);
1819            }
1820            _ => panic!("expected LinkNameHash"),
1821        }
1822        match &records[1] {
1823            BTreeV2Record::LinkNameHash { hash, heap_id } => {
1824                assert_eq!(*hash, 0x11223344);
1825                assert_eq!(heap_id, &[9, 10, 11, 12, 13, 14, 15, 16]);
1826            }
1827            _ => panic!("expected LinkNameHash"),
1828        }
1829    }
1830
1831    fn build_type5_record(hash: u32, heap_id: [u8; 8]) -> Vec<u8> {
1832        let mut record = Vec::new();
1833        record.extend_from_slice(&hash.to_le_bytes());
1834        record.extend_from_slice(&heap_id);
1835        record
1836    }
1837
1838    fn build_type5_leaf(records: &[(u32, [u8; 8])], node_size: usize) -> Vec<u8> {
1839        let mut leaf = Vec::new();
1840        leaf.extend_from_slice(b"BTLF");
1841        leaf.push(0);
1842        leaf.push(5);
1843        for &(hash, heap_id) in records {
1844            leaf.extend_from_slice(&build_type5_record(hash, heap_id));
1845        }
1846        let checksum = jenkins_lookup3(&leaf);
1847        leaf.extend_from_slice(&checksum.to_le_bytes());
1848        leaf.resize(node_size, 0);
1849        leaf
1850    }
1851
1852    fn build_type5_internal(
1853        records: &[(u32, [u8; 8])],
1854        children: &[(u64, u8)],
1855        node_size: usize,
1856    ) -> Vec<u8> {
1857        assert_eq!(children.len(), records.len() + 1);
1858
1859        let mut node = Vec::new();
1860        node.extend_from_slice(b"BTIN");
1861        node.push(0);
1862        node.push(5);
1863        for &(hash, heap_id) in records {
1864            node.extend_from_slice(&build_type5_record(hash, heap_id));
1865        }
1866        for &(address, child_records) in children {
1867            node.extend_from_slice(&address.to_le_bytes());
1868            node.push(child_records);
1869        }
1870        let checksum = jenkins_lookup3(&node);
1871        node.extend_from_slice(&checksum.to_le_bytes());
1872        node.resize(node_size, 0);
1873        node
1874    }
1875
1876    fn link_hashes(records: &[BTreeV2Record]) -> Vec<u32> {
1877        records
1878            .iter()
1879            .map(|record| match record {
1880                BTreeV2Record::LinkNameHash { hash, .. } => *hash,
1881                other => panic!("expected LinkNameHash, got {other:?}"),
1882            })
1883            .collect()
1884    }
1885
1886    #[test]
1887    fn collect_depth_one_tree_includes_internal_records_in_order() {
1888        // Mirrors HDF5 H5B2__iterate_node: recurse into child[i], visit
1889        // record[i], then recurse into the final child. Internal-node records
1890        // are callback records, not just separators.
1891        let node_size = 128usize;
1892        let left_addr = node_size as u64;
1893        let right_addr = (node_size * 2) as u64;
1894        let header = BTreeV2Header {
1895            btree_type: 5,
1896            node_size: node_size as u32,
1897            record_size: 12,
1898            depth: 1,
1899            split_percent: 75,
1900            merge_percent: 40,
1901            root_node_address: 0,
1902            num_records_in_root: 1,
1903            total_records: 3,
1904        };
1905
1906        let root = build_type5_internal(
1907            &[(20, [20; 8])],
1908            &[(left_addr, 1), (right_addr, 1)],
1909            node_size,
1910        );
1911        let left_leaf = build_type5_leaf(&[(10, [10; 8])], node_size);
1912        let right_leaf = build_type5_leaf(&[(30, [30; 8])], node_size);
1913
1914        let mut data = vec![0; node_size * 3];
1915        data[0..node_size].copy_from_slice(&root);
1916        data[node_size..node_size * 2].copy_from_slice(&left_leaf);
1917        data[node_size * 2..node_size * 3].copy_from_slice(&right_leaf);
1918
1919        let records = collect_btree_v2_records(&data, &header, 8, 8, None, &[], None).unwrap();
1920        assert_eq!(link_hashes(&records), vec![10, 20, 30]);
1921
1922        let storage = crate::storage::BytesStorage::new(data);
1923        let records =
1924            collect_btree_v2_records_storage(&storage, &header, 8, 8, None, &[], None).unwrap();
1925        assert_eq!(link_hashes(&records), vec![10, 20, 30]);
1926    }
1927
1928    #[test]
1929    fn collect_rejects_depth_above_traversal_limit() {
1930        let header = BTreeV2Header {
1931            btree_type: 5,
1932            node_size: 128,
1933            record_size: 12,
1934            depth: MAX_BTREE_V2_DEPTH + 1,
1935            split_percent: 75,
1936            merge_percent: 40,
1937            root_node_address: 0,
1938            num_records_in_root: 1,
1939            total_records: 1,
1940        };
1941        let data = vec![0; 128];
1942
1943        let err = collect_btree_v2_records(&data, &header, 8, 8, None, &[], None).unwrap_err();
1944        assert!(
1945            matches!(err, Error::InvalidData(message) if message.contains("exceeds traversal limit"))
1946        );
1947    }
1948
1949    #[test]
1950    fn collect_rejects_revisited_leaf_node() {
1951        let node_size = 128usize;
1952        let leaf_addr = node_size as u64;
1953        let header = BTreeV2Header {
1954            btree_type: 5,
1955            node_size: node_size as u32,
1956            record_size: 12,
1957            depth: 1,
1958            split_percent: 75,
1959            merge_percent: 40,
1960            root_node_address: 0,
1961            num_records_in_root: 1,
1962            total_records: 3,
1963        };
1964
1965        let root = build_type5_internal(
1966            &[(20, [20; 8])],
1967            &[(leaf_addr, 1), (leaf_addr, 1)],
1968            node_size,
1969        );
1970        let leaf = build_type5_leaf(&[(10, [10; 8])], node_size);
1971
1972        let mut data = vec![0; node_size * 2];
1973        data[0..node_size].copy_from_slice(&root);
1974        data[node_size..node_size * 2].copy_from_slice(&leaf);
1975
1976        let err = collect_btree_v2_records(&data, &header, 8, 8, None, &[], None).unwrap_err();
1977        assert!(matches!(err, Error::InvalidData(message) if message.contains("revisits node")));
1978
1979        let storage = crate::storage::BytesStorage::new(data);
1980        let err =
1981            collect_btree_v2_records_storage(&storage, &header, 8, 8, None, &[], None).unwrap_err();
1982        assert!(matches!(err, Error::InvalidData(message) if message.contains("revisits node")));
1983    }
1984
1985    #[test]
1986    fn collect_link_name_hash_records_filters_leaf_records() {
1987        let record_size: u16 = 12;
1988        let node_size: u32 = 4096;
1989        let header = BTreeV2Header {
1990            btree_type: 5,
1991            node_size,
1992            record_size,
1993            depth: 0,
1994            split_percent: 75,
1995            merge_percent: 40,
1996            root_node_address: 0,
1997            num_records_in_root: 2,
1998            total_records: 2,
1999        };
2000
2001        let mut leaf = Vec::new();
2002        leaf.extend_from_slice(b"BTLF");
2003        leaf.push(0);
2004        leaf.push(5);
2005        leaf.extend_from_slice(&0x1111_1111u32.to_le_bytes());
2006        leaf.extend_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]);
2007        leaf.extend_from_slice(&0x2222_2222u32.to_le_bytes());
2008        leaf.extend_from_slice(&[9, 10, 11, 12, 13, 14, 15, 16]);
2009        let checksum = jenkins_lookup3(&leaf);
2010        leaf.extend_from_slice(&checksum.to_le_bytes());
2011        leaf.resize(node_size as usize, 0);
2012
2013        let records =
2014            collect_btree_v2_link_name_hash_records(&leaf, &header, 8, 8, 0x2222_2222).unwrap();
2015        assert_eq!(records.len(), 1);
2016        match &records[0] {
2017            BTreeV2Record::LinkNameHash { hash, heap_id } => {
2018                assert_eq!(*hash, 0x2222_2222);
2019                assert_eq!(heap_id, &[9, 10, 11, 12, 13, 14, 15, 16]);
2020            }
2021            other => panic!("expected LinkNameHash, got {other:?}"),
2022        }
2023    }
2024
2025    #[test]
2026    fn child_link_hash_filter_prunes_disjoint_hash_ranges() {
2027        let records = vec![
2028            BTreeV2Record::LinkNameHash {
2029                hash: 10,
2030                heap_id: vec![],
2031            },
2032            BTreeV2Record::LinkNameHash {
2033                hash: 20,
2034                heap_id: vec![],
2035            },
2036        ];
2037
2038        assert!(child_may_match_link_name_hash(&records, 0, Some(10)));
2039        assert!(!child_may_match_link_name_hash(&records, 0, Some(15)));
2040        assert!(child_may_match_link_name_hash(&records, 1, Some(15)));
2041        assert!(!child_may_match_link_name_hash(&records, 2, Some(15)));
2042        assert!(child_may_match_link_name_hash(&records, 2, Some(20)));
2043    }
2044}