security: Tier 4a — bounds-check audit + new dataset-read fuzz target
CI / test (push) Failing after 4s

- Add ensure_len(data, offset, needed) helper to chunked_read.rs,
  data_read.rs, and local_heap.rs (matching the existing btree_v1.rs/
  object_header.rs convention) and use it at every plain-arithmetic
  offset+size bounds check found in these files, closing usize-overflow
  panics reachable from crafted near-usize::MAX offsets/addresses.
- collect_chunk_info: add a depth-limited internal wrapper
  (collect_chunk_info_inner, MAX_CHUNK_BTREE_DEPTH=64) to reject a
  crafted self-referencing/cyclic B-tree v1 chunk index instead of
  recursing unboundedly (stack-overflow DoS).
- read_compound_fields: validate byte_offset+field_size against the
  compound's declared element size before slicing, instead of an
  unguarded out-of-bounds panic on a crafted member offset.
- read_chunked_data/_cached/_sweep/_indexed: guard `ndims - 1` against
  underflow for a degenerate zero-dimension chunked layout.
- copy_chunk_to_output: rewrite all offset/stride arithmetic (both the
  1-D fast path and the general N-D path) to use checked_add/checked_mul,
  skipping an out-of-range row/chunk instead of panicking on overflow.

Add a new cargo-fuzz target, fuzz_dataset_read, that walks every dataset
in a parsed file via the clawhdf5 facade and exercises the contiguous/
chunked/compact raw-data read paths that the existing fuzz_full_file
target doesn't reach. Seeded with the chunked/VDS/compound-relevant test
fixtures plus two crash regressions found during this pass (the
copy_chunk_to_output overflow and the ndims-1 underflow, both fixed
above — this target found real bugs within the first couple of runs).
Not wired into CI (nightly-only, multi-minute runs); documented in
fuzz/README.md as a manual/scheduled check instead. Also fixed the
README's stale rustyhdf5-format naming while touching this file.

Added regression tests for every fix (near-usize::MAX offsets, the
self-referencing B-tree case, the compound byte_offset overrun, the
zero-dim layout, and both copy_chunk_to_output overflow paths) so these
are caught by `cargo test`, not just the fuzz corpus.
This commit is contained in:
Omar Sobh
2026-08-05 13:05:30 -07:00
parent a319405ffc
commit 297ee5ec17
21 changed files with 406 additions and 89 deletions
+28 -6
View File
@@ -16,6 +16,21 @@ pub struct LocalHeap {
pub data_segment_address: u64,
}
/// Checks that `[offset, offset + needed)` fits within `data`, guarding the
/// addition against `usize` overflow from a crafted near-`usize::MAX` offset.
fn ensure_len(data: &[u8], offset: usize, needed: usize) -> Result<(), FormatError> {
if offset
.checked_add(needed)
.is_none_or(|end| end > data.len())
{
return Err(FormatError::UnexpectedEof {
expected: offset.saturating_add(needed),
available: data.len(),
});
}
Ok(())
}
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()) {
@@ -47,12 +62,7 @@ impl LocalHeap {
let ls = length_size as usize;
let os = offset_size as usize;
let total = 8 + ls * 2 + os;
if offset + total > file_data.len() {
return Err(FormatError::UnexpectedEof {
expected: offset + total,
available: file_data.len(),
});
}
ensure_len(file_data, offset, total)?;
if &file_data[offset..offset + 4] != b"HEAP" {
return Err(FormatError::InvalidLocalHeapSignature);
@@ -172,6 +182,18 @@ mod tests {
}
}
#[test]
fn parse_rejects_near_usize_max_offset_without_panicking() {
// Found by fuzzing: `offset + total` overflowed for a crafted
// near-usize::MAX offset.
let file = build_heap_file(0, 100, &["hello"], 8, 8);
let result = LocalHeap::parse(&file, usize::MAX - 4, 8, 8);
assert!(
matches!(result, Err(FormatError::UnexpectedEof { .. })),
"expected a clean UnexpectedEof, got {result:?}"
);
}
#[test]
fn parse_heap_header() {
let file = build_heap_file(0, 100, &["hello", "world"], 8, 8);