fix(format): bound what a VL read retains on a crafted global heap

VlResolver kept an owned copy of every object of every heap collection
it parsed, for the whole read. Collections nested inside each other's
object data, 32 bytes apart with each element pointing at a different
one, made retained memory O(elements x file size): 1.58 GB for a 744 KB
file (read_vl_strings did the same before VlResolver). Chaining every
collection's objects into one shared run of tiny objects made parse
time O(elements x objects) as well. libhdf5 refuses these files.

- The cache records where each object lies (GlobalHeapCollection::
  parse_index, new) instead of copying it, and is dropped past a 32 MiB
  budget.
- A collection overlapping one already read is an error: libhdf5 gives
  every collection its own block, so only a crafted file has them.
- parse and parse_index refuse a collection that runs past the end of
  the file and an object that runs past the end of its collection.

tests/vl_heap_bounds.rs measures peak heap use with a counting
allocator: 129 MB and 350 MB live before on its two crafted files (64 KB
and 176 KB), 97 KB and 0.9 MB now. Conformance unchanged at 575 of 697.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-26 08:56:42 -05:00
co-authored by Claude Opus 5.5
parent 8ce6eca34d
commit 41b7837d0a
5 changed files with 453 additions and 61 deletions
+88 -20
View File
@@ -1,7 +1,7 @@
//! HDF5 Global Heap collection parsing.
#[cfg(not(feature = "std"))]
use alloc::vec::Vec;
use alloc::{format, string::String, vec::Vec};
use crate::error::FormatError;
@@ -52,11 +52,42 @@ fn read_length(data: &[u8], offset: usize, length_size: u8) -> Result<u64, Forma
})
}
fn object_overrun_msg(index: u16, size: usize, collection_size: u64) -> String {
format!(
"global heap object {index} ({size} bytes) runs past the end of its \
{collection_size}-byte collection"
)
}
/// Round up to next multiple of 8.
fn pad8(x: usize) -> usize {
(x + 7) & !7
}
/// Where one object of a global heap collection lies in the file, without
/// its data: see [`GlobalHeapCollection::parse_index`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GlobalHeapObjectRef {
/// Object index (1-based; 0 is the free space marker).
pub index: u16,
/// Reference count.
pub reference_count: u16,
/// Offset of the object's data in the file data the collection was
/// parsed from.
pub offset: usize,
/// Size of the object's data in bytes.
pub size: usize,
}
/// A global heap collection's objects, located but not copied.
#[derive(Debug, Clone)]
pub struct GlobalHeapIndex {
/// Total size of this collection including header.
pub collection_size: u64,
/// The objects, in file order.
pub objects: Vec<GlobalHeapObjectRef>,
}
impl GlobalHeapCollection {
/// Parse a global heap collection at the given offset in the file data.
pub fn parse(
@@ -64,6 +95,33 @@ impl GlobalHeapCollection {
offset: usize,
length_size: u8,
) -> Result<GlobalHeapCollection, FormatError> {
let index = Self::parse_index(file_data, offset, length_size)?;
Ok(GlobalHeapCollection {
collection_size: index.collection_size,
objects: index
.objects
.iter()
.map(|o| GlobalHeapObject {
index: o.index,
reference_count: o.reference_count,
data: file_data[o.offset..o.offset + o.size].to_vec(),
})
.collect(),
})
}
/// Locate the objects of the global heap collection at `offset` without
/// copying their data, so a caller can keep many collections indexed
/// for the cost of their object headers.
///
/// The collection must lie inside `file_data`, and every object inside
/// the collection, as libhdf5 lays them out; an object that runs past
/// its collection is an error.
pub fn parse_index(
file_data: &[u8],
offset: usize,
length_size: u8,
) -> Result<GlobalHeapIndex, FormatError> {
// signature(4) + version(1) + reserved(3) + collection_size(length_size),
// padded to a multiple of 8 as libhdf5 lays it out (`H5HG_SIZEOF_HDR`).
// With 8-byte lengths the padding is 0; with 4-byte lengths it is 4,
@@ -81,25 +139,25 @@ impl GlobalHeapCollection {
}
let collection_size = read_length(file_data, offset + 8, length_size)?;
let collection_size_usize =
usize::try_from(collection_size).map_err(|_| FormatError::UnexpectedEof {
expected: u64::MAX as usize,
let collection_end = usize::try_from(collection_size)
.ok()
.and_then(|size| offset.checked_add(size))
.ok_or(FormatError::UnexpectedEof {
expected: usize::MAX,
available: file_data.len(),
})?;
let collection_end =
offset
.checked_add(collection_size_usize)
.ok_or(FormatError::UnexpectedEof {
expected: usize::MAX,
available: file_data.len(),
})?;
if collection_end > file_data.len() {
return Err(FormatError::UnexpectedEof {
expected: collection_end,
available: file_data.len(),
});
}
let mut pos = offset + header_size;
let mut objects = Vec::new();
// Parse objects until we hit index 0 (free space) or run out of space
while pos + 2 <= collection_end {
ensure_len(file_data, pos, 2)?;
let object_index = u16::from_le_bytes([file_data[pos], file_data[pos + 1]]);
if object_index == 0 {
@@ -110,26 +168,36 @@ impl GlobalHeapCollection {
// object_index(2) + reference_count(2) + reserved(4) +
// object_size(length_size), padded to 8 (`H5HG_SIZEOF_OBJHDR`).
let obj_header_size = pad8(8 + length_size as usize);
ensure_len(file_data, pos, obj_header_size)?;
ensure_len(&file_data[..collection_end], pos, obj_header_size)?;
let reference_count = u16::from_le_bytes([file_data[pos + 2], file_data[pos + 3]]);
let object_size = read_length(file_data, pos + 8, length_size)? as usize;
let object_size = usize::try_from(read_length(file_data, pos + 8, length_size)?)
.map_err(|_| FormatError::Overflow("global heap object size".into()))?;
pos += obj_header_size;
ensure_len(file_data, pos, object_size)?;
let data = file_data[pos..pos + object_size].to_vec();
if pos
.checked_add(object_size)
.is_none_or(|end| end > collection_end)
{
return Err(FormatError::VlDataError(object_overrun_msg(
object_index,
object_size,
collection_size,
)));
}
objects.push(GlobalHeapObject {
objects.push(GlobalHeapObjectRef {
index: object_index,
reference_count,
data,
offset: pos,
size: object_size,
});
// Advance past data + padding to 8-byte boundary
pos += pad8(object_size);
pos = pos.saturating_add(pad8(object_size));
}
Ok(GlobalHeapCollection {
Ok(GlobalHeapIndex {
collection_size,
objects,
})