Merge branch 'feat/p1-plugin-filters' into feat/p1-proof

# Conflicts:
#	crates/clawhdf5/tests/h5py_chunked_read_tests.rs
#	docs/known-issues.md
This commit is contained in:
osobh
2026-09-26 01:37:58 -05:00
27 changed files with 3492 additions and 114 deletions
@@ -381,3 +381,77 @@ with h5py.File("{p}", "r") as f:
let fa = file.dataset("fa").unwrap().read_i32().unwrap();
assert!(fa.iter().copied().eq(0..3 * 70000), "fa");
}
// ---------------------------------------------------------------------------
// 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());
}