A create_dataset(..., chunks=..., maxshape=(None, ...)) file — the ordinary h5py append-able dataset under libver='latest' — used to come back from OxiH5 as a typed NotImplemented. Not anymore.
Today we released OxiH5 0.2.3 — the release that closes six writer roadmap gaps in one pass, teaches the reader the real on-disk shape of an extensible-array chunk index instead of refusing to touch it, and fixes a mis-parsed array datatype plus five crash/OOM classes on crafted input, two of them surfaced by brand-new fuzz targets within seconds of their first run.
No libhdf5. No FFI. No -sys crates. OxiH5 parses and writes the real HDF5 binary format — superblock, object headers, B-trees, heaps, the global heap, the filter pipeline, all eleven datatype classes — using nothing but std.
#![forbid(unsafe_code)] guards oxih5-core; the facade crate carries exactly one documented unsafe block, the mmap call behind open_mmap. It compiles to a single static binary (or WASM) and runs everywhere.
Why OxiH5 0.2.3 is a game changer
Before this release:
- A class-10 array datatype’s dimensions were decoded as 8-byte fields — they’re 4-byte fields in every real version of the class — so an ordinary
(2, 3)array silently came back as[216172782147338240, 72057594037927936]and itsint32base type as a 67-megabyte unsigned integer. No error, just garbage. - The writer had no compound records, no ragged vlen sequences, no array/opaque/bitfield element types, no big-endian or half-precision floats, and no soft/external/hard-alias links. Anything past scalar numeric types and fixed/vlen strings meant falling back to libhdf5.
- Every new-style group written by OxiH5 was old-style symbol-table storage only — there was no way to write the compact or dense link-message groups libhdf5 itself produces past eight members.
- The extensible-array chunk index — the one libhdf5 picks for a chunked dataset with exactly one unlimited dimension — returned
NotImplemented. That’s not an exotic layout; it’s whatcreate_dataset(chunks=..., maxshape=(None, ...))writes by default underlibver='latest'. - A crafted or corrupted file could divide by zero on a zero chunk dimension, panic on a zero-stride hyperslab, overflow a buffer-size multiplication on 32-bit/wasm32, walk an integer-overflowed B-tree offset straight into a slice panic, or OOM the process from a 39-byte VDS mapping block claiming an unbounded block count.
OxiH5 0.2.3 ends all of that:
- Six writer gaps closed: compound records (
create_compound_dataset), ragged vlen sequences (create_vlen_sequence_dataset+create_vlen_i32_dataset/create_vlen_f64_dataset), array/opaque/bitfield element types (create_array_dataset/create_opaque_dataset/create_bitfield_dataset), big-endian datasets and attributes, half-precision floats (both viawrite_dataset_numeric/write_numeric_attr), and soft/external/hard-alias links (create_soft_link/create_external_link/create_hard_link). - New-style link-message groups, compact and dense, with a real fractal-heap writer and a version-2 B-tree name index whose records are sorted by the Jenkins lookup3 hash — exactly what
H5B2__locate_recordexpects — plusset_track_orderfor creation-order preservation. - Extensible-array chunk indexes now decode in full: header, index block, super blocks, unpaged and paged data blocks, both element clients (unfiltered and filtered), and the swizzled (rotated, not swapped) coordinate mapping.
- The array-datatype read bug is fixed, with both real on-disk forms (version 2 with reserved bytes and permutation indices, version 3 without) implemented and a sub-version-2 array reported rather than guessed at.
- Five crash/OOM classes closed, two of them found within seconds by brand-new fuzz targets on their first run (
fuzz_btree_v1_chunk,fuzz_vds). - 987 tests pass (
--all-features; 966 with default features) plus 21 doc tests, across all four crates (~31.1k SLOC).
Technical Deep Dive: what changed under the hood
- The extensible-array index isn’t self-describing — the client is. The unfiltered client (
H5EA_CLS_CHUNK_ID) stores a bare 8-byte chunk address and takes its size from the uncompressed chunk size; the filtered client (H5EA_CLS_FILT_CHUNK_ID) stores the address, a variable-width stored size, and a 4-byte filter mask. A chunk’s position comes fromH5VM_array_offset_preover swizzled coordinates — the unlimited dimension rotated to the front, not swapped with dimension 0 — computed against the dataspace’s maximum dimensions so the numbering survives the dataset growing. Data-block element counts follow thesblk_inforecurrence libhdf5 derives from the header’s own creation parameters, and paged data blocks (per-page checksums, page-init bitmasks) are decoded rather than treated as allocator leftovers. - New-style groups need real metadata checksums, not just the right bytes.
write/checksum.rsimplements the Jenkins lookup3 hash — alsoH5_checksum_metadata— over exactly the byte ranges the fractal heap header, its root direct block, the B-tree v2 header, and its leaf node cover. Pastmax_compact(8 members), the group converts from Link Info + Link messages to a fractal heap indexed by that checksum-verified B-tree, and a converted old-style group keeps its empty B-tree/local-heap/SNOD structures with no symbol-table message — the exact shape libhdf5 leaves behind on conversion. - Every integer that reaches a slice or an allocation from on-disk bytes now goes through
checked_add/checked_mul.btree_v1_chunk::collect‘s old bounds check (off + 24 > file_data.len()) could itself overflow and silently pass a hugeoffthrough to the next slice;parse_hyperslab_v1_blocksused to hand a raw on-disku32block count straight toVec::with_capacity, so a 39-byte crafted VDS mapping block could request a multi-gigabyte allocation before reading a single byte. Both are typed errors now, alongside the vlen/global-heap decoders’n_elems * 16multiplications. chunked.rs(1963 lines) andea_index.rs(819 lines) both split along their natural seams —chunked/{cache,geometry,index,read,slice,tests}.rsandea_index/{header,blocks,elements,tests}.rs— withmod.rsglob re-exporting everything that used to be reachable at the old path. Purely internal; every public item and every test keeps its exact address.
Getting Started
cargo add oxih5
use oxih5::FileWriter;
let path = std::env::temp_dir().join("output.h5");
let mut writer = FileWriter::new();
// A 1-D float64 dataset, chunked and gzip-compressed.
let readings: Vec<f64> = (0..1024).map(|i| i as f64 * 0.5).collect();
writer.write_dataset_f64("readings", &readings, &[readings.len()])?;
writer.set_chunking("readings", &[256])?;
writer.set_deflate("readings", 6)?;
writer.write_string_attr("readings", "units", "volts")?;
// Nested groups are created along the way — no separate "create group" call.
let flags: Vec<i32> = (0..12).collect();
writer.write_dataset_i32("instrument/status/flags", &flags, &[3, 4])?;
writer.write_string_attr("instrument/status", "firmware_version", "2.3.1")?;
writer.build(&path)?;
let file = oxih5::open(&path)?;
let ds = file.dataset("readings")?;
println!("read back {} elements, shape {:?}", ds.as_f64()?.len(), ds.shape);
# Ok::<(), oxih5::OxiH5Error>(())
This is trimmed from the runnable crates/oxih5/examples/write_dataset.rs
(cargo run -p oxih5 --example write_dataset); read_dataset.rs and
oxinetcdf’s write_and_read.rs are the same idea for reading via slices
and hyperslabs, and for NetCDF-4 dimensions/variables/attributes.
What’s New in 0.2.3
- Writer capability wave: compound (record) datasets with caller-declared member offsets, ragged variable-length sequence datasets, array/opaque/bitfield element types, big-endian datasets and attributes, half-precision (
f16) floats via a new round-to-nearest-ties-to-evenf32_to_f16, and soft/external/hard-alias links (a hard alias correctly raises the target’s reference count, matching libhdf5). - New-style link-message groups, compact and dense, with
set_track_order/set_link_storageand a newinterop_fixtures.rsexample whose nine outputs were all confirmed byte-openable by h5py 3.16 / libhdf5 2.0.0. - Extensible-array chunk indexes are read, not reported — 20 unit tests plus 12 integration tests against a new h5py-authored fixture cover every level of the structure.
- The class-10 array datatype read bug is fixed, pinned against the exact message
h5t.array_create(NATIVE_INT32, (2, 3))produces under h5py 3.16 / libhdf5 2.0.0. - Five crash/OOM classes closed: zero chunk dimension, zero-stride hyperslab selections, vlen/global-heap size-multiplication overflow, B-tree v1 chunk address overflow, and an unbounded VDS block count — plus the last production
.expect()removed from the parallel chunk-read path. - Six new fuzz targets (
fuzz_fa_index,fuzz_ea_index,fuzz_btree_v1_chunk,fuzz_filters,fuzz_vds,fuzz_vlen_values) driving parsers directly with raw bytes rather than only through a structurally valid whole file — bringing the fuzz-target count to 10. - Two internal refactors to stay under the workspace’s 2000-line-per-file cap:
chunked.rsandea_index.rsboth split into submodules with the public API unchanged. oxiarc-deflate/oxiarc-szipbumped 0.3.6 → 0.4.1.
Tips
- Compound records take raw row bytes plus explicit member offsets — never a typed struct. That’s deliberate: a packed layout and a C-padded one over the same members are different, valid records, and the offsets say which one your bytes hold.
use oxih5::FileWriter; use oxih5_core::{ByteOrder, CompoundField, Dtype}; let fields = vec![ CompoundField { name: "id".to_string(), offset: 0, dtype: Dtype::Int { size: 4, signed: true, order: ByteOrder::Little } }, CompoundField { name: "value".to_string(), offset: 4, dtype: Dtype::Float { size: 8, order: ByteOrder::Little } }, ]; let mut rows = Vec::new(); for (id, value) in [(1i32, 2.5f64), (3, 4.5)] { rows.extend_from_slice(&id.to_le_bytes()); rows.extend_from_slice(&value.to_le_bytes()); } let path = std::env::temp_dir().join("records.h5"); FileWriter::new() .create_compound_dataset("/events", &fields, 12, &rows, &[2])? .build(&path)?; # Ok::<(), oxih5::OxiH5Error>(()) - Ragged data doesn’t need the general form.
create_vlen_i32_dataset/create_vlen_f64_datasetdo the serialisation for you —create_vlen_sequence_datasetis there for any other fixed base type.use oxih5::FileWriter; let path = std::env::temp_dir().join("ragged.h5"); FileWriter::new() .create_vlen_i32_dataset("/rows", &[vec![1, 2, 3], vec![], vec![10]])? .build(&path)?; # Ok::<(), oxih5::OxiH5Error>(()) - A hard link’s target doesn’t have to exist yet. It’s resolved against the finished layout at
build()time, so you can link before you write.use oxih5::FileWriter; let path = std::env::temp_dir().join("alias.h5"); let mut w = FileWriter::new(); w.create_hard_link("/alias", "/data")?; // target not yet created w.write_dataset_i32("/data", &[1, 2, 3], &[3])?; w.build(&path)?; # Ok::<(), oxih5::OxiH5Error>(()) write_dataset_numericis the one entry point for big-endian andf16. Every per-typewrite_dataset_*method isByteOrder::Little-only; reach forNumericValues/ByteOrder::Big(orNumericValues::F16) when you need the other shapes.use oxih5::{ByteOrder, FileWriter, NumericValues}; let mut w = FileWriter::new(); w.write_dataset_numeric("be", NumericValues::F32(&[1.0, 2.0]), ByteOrder::Big, &[2])?; w.write_dataset_numeric("half", NumericValues::F16(&[0.5, 1.5]), ByteOrder::Little, &[2])?; # Ok::<(), oxih5::OxiH5Error>(())- Reading a half-precision dataset back uses a distinct accessor.
Dataset::as_f32is for binary32 and returnsTypeMismatchon a 2-byte float — useDataset::as_f16(oriter_f16), which yieldsf32values. - If you were relying on the extensible-array
NotImplementedas a signal, drop the workaround. Anylibver='latest'dataset chunked with a single unlimited dimension now reads throughFile::dataset/dataset_slicelike any other chunked layout.
This is the foundation
OxiH5 belongs to NoFFI — the COOLJAPAN initiative retiring every C/C++/Fortran/-sys FFI dependency in the Rust ecosystem with a clean, memory-safe, Pure-Rust implementation. Real consumers are pinned at 0.2.3 across the workspace today:
oxinetcdf— ships in the same workspace, atop the same reader/writer core.- OxiGeo — depends on
oxih5andoxih5-core0.2.3 for its HDF5/NetCDF raster paths. - OxiProj — depends on
oxih50.2.3 for coordinate-reference-system data. - SciRS2 —
scirs2-iodepends onoxih5/oxih5-core0.2.3 directly;scirs2-datasetspulls them in behind its optionalhdf5_iofeature.
Repository: https://github.com/cool-japan/oxih5
Star the repo ⭐ if a chunk index returning NotImplemented on the default append-able h5py layout shouldn’t have been acceptable for this long.
The era of a Pure-Rust HDF5 writer that could only do scalars and strings is over. Compound records, ragged sequences, real links, and a chunk index that actually decodes — that’s 0.2.3.
— KitaSan at COOLJAPAN OÜ August 6, 2026