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