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:
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user