fix(format): a chunk that decodes short is an error, not zero-filled

HDF5 stores every chunk at the full chunk size (edge chunks are padded
before filtering, and with "don't filter partial edge chunks" they are
stored raw at full size), so a filter pipeline that decodes to fewer bytes
means a corrupt chunk. Every chunk reader padded it with zeros and
returned it as data. libhdf5 returns the rest uninitialised, or fails when
the filter checks (Blosc with nbytes = 0).

New filters::decompress_chunk_exact decodes and then requires exactly the
chunk size, with the chunk's coordinates in the error
(ChunkedReadError "chunk at [16] decoded to 16 bytes, expected 32"). It
replaces decompress_chunk_masked at every chunk read path: the full read
(sequential and lane-partitioned), the cached read, the sweep read, the
planned-selection read, parallel_read's three decoders and partial_read's
box read. decompress_chunk_masked is unchanged (fractal-heap huge objects
already checked their own size). Blosc also rejects a frame declaring no
data where the chunk size is known.

Tests, each failing with the check disabled: filters and parallel_read
unit tests; h5py_short_decoded_chunk_is_an_error (gzip chunks rewritten
short with write_direct_chunk: 1-D, a 2-D edge chunk, and 40 chunks with
shuffle, read through File full/cached/selection reads, a selection that
avoids the chunk still reads, MmapFile and LazyFile, with and without the
parallel feature); plugin_filters_interop short_decoding_chunks_are_errors
(Blosc nbytes=0 and short, LZF and bzip2 short; the Blosc nbytes=0 case
read as 16 zeros before). The existing don't-filter-partial-edge-chunks
tests still pass. Conformance (tank, 2026-09-26): 573 of 697 ok, and no
file changed class, reader result or first issue against the pre-fix run.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-26 01:22:26 -05:00
co-authored by Claude Opus 5.5
parent 7f52a6f3ba
commit a5bd70216c
7 changed files with 289 additions and 13 deletions
@@ -343,3 +343,77 @@ print("OK")
let want: Vec<u8> = line[990..].iter().flat_map(|v| v.to_le_bytes()).collect();
assert_eq!(tail, want);
}
// ---------------------------------------------------------------------------
// Chunks that decode short
// ---------------------------------------------------------------------------
/// HDF5 stores every chunk at the full chunk size, so a chunk whose filters
/// decode to fewer bytes is corrupt. libhdf5 returns the rest of such a
/// chunk uninitialised (or, for filters that check, fails); clawhdf5 padded
/// it with zeros and returned it as data. Every read path must fail,
/// naming the chunk, and chunks that decode fully must still read.
#[test]
fn h5py_short_decoded_chunk_is_an_error() {
skip_if_no_python!();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("short.h5");
let p = path.display().to_string();
run_python(&format!(
r#"
import h5py, numpy as np, zlib
with h5py.File("{p}", "w") as f:
# 1-D, 4 gzip chunks of 8 i4; chunk 2 (offset 16) inflates to 16 bytes.
ds = f.create_dataset("line", shape=(32,), chunks=(8,), dtype="<i4", compression="gzip")
ds[...] = np.arange(32, dtype="<i4") + 1
ds.id.write_direct_chunk((16,), zlib.compress(np.arange(4, dtype="<i4").tobytes()))
# 2-D with a partial edge chunk; the short chunk is the edge one (8, 4).
g = f.create_dataset("grid", shape=(10, 7), chunks=(4, 4), dtype="<f8", compression="gzip")
g[...] = np.arange(70, dtype="<f8").reshape(10, 7) + 1
g.id.write_direct_chunk((8, 4), zlib.compress(np.ones(3, dtype="<f8").tobytes()))
# 40 chunks (enough for the parallel decoder), one short, shuffle + gzip.
m = f.create_dataset("many", shape=(320,), chunks=(8,), dtype="<i4",
compression="gzip", shuffle=True)
m[...] = np.arange(320, dtype="<i4") + 1
m.id.write_direct_chunk((200,), zlib.compress(bytes(31)))
"#
));
let hyperslab = |start: u64, count: u64| Selection::Hyperslab {
start: vec![start],
stride: vec![1],
count: vec![1],
block: vec![count],
};
let file = File::open(&path).unwrap();
for (name, coords) in [("line", "[16"), ("grid", "[8, 4"), ("many", "[200")] {
let ds = file.dataset(name).unwrap();
let full = || {
if name == "grid" {
ds.read_f64().map(|_| ())
} else {
ds.read_i32().map(|_| ())
}
};
let err = full().expect_err(name).to_string();
assert!(err.contains(coords), "{name}: {err}");
// Cached reads decode the same way; a second read fails too.
assert!(full().is_err(), "{name}");
assert!(ds.read_selection(&Selection::All).is_err(), "{name}");
}
let line = file.dataset("line").unwrap();
assert!(line.read_selection(&hyperslab(14, 4)).is_err());
// A selection that avoids the short chunk still reads.
let want: Vec<u8> = (1..=16i32).flat_map(i32::to_le_bytes).collect();
assert_eq!(line.read_selection(&hyperslab(0, 16)).unwrap(), want);
let many = file.dataset("many").unwrap();
assert!(many.read_selection(&hyperslab(190, 20)).is_err());
// The memory-mapped and lazy readers.
let mm = clawhdf5::MmapFile::open(&path).unwrap();
assert!(mm.dataset("line").unwrap().read_i32().is_err());
assert!(mm.dataset("many").unwrap().read_i32().is_err());
let lazy = clawhdf5::LazyFile::open_mmap(&path).unwrap();
assert!(lazy.dataset("line").unwrap().read_i32().is_err());
assert!(lazy.dataset("grid").unwrap().read_f64().is_err());
}