Files
clawhdf5/crates/clawhdf5-format/src/btree_v2.rs
T
osobhandClaude Opus 5.5 8196fab72a fix(format): read v2 B-tree internal nodes with libhdf5's pointer widths
An internal node's child pointer is an address, the child's record count
and (below the first internal level) the child subtree's total record
count. libhdf5 (H5B2__hdr_init) encodes the record count in the width of
a leaf's maximum and the subtree total in the width of cum_max_nrec for
that depth, computed level by level from the node size. The reader
guessed 2 * leaf_max and leaf_max^depth, which agree at depth 2 but not
at depth 3: a 24 000-link group's name index has depth 3, its root's
pointers were read 3 bytes wide instead of 2, and listing failed with a
garbage heap offset.

Regression tests: dense_group_with_a_three_level_name_index (h5py writes
24 000 links; listing compared with h5py) and
subtree_capacity_matches_libhdf5.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:53:00 -05:00

701 lines
24 KiB
Rust

//! HDF5 B-tree v2 parsing.
#[cfg(not(feature = "std"))]
use alloc::vec::Vec;
#[cfg(feature = "checksum")]
use byteorder::{ByteOrder, LittleEndian};
use crate::error::FormatError;
/// Parsed B-tree v2 header (signature "BTHD").
#[derive(Debug, Clone)]
pub struct BTreeV2Header {
/// B-tree type: 5=links indexed by name, 6=links indexed by creation order, etc.
pub tree_type: u8,
/// Node size in bytes.
pub node_size: u32,
/// Record size in bytes.
pub record_size: u16,
/// Depth of the tree (0 = root is a leaf).
pub depth: u16,
/// Address of root node.
pub root_node_address: u64,
/// Number of records in the root node.
pub num_records_in_root: u16,
/// Total number of records in all nodes.
pub total_records: u64,
}
/// A single record from a B-tree v2 node.
#[derive(Debug, Clone)]
pub struct BTreeV2Record {
/// Raw record bytes (record_size bytes).
pub data: Vec<u8>,
}
fn read_offset(data: &[u8], pos: usize, size: u8) -> Result<u64, FormatError> {
let s = size as usize;
if pos.checked_add(s).is_none_or(|end| end > data.len()) {
return Err(FormatError::UnexpectedEof {
expected: pos.saturating_add(s),
available: data.len(),
});
}
Ok(match size {
2 => u16::from_le_bytes([data[pos], data[pos + 1]]) as u64,
4 => u32::from_le_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as u64,
8 => u64::from_le_bytes([
data[pos],
data[pos + 1],
data[pos + 2],
data[pos + 3],
data[pos + 4],
data[pos + 5],
data[pos + 6],
data[pos + 7],
]),
_ => return Err(FormatError::InvalidOffsetSize(size)),
})
}
fn ensure_len(data: &[u8], pos: usize, needed: usize) -> Result<(), FormatError> {
match pos.checked_add(needed) {
Some(end) if end <= data.len() => Ok(()),
_ => Err(FormatError::UnexpectedEof {
expected: pos.saturating_add(needed),
available: data.len(),
}),
}
}
/// Compute the number of bytes needed to represent a count, using variable-width encoding.
/// B-tree v2 uses this for the number of records fields in internal nodes.
fn bytes_for_max_records(max_nrec: u64) -> usize {
if max_nrec == 0 {
return 1;
}
let bits = 64 - max_nrec.leading_zeros() as usize;
bits.div_ceil(8)
}
/// Read a variable-width unsigned integer (1-8 bytes, LE).
fn read_var_uint(data: &[u8], pos: usize, width: usize) -> Result<u64, FormatError> {
ensure_len(data, pos, width)?;
let mut val = 0u64;
for i in 0..width {
val |= (data[pos + i] as u64) << (i * 8);
}
Ok(val)
}
impl BTreeV2Header {
/// Parse a B-tree v2 header at the given offset.
pub fn parse(
file_data: &[u8],
offset: usize,
offset_size: u8,
length_size: u8,
) -> Result<BTreeV2Header, FormatError> {
ensure_len(file_data, offset, 4)?;
if &file_data[offset..offset + 4] != b"BTHD" {
return Err(FormatError::InvalidBTreeV2Signature);
}
ensure_len(file_data, offset, 4 + 1 + 1 + 4 + 2 + 2 + 1 + 1)?;
let version = file_data[offset + 4];
if version != 0 {
return Err(FormatError::InvalidBTreeV2Version(version));
}
let tree_type = file_data[offset + 5];
let node_size = u32::from_le_bytes([
file_data[offset + 6],
file_data[offset + 7],
file_data[offset + 8],
file_data[offset + 9],
]);
let record_size = u16::from_le_bytes([file_data[offset + 10], file_data[offset + 11]]);
let depth = u16::from_le_bytes([file_data[offset + 12], file_data[offset + 13]]);
let _split_percent = file_data[offset + 14];
let _merge_percent = file_data[offset + 15];
let mut pos = offset + 16;
let root_node_address = read_offset(file_data, pos, offset_size)?;
pos += offset_size as usize;
ensure_len(file_data, pos, 2)?;
let num_records_in_root = u16::from_le_bytes([file_data[pos], file_data[pos + 1]]);
pos += 2;
let total_records = read_offset(file_data, pos, length_size)?;
#[allow(unused_assignments)]
{
pos += length_size as usize;
}
// Validate header checksum
#[cfg(feature = "checksum")]
{
ensure_len(file_data, pos, 4)?;
let stored = LittleEndian::read_u32(&file_data[pos..pos + 4]);
let computed = crate::checksum::jenkins_lookup3(&file_data[offset..pos]);
if computed != stored {
return Err(FormatError::ChecksumMismatch {
expected: stored,
computed,
});
}
}
Ok(BTreeV2Header {
tree_type,
node_size,
record_size,
depth,
root_node_address,
num_records_in_root,
total_records,
})
}
}
/// Compute maximum records per node for a given depth level.
/// leaf: (node_size - overhead) / record_size
/// internal: depends on pointers
fn max_records_leaf(node_size: u32, record_size: u16) -> u64 {
// Leaf overhead: signature(4) + version(1) + type(1) + checksum(4) = 10
let overhead = 10u32;
if node_size <= overhead || record_size == 0 {
return 0;
}
((node_size - overhead) / record_size as u32) as u64
}
/// Deepest B-tree v2 accepted. See [`collect_btree_v2_records`].
const MAX_DEPTH: u16 = 64;
/// Take `n` records from the traversal's budget, or refuse the tree.
fn spend(budget: &mut usize, n: usize) -> Result<(), FormatError> {
*budget = budget
.checked_sub(n)
.ok_or(FormatError::NestingDepthExceeded)?;
Ok(())
}
/// Collect all records from a B-tree v2 by traversing from the root.
pub fn collect_btree_v2_records(
file_data: &[u8],
header: &BTreeV2Header,
offset_size: u8,
length_size: u8,
) -> Result<Vec<BTreeV2Record>, FormatError> {
if header.total_records == 0 || header.num_records_in_root == 0 {
return Ok(Vec::new());
}
// Recursion is one frame per level, and the depth is read from the file:
// a crafted header claiming 65 535 levels over a node that is its own
// child overflowed the stack. 64 matches the fractal heap's guard, and no
// real tree comes close — even at the minimum fan-out of two it would
// hold more than 2^64 records.
if header.depth > MAX_DEPTH {
return Err(FormatError::NestingDepthExceeded);
}
// A valid tree stores each record once, in its own bytes, so it cannot
// hold more records than the file has room for. Children are addresses,
// though, and nothing makes them distinct: levels whose children all
// point at one shared node below reach it fan-out^depth times, which is
// millions of records from a few kilobytes. Counting against what the
// file could physically contain bounds that without trusting the
// header's own `total_records`.
let mut budget = file_data.len() / usize::from(header.record_size.max(1));
let max_leaf_nrec = max_records_leaf(header.node_size, header.record_size);
if header.depth == 0 {
// Root is a leaf
parse_leaf_records(
file_data,
header.root_node_address as usize,
header.num_records_in_root,
header.record_size,
)
} else {
// Root is internal; traverse recursively
let mut records = Vec::new();
collect_internal_records(
file_data,
header.root_node_address as usize,
header.num_records_in_root,
header.depth,
header.record_size,
header.node_size,
offset_size,
length_size,
max_leaf_nrec,
&mut budget,
&mut records,
)?;
Ok(records)
}
}
/// Parse records from a leaf node (signature "BTLF").
fn parse_leaf_records(
file_data: &[u8],
offset: usize,
num_records: u16,
record_size: u16,
) -> Result<Vec<BTreeV2Record>, FormatError> {
// signature(4) + version(1) + type(1) = 6 bytes header
ensure_len(file_data, offset, 6)?;
if &file_data[offset..offset + 4] != b"BTLF" {
return Err(FormatError::InvalidBTreeV2Signature);
}
let pos = offset + 6;
let rs = record_size as usize;
let total = (num_records as usize)
.checked_mul(rs)
.ok_or(FormatError::UnexpectedEof {
expected: usize::MAX,
available: file_data.len(),
})?;
ensure_len(file_data, pos, total)?;
// Validate checksum: 4 bytes after records + padding
#[cfg(feature = "checksum")]
{
let checksum_pos = pos + total;
if file_data.len() >= checksum_pos + 4 {
let stored = LittleEndian::read_u32(&file_data[checksum_pos..checksum_pos + 4]);
let computed = crate::checksum::jenkins_lookup3(&file_data[offset..checksum_pos]);
if computed != stored {
return Err(FormatError::ChecksumMismatch {
expected: stored,
computed,
});
}
}
}
let mut records = Vec::with_capacity(num_records as usize);
for i in 0..num_records as usize {
let start = pos + i * rs;
records.push(BTreeV2Record {
data: file_data[start..start + rs].to_vec(),
});
}
Ok(records)
}
/// Recursively collect records from an internal node.
#[allow(clippy::too_many_arguments, clippy::only_used_in_recursion)]
fn collect_internal_records(
file_data: &[u8],
offset: usize,
num_records: u16,
depth: u16,
record_size: u16,
node_size: u32,
offset_size: u8,
length_size: u8,
max_leaf_nrec: u64,
budget: &mut usize,
out: &mut Vec<BTreeV2Record>,
) -> Result<(), FormatError> {
// signature(4) + version(1) + type(1) = 6
ensure_len(file_data, offset, 6)?;
if &file_data[offset..offset + 4] != b"BTIN" {
return Err(FormatError::InvalidBTreeV2Signature);
}
let nr = num_records as usize;
let rs = record_size as usize;
let mut pos = offset + 6;
// Read all records first
let records_total = nr.checked_mul(rs).ok_or(FormatError::UnexpectedEof {
expected: usize::MAX,
available: file_data.len(),
})?;
ensure_len(file_data, pos, records_total)?;
let records_start = pos;
pos += records_total;
// Child pointer layout, as libhdf5 computes it (H5B2__hdr_init): the
// child's record count is always encoded in the width needed for a
// *leaf's* maximum, and — below the first internal level — the child
// subtree's total record count in the width needed for the most records
// a subtree of that depth can hold.
let child_depth = depth - 1;
let nrec_width = bytes_for_max_records(max_leaf_nrec);
let total_nrec_width = if depth > 1 {
bytes_for_max_records(cum_max_records(
node_size,
record_size,
offset_size,
max_leaf_nrec,
child_depth,
))
} else {
0
};
let num_children = nr + 1;
let child_ptr_size = offset_size as usize + nrec_width + total_nrec_width;
ensure_len(file_data, pos, num_children * child_ptr_size)?;
// Read child pointers
let mut children = Vec::with_capacity(num_children);
for _ in 0..num_children {
let addr = read_offset(file_data, pos, offset_size)?;
pos += offset_size as usize;
let child_nrec = read_var_uint(file_data, pos, nrec_width)? as u16;
pos += nrec_width;
pos += total_nrec_width; // skip total records in subtree
children.push((addr, child_nrec));
}
// Interleave: child[0], record[0], child[1], record[1], ..., child[nr]
// We collect child[0] records, then record[0], then child[1], etc.
for (i, &(child_addr, child_nrec)) in children.iter().enumerate() {
if child_depth == 0 {
// Before parsing, so a refused tree is not also a large allocation.
spend(budget, usize::from(child_nrec))?;
let leaf_recs =
parse_leaf_records(file_data, child_addr as usize, child_nrec, record_size)?;
out.extend(leaf_recs);
} else {
collect_internal_records(
file_data,
child_addr as usize,
child_nrec,
child_depth,
record_size,
node_size,
offset_size,
length_size,
max_leaf_nrec,
budget,
out,
)?;
}
// Add record[i] (except after the last child)
if i < nr {
let rec_offset = i.checked_mul(rs).ok_or(FormatError::UnexpectedEof {
expected: usize::MAX,
available: file_data.len(),
})?;
let rec_start =
records_start
.checked_add(rec_offset)
.ok_or(FormatError::UnexpectedEof {
expected: usize::MAX,
available: file_data.len(),
})?;
let rec_end = rec_start
.checked_add(rs)
.ok_or(FormatError::UnexpectedEof {
expected: usize::MAX,
available: file_data.len(),
})?;
if rec_end > file_data.len() {
return Err(FormatError::UnexpectedEof {
expected: rec_end,
available: file_data.len(),
});
}
spend(budget, 1)?;
out.push(BTreeV2Record {
data: file_data[rec_start..rec_end].to_vec(),
});
}
}
Ok(())
}
/// Most records a subtree whose root is at `depth` can hold (libhdf5's
/// `cum_max_nrec`): a leaf holds `max_leaf_nrec`; an internal node at depth
/// `d` holds `max_nrec(d)` records and `max_nrec(d) + 1` subtrees of depth
/// `d - 1`, where `max_nrec(d)` is what fits in a node once each record is
/// paired with a child pointer of the width depth `d` needs.
fn cum_max_records(
node_size: u32,
record_size: u16,
offset_size: u8,
max_leaf_nrec: u64,
depth: u16,
) -> u64 {
// Internal node overhead: signature(4) + version(1) + type(1) + checksum(4).
const PREFIX: u64 = 10;
let nrec_width = bytes_for_max_records(max_leaf_nrec) as u64;
let mut cum = max_leaf_nrec;
let mut cum_width = 0u64;
for d in 1..=depth {
let ptr = u64::from(offset_size) + nrec_width + if d > 1 { cum_width } else { 0 };
let max_nrec = u64::from(node_size)
.saturating_sub(PREFIX)
.saturating_sub(ptr)
/ (u64::from(record_size) + ptr).max(1);
cum = max_nrec
.saturating_add(1)
.saturating_mul(cum)
.saturating_add(max_nrec);
cum_width = bytes_for_max_records(cum) as u64;
}
cum
}
#[cfg(test)]
mod tests {
use super::*;
#[allow(clippy::too_many_arguments)]
fn build_btree_v2_header(
tree_type: u8,
node_size: u32,
record_size: u16,
depth: u16,
root_addr: u64,
num_records_root: u16,
total_records: u64,
offset_size: u8,
length_size: u8,
) -> Vec<u8> {
let mut buf = Vec::new();
buf.extend_from_slice(b"BTHD");
buf.push(0); // version
buf.push(tree_type);
buf.extend_from_slice(&node_size.to_le_bytes());
buf.extend_from_slice(&record_size.to_le_bytes());
buf.extend_from_slice(&depth.to_le_bytes());
buf.push(85); // split_percent
buf.push(40); // merge_percent
match offset_size {
4 => buf.extend_from_slice(&(root_addr as u32).to_le_bytes()),
8 => buf.extend_from_slice(&root_addr.to_le_bytes()),
_ => {}
}
buf.extend_from_slice(&num_records_root.to_le_bytes());
match length_size {
4 => buf.extend_from_slice(&(total_records as u32).to_le_bytes()),
8 => buf.extend_from_slice(&total_records.to_le_bytes()),
_ => {}
}
let checksum = crate::checksum::jenkins_lookup3(&buf);
buf.extend_from_slice(&checksum.to_le_bytes());
buf
}
fn build_leaf_node(tree_type: u8, records: &[&[u8]]) -> Vec<u8> {
let mut buf = Vec::new();
buf.extend_from_slice(b"BTLF");
buf.push(0); // version
buf.push(tree_type);
for rec in records {
buf.extend_from_slice(rec);
}
let checksum = crate::checksum::jenkins_lookup3(&buf);
buf.extend_from_slice(&checksum.to_le_bytes());
buf
}
/// An internal node laid out exactly as `collect_internal_records` will
/// read it at `depth`: `records` zeroed records, then `children` pointers,
/// all to `child_addr` claiming `child_nrec` records.
fn internal_node(
depth: u16,
node_size: u32,
record_size: u16,
records: usize,
children: usize,
child_addr: u64,
child_nrec: u64,
) -> Vec<u8> {
let max_leaf = max_records_leaf(node_size, record_size);
let nrec_width = bytes_for_max_records(max_leaf);
let total_width = if depth > 1 {
bytes_for_max_records(cum_max_records(
node_size,
record_size,
8,
max_leaf,
depth - 1,
))
} else {
0
};
let mut buf = b"BTIN".to_vec();
buf.extend_from_slice(&[0, 5]);
buf.resize(buf.len() + records * record_size as usize, 0);
for _ in 0..children {
buf.extend_from_slice(&child_addr.to_le_bytes());
buf.extend_from_slice(&child_nrec.to_le_bytes()[..nrec_width]);
buf.resize(buf.len() + total_width, 0);
}
buf
}
fn header(depth: u16, root: u64, root_nrec: u16, total: u64) -> BTreeV2Header {
BTreeV2Header {
tree_type: 5,
node_size: 512,
record_size: 8,
depth,
root_node_address: root,
num_records_in_root: root_nrec,
total_records: total,
}
}
#[test]
fn a_node_that_is_its_own_child_is_rejected_not_recursed() {
// One internal node whose two children are itself, under a header
// claiming the deepest tree a u16 allows. The layout stops depending
// on depth once the subtree-total width saturates, so every level
// parses cleanly and recursion runs ~65 000 frames deep: before the
// cap this overflowed the stack and aborted the process, from a file
// of under 100 bytes.
let mut data = internal_node(u16::MAX, 512, 8, 1, 2, 0, 1);
data.resize(4096, 0);
let result = collect_btree_v2_records(&data, &header(u16::MAX, 0, 1, 1), 8, 8);
assert!(result.is_err(), "{result:?}");
}
#[test]
fn a_shared_subtree_cannot_multiply_the_work() {
// A chain of distinct levels, each node's children all pointing at the
// single node below, ending in a real leaf. Every node parses and
// nothing is cyclic, yet the leaf is reached fan-out^depth times: 62
// children over 4 levels is ~15 million leaf visits from a few
// kilobytes. A valid tree cannot hold more records than the file has
// room for, so that bounds the traversal instead.
let (node_size, record_size) = (512u32, 8u16);
let fanout = 62usize;
let depth = 4u16;
let leaf = build_leaf_node(5, &[&[0u8; 8][..]]);
// Lay out root first, then each lower level, then the leaf.
let mut nodes: Vec<Vec<u8>> = Vec::new();
let mut addrs = Vec::new();
let mut at = 0u64;
let mut sizes = Vec::new();
for d in (1..=depth).rev() {
let n = internal_node(d, node_size, record_size, fanout - 1, fanout, 0, 0);
sizes.push(n.len());
}
for size in &sizes {
addrs.push(at);
at += *size as u64;
}
let leaf_addr = at;
for (i, d) in (1..=depth).rev().enumerate() {
let (child, child_nrec) = if d == 1 {
(leaf_addr, 1)
} else {
(addrs[i + 1], fanout as u64 - 1)
};
nodes.push(internal_node(
d,
node_size,
record_size,
fanout - 1,
fanout,
child,
child_nrec,
));
}
let mut data: Vec<u8> = nodes.concat();
data.extend_from_slice(&leaf);
data.resize(data.len() + 64, 0);
let started = std::time::Instant::now();
let result =
collect_btree_v2_records(&data, &header(depth, 0, fanout as u16 - 1, u64::MAX), 8, 8);
assert!(
result.is_err(),
"expected a refusal, got {} records",
result.map_or(0, |r| r.len())
);
assert!(
started.elapsed() < std::time::Duration::from_secs(2),
"took {:?}",
started.elapsed()
);
}
#[test]
fn parse_header() {
let data = build_btree_v2_header(5, 512, 11, 0, 0x1000, 3, 3, 8, 8);
let hdr = BTreeV2Header::parse(&data, 0, 8, 8).unwrap();
assert_eq!(hdr.tree_type, 5);
assert_eq!(hdr.node_size, 512);
assert_eq!(hdr.record_size, 11);
assert_eq!(hdr.depth, 0);
assert_eq!(hdr.root_node_address, 0x1000);
assert_eq!(hdr.num_records_in_root, 3);
assert_eq!(hdr.total_records, 3);
}
#[test]
fn parse_leaf_with_2_records() {
let rec1 = [1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
let rec2 = [11u8, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21];
let leaf = build_leaf_node(5, &[&rec1, &rec2]);
let leaf_offset = 256usize;
let header = build_btree_v2_header(5, 512, 11, 0, leaf_offset as u64, 2, 2, 8, 8);
let mut file_data = vec![0u8; 512];
file_data[..header.len()].copy_from_slice(&header);
file_data[leaf_offset..leaf_offset + leaf.len()].copy_from_slice(&leaf);
let hdr = BTreeV2Header::parse(&file_data, 0, 8, 8).unwrap();
let records = collect_btree_v2_records(&file_data, &hdr, 8, 8).unwrap();
assert_eq!(records.len(), 2);
assert_eq!(records[0].data, rec1.to_vec());
assert_eq!(records[1].data, rec2.to_vec());
}
#[test]
fn invalid_signature() {
let mut data = build_btree_v2_header(5, 512, 11, 0, 0, 0, 0, 8, 8);
data[0] = b'X';
let err = BTreeV2Header::parse(&data, 0, 8, 8).unwrap_err();
assert_eq!(err, FormatError::InvalidBTreeV2Signature);
}
#[test]
fn invalid_version() {
let mut data = build_btree_v2_header(5, 512, 11, 0, 0, 0, 0, 8, 8);
data[4] = 1; // bad version
let err = BTreeV2Header::parse(&data, 0, 8, 8).unwrap_err();
assert_eq!(err, FormatError::InvalidBTreeV2Version(1));
}
#[test]
fn empty_tree() {
let header = build_btree_v2_header(5, 512, 11, 0, 0, 0, 0, 8, 8);
let hdr = BTreeV2Header::parse(&header, 0, 8, 8).unwrap();
let records = collect_btree_v2_records(&header, &hdr, 8, 8).unwrap();
assert!(records.is_empty());
}
#[test]
fn subtree_capacity_matches_libhdf5() {
// A link-name index (11-byte records, 512-byte nodes, 8-byte
// addresses): libhdf5's H5B2__hdr_init gives 45 records per leaf,
// then cum_max_nrec 1 149 at depth 1 and 26 449 at depth 2 — two
// bytes of subtree count in a depth-3 root's child pointers, where
// leaf_max^3 = 91 125 would need three.
let leaf = max_records_leaf(512, 11);
assert_eq!(leaf, 45);
assert_eq!(cum_max_records(512, 11, 8, leaf, 0), 45);
assert_eq!(cum_max_records(512, 11, 8, leaf, 1), 1_149);
assert_eq!(cum_max_records(512, 11, 8, leaf, 2), 26_449);
}
}