diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 1a7cdde..cfd9624 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -22,6 +22,9 @@ jobs:
run: rustup component add rustfmt clippy
- name: Install thumbv7em-none-eabihf target
run: rustup target add thumbv7em-none-eabihf
+ - name: Install wasm32-unknown-unknown target
+ # ci-test.sh builds the reader and clawhdf5-wasm for the browser.
+ run: rustup target add wasm32-unknown-unknown
- name: Install Python interop dependencies
# The interop suites used to skip silently when python3/h5py were
# missing, so they never ran in CI. Install them and make a missing
@@ -31,12 +34,18 @@ jobs:
# cmake builds libz-ng-sys for the opt-in `fast-deflate` (zlib-ng)
# steps in ci-test.sh; rust:latest does not ship it. The default
# build (pure-Rust zlib-rs) does not need it.
- apt-get install -y --no-install-recommends python3 python3-venv cmake
+ # hdf5-tools: h5ls/h5stat/h5dump/h5diff, which the h5rs
+ # (clawhdf5-tools) interop tests compare against.
+ apt-get install -y --no-install-recommends python3 python3-venv cmake hdf5-tools
python3 -m venv /opt/interop
/opt/interop/bin/pip install --no-cache-dir h5py numpy netCDF4 xarray hdf5plugin
echo "/opt/interop/bin" >> "$GITHUB_PATH"
- name: Show interop library versions
- run: /opt/interop/bin/python -c "import h5py, netCDF4, hdf5plugin; print('h5py', h5py.__version__, 'HDF5', h5py.version.hdf5_version, 'netCDF4', netCDF4.__version__, 'hdf5plugin', hdf5plugin.version)"
+ # h5dump's version too: the h5rs dump test requires its exact output
+ # (checked against Debian's 1.14.5 in rust:latest and 1.14.6).
+ run: |
+ /opt/interop/bin/python -c "import h5py, netCDF4, hdf5plugin; print('h5py', h5py.__version__, 'HDF5', h5py.version.hdf5_version, 'netCDF4', netCDF4.__version__, 'hdf5plugin', hdf5plugin.version)"
+ h5dump --version
- name: Run CI script
env:
# Name the interpreter outright rather than relying on $GITHUB_PATH
diff --git a/BENCHMARKS.md b/BENCHMARKS.md
index f6e6375..c105698 100644
--- a/BENCHMARKS.md
+++ b/BENCHMARKS.md
@@ -482,6 +482,131 @@ The rows and columns of the uncompressed layouts are within 20% (chunked
column 0.45 -> 0.49 ms, contiguous column 2.55 -> 2.61 ms). This run does not
explain the slower windows.
+## Concurrent reads
+
+### Results (2026-09-26, tank)
+
+Measured on tank (AMD Ryzen 7 7800X3D, 8 cores / 16 threads, 61 GiB, Linux
+7.0) at commit `91644d8`, load average 1.84 when the run started (the
+1-minute figure rose to 3.7 during the runs; that is mostly the benchmark's
+own threads). Warm page cache. clawhdf5 2.7.0 (workspace), h5py 3.16.0 on
+HDF5 2.0.0. Commands exactly as in the **Run** box below; files at their
+defaults (64 datasets of 16384 x 1024 `f32`, 64 MiB each; deflate chunks
+256 x 256, level 4). MB/s is decoded data, the median of the repetitions;
+eff is scaling efficiency against the same tool's 1-thread row.
+
+Each read decoding on its calling thread (`--decode-threads 1`, like h5py):
+
+| layout | mode | threads | clawhdf5 MB/s (eff) | h5py threads MB/s (eff) | h5py processes MB/s (eff) |
+|---|---|---:|---:|---:|---:|
+| deflate | distinct | 1 | 421 (1.00) | 433 (1.00) | 421 (1.00) |
+| deflate | distinct | 4 | 890 (0.53) | 428 (0.25) | 1651 (0.98) |
+| deflate | distinct | 16 | 880 (0.13) | 427 (0.06) | 4424 (0.66) |
+| deflate | same | 1 | 151 (1.00) | 130 (1.00) | 129 (1.00) |
+| deflate | same | 4 | 490 (0.81) | 129 (0.25) | 497 (0.96) |
+| deflate | same | 16 | 1244 (0.52) | 128 (0.06) | 1402 (0.68) |
+| contiguous | distinct | 1 | 2495 (1.00) | 9789 (1.00) | 9169 (1.00) |
+| contiguous | distinct | 16 | 8083 (0.20) | 8096 (0.05) | 12272 (0.08) |
+| contiguous | same | 1 | 624 (1.00) | 5022 (1.00) | 5172 (1.00) |
+| contiguous | same | 16 | 4778 (0.48) | 4411 (0.05) | 37138 (0.45) |
+
+With the default rayon pool decoding inside each read, deflate `distinct`
+is 912 MB/s at 1 thread (2.1x h5py) and 2824 MB/s at 16 (6.6x h5py threads,
+0.64x h5py processes); the other rows are within a few percent of the table
+above. Full tables (2, 4, 8 threads, both decode modes) come from
+`compare_concurrent_read.py` on the JSON files.
+
+What this shows:
+- **h5py threads do not scale** (flat at about 430 MB/s on deflate, every
+ thread count): libhdf5's global lock.
+- **clawhdf5 threads on one `File` do, for hyperslab reads of compressed
+ data:** 1244 MB/s at 16 threads, 9.7x h5py threads and 0.89x h5py
+ processes, without a process pool.
+- **Where clawhdf5 is behind** (open performance bugs, see
+ `docs/known-issues.md`):
+ - *Full reads of chunked datasets stop scaling at about 4 threads*
+ (about 880 MB/s) while h5py processes reach 4424 MB/s. Hyperslab
+ reads, which bypass the `File`'s chunk cache, keep scaling, so the
+ cache (one mutex and one 16 MiB budget per `File`, thrashed by 64 MiB
+ datasets) is the suspect.
+ - *Contiguous reads are slow*: 2.5 GB/s for a single-threaded full read
+ against h5py's 9.8 GB/s (0.25x), and 0.12x for 256 x 256 hyperslabs.
+ Threads close the gap (about 1.0x h5py at 16), but single-thread
+ contiguous I/O is a real deficit.
+
+The question: libhdf5's threadsafe build serialises every API call under one
+global mutex, and h5py holds a global lock around every call too, so threads
+reading through h5py cannot decode in parallel; h5py users scale with
+processes. A clawhdf5 `File` is `Send + Sync`, and nothing on the read paths
+this harness uses (`read_f32`, `read_f32_selection`) takes a library-wide
+lock: the one mutex is the `File`'s chunk cache (keyed per dataset), taken by
+full reads of chunked datasets for each chunk's O(1) lookup and insert, never
+across a decode; hyperslab reads do not use the cache. How does
+decoded throughput scale with threads on one open file, against h5py threads
+and h5py processes on the same files?
+
+Workload (`crates/clawhdf5-bench/src/bin/concurrent_read.rs`; the h5py script
+mirrors it): `
/deflate.h5` and `/contiguous.h5`, each with 64 `f32`
+datasets of 64 MiB decoded (`[16384, 1024]`; the deflate file chunked
+`256 x 256`, level 4), written by clawhdf5 on first use and reused while
+`manifest.json` matches. The data is a slowly varying ramp plus 8 bits of
+noise per element, every value exact in `f32`, so both harnesses check what
+they read; it deflates about 3.1x (128 MiB -> 40.7 MiB for two 64 MiB
+datasets). For each layout and thread count
+(1, 2, 4, 8, 16; fixed total work per repetition, split among the threads):
+
+- `distinct`: every dataset read in full once, thread `t` taking datasets
+ `t, t + T, ...`;
+- `same`: 1024 random `256 x 256` hyperslabs of `d00` in total, from a seeded
+ splitmix64 stream that both harnesses generate identically.
+
+Reported per row: MB/s of decoded (selected) data from the median of the
+repetitions, and scaling efficiency `MB/s(T) / (T x MB/s(1))`. Each worker
+times itself from a start barrier; a repetition spans the earliest start to
+the latest finish. Page cache: warm by default (each file is read once before
+timing); `--cold` evicts the files with `posix_fadvise(POSIX_FADV_DONTNEED)`
+before every repetition (no root needed; best effort). clawhdf5 opens one
+`File` per repetition, shared by all threads; h5py threads share one
+`h5py.File`; h5py processes (spawned before timing) each open the file inside
+the timed region.
+
+Decode inside a single clawhdf5 read is itself parallel in this binary
+(clawhdf5-format's `parallel` feature, enabled here through clawhdf5-agent;
+it is off in the facade's default features), so a 1-thread clawhdf5 full read
+of the deflate file already uses the whole rayon pool. Run both
+`--decode-threads 1` (each read decodes on its calling thread, like h5py —
+this isolates the API's own scaling) and the default pool.
+
+> **Run** (from the repository root). The default files take about 5.4 GiB
+> of disk (4 GiB contiguous + about 1.3 GiB deflate). Generating them is
+> memory-hungry because `FileBuilder` holds a whole file in memory: peak RSS
+> was 676 MB for `--datasets 2 --mib 64` (2026-09-25, tank,
+> `/usr/bin/time -f %M`), about 5x one file's decoded size, so expect about
+> 21 GB at the defaults (once; later runs reuse the files). Put `--dir` on a
+> real disk, not tmpfs, if `--cold` is to mean anything.
+>
+> ```bash
+> DIR=/path/on/disk/concurrent-read
+> BENCH=crates/clawhdf5-bench/scripts
+> PY=.venv/bin/python # h5py 3.16 / HDF5 2.0 in this repo
+> cargo build --release -p clawhdf5-bench --bin concurrent_read
+> B=target/release/concurrent_read
+> $B --dir $DIR --json claw-pool.json # generates on first run
+> $B --dir $DIR --decode-threads 1 --json claw-1.json
+> $PY $BENCH/concurrent_read_h5py.py --dir $DIR --executor threads --json h5py-threads.json
+> $PY $BENCH/concurrent_read_h5py.py --dir $DIR --executor processes --json h5py-procs.json
+> $PY $BENCH/compare_concurrent_read.py claw-1.json h5py-threads.json h5py-procs.json
+> $PY $BENCH/compare_concurrent_read.py claw-pool.json h5py-threads.json h5py-procs.json
+> ```
+>
+> Cold page cache: add `--cold` to every harness command. Smoke test (seconds):
+> `$B --dir /tmp/cr --datasets 4 --mib 1 --threads 1,2,4 --slabs 16 --reps 1`
+> and the same `--threads/--slabs/--reps` to the h5py script.
+
+Other flags (both harnesses): `--threads`, `--reps`, `--slab`, `--slabs`,
+`--seed`, `--modes distinct,same`, `--layouts deflate,contiguous`; sizes
+(`--datasets`, `--mib`) only on the Rust harness, which writes the files.
+
## Search harness baseline (v2.3.0)
Produced by `cargo run --release -p clawhdf5-bench --bin search_harness -- --full`
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1fc429d..ef4bd2f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,57 @@
## Unreleased
+### Plugin filters (2026-09-26)
+- **LZF, bitshuffle, bzip2 and Blosc read and write, in pure Rust.** Files
+ written by h5py with `compression="lzf"`, or with hdf5plugin's
+ `Bitshuffle`, `BZip2` and `Blosc`, failed with `UnsupportedFilter`. New
+ `clawhdf5-format`/`clawhdf5` features: `lzf` (32000, **on by default**, no
+ dependencies), `bitshuffle` (32008: transpose only, LZ4 and Zstandard
+ modes), `bzip2` (307), `blosc` (32001: Blosc 1 frames with BloscLZ,
+ LZ4/LZ4HC, Snappy, Zlib and Zstandard codecs and byte/bit shuffle;
+ BloscLZ is decoded by a port of c-blosc 1.21's decoder, and cannot be
+ written), and `plugin-filters` for all four. None compiles C: Zstandard is
+ ruzstd, bzip2 is libbz2-rs-sys. Write with `DatasetBuilder::with_lzf()`,
+ `with_bitshuffle(..)`, `with_bzip2(..)`, `with_blosc(..)` or
+ `with_plugin_filter(PluginFilter::..)`; `ChunkOptions` gains a `plugin`
+ field (**breaking** for code that builds `ChunkOptions` with a struct
+ literal and no `..Default::default()`). Tested both ways against h5py 3.16
+ + hdf5plugin 7.1 over 1-3-D shapes with partial edge chunks, 1-8-byte
+ types in both byte orders and incompressible data
+ (`crates/clawhdf5/tests/plugin_filters_interop.rs`). Conformance: 573 of
+ 697 files ok (was 569) — h5ex_d_lzf/bshuf/bzip2/blosc.
+- **Filter registry.** Filters are looked up by ID in
+ `clawhdf5_format::filter_registry` instead of a `match`: the built-in
+ table (per build), then codecs registered at run time with
+ `register_filter(id, codec)` — a decoding closure or a `FilterCodec` that
+ can also encode. Built-in IDs cannot be overridden; a registered decoder's
+ output is held to the chunk-size bound. Unknown IDs still fail with
+ `UnsupportedFilter(id)`, whose message now names known filters and the
+ missing feature ("unsupported filter: 32026 (Blosc2, not implemented by
+ clawhdf5)").
+- **Not implemented:** Blosc2 (32026) and ZFP (32013) remain a clear error.
+- **Wrong data: a chunk that decodes short read as zeros** (pre-existing, every
+ filter). HDF5 stores every chunk at the full chunk size, so a filter
+ pipeline that decodes to fewer bytes means a corrupt chunk; every chunk
+ reader (full, cached, selection, parallel, partial) padded it with zeros.
+ It is now an error naming the chunk ("chunk at [16] decoded to 16 bytes,
+ expected 32"), via the new `filters::decompress_chunk_exact`. libhdf5
+ returns the rest of such a chunk uninitialised, or fails when the filter
+ checks. A Blosc frame declaring no data for a non-empty chunk is an error
+ too. Legitimate edge chunks are unaffected (they are stored full-size,
+ filtered or not); conformance is unchanged at 573 of 697, with no file
+ changing class.
+- **Crash: a hostile Blosc chunk panicked** in builds with overflow checks
+ (debug builds, `cargo test`, `maturin develop`): a frame size below the
+ 16-byte header underflowed. It is now an error. Every new decoder (LZF,
+ bitshuffle, bzip2, Blosc/BloscLZ) is fuzzed with random and mutated frames
+ in the unit tests.
+- **`register_filter(32023, ..)` works with the `pcodec` feature.** 32023 is
+ Granular BitRound's ID; the built-in entry there only reads clawhdf5
+ <= 2.7.0's pcodec chunks (filter name `"pcodec"`), so a registered codec now
+ handles every other chunk with that ID, and writes. It was refused as
+ "built in".
+
### Upgrade Notes
- **HDF5 correctness audit (2026-09-25).** A sweep of 686 public files (the
libhdf5 test files, the HDF Group's CVE reproducers, pyfive, netcdf-c,
@@ -112,6 +163,65 @@
`quantized_index = false`, or pass `create --f32-index` to the CLI, to opt
out. The CLI's `--quantized-index` is still accepted but is now a no-op.
+### Tools
+- New crate **`clawhdf5-tools`** with the binary **`h5rs`**: HDF5
+ command-line tools without libhdf5, built only on the `clawhdf5` facade
+ and `clawhdf5-format` (no C, so it also builds as a static musl binary).
+ - `h5rs ls [-r] [-v] FILE[/path]` lists objects like h5ls (its first two
+ columns are h5ls's text on the test files) plus the datatype; `-v` adds
+ address, link count, layout and chunk index, chunk size, storage,
+ filters, datatype and attributes.
+ - `h5rs dump [--json] [-A] [-p] [-d PATH] FILE` prints DDL text that is
+ byte-identical to h5dump 1.14.6's (and to Debian's 1.14.5, which CI
+ uses) on the test files (all layouts and
+ chunk indexes, v1/v2 groups, compound, enum, strings, links, named
+ types, attributes; null-padded strings show their NULs at any depth),
+ or JSON in the HDF Group's hdf5-json layout (schema in the crate
+ README). Nested compounds print inline and `long double` values as
+ errors (exit 1); both are listed in the README.
+ - `h5rs stat FILE` reports h5stat's object, link, rank, layout, filter,
+ attribute, raw-data and file-size figures (equal to h5stat's on the test
+ files); metadata space is one figure, not broken down.
+ - `h5rs diff [-r] [-q] [-n N] [-d D] [-p R] [--follow-symlinks] A B [OBJ1
+ [OBJ2]]` (option names as h5diff's: `-c` is `--compare`, the count is
+ `-n`/`--count=N`) compares objects, kinds, datatypes, shapes, attributes, values and link
+ targets; exit status 0/1/2 as h5diff's. Soft links are compared by
+ target path, as h5diff's default, or with `--follow-symlinks` by the
+ objects they lead to (external links are never followed). Every path is
+ compared, including every name of a hard-linked object and the members
+ of a hard-linked group; with a `-d`/`-p` tolerance, integers are
+ compared exactly in integer arithmetic (no loss above 2^53), and a `-p`
+ below the f64 epsilon compares exactly, as h5diff's. Objects that cannot
+ be compared count as a difference (h5diff exits 0 for them), and NaN
+ equals NaN.
+ - `h5rs check [--data] FILE` is a structural validator: it walks every
+ object, parses every header message, verifies the checksums of every
+ version 2+ structure it meets (superblock, object headers and
+ continuation chunks, v2 B-tree nodes, fractal heap headers and — which
+ the library's reads do not — every direct and indirect heap block, and
+ extensible/fixed array chunk indexes), checks each chunk index against
+ its dataset (aligned, in-extent, unique, plausibly sized chunks), and
+ that raw data lies inside the file without overlaps. Every problem is
+ printed with its address; exit 1 when there are any. libhdf5's h5check
+ reads only the 1.8 format. On the conformance corpus it passes all 418
+ files that both clawhdf5 and h5py read in full, and `check --data` flags
+ 134 of the 150 CVE and fuzzer files of the `cve_hdf5` corpus (tank,
+ 2026-09-26). `--data` also follows variable-length data into its global
+ heap collections and reports a damaged one at its address. It inherits
+ the library's tolerance, though: 9 of the 16 it passes are files h5dump
+ 1.14.6 rejects (see `docs/known-issues.md`, header checks).
+ - Values over `--max-bytes` (default 1 GiB) are reported instead of read;
+ a panic is caught and reported as an internal error (exit 3).
+ `scripts/h5rs-fuzz.sh` runs every subcommand over a corpus (default the
+ CVE reproducers, optionally with byte-flipped copies) with overflow
+ checks, a timeout and a memory limit, and fails on any panic, crash or
+ hang; `scripts/h5rs-check-ok-files.sh` runs `check --data` over the
+ fully-read conformance files.
+ - Because the library does not verify fractal heap block checksums when
+ it reads a dense group's links or dense attributes, `h5rs` verifies a
+ heap's blocks before reading from it and refuses a damaged one, as
+ libhdf5 does, instead of printing what the damaged block holds.
+
### Signing
- `clawhdf5-agent`: **Ed25519-signed checkpoints** — the README's
"cryptographically verifiable memory", now true. With
@@ -195,8 +305,22 @@
README claimed but nothing measured.
- `footprint_bench` reports whether it built `float16` or `f32` stores and
takes `--f32`; it had kept printing "f32" after the default changed.
+- New `concurrent_read` harness, with an h5py counterpart
+ (`crates/clawhdf5-bench/scripts/concurrent_read_h5py.py`, threads or
+ processes) and `compare_concurrent_read.py`: decoded read throughput and
+ scaling efficiency at 1-16 threads on one open file, full reads of distinct
+ datasets and random hyperslabs of one dataset, deflate and contiguous, warm
+ or `--cold` page cache, JSON output. Not yet measured — `BENCHMARKS.md`
+ ("Concurrent reads") has the commands and no numbers.
### Interop
+- **h5py could not open chunked datasets we wrote with a chunk dimension
+ from 65 536 to 16 777 215.** A version-4 layout must store its chunk
+ dimensions in the fewest bytes that hold the largest (3 for 70 000);
+ the writer rounded 3 up to 4, and HDF5 2.0.0 (h5py 3.16) refuses that
+ ("stored chunk dimension encoding length does not match value calculated
+ from chunk dimensions"). Newer libhdf5 and clawhdf5 read those files; new
+ files use the exact width. Test: `we_write_chunk_dimensions_in_the_fewest_bytes`.
- **Conformance sweep in the repo** (`conformance/`, report in
`CONFORMANCE.md`). `conformance/run.sh` fetches eight public HDF5 corpora
pinned by commit (libhdf5's test files, the HDF Group's CVE reproducers,
@@ -280,6 +404,32 @@
infinity; batches are all or nothing. CLI: `create --float16`. See
`BENCHMARKS.md`, "float16 embedding storage".
+### Browser (WebAssembly)
+- **New crate `clawhdf5-wasm`:** the reader compiled to
+ `wasm32-unknown-unknown` with a wasm-bindgen JavaScript API —
+ `open(bytes)`, `list`, `info`, `attrs`, `read`, `readHyperslab` — returning
+ typed arrays of the stored width (`BigInt64Array` for 64-bit integers),
+ string arrays for strings and enums, and a thrown `Error` for types with no
+ typed-array form (compound, reference, opaque, VL sequences) or filters the
+ build lacks (Zstd, SZIP). Read-only; the file is held in memory.
+- **`examples/wasm-viewer/`:** a drop-a-file HDF5/NetCDF-4 viewer page (tree,
+ type/shape/attributes, values paged as hyperslabs; `?file=&path=` opens a
+ URL). `build.sh` produces the package; `test/run.sh` checks it under Node
+ (251 checks against values h5py/libhdf5 read back from an h5py- and a
+ netCDF4-written file) and renders the page in headless Chromium. Size,
+ measured 2026-09-26 on tank (`gzip -9 -n`): 627,501 B of wasm, 191,639 B
+ gzipped, plus 21,826 B (4,487 B) of JS glue; h5wasm 0.10.3's embedded wasm
+ is 3,544,184 B (907,096 B) — full libhdf5, so not equal functionality. See
+ `examples/wasm-viewer/README.md`.
+- The facade's read path already built for `wasm32-unknown-unknown` (nothing
+ needed gating); `ci-test.sh` now builds it (`--no-default-features`) and
+ lints `clawhdf5-wasm` for that target, and CI installs the target. The Node
+ and browser tests run in `ci-test.sh` only where `node` and `wasm-bindgen`
+ exist (not the CI container); CI checks the same expectations natively
+ (`clawhdf5-wasm`'s `h5py_interop` test).
+- `Dataset::raw_datatype()` (facade) returns the full stored datatype, for
+ decoding `read_selection` bytes with `clawhdf5_format::data_read`.
+
### Build
- **Pure-Rust default.** `clawhdf5-format`, `clawhdf5-filters` and the
`clawhdf5` facade default to the `zlib-rs` deflate backend; `fast-deflate`
@@ -296,6 +446,84 @@
- CI keeps zlib-ng building and tested; the arm64 job no longer needs cmake.
### Correctness
+- **Corrupt files libhdf5 refuses are now refused instead of read.** On the
+ HDF Group's CVE reproducers, 18 objects that libhdf5 (HDF5 2.0, through
+ h5py) refuses to open were read by clawhdf5, some as wrong data (a chunk
+ dimension of 0 read as all fill values; chunks read at offsets off the
+ chunk grid). The
+ parser now makes libhdf5's checks, with libhdf5's error text:
+ - object headers (`FormatError::InvalidObjectHeader`): every message of a
+ v1 chunk is read and more than the prefix's count is refused (the rest
+ used to be dropped); v1 message sizes must be multiples of 8 and a v1
+ chunk cannot end in a gap; a message running past its chunk is an error
+ (it used to end the chunk quietly); contradictory message flags; a
+ message of a class that cannot be shared flagged shareable; a
+ reference-count message in a v1 header; malformed continuation,
+ reference-count and modification-time messages; unknown v2 header
+ flags.
+ - datatypes (`FormatError::InvalidDatatype`): size 0; integer bits outside
+ the type; float exponent/mantissa outside the type, empty or
+ overlapping; a compound with no members, a member outside the compound,
+ a duplicate name or overlapping members; an enum whose size differs from
+ its base type's or with an empty name; array rank over 32 or a zero
+ dimension; an opaque tag length that is not a multiple of 8; in a
+ version-1 (unchecksummed) header, a numeric type that leaves more than
+ half its bits unused (`Datatype::parse_in_header`,
+ `Datatype::check_unused_bits`). A v1/v2 float's class bit 6 was read as
+ VAX byte order; libhdf5 ignores it before version 3, and so does this.
+ The overlap check measures each earlier member by its stored size, as
+ libhdf5 does, so a variable-length member (4 + offset size + 4 bytes)
+ in a file with 4-byte offsets does not overlap the member after it.
+ - chunked layouts (`FormatError::InvalidChunkDimensions`): a zero chunk
+ dimension, a chunk rank that does not match the dataspace, a chunk of
+ 4 GiB or more indexed by a v1 B-tree (layout version 3 or earlier;
+ 0x80000000-sized chunks hung the reader — layout versions 4 and 5 allow
+ larger chunks, and HDF5 2.0 writes them), an element size in the
+ layout that differs from the datatype's stored size (the chunks were
+ laid out with the wrong element size), and v1 B-tree
+ chunk keys whose offsets are not multiples of the chunk dimensions,
+ including the keys that only bound a node
+ (`chunked_read::collect_chunk_info_checked`).
+ - truncated files (`FormatError::TruncatedFile`, `Superblock::data_end`):
+ a file shorter than the end of file its superblock records is refused
+ ("truncated file"), and nothing past that end is read. Every reader
+ does this: `File`, `LazyFile` and `MmapFile`, and in `clawhdf5-io`
+ `NativeVol` (at `open`, and on read for `from_bytes`),
+ `AsyncHDF5File` and `MpiVol` (the MPI path is not built in CI: it
+ needs an MPI installation).
+ - the writer: `FileWriter::finish()` / `FileBuilder::finish()` refuse a
+ datatype the reader would refuse (`FormatError::SerializationError`,
+ "datatype cannot be written: ..."), such as a compound with a repeated
+ field name or no fields, or an enum member with an empty name
+ (`CompoundTypeBuilder` and `EnumTypeBuilder` build them without
+ complaint). These were never valid HDF5 — h5py refuses them — and
+ clawhdf5 wrote them, which made files it could not read back.
+
+ Checks newer libhdf5 releases make but HDF5 2.0 does not (bit-field
+ offsets, the variable-length kind, array sizes) are left out, so files
+ h5py opens still open. Two libhdf5 checks are skipped on purpose because
+ clawhdf5 up to v2.7.0 wrote files that fail them without being wrong:
+ the sign bit of every float at position 63, and a size-0 string type for
+ an empty-string attribute (new fixtures written by v2.7.0 guard this).
+ Conformance: 569 -> 571 ok (h5stat_err_refcount.h5,
+ h5clear_fsm_persist_less.h5), and 17 of the 18 CVE objects now fail as in
+ libhdf5 (see `docs/known-issues.md` for the one left), as do 10 files
+ h5py refuses as truncated. Tests:
+ `header_validation_interop.rs` (h5py writes, the test damages a copy, both
+ libraries must refuse it), `legacy_writer_files.rs`, and unit tests next
+ to each check. **Breaking (format crate):** `FormatError` gained
+ `InvalidObjectHeader`, `InvalidDatatype`, `InvalidChunkDimensions` and
+ `TruncatedFile`; an exhaustive `match` on it needs the new arms.
+- **Chunked datasets whose chunk dimensions take 3, 5, 6 or 7 bytes did not
+ open.** A version-4 layout (`libver="latest"`) stores each chunk dimension
+ in the fewest bytes that hold the largest one, so a chunk dimension from
+ 65 536 to 16 777 215 (e.g. h5py `chunks=(70000,)`) takes 3 bytes; only 1, 2,
+ 4 and 8 were read, and the rest failed with `UnexpectedEof`. Widths 1-8 are
+ read now, and 0 or more than 8 is refused as libhdf5 refuses it. A width
+ larger than needed is accepted: HDF5 2.0.0 refuses one ("stored chunk
+ dimension encoding length does not match"), but libhdf5 since
+ HDFGroup/hdf5@e124c36 (2026-06-05) reads it, and clawhdf5 itself wrote such
+ layouts.
- `clawhdf5-format` VDS: variable-length and reference data from a source in
another file is refused. Those elements are global-heap IDs and object
addresses in the source file; copied into the virtual dataset they would
diff --git a/CLAUDE.md b/CLAUDE.md
index 3f8d9c1..1cb7aba 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -5,13 +5,13 @@ Pure-Rust HDF5 format implementation with HNSW vector search, WAL-backed persist
## Architecture
-Cargo workspace with 16 crates under `crates/` (plus `libaec-sys`, an internal FFI bindings crate for the optional `szip` feature):
+Cargo workspace with 18 crates under `crates/` (plus `libaec-sys`, an internal FFI bindings crate for the optional `szip` feature):
| Crate | Role |
|-------|------|
| `clawhdf5-format` | HDF5 binary spec parser (superblock, B-tree, heap) — also holds shared type definitions and physical constants |
| `clawhdf5-io` | Read/write implementation |
-| `clawhdf5-filters` | Deflate backends (zlib-rs, zlib-ng, Apple Compression); the HDF5 filter pipeline and the other codecs (LZ4, Zstd, SZIP, N-Bit, scale-offset, pcodec) live in `clawhdf5-format`. No Blosc. |
+| `clawhdf5-filters` | Deflate backends (zlib-rs, zlib-ng, Apple Compression); the HDF5 filter pipeline, the filter registry (`clawhdf5_format::filter_registry`) and the other codecs (LZ4, Zstd, SZIP, N-Bit, scale-offset, pcodec, and the pure-Rust plugin filters LZF, bitshuffle, bzip2, Blosc 1) live in `clawhdf5-format`. No Blosc2 or ZFP. |
| `clawhdf5-derive` | Proc-macro derive for HDF5-serializable structs |
| `clawhdf5` | Main facade crate |
| `clawhdf5-netcdf4` | NetCDF-4 compatibility layer |
@@ -21,9 +21,11 @@ Cargo workspace with 16 crates under `crates/` (plus `libaec-sys`, an internal F
| `clawhdf5-accel` | CPU SIMD acceleration path |
| `clawhdf5-migrate` | SQLite → HDF5 agent-memory migration |
| `clawhdf5-android` | Android JNI bindings |
-| `clawhdf5-cli` | Command-line interface |
+| `clawhdf5-cli` | Command-line interface (agent memory) |
+| `clawhdf5-tools` | `h5rs`: pure-Rust HDF5 tools — `ls`, `dump` (DDL / hdf5-json), `stat`, `diff`, `check` (structural + checksum validator) |
| `clawhdf5-napi` | Node.js native addon bindings |
| `clawhdf5-py` | PyO3 Python bindings |
+| `clawhdf5-wasm` | WebAssembly (wasm-bindgen) reader for the browser; demo in `examples/wasm-viewer/` |
| `clawhdf5-bench` | Benchmark suite |
## Key Features
@@ -149,6 +151,14 @@ Cargo workspace with 16 crates under `crates/` (plus `libaec-sys`, an internal F
`MemorySource` for this bookkeeping is inferred from the caller-supplied
`source_channel` string (a heuristic, not an authenticated trust boundary).
- GPU-accelerated vector distance computation (`clawhdf5-gpu`, wgpu); HDF5 I/O itself is CPU-only
+- Browser: `clawhdf5-wasm` (wasm-bindgen, read-only, file held in memory;
+ no Zstd/SZIP since they link C) and the `examples/wasm-viewer/` page.
+ `examples/wasm-viewer/test/run.sh` builds the package (needs the
+ `wasm-bindgen` CLI at the crate's exact version) and tests it under Node
+ and headless Chromium (a Playwright download in `~/.cache/ms-playwright`
+ on tank); the CI container has neither, so CI runs the native
+ `clawhdf5-wasm` `h5py_interop` test on the same fixture. Size numbers are
+ in the example's README.
- Python and Node.js bindings for cross-language use
- NetCDF-4 compatibility for scientific data interop
@@ -188,6 +198,16 @@ cargo run -p clawhdf5-cli -- --help
# create, save, search, recall, stats, flush-wal, agents-md, export, snapshot subcommands
```
+### HDF5 tools (`h5rs`, crate `clawhdf5-tools`)
+```bash
+cargo run -p clawhdf5-tools -- ls -r file.h5 # also dump [--json], stat, diff, check
+bash scripts/h5rs-fuzz.sh # every subcommand over the CVE corpus: no panic/crash/hang
+bash scripts/h5rs-check-ok-files.sh --data # check passes every fully-read conformance file
+```
+Its interop tests compare against h5ls/h5stat/h5dump/h5diff (Debian
+`hdf5-tools`, installed in CI); `dump` must stay byte-identical to h5dump on
+the test files.
+
### Python bindings
```bash
cd crates/clawhdf5-py
diff --git a/CONFORMANCE.md b/CONFORMANCE.md
index aca1b5d..fd6fbed 100644
--- a/CONFORMANCE.md
+++ b/CONFORMANCE.md
@@ -13,8 +13,8 @@ fatal. This file is generated by `conformance/run.sh`; do not edit it by hand.
| | |
|---|---|
-| date | 2026-09-26 03:46 UTC |
-| clawhdf5 commit | `10d1029ead524e2fe64c2cd7f61b28067d9e449c` |
+| date | 2026-09-26 06:50 UTC |
+| clawhdf5 commit | `72306c601399748616bc9d061be2ebc4c1bea9e0` |
| machine | `tank`: AMD Ryzen 7 7800X3D 8-Core Processor, 16 CPUs, 61 GiB, Linux 7.0.0-34-generic x86_64 |
| command | `conformance/run.sh --no-fetch --update-baseline` |
| rustc | rustc 1.98.1 (48a229cea 2026-09-01) |
@@ -38,14 +38,14 @@ A file's class is the first that applies:
| NCAS-CMS_pyfive | 33 | 32 | 0 | 1 | 0 | 0 | 0 | 0 | 0 |
| cve_hdf5 | 147 | 100 | 6 | 9 | 32 | 0 | 0 | 0 | 0 |
| h5py_data | 4 | 4 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
-| hdf5 | 466 | 386 | 8 | 12 | 60 | 0 | 0 | 0 | 0 |
+| hdf5 | 466 | 392 | 4 | 10 | 60 | 0 | 0 | 0 | 0 |
| netcdf-c | 20 | 20 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| netcdf4-python | 18 | 18 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| usnistgov_h5wasm | 5 | 5 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| xarray-data | 4 | 4 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
-| **all** | **697** | **569** | **14** | **22** | **92** | **0** | **0** | **0** | **0** |
+| **all** | **697** | **575** | **10** | **20** | **92** | **0** | **0** | **0** | **0** |
-2 of the 22 mismatches are a known h5py bug, not ours (see *Known not-our-bug*).
+2 of the 20 mismatches are a known h5py bug, not ours (see *Known not-our-bug*).
Corpora (fetched by `conformance/fetch-corpus.sh` into the gitignored `conformance/.cache/`):
@@ -70,9 +70,9 @@ Grouped by normalised error message. *files* counts files whose class this cause
| files | objects | error | examples |
|---:|---:|---|---|
-| 6 | 6 | `UnsupportedFilter(N)` | `hdf5/HDF5Examples/C/H5FLT/tfiles/h5ex_d_blosc.h5`, `hdf5/HDF5Examples/C/H5FLT/tfiles/h5ex_d_blosc2.h5`, `hdf5/HDF5Examples/C/H5FLT/tfiles/h5ex_d_bshuf.h5` (+3 more) |
| 3 | 3 | `DataSizeMismatch { expected: N, actual: N }` | `cve_hdf5/cvefiles/cve-2020-18494.h5`, `cve_hdf5/cvefiles/cve-2024-32623.h5`, `cve_hdf5/cvefiles/cve-2025-2309.h5` |
| 2 | 2 | `ChunkedReadError("…")` | `cve_hdf5/cvefiles/cve-2025-2308.h5`, `hdf5/test/testfiles/bad_nbit_parms_walk.h5` |
+| 2 | 2 | `UnsupportedFilter(N)` | `hdf5/HDF5Examples/C/H5FLT/tfiles/h5ex_d_blosc2.h5`, `hdf5/HDF5Examples/C/H5FLT/tfiles/h5ex_d_zfp.h5` |
| 1 | 1 | `UnexpectedEof { expected: N, available: N }` | `cve_hdf5/cvefiles/cve-2019-9151.h5` |
| 1 | 1 | `MissingMessage(Dataspace)` | `cve_hdf5/cvefiles/cve-2024-33874.h5` |
| 1 | 1 | `InvalidObjectHeaderVersion(N)` | `hdf5/tools/test/testfiles/h5clear_mdc_image.h5` |
@@ -82,9 +82,9 @@ Grouped by normalised error message. *files* counts files whose class this cause
| files | objects | cause | examples |
|---:|---:|---|---|
| 13 | 14 | `missing-object` | `cve_hdf5/cvefiles/cve-2019-8397.h5`, `cve_hdf5/cvefiles/cve-2019-8398.h5`, `cve_hdf5/cvefiles/cve-2021-46243.h5` (+10 more) |
-| 3 | 7 | `extra-attr` | `cve_hdf5/cvefiles/cve-2018-17438`, `cve_hdf5/cvefiles/cve-2018-17439`, `cve_hdf5/cvefiles/cve-2024-33874.h5` |
-| 3 | 6 | `extra-object` | `cve_hdf5/cvefiles/cve-2021-46244.h5`, `hdf5/tools/test/testfiles/h5clear_fsm_persist_less.h5`, `hdf5/tools/test/testfiles/h5stat_err_refcount.h5` |
+| 2 | 6 | `extra-attr` | `cve_hdf5/cvefiles/cve-2018-17438`, `cve_hdf5/cvefiles/cve-2018-17439` |
| 1 | 1 | `attr-values: ours=vlen(>u8) h5py=object layout=- filters=-` | `NCAS-CMS_pyfive/tests/data/attr_datatypes.hdf5` |
+| 1 | 4 | `extra-object` | `cve_hdf5/cvefiles/cve-2021-46244.h5` |
| 1 | 1 | `values: ours=i2 h5py=>i2 layout=chunked filters=[6]` | `cve_hdf5/cvefiles/cve-2025-44905.h5` |
| 1 | 1 | `values: ours=>f4 h5py=>f4 layout=chunked filters=[2]` | `cve_hdf5/cvefiles/cve-2025-44905.h5` |
@@ -102,7 +102,7 @@ columns are.
| tool | read | error | panic | crash | hang | oom |
|---|---:|---:|---:|---:|---:|---:|
-| clawhdf5 | 142 | 5 | 0 | 0 | 0 | 0 |
+| clawhdf5 | 140 | 7 | 0 | 0 | 0 | 0 |
| h5dump 1.14.6 | 16 | 129 | 0 | 2 | 0 | 0 |
| h5py 3.16.0 / HDF5 2.0.0 | 115 | 31 | 0 | 1 | 0 | 0 |
@@ -112,19 +112,19 @@ columns are.
|---|---|---|---|---|
| cvefiles/cve-2016-4330.h5 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
| cvefiles/cve-2016-4331.h5 | error exit | read 25 obj, 1 errors | read 25 obj, 1 errors | ok |
-| cvefiles/cve-2016-4332-mtime-new.h5 | error exit | read 25 obj, 1 errors | read 25 obj | ok |
-| cvefiles/cve-2016-4332-mtime.h5 | error exit | read 4 obj, 3 errors | read 4 obj | ok |
-| cvefiles/cve-2016-4332-stab.h5 | error exit | open error | read 65 obj | h5py-cannot-read |
+| cvefiles/cve-2016-4332-mtime-new.h5 | error exit | read 25 obj, 1 errors | read 25 obj, 1 errors | ok |
+| cvefiles/cve-2016-4332-mtime.h5 | error exit | read 4 obj, 3 errors | read 4 obj, 3 errors | ok |
+| cvefiles/cve-2016-4332-stab.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
| cvefiles/cve-2016-4333.h5 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
| cvefiles/cve-2017-17505.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
| cvefiles/cve-2017-17506.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
| cvefiles/cve-2017-17507.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
-| cvefiles/cve-2017-17508.h5 | error exit | read 2 obj, 1 errors | read 2 obj | ok |
+| cvefiles/cve-2017-17508.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
| cvefiles/cve-2017-17509.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
| cvefiles/cve-2018-11202.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
| cvefiles/cve-2018-11203.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
-| cvefiles/cve-2018-11204.h5 | error exit | read 2 obj, 1 errors | read 2 obj | ok |
-| cvefiles/cve-2018-11205.h5 | error exit | read 2 obj, 1 errors | read 2 obj | ok |
+| cvefiles/cve-2018-11204.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
+| cvefiles/cve-2018-11205.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
| cvefiles/cve-2018-11206-new.h5 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
| cvefiles/cve-2018-11206-old.h5 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
| cvefiles/cve-2018-11207.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
@@ -135,10 +135,10 @@ columns are.
| cvefiles/cve-2018-13870.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
| cvefiles/cve-2018-13871.h5 | error exit | read 2 obj | read 2 obj | ok |
| cvefiles/cve-2018-13872.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
-| cvefiles/cve-2018-13873.h5 | error exit | read 1 obj, 1 errors | read 1 obj | ok |
-| cvefiles/cve-2018-13874.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
+| cvefiles/cve-2018-13873.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
+| cvefiles/cve-2018-13874.h5 | error exit | open error | open error | h5py-cannot-read |
| cvefiles/cve-2018-13875.h5 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
-| cvefiles/cve-2018-13876.h5 | error exit | open error | read 2 obj, 1 errors | h5py-cannot-read |
+| cvefiles/cve-2018-13876.h5 | error exit | open error | open error | h5py-cannot-read |
| cvefiles/cve-2018-14031.h5 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
| cvefiles/cve-2018-14033.h5 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
| cvefiles/cve-2018-14034.h5 | error exit | read 1 obj, 2 errors | read 1 obj | ok |
@@ -176,13 +176,13 @@ columns are.
| cvefiles/cve-2021-45833.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
| cvefiles/cve-2021-46242.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
| cvefiles/cve-2021-46243.h5 | error exit | read 3 obj, 2 errors | read 2 obj, 1 errors | mismatch |
-| cvefiles/cve-2021-46244.h5 | error exit | read 2 obj, 1 errors | read 6 obj, 3 errors | mismatch |
+| cvefiles/cve-2021-46244.h5 | error exit | read 2 obj, 1 errors | read 6 obj, 4 errors | mismatch |
| cvefiles/cve-2024-29157.h5 | error exit | read 4 obj, 7 errors | read 4 obj, 7 errors | ok |
| cvefiles/cve-2024-29158.h5 | ok | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
| cvefiles/cve-2024-29159.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
| cvefiles/cve-2024-29160.h5 | error exit | read 4 obj, 1 errors | read 4 obj, 1 errors | ok |
-| cvefiles/cve-2024-29161.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 2 errors | ok |
-| cvefiles/cve-2024-29162.h5 | error exit | read 17 obj, 4 errors | read 17 obj, 3 errors | ok |
+| cvefiles/cve-2024-29161.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
+| cvefiles/cve-2024-29162.h5 | error exit | read 17 obj, 4 errors | read 17 obj, 4 errors | ok |
| cvefiles/cve-2024-29163.h5 | error exit | read 7 obj, 1 errors | read 7 obj, 1 errors | ok |
| cvefiles/cve-2024-29164.h5 | ok | read 3 obj | read 3 obj | ok |
| cvefiles/cve-2024-29165.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
@@ -197,19 +197,19 @@ columns are.
| cvefiles/cve-2024-32611.h5 | ok | read 6 obj | read 6 obj | ok |
| cvefiles/cve-2024-32612.h5 | ok | read 3 obj | read 3 obj | ok |
| cvefiles/cve-2024-32613.h5 | error exit | read 7 obj, 1 errors | read 7 obj, 1 errors | ok |
-| cvefiles/cve-2024-32614.h5 | error exit | read 25 obj, 2 errors | read 25 obj, 1 errors | ok |
+| cvefiles/cve-2024-32614.h5 | error exit | read 25 obj, 2 errors | read 25 obj, 2 errors | ok |
| cvefiles/cve-2024-32615.h5 | error exit | read 4 obj, 1 errors | read 4 obj, 1 errors | ok |
-| cvefiles/cve-2024-32616.h5 | error exit | read 10 obj, 7 errors | read 10 obj, 5 errors | ok |
+| cvefiles/cve-2024-32616.h5 | error exit | read 10 obj, 7 errors | read 10 obj, 6 errors | ok |
| cvefiles/cve-2024-32617.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
-| cvefiles/cve-2024-32618.h5 | error exit | read 4 obj, 2 errors | read 3 obj | mismatch |
-| cvefiles/cve-2024-32619.h5 | error exit | read 3 obj, 2 errors | read 3 obj | ok |
+| cvefiles/cve-2024-32618.h5 | error exit | read 4 obj, 2 errors | read 3 obj, 1 errors | mismatch |
+| cvefiles/cve-2024-32619.h5 | error exit | read 3 obj, 2 errors | read 3 obj, 2 errors | ok |
| cvefiles/cve-2024-32620.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
| cvefiles/cve-2024-32621.h5 | ok | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
| cvefiles/cve-2024-32622.h5 | ok | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
| cvefiles/cve-2024-32623.h5 | ok | read 6 obj | read 6 obj, 1 errors | our-error |
| cvefiles/cve-2024-32624.h5 | error exit | read 6 obj, 1 errors | read 6 obj | ok |
-| cvefiles/cve-2024-33873.h5 | error exit | read 4 obj, 1 errors | read 4 obj | ok |
-| cvefiles/cve-2024-33874.h5 | ok | read 6 obj, 1 errors | read 6 obj, 1 errors | our-error |
+| cvefiles/cve-2024-33873.h5 | error exit | read 4 obj, 1 errors | read 4 obj, 1 errors | ok |
+| cvefiles/cve-2024-33874.h5 | ok | read 6 obj, 1 errors | read 6 obj, 2 errors | our-error |
| cvefiles/cve-2024-33875.h5 | ok | read 2 obj | read 2 obj | ok |
| cvefiles/cve-2024-33876.h5 | ok | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
| cvefiles/cve-2024-33877.h5 | error exit | read 8 obj, 1 errors | read 8 obj, 1 errors | ok |
@@ -246,12 +246,12 @@ columns are.
| cvefiles/cve-2025-7068.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
| cvefiles/cve-2025-7069.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
| cvefiles/cve-2026-26200.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
-| cvefiles/cve-2026-34734.h5 | error exit | read 2 obj, 1 errors | read 2 obj | ok |
+| cvefiles/cve-2026-34734.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
| cvefiles/cve-2026-92627.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
| cvefiles/unknown-1.h5 | error exit | read 11 obj, 1 errors | read 11 obj, 1 errors | ok |
| fuzzerfiles/gh-4431-poc-03.h5 | error exit | read 1 obj | read 1 obj | ok |
| fuzzerfiles/gh-4432-poc-05.h5 | SIGSEGV | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
-| fuzzerfiles/gh-4433-poc-08.h5 | error exit | read 1 obj, 1 errors | read 1 obj | ok |
+| fuzzerfiles/gh-4433-poc-08.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
| fuzzerfiles/gh-4434-poc-09.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
| fuzzerfiles/gh-4435-poc-10.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
| fuzzerfiles/gh-4585.h5 | error exit | open error | open error | h5py-cannot-read |
@@ -279,10 +279,8 @@ columns are.
## Objects h5py fails on but clawhdf5 reads
-- 19 x `KeyError: '…'`
- 19 x `OSError: Can't synchronously read data (no appropriate function for conversion path)`
- 1 x `TypeError: unhandled dtype kind M (dtype('…'))`
-- 1 x `OSError: Can't synchronously read data (bad coordinate offset)`
- 1 x `TypeError: No NumPy equivalent for TypeTimeID exists`
- 1 x `KeyError: "…"`
- 1 x `ValueError: Insufficient precision in available types to represent (N, N, N, N, N)`
diff --git a/Cargo.toml b/Cargo.toml
index 2e1f6fe..0801b3d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -16,6 +16,8 @@ members = [
"crates/clawhdf5-cli",
"crates/clawhdf5-napi",
"crates/clawhdf5-bench",
+ "crates/clawhdf5-tools",
+ "crates/clawhdf5-wasm",
"crates/libaec-sys",
]
resolver = "2"
@@ -34,3 +36,12 @@ tempfile = "3"
criterion = { version = "0.5", features = ["html_reports"] }
half = "2.7"
serde = { version = "1", features = ["derive"] }
+
+# The browser build of clawhdf5-wasm (examples/wasm-viewer/build.sh): size
+# over speed, whole-program optimisation. Native profiles are unaffected.
+[profile.wasm-release]
+inherits = "release"
+opt-level = "s"
+lto = true
+codegen-units = 1
+panic = "abort"
diff --git a/README.md b/README.md
index 5d4c707..b9e3aa1 100644
--- a/README.md
+++ b/README.md
@@ -587,14 +587,14 @@ let exported = backend.export_markdown("MEMORY.md")?;
## Crate Map
```
-clawhdf5 workspace (16 crates, ~86K lines of Rust in src/, ~104K with tests
+clawhdf5 workspace (17 crates, ~86K lines of Rust in src/, ~104K with tests
and benches; plus libaec-sys, an internal FFI bindings
crate for the optional szip feature)
│
├── Core HDF5
│ ├── clawhdf5-format — Binary parser/writer (no_std-capable), shared type definitions
│ ├── clawhdf5-io — I/O abstraction (file/memory readers; optional mmap, async, HSDS, MPI)
-│ ├── clawhdf5-filters — Fast deflate path (zlib-ng); lz4/zstd/pcodec/szip filters live in clawhdf5-format
+│ ├── clawhdf5-filters — Fast deflate path (zlib-ng); the filter registry and the lz4/zstd/pcodec/szip/LZF/bitshuffle/bzip2/Blosc filters live in clawhdf5-format
│ ├── clawhdf5-derive — Proc macros
│ ├── clawhdf5 — High-level API
│ ├── clawhdf5-netcdf4 — NetCDF-4 support
@@ -610,7 +610,8 @@ clawhdf5 workspace (16 crates, ~86K lines of Rust in src/, ~104K with tests
│
├── Bindings
│ ├── clawhdf5-py — Python (PyO3)
-│ └── clawhdf5-napi — Node.js (napi-rs)
+│ ├── clawhdf5-napi — Node.js (napi-rs)
+│ └── clawhdf5-wasm — Browser (WebAssembly, wasm-bindgen; read-only)
│
└── Tooling
└── clawhdf5-bench — Benchmark suite
@@ -700,6 +701,22 @@ stores keep their setting. Opt out with `float16 = false` or
| `system-zlib` | no | System zlib backend for deflate (C) |
| `blake3_hash` | no | BLAKE3 content hashing for provenance |
| `szip` | no | SZIP filter (id 4) via libaec (C, through the internal `libaec-sys` crate) |
+| `lzf` | **yes** | LZF filter (id 32000), h5py's built-in `compression="lzf"`: read and write. No dependencies |
+| `bitshuffle` | no | Bitshuffle filter (id 32008) with its LZ4 and Zstandard modes: read and write. Pure Rust (lz4_flex, ruzstd) |
+| `bzip2` | no | bzip2 filter (id 307): read and write. Pure Rust (the `bzip2` crate's libbz2-rs-sys backend compiles no C) |
+| `blosc` | no | Blosc 1 filter (id 32001): reads BloscLZ, LZ4/LZ4HC, Snappy, Zlib and Zstandard frames with byte or bit shuffle; writes LZ4, Snappy, Zlib or Zstandard (not BloscLZ). Pure Rust |
+| `plugin-filters` | no | All four above |
+
+Blosc2 (32026) and ZFP (32013) are not implemented: reading them fails with
+`UnsupportedFilter`, whose message names the filter. Any other filter can be
+supplied at run time with `filter_registry::register_filter` (a decoder
+closure, or a `FilterCodec` that also encodes). The facade (`clawhdf5`)
+forwards `lzf`, `bitshuffle`, `bzip2`, `blosc` and `plugin-filters`. Write
+with `DatasetBuilder::with_lzf()`, `with_bitshuffle(..)`, `with_bzip2(..)`
+and `with_blosc(..)`; h5py + hdf5plugin read the result (tested both ways in
+`crates/clawhdf5/tests/plugin_filters_interop.rs`). The pure-Rust Zstandard
+encoder has one level (about zstd's level 1); no speed or ratio claims are
+made for these codecs.
### `clawhdf5-ann`
diff --git a/conformance/baseline.json b/conformance/baseline.json
index d17769a..eb3747e 100644
--- a/conformance/baseline.json
+++ b/conformance/baseline.json
@@ -1,15 +1,15 @@
{
"comment": "conformance/run.sh fails if the ok count drops below `ok` or a file in `ok_files` stops being ok. Regenerate with `conformance/run.sh --update-baseline` after an intended change.",
- "commit": "10d1029ead524e2fe64c2cd7f61b28067d9e449c",
- "date": "2026-09-26 03:46 UTC",
+ "commit": "72306c601399748616bc9d061be2ebc4c1bea9e0",
+ "date": "2026-09-26 06:50 UTC",
"reference": "h5py 3.16.0 / HDF5 2.0.0",
"files": 697,
- "ok": 569,
+ "ok": 575,
"counts": {
"h5py-cannot-read": 92,
- "mismatch": 22,
- "ok": 569,
- "our-error": 14
+ "mismatch": 20,
+ "ok": 575,
+ "our-error": 10
},
"per_corpus": {
"NCAS-CMS_pyfive": {
@@ -27,9 +27,9 @@
},
"hdf5": {
"h5py-cannot-read": 60,
- "mismatch": 12,
- "ok": 386,
- "our-error": 8
+ "mismatch": 10,
+ "ok": 392,
+ "our-error": 4
},
"netcdf-c": {
"ok": 20
@@ -182,9 +182,13 @@
"h5py_data/vlen_string_dset_utc.h5",
"h5py_data/vlen_string_s390x.h5",
"hdf5/HDF5Examples/C/H5FLT/tfiles/h5ex_d_bitgroom.h5",
+ "hdf5/HDF5Examples/C/H5FLT/tfiles/h5ex_d_blosc.h5",
+ "hdf5/HDF5Examples/C/H5FLT/tfiles/h5ex_d_bshuf.h5",
+ "hdf5/HDF5Examples/C/H5FLT/tfiles/h5ex_d_bzip2.h5",
"hdf5/HDF5Examples/C/H5FLT/tfiles/h5ex_d_granularbr.h5",
"hdf5/HDF5Examples/C/H5FLT/tfiles/h5ex_d_jpeg.h5",
"hdf5/HDF5Examples/C/H5FLT/tfiles/h5ex_d_lz4.h5",
+ "hdf5/HDF5Examples/C/H5FLT/tfiles/h5ex_d_lzf.h5",
"hdf5/HDF5Examples/C/H5FLT/tfiles/h5ex_d_zstd.h5",
"hdf5/HDF5Examples/C/H5G/16/h5ex_g_iterate.h5",
"hdf5/HDF5Examples/C/H5G/16/h5ex_g_traverse.h5",
@@ -276,6 +280,7 @@
"hdf5/tools/test/testfiles/file_space.h5",
"hdf5/tools/test/testfiles/filter_fail.h5",
"hdf5/tools/test/testfiles/h5clear_fsm_persist_equal.h5",
+ "hdf5/tools/test/testfiles/h5clear_fsm_persist_less.h5",
"hdf5/tools/test/testfiles/h5clear_fsm_persist_noclose.h5",
"hdf5/tools/test/testfiles/h5clear_fsm_persist_user_equal.h5",
"hdf5/tools/test/testfiles/h5clear_fsm_persist_user_less.h5",
@@ -386,6 +391,7 @@
"hdf5/tools/test/testfiles/h5repack_uint8be_ex.h5",
"hdf5/tools/test/testfiles/h5stat_err_old_fill.h5",
"hdf5/tools/test/testfiles/h5stat_err_old_layout.h5",
+ "hdf5/tools/test/testfiles/h5stat_err_refcount.h5",
"hdf5/tools/test/testfiles/h5stat_filters.h5",
"hdf5/tools/test/testfiles/h5stat_idx.h5",
"hdf5/tools/test/testfiles/h5stat_newgrat.h5",
diff --git a/conformance/probe/Cargo.lock b/conformance/probe/Cargo.lock
index 0ffb1f7..964e0ab 100644
--- a/conformance/probe/Cargo.lock
+++ b/conformance/probe/Cargo.lock
@@ -29,6 +29,15 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+[[package]]
+name = "bzip2"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c"
+dependencies = [
+ "libbz2-rs-sys",
+]
+
[[package]]
name = "cc"
version = "1.5.1"
@@ -52,12 +61,15 @@ name = "clawhdf5-format"
version = "2.7.0"
dependencies = [
"byteorder",
+ "bzip2",
"flate2",
"libaec-sys",
"lz4_flex",
"pco",
"portable-atomic",
+ "ruzstd",
"sha2",
+ "snap",
"zstd",
]
@@ -192,6 +204,12 @@ dependencies = [
"pkg-config",
]
+[[package]]
+name = "libbz2-rs-sys"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
+
[[package]]
name = "libc"
version = "0.2.189"
@@ -286,6 +304,15 @@ dependencies = [
"rand_core",
]
+[[package]]
+name = "ruzstd"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888"
+dependencies = [
+ "twox-hash",
+]
+
[[package]]
name = "serde"
version = "1.0.229"
@@ -351,6 +378,12 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+[[package]]
+name = "snap"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886"
+
[[package]]
name = "syn"
version = "2.0.119"
diff --git a/conformance/probe/Cargo.toml b/conformance/probe/Cargo.toml
index f5c68af..62470b2 100644
--- a/conformance/probe/Cargo.toml
+++ b/conformance/probe/Cargo.toml
@@ -12,7 +12,7 @@ description = "Walks an HDF5 file with clawhdf5-format and prints a canonical JS
[workspace]
[dependencies]
-clawhdf5-format = { path = "../../crates/clawhdf5-format", features = ["lz4", "zstd", "szip", "pcodec"] }
+clawhdf5-format = { path = "../../crates/clawhdf5-format", features = ["lz4", "zstd", "szip", "pcodec", "plugin-filters"] }
serde_json = "1"
sha2 = "0.10"
diff --git a/conformance/probe/src/main.rs b/conformance/probe/src/main.rs
index 1298cf0..87c660d 100644
--- a/conformance/probe/src/main.rs
+++ b/conformance/probe/src/main.rs
@@ -309,11 +309,19 @@ impl<'a> Ctx<'a> {
}
}
+ fn read_named_datatype(&self, h: &ObjectHeader) -> Result<(), String> {
+ let dtb = self
+ .payload(h, MessageType::Datatype)?
+ .ok_or("MissingMessage(Datatype)")?;
+ Datatype::parse_in_header(&dtb, h.version).map_err(e)?;
+ Ok(())
+ }
+
fn read_dataset(&self, h: &ObjectHeader, rec: &mut Map) -> Result<(), String> {
let dtb = self
.payload(h, MessageType::Datatype)?
.ok_or("MissingMessage(Datatype)")?;
- let (dt, _) = Datatype::parse(&dtb).map_err(e)?;
+ let (dt, _) = Datatype::parse_in_header(&dtb, h.version).map_err(e)?;
rec.insert("dtype".into(), Value::String(dtype_str(&dt)));
let dsb = self
.payload(h, MessageType::Dataspace)?
@@ -716,6 +724,17 @@ fn main() {
return;
}
};
+ // libhdf5 refuses a truncated file and reads nothing past the recorded
+ // end of file.
+ let base = (data.len() - hdf5.len()) as u64;
+ let hdf5 = match sb.data_end(base, data.len() as u64) {
+ Ok(end) => &hdf5[..end as usize],
+ Err(err) => {
+ top.insert("open_error".into(), Value::String(e(err)));
+ println!("{}", Value::Object(top));
+ return;
+ }
+ };
top.insert("superblock_version".into(), json!(sb.version));
let ctx = Ctx {
data: hdf5,
@@ -778,6 +797,13 @@ fn main() {
{
rec.insert("error".into(), Value::String(msg));
}
+ // Opening a committed datatype decodes it (h5py's `f[name]` fails on
+ // one libhdf5 cannot decode), so decode it here too.
+ if kind == "datatype"
+ && let Err(msg) = guarded(|| ctx.read_named_datatype(&h))
+ {
+ rec.insert("error".into(), Value::String(msg));
+ }
if kind != "datatype" {
match guarded(|| ctx.attrs(&h)) {
Ok(m) => {
diff --git a/crates/clawhdf5-bench/Cargo.toml b/crates/clawhdf5-bench/Cargo.toml
index 5ac6350..b48afbf 100644
--- a/crates/clawhdf5-bench/Cargo.toml
+++ b/crates/clawhdf5-bench/Cargo.toml
@@ -34,6 +34,10 @@ path = "src/bin/consolidation_efficiency.rs"
name = "ephemeral_perf"
path = "src/bin/ephemeral_perf.rs"
+[[bin]]
+name = "concurrent_read"
+path = "src/bin/concurrent_read.rs"
+
[[bin]]
name = "mpi_io_bench"
path = "src/bin/mpi_io_bench.rs"
@@ -64,6 +68,10 @@ clawhdf5-io = { path = "../clawhdf5-io" }
mpi = { version = "0.8", optional = true }
serde = { workspace = true }
serde_json = "1"
+# concurrent_read: size the decode pool (--decode-threads) and evict files
+# from the page cache (--cold, posix_fadvise). Both pure Rust / bindings only.
+rayon = "1"
+libc = "0.2"
tempfile = { workspace = true }
# Optional: libhdf5 C wrapper for side-by-side comparison (requires system libhdf5).
# Enable with: cargo bench -p clawhdf5-bench --features libhdf5-compare
diff --git a/crates/clawhdf5-bench/scripts/__pycache__/compare_concurrent_read.cpython-314.pyc b/crates/clawhdf5-bench/scripts/__pycache__/compare_concurrent_read.cpython-314.pyc
new file mode 100644
index 0000000..85432af
Binary files /dev/null and b/crates/clawhdf5-bench/scripts/__pycache__/compare_concurrent_read.cpython-314.pyc differ
diff --git a/crates/clawhdf5-bench/scripts/__pycache__/concurrent_read_h5py.cpython-314.pyc b/crates/clawhdf5-bench/scripts/__pycache__/concurrent_read_h5py.cpython-314.pyc
new file mode 100644
index 0000000..4f759f2
Binary files /dev/null and b/crates/clawhdf5-bench/scripts/__pycache__/concurrent_read_h5py.cpython-314.pyc differ
diff --git a/crates/clawhdf5-bench/scripts/compare_concurrent_read.py b/crates/clawhdf5-bench/scripts/compare_concurrent_read.py
new file mode 100644
index 0000000..b7c75c2
--- /dev/null
+++ b/crates/clawhdf5-bench/scripts/compare_concurrent_read.py
@@ -0,0 +1,70 @@
+#!/usr/bin/env python3
+"""Tabulate concurrent_read JSON results (clawhdf5, h5py threads/processes).
+
+ python compare_concurrent_read.py clawhdf5.json h5py-threads.json h5py-procs.json
+
+Prints one Markdown table: for each layout, mode and thread count, every
+tool's MB/s and scaling efficiency, and the first file's MB/s relative to each
+of the others. Refuses to compare runs whose workload parameters differ.
+"""
+
+import json
+import sys
+
+COMPARED = ("datasets", "rows", "cols", "chunk", "deflate_level", "slab", "slabs", "seed")
+
+
+def main(paths):
+ if len(paths) < 2:
+ sys.exit(__doc__)
+ docs = []
+ for p in paths:
+ with open(p) as fh:
+ docs.append(json.load(fh))
+ ref = docs[0]
+ for d, p in zip(docs[1:], paths[1:]):
+ diff = [k for k in COMPARED if d["params"].get(k) != ref["params"].get(k)]
+ if diff:
+ sys.exit(f"{p}: workload differs from {paths[0]} in {', '.join(diff)}")
+ if d["cache"] != ref["cache"]:
+ print(f"warning: {p} ran {d['cache']!r}, {paths[0]} ran {ref['cache']!r}",
+ file=sys.stderr)
+ if d.get("host") != ref.get("host"):
+ print(f"warning: {p} ran on {d.get('host')}, {paths[0]} on {ref.get('host')}",
+ file=sys.stderr)
+
+ names = [d["tool"] for d in docs]
+ for d in docs:
+ extra = f", HDF5 {d['hdf5_version']}" if "hdf5_version" in d else ""
+ print(f"- {d['tool']} {d['version']}{extra}: host {d.get('host')}, "
+ f"{d.get('cpus')} CPUs, cache {d['cache']}, decode threads per read "
+ f"{d.get('decode_threads')}")
+ p = ref["params"]
+ print(f"\n{p['datasets']} datasets of {p['rows']} x {p['cols']} f32, chunks "
+ f"{p['chunk'][0]} x {p['chunk'][1]} (deflate {p['deflate_level']}); "
+ f"`same`: {p['slabs']} slabs of {p['slab']} x {p['slab']}\n")
+
+ index = [{(r["layout"], r["mode"], r["threads"]): r for r in d["results"]} for d in docs]
+ keys = [(r["layout"], r["mode"], r["threads"]) for r in ref["results"]]
+
+ head = ["layout", "mode", "threads"]
+ head += [f"{n} MB/s (eff)" for n in names]
+ head += [f"{names[0]} / {n}" for n in names[1:]]
+ print("| " + " | ".join(head) + " |")
+ print("|---|---|" + "---:|" * (len(head) - 2))
+ for key in keys:
+ cells = [key[0], key[1], str(key[2])]
+ rs = [ix.get(key) for ix in index]
+ for r in rs:
+ if r is None:
+ cells.append("-")
+ else:
+ eff = "-" if r["efficiency"] is None else f"{r['efficiency']:.2f}"
+ cells.append(f"{r['mb_s']:.0f} ({eff})")
+ for r in rs[1:]:
+ cells.append("-" if r is None else f"{rs[0]['mb_s'] / r['mb_s']:.2f}x")
+ print("| " + " | ".join(cells) + " |")
+
+
+if __name__ == "__main__":
+ main(sys.argv[1:])
diff --git a/crates/clawhdf5-bench/scripts/concurrent_read_h5py.py b/crates/clawhdf5-bench/scripts/concurrent_read_h5py.py
new file mode 100644
index 0000000..883a850
--- /dev/null
+++ b/crates/clawhdf5-bench/scripts/concurrent_read_h5py.py
@@ -0,0 +1,265 @@
+#!/usr/bin/env python3
+"""The concurrent_read workload with h5py, on the files concurrent_read wrote.
+
+libhdf5 serialises every API call under one global lock, and h5py holds its
+own global lock around every call as well, so h5py *threads* cannot decode in
+parallel. h5py users scale with *processes* instead; ``--executor processes``
+measures that (each worker opens the file itself).
+
+The workload mirrors ``crates/clawhdf5-bench/src/bin/concurrent_read.rs``:
+
+* ``distinct``: every dataset read in full once per repetition; worker ``t``
+ of ``T`` reads datasets ``t, t + T, ...``.
+* ``same``: ``--slabs`` random ``--slab`` x ``--slab`` hyperslabs of ``d00``
+ (slab ``j`` to worker ``j % T``), offsets from the same splitmix64 stream.
+
+Each worker times itself from a start barrier; a repetition spans the earliest
+start to the latest finish (CLOCK_MONOTONIC, comparable across processes).
+Threads share one ``h5py.File`` per repetition; process workers open the file
+inside the timed region (a few ms against reads of many MiB).
+
+Generate the files first with the Rust harness (it writes ``manifest.json``),
+then, for example::
+
+ python concurrent_read_h5py.py --dir DIR --executor threads --json h5py-threads.json
+ python concurrent_read_h5py.py --dir DIR --executor processes --json h5py-procs.json
+"""
+
+import argparse
+import json
+import multiprocessing as mp
+import os
+import platform
+import socket
+import sys
+import threading
+import time
+
+import h5py
+import numpy as np
+
+M64 = (1 << 64) - 1
+
+
+def splitmix64(state):
+ """Return (new_state, value); the same stream as the Rust harness."""
+ state = (state + 0x9E3779B97F4A7C15) & M64
+ z = state
+ z = ((z ^ (z >> 30)) * 0xBF58476D1CE4E5B9) & M64
+ z = ((z ^ (z >> 27)) * 0x94D049BB133111EB) & M64
+ return state, z ^ (z >> 31)
+
+
+def value(k, i):
+ """Element i (row-major) of dataset k, exactly as concurrent_read writes it."""
+ _, noise = splitmix64(i ^ (k << 40))
+ return np.float32((((i >> 6) % 16384) + k) + (noise & 0xFF) / 256.0)
+
+
+def slab_offsets(seed, count, rows, cols, slab):
+ s = seed
+ out = []
+ for _ in range(count):
+ s, r = splitmix64(s)
+ s, c = splitmix64(s)
+ out.append((r % (rows - slab + 1), c % (cols - slab + 1)))
+ return out
+
+
+def now():
+ return time.clock_gettime(time.CLOCK_MONOTONIC)
+
+
+def work(f, mode, t, threads, m, slabs, slab, verify):
+ """Worker t's share of one repetition on an open h5py.File."""
+ n = m["rows"] * m["cols"]
+ if mode == "distinct":
+ for k in range(t, m["datasets"], threads):
+ got = f[f"d{k:02d}"][...]
+ assert got.size == n
+ if verify:
+ flat = got.reshape(-1)
+ for i in (0, n // 3, n - 1):
+ assert flat[i] == value(k, i), f"d{k:02d}[{i}]"
+ else:
+ ds = f["d00"]
+ cols = m["cols"]
+ for r, c in slabs[t::threads]:
+ got = ds[r : r + slab, c : c + slab]
+ assert got.shape == (slab, slab)
+ if verify:
+ assert got[0, 0] == value(0, r * cols + c)
+ last = (r + slab - 1) * cols + c + slab - 1
+ assert got[-1, -1] == value(0, last)
+
+
+# ----- process workers ------------------------------------------------------
+
+_barrier = None
+
+
+def _init(barrier):
+ global _barrier
+ _barrier = barrier
+
+
+def _proc_task(task):
+ path, mode, t, threads, m, slabs, slab = task
+ _barrier.wait()
+ start = now()
+ with h5py.File(path, "r") as f:
+ work(f, mode, t, threads, m, slabs, slab, False)
+ return start, now()
+
+
+def _noop(_):
+ return os.getpid()
+
+
+def run_threads(path, mode, threads, m, slabs, slab):
+ spans = [None] * threads
+ barrier = threading.Barrier(threads)
+ with h5py.File(path, "r") as f:
+
+ def body(t):
+ barrier.wait()
+ start = now()
+ work(f, mode, t, threads, m, slabs, slab, False)
+ spans[t] = (start, now())
+
+ ts = [threading.Thread(target=body, args=(t,)) for t in range(threads)]
+ for th in ts:
+ th.start()
+ for th in ts:
+ th.join()
+ return max(e for _, e in spans) - min(s for s, _ in spans)
+
+
+def run_processes(pool, path, mode, threads, m, slabs, slab):
+ tasks = [(path, mode, t, threads, m, slabs, slab) for t in range(threads)]
+ # One task per worker: each blocks in the barrier until all T have
+ # started, so no worker can take a second task.
+ spans = pool.map(_proc_task, tasks, chunksize=1)
+ return max(e for _, e in spans) - min(s for s, _ in spans)
+
+
+def warm(path):
+ with open(path, "rb") as fh:
+ while fh.read(1 << 24):
+ pass
+
+
+def evict(path):
+ fd = os.open(path, os.O_RDONLY)
+ try:
+ os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
+ finally:
+ os.close(fd)
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
+ ap.add_argument("--dir", default="concurrent-read-data")
+ ap.add_argument("--executor", choices=["threads", "processes"], default="threads")
+ ap.add_argument("--threads", default="1,2,4,8,16")
+ ap.add_argument("--reps", type=int, default=3)
+ ap.add_argument("--slab", type=int, default=256)
+ ap.add_argument("--slabs", type=int, default=1024)
+ ap.add_argument("--seed", type=int, default=42)
+ ap.add_argument("--cold", action="store_true")
+ ap.add_argument("--modes", default="distinct,same")
+ ap.add_argument("--layouts", default="deflate,contiguous")
+ ap.add_argument("--json")
+ a = ap.parse_args()
+
+ # The Rust harness pins this value (splitmix64_reference).
+ assert splitmix64(42)[1] == 0xBDD732262FEB6E95, "splitmix64 port is wrong"
+
+ try:
+ with open(os.path.join(a.dir, "manifest.json")) as fh:
+ m = json.load(fh)
+ except FileNotFoundError:
+ sys.exit(f"{a.dir}/manifest.json not found: generate the files with "
+ "`cargo run --release -p clawhdf5-bench --bin concurrent_read -- --dir ...` first")
+ threads_list = [int(x) for x in a.threads.split(",")]
+ modes = a.modes.split(",")
+ layouts = a.layouts.split(",")
+ if a.slab < 1 or a.slab > min(m["rows"], m["cols"]):
+ sys.exit(f"--slab must be 1..={min(m['rows'], m['cols'])}")
+ files = dict(m["files"])
+ slabs = slab_offsets(a.seed, a.slabs, m["rows"], m["cols"], a.slab)
+ dataset_bytes = m["rows"] * m["cols"] * 4
+ tool = f"h5py-{a.executor}"
+
+ ctx = mp.get_context("spawn") # never fork a process holding HDF5 state
+ pools = {}
+ if a.executor == "processes":
+ for t in threads_list:
+ pool = ctx.Pool(t, initializer=_init, initargs=(ctx.Barrier(t),))
+ pool.map(_noop, range(t)) # start the workers outside the timing
+ pools[t] = pool
+
+ rows = []
+ print("| layout | mode | threads | MB/s | efficiency | median s |")
+ print("|---|---|---:|---:|---:|---:|")
+ try:
+ for layout in layouts:
+ path = os.path.join(a.dir, files[layout])
+ if not a.cold:
+ warm(path)
+ for mode in modes:
+ with h5py.File(path, "r") as f: # untimed, checked pass
+ work(f, mode, 0, 1, m, slabs, a.slab, True)
+ nbytes = (dataset_bytes * m["datasets"] if mode == "distinct"
+ else a.slab * a.slab * 4 * a.slabs)
+ base = None
+ for t in threads_list:
+ times = []
+ for _ in range(a.reps):
+ if a.cold:
+ evict(path)
+ if a.executor == "threads":
+ times.append(run_threads(path, mode, t, m, slabs, a.slab))
+ else:
+ times.append(run_processes(pools[t], path, mode, t, m, slabs, a.slab))
+ med = sorted(times)[len(times) // 2]
+ mb_s = nbytes / (1 << 20) / med
+ if t == 1:
+ base = mb_s
+ eff = mb_s / (t * base) if base else None
+ print(f"| {layout} | {mode} | {t} | {mb_s:.0f} | "
+ f"{'-' if eff is None else f'{eff:.2f}'} | {med:.4f} |")
+ rows.append({
+ "layout": layout, "mode": mode, "threads": t, "bytes": nbytes,
+ "times_s": times, "median_s": med, "mb_s": mb_s, "efficiency": eff,
+ })
+ finally:
+ for pool in pools.values():
+ pool.terminate()
+
+ if a.json:
+ doc = {
+ "tool": tool,
+ "version": h5py.__version__,
+ "hdf5_version": h5py.version.hdf5_version,
+ "python": platform.python_version(),
+ "host": socket.gethostname(),
+ "cpus": os.cpu_count(),
+ "unix_time": int(time.time()),
+ "cache": ("cold (posix_fadvise DONTNEED before each repetition)"
+ if a.cold else "warm"),
+ "decode_threads": 1,
+ "params": {
+ "datasets": m["datasets"], "rows": m["rows"], "cols": m["cols"],
+ "chunk": m["chunk"], "deflate_level": m["deflate_level"],
+ "mib": dataset_bytes // (1 << 20), "slab": a.slab, "slabs": a.slabs,
+ "seed": a.seed, "reps": a.reps, "dir": a.dir,
+ },
+ "results": rows,
+ }
+ with open(a.json, "w") as fh:
+ json.dump(doc, fh, indent=2)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/crates/clawhdf5-bench/src/bin/concurrent_read.rs b/crates/clawhdf5-bench/src/bin/concurrent_read.rs
new file mode 100644
index 0000000..a87ac26
--- /dev/null
+++ b/crates/clawhdf5-bench/src/bin/concurrent_read.rs
@@ -0,0 +1,523 @@
+//! Concurrent-read harness: how does decoded read throughput scale with the
+//! number of threads reading one open file?
+//!
+//! libhdf5 (threadsafe build) serialises every API call under one global
+//! mutex, and h5py holds it too, so threads cannot decode in parallel there.
+//! A clawhdf5 [`File`] is `Send + Sync`; this harness measures what that buys.
+//! `crates/clawhdf5-bench/scripts/concurrent_read_h5py.py` runs the same
+//! workload on the same files with h5py (threads, and processes), and
+//! `compare_concurrent_read.py` tabulates the JSON both write.
+//!
+//! Files (generated on first use, reused while `manifest.json` matches):
+//!
+//! * `/deflate.h5`: `--datasets` datasets `d00`, `d01`, ... of `f32`,
+//! `--mib` MiB decoded each, shape `[mib * 256, 1024]`, chunks `256 x 256`,
+//! deflate level 4.
+//! * `/contiguous.h5`: the same datasets, contiguous.
+//!
+//! Modes, for each layout and each thread count `T` (strong scaling: the total
+//! work per repetition is fixed, split among the threads):
+//!
+//! * `distinct`: every dataset is read in full once; thread `t` reads datasets
+//! `t, t + T, t + 2T, ...`.
+//! * `same`: all threads read `d00`, `--slabs` random `--slab` x `--slab`
+//! hyperslabs in total (slab `j` goes to thread `j % T`). The offsets come
+//! from a splitmix64 stream seeded with `--seed`, identical in the h5py
+//! script.
+//!
+//! One `File` per layout per repetition is shared by all threads (opened
+//! fresh each repetition, so no chunk cache carries over). Page cache:
+//! `warm` (default) reads every file once before timing; `--cold` evicts the
+//! files from the page cache with `posix_fadvise(POSIX_FADV_DONTNEED)` before
+//! every repetition (no root needed; it only evicts clean, unmapped pages, so
+//! it is best effort — the JSON says which was used).
+//!
+//! Decode inside one read is itself parallel when clawhdf5-format's `parallel`
+//! feature is on (it is in this binary, via clawhdf5-agent). `--decode-threads
+//! N` sizes that rayon pool; `--decode-threads 1` measures the API's own
+//! thread scaling, comparable with h5py where each call decodes on the
+//! calling thread.
+//!
+//! ```text
+//! cargo run --release -p clawhdf5-bench --bin concurrent_read -- \
+//! --dir /data/concurrent-read --json clawhdf5.json
+//! cargo run --release -p clawhdf5-bench --bin concurrent_read -- \
+//! --dir /tmp/cr --datasets 4 --mib 1 --threads 1,2 --slabs 16 --reps 1 # smoke
+//! ```
+
+use std::path::{Path, PathBuf};
+use std::sync::Barrier;
+use std::time::Instant;
+
+use clawhdf5::{File, FileBuilder, Selection};
+use serde::{Deserialize, Serialize};
+
+const COLS: u64 = 1024;
+const ROWS_PER_MIB: u64 = 256; // 256 rows x 1024 cols x 4 bytes = 1 MiB
+const CHUNK: u64 = 256;
+const DEFLATE_LEVEL: u32 = 4;
+const LAYOUTS: [&str; 2] = ["deflate", "contiguous"];
+const MANIFEST_VERSION: u32 = 1;
+
+/// splitmix64 — shared with the h5py script, which must produce the same
+/// stream (both the data and the hyperslab offsets depend on it).
+fn splitmix64(state: &mut u64) -> u64 {
+ *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
+ let mut z = *state;
+ z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
+ z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
+ z ^ (z >> 31)
+}
+
+/// Element `i` (row-major) of dataset `k`: a slowly varying integer part plus
+/// 8 bits of noise, so deflate has real work to do (about 3.1x) and every value
+/// is exact in `f32` (< 2^15 with 8 fraction bits), which lets both harnesses
+/// check what they read against this formula.
+fn value(k: u64, i: u64) -> f32 {
+ let mut s = i ^ (k << 40);
+ let noise = splitmix64(&mut s) & 0xff;
+ (((i >> 6) % 16384) + k) as f32 + noise as f32 / 256.0
+}
+
+#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
+struct Manifest {
+ version: u32,
+ datasets: u64,
+ rows: u64,
+ cols: u64,
+ chunk: [u64; 2],
+ deflate_level: u32,
+ files: Vec<(String, String)>, // (layout, file name)
+ writer: String,
+}
+
+fn manifest_for(datasets: u64, mib: u64) -> Manifest {
+ Manifest {
+ version: MANIFEST_VERSION,
+ datasets,
+ rows: mib * ROWS_PER_MIB,
+ cols: COLS,
+ chunk: [CHUNK, CHUNK],
+ deflate_level: DEFLATE_LEVEL,
+ files: LAYOUTS
+ .iter()
+ .map(|l| (l.to_string(), format!("{l}.h5")))
+ .collect(),
+ writer: format!("clawhdf5 {}", env!("CARGO_PKG_VERSION")),
+ }
+}
+
+fn dataset_values(k: u64, n: u64) -> Vec {
+ (0..n).map(|i| value(k, i)).collect()
+}
+
+/// Write the files unless `dir` already holds ones matching `want`.
+fn ensure_files(dir: &Path, want: &Manifest) -> std::io::Result {
+ let manifest_path = dir.join("manifest.json");
+ if let Ok(text) = std::fs::read_to_string(&manifest_path)
+ && let Ok(have) = serde_json::from_str::(&text)
+ && have.version == want.version
+ && have.datasets == want.datasets
+ && have.rows == want.rows
+ && have.cols == want.cols
+ && have.chunk == want.chunk
+ && have.deflate_level == want.deflate_level
+ && have.files == want.files
+ && want.files.iter().all(|(_, f)| dir.join(f).exists())
+ {
+ return Ok(false);
+ }
+ std::fs::create_dir_all(dir)?;
+ // A stale manifest must not survive a half-written regeneration.
+ let _ = std::fs::remove_file(&manifest_path);
+ let n = want.rows * want.cols;
+ for (layout, file) in &want.files {
+ // One layout at a time keeps the peak memory to about twice one
+ // file's decoded size.
+ let mut b = FileBuilder::new();
+ for k in 0..want.datasets {
+ let ds = b.create_dataset(&format!("d{k:02}"));
+ ds.with_f32_data(&dataset_values(k, n))
+ .with_shape(&[want.rows, want.cols]);
+ if layout == "deflate" {
+ ds.with_chunks(&[CHUNK.min(want.rows), CHUNK])
+ .with_deflate(DEFLATE_LEVEL);
+ }
+ }
+ b.write(dir.join(file)).map_err(std::io::Error::other)?;
+ }
+ std::fs::write(
+ &manifest_path,
+ serde_json::to_string_pretty(want).map_err(std::io::Error::other)?,
+ )?;
+ Ok(true)
+}
+
+fn slab_offsets(seed: u64, count: usize, rows: u64, cols: u64, slab: u64) -> Vec<(u64, u64)> {
+ let mut s = seed;
+ (0..count)
+ .map(|_| {
+ let r = splitmix64(&mut s) % (rows - slab + 1);
+ let c = splitmix64(&mut s) % (cols - slab + 1);
+ (r, c)
+ })
+ .collect()
+}
+
+/// Warm the page cache by reading every byte of `path`.
+fn warm(path: &Path) -> std::io::Result<()> {
+ let mut f = std::fs::File::open(path)?;
+ std::io::copy(&mut f, &mut std::io::sink())?;
+ Ok(())
+}
+
+/// Ask the kernel to drop `path`'s pages from the page cache.
+fn evict(path: &Path) -> std::io::Result<()> {
+ use std::os::fd::AsRawFd;
+ let f = std::fs::File::open(path)?;
+ // SAFETY: plain syscall on a valid, open file descriptor.
+ let rc = unsafe { libc::posix_fadvise(f.as_raw_fd(), 0, 0, libc::POSIX_FADV_DONTNEED) };
+ if rc != 0 {
+ return Err(std::io::Error::from_raw_os_error(rc));
+ }
+ Ok(())
+}
+
+#[derive(Serialize)]
+struct Row {
+ layout: String,
+ mode: String,
+ threads: usize,
+ /// Decoded (selected) bytes read per repetition.
+ bytes: u64,
+ times_s: Vec,
+ median_s: f64,
+ mb_s: f64,
+ /// `mb_s / (threads * mb_s at threads = 1)`; null without a 1-thread row.
+ efficiency: Option,
+}
+
+struct Args {
+ dir: PathBuf,
+ datasets: u64,
+ mib: u64,
+ threads: Vec,
+ reps: usize,
+ slab: u64,
+ slabs: usize,
+ seed: u64,
+ cold: bool,
+ decode_threads: usize,
+ modes: Vec,
+ layouts: Vec,
+ json: Option,
+}
+
+const USAGE: &str = "\
+usage: concurrent_read [--dir DIR] [--datasets N] [--mib N] [--threads 1,2,4,8,16]
+ [--reps N] [--slab N] [--slabs N] [--seed N] [--cold]
+ [--decode-threads N] [--modes distinct,same]
+ [--layouts deflate,contiguous] [--json FILE]";
+
+fn parse_list(s: &str) -> Result, String> {
+ s.split(',')
+ .map(|x| x.trim().parse().map_err(|_| format!("bad list item {x:?}")))
+ .collect()
+}
+
+fn parse_args() -> Result {
+ let mut a = Args {
+ dir: PathBuf::from("concurrent-read-data"),
+ datasets: 64,
+ mib: 64,
+ threads: vec![1, 2, 4, 8, 16],
+ reps: 3,
+ slab: 256,
+ slabs: 1024,
+ seed: 42,
+ cold: false,
+ decode_threads: 0,
+ modes: vec!["distinct".into(), "same".into()],
+ layouts: LAYOUTS.iter().map(|s| s.to_string()).collect(),
+ json: None,
+ };
+ let mut it = std::env::args().skip(1);
+ while let Some(flag) = it.next() {
+ if flag == "--cold" {
+ a.cold = true;
+ continue;
+ }
+ if flag == "-h" || flag == "--help" {
+ return Err(USAGE.into());
+ }
+ let v = it.next().ok_or(format!("{flag} needs a value\n{USAGE}"))?;
+ let num = |v: &str| {
+ v.parse::()
+ .map_err(|_| format!("{flag}: bad number {v:?}"))
+ };
+ match flag.as_str() {
+ "--dir" => a.dir = v.into(),
+ "--datasets" => a.datasets = num(&v)?,
+ "--mib" => a.mib = num(&v)?,
+ "--threads" => a.threads = parse_list(&v)?,
+ "--reps" => a.reps = num(&v)? as usize,
+ "--slab" => a.slab = num(&v)?,
+ "--slabs" => a.slabs = num(&v)? as usize,
+ "--seed" => a.seed = num(&v)?,
+ "--decode-threads" => a.decode_threads = num(&v)? as usize,
+ "--modes" => a.modes = parse_list(&v)?,
+ "--layouts" => a.layouts = parse_list(&v)?,
+ "--json" => a.json = Some(v.into()),
+ _ => return Err(format!("unknown flag {flag}\n{USAGE}")),
+ }
+ }
+ if a.datasets == 0 || a.datasets > 100 {
+ return Err("--datasets must be 1..=100".into());
+ }
+ if a.mib == 0 || a.reps == 0 || a.slabs == 0 || a.threads.contains(&0) {
+ return Err("--mib, --reps, --slabs and every --threads value must be > 0".into());
+ }
+ if a.slab == 0 || a.slab > COLS || a.slab > a.mib * ROWS_PER_MIB {
+ return Err(format!(
+ "--slab must be 1..={}",
+ COLS.min(a.mib * ROWS_PER_MIB)
+ ));
+ }
+ for m in &a.modes {
+ if m != "distinct" && m != "same" {
+ return Err(format!("unknown mode {m:?}"));
+ }
+ }
+ for l in &a.layouts {
+ if !LAYOUTS.contains(&l.as_str()) {
+ return Err(format!("unknown layout {l:?}"));
+ }
+ }
+ Ok(a)
+}
+
+/// One timed repetition: `T` threads on one shared `File`. Returns seconds.
+fn run_once(
+ path: &Path,
+ mode: &str,
+ threads: usize,
+ m: &Manifest,
+ slabs: &[(u64, u64)],
+ slab: u64,
+ verify: bool,
+) -> f64 {
+ let file = File::open(path).expect("open");
+ let barrier = Barrier::new(threads + 1); // + the spawning thread
+ let n = m.rows * m.cols;
+ // Each thread times itself from the barrier; the repetition spans the
+ // earliest start to the latest finish (timing on the spawning thread
+ // instead undercounts whenever it is scheduled after the workers ran).
+ let spans: Vec<(Instant, Instant)> = std::thread::scope(|s| {
+ let handles: Vec<_> = (0..threads)
+ .map(|t| {
+ let (file, barrier) = (&file, &barrier);
+ s.spawn(move || {
+ barrier.wait();
+ let start = Instant::now();
+ match mode {
+ "distinct" => {
+ for k in (t as u64..m.datasets).step_by(threads) {
+ let got = file.dataset(&format!("d{k:02}")).unwrap().read_f32();
+ let got = got.unwrap();
+ assert_eq!(got.len() as u64, n);
+ if verify {
+ for i in [0, n / 3, n - 1] {
+ assert_eq!(got[i as usize], value(k, i), "d{k:02}[{i}]");
+ }
+ }
+ std::hint::black_box(got);
+ }
+ }
+ _ => {
+ let ds = file.dataset("d00").unwrap();
+ for &(r, c) in slabs.iter().skip(t).step_by(threads) {
+ let sel = Selection::Hyperslab {
+ start: vec![r, c],
+ stride: vec![1, 1],
+ count: vec![slab, slab],
+ block: vec![1, 1],
+ };
+ let got = ds.read_f32_selection(&sel).unwrap();
+ assert_eq!(got.len() as u64, slab * slab);
+ if verify {
+ let last = (r + slab - 1) * m.cols + c + slab - 1;
+ assert_eq!(got[0], value(0, r * m.cols + c));
+ assert_eq!(*got.last().unwrap(), value(0, last));
+ }
+ std::hint::black_box(got);
+ }
+ }
+ }
+ (start, Instant::now())
+ })
+ })
+ .collect();
+ barrier.wait();
+ handles.into_iter().map(|h| h.join().unwrap()).collect()
+ });
+ let start = spans.iter().map(|s| s.0).min().unwrap();
+ let end = spans.iter().map(|s| s.1).max().unwrap();
+ (end - start).as_secs_f64()
+}
+
+fn median(v: &[f64]) -> f64 {
+ let mut s = v.to_vec();
+ s.sort_by(f64::total_cmp);
+ s[s.len() / 2]
+}
+
+fn hostname() -> String {
+ std::fs::read_to_string("/proc/sys/kernel/hostname")
+ .map(|s| s.trim().to_string())
+ .unwrap_or_else(|_| "unknown".into())
+}
+
+fn main() {
+ let args = match parse_args() {
+ Ok(a) => a,
+ Err(e) => {
+ eprintln!("{e}");
+ std::process::exit(2);
+ }
+ };
+ if cfg!(debug_assertions) {
+ eprintln!("warning: debug build — numbers are meaningless. Use --release.");
+ }
+ if args.decode_threads > 0 {
+ rayon::ThreadPoolBuilder::new()
+ .num_threads(args.decode_threads)
+ .build_global()
+ .expect("configure rayon pool");
+ }
+
+ let manifest = manifest_for(args.datasets, args.mib);
+ let t = Instant::now();
+ match ensure_files(&args.dir, &manifest) {
+ Ok(true) => eprintln!(
+ "generated {} in {:.1} s",
+ args.dir.display(),
+ t.elapsed().as_secs_f64()
+ ),
+ Ok(false) => eprintln!("reusing {}", args.dir.display()),
+ Err(e) => {
+ eprintln!("cannot write test files in {}: {e}", args.dir.display());
+ std::process::exit(1);
+ }
+ }
+ let path_of = |layout: &str| args.dir.join(format!("{layout}.h5"));
+ let slabs = slab_offsets(
+ args.seed,
+ args.slabs,
+ manifest.rows,
+ manifest.cols,
+ args.slab,
+ );
+ let dataset_bytes = manifest.rows * manifest.cols * 4;
+
+ let mut rows: Vec = Vec::new();
+ println!("| layout | mode | threads | MB/s | efficiency | median s |");
+ println!("|---|---|---:|---:|---:|---:|");
+ for layout in &args.layouts {
+ let path = path_of(layout);
+ // Untimed pass: page cache warm (unless --cold), results checked.
+ if !args.cold {
+ warm(&path).expect("warm page cache");
+ }
+ for mode in &args.modes {
+ run_once(&path, mode, 1, &manifest, &slabs, args.slab, true);
+ let bytes = match mode.as_str() {
+ "distinct" => dataset_bytes * manifest.datasets,
+ _ => args.slab * args.slab * 4 * args.slabs as u64,
+ };
+ let mut base: Option = None;
+ for &threads in &args.threads {
+ let times: Vec = (0..args.reps)
+ .map(|_| {
+ if args.cold {
+ evict(&path).expect("posix_fadvise");
+ }
+ run_once(&path, mode, threads, &manifest, &slabs, args.slab, false)
+ })
+ .collect();
+ let med = median(×);
+ let mb_s = bytes as f64 / (1 << 20) as f64 / med;
+ if threads == 1 {
+ base = Some(mb_s);
+ }
+ let efficiency = base.map(|b| mb_s / (threads as f64 * b));
+ println!(
+ "| {layout} | {mode} | {threads} | {mb_s:.0} | {} | {med:.4} |",
+ efficiency.map_or("-".into(), |e| format!("{e:.2}"))
+ );
+ rows.push(Row {
+ layout: layout.clone(),
+ mode: mode.clone(),
+ threads,
+ bytes,
+ times_s: times,
+ median_s: med,
+ mb_s,
+ efficiency,
+ });
+ }
+ }
+ }
+
+ if let Some(out) = &args.json {
+ let doc = serde_json::json!({
+ "tool": "clawhdf5",
+ "version": env!("CARGO_PKG_VERSION"),
+ "host": hostname(),
+ "cpus": std::thread::available_parallelism().map_or(0, |n| n.get()),
+ "unix_time": std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map_or(0, |d| d.as_secs()),
+ "cache": if args.cold { "cold (posix_fadvise DONTNEED before each repetition)" } else { "warm" },
+ "decode_threads": rayon::current_num_threads(),
+ "params": {
+ "datasets": manifest.datasets,
+ "mib": args.mib,
+ "rows": manifest.rows,
+ "cols": manifest.cols,
+ "chunk": manifest.chunk,
+ "deflate_level": manifest.deflate_level,
+ "slab": args.slab,
+ "slabs": args.slabs,
+ "seed": args.seed,
+ "reps": args.reps,
+ "dir": args.dir,
+ },
+ "results": rows,
+ });
+ std::fs::write(out, serde_json::to_string_pretty(&doc).unwrap()).expect("write json");
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn values_are_exact_in_f32() {
+ for k in [0, 7, 63] {
+ for i in [0u64, 1, 4095, 1 << 20, (1 << 24) - 1] {
+ let v = value(k, i);
+ assert_eq!(v, (v as f64) as f32);
+ assert!(v < 32768.0);
+ assert_eq!((v * 256.0).fract(), 0.0);
+ }
+ }
+ }
+
+ /// The h5py script hard-codes this vector to check its splitmix64 port.
+ #[test]
+ fn splitmix64_reference() {
+ let mut s = 42;
+ assert_eq!(splitmix64(&mut s), 0xBDD7_3226_2FEB_6E95);
+ }
+}
diff --git a/crates/clawhdf5-bench/tests/concurrent_read_smoke.rs b/crates/clawhdf5-bench/tests/concurrent_read_smoke.rs
new file mode 100644
index 0000000..a8ab5c7
--- /dev/null
+++ b/crates/clawhdf5-bench/tests/concurrent_read_smoke.rs
@@ -0,0 +1,148 @@
+//! Keeps the concurrent-read harnesses working: runs `concurrent_read`, the
+//! h5py script (threads and processes) and the comparison script end to end
+//! on tiny files. h5py reading the files also checks, element by element at
+//! spot positions, that both harnesses generate the same data and slabs.
+//!
+//! The h5py half is skipped when python3 with h5py is unavailable, unless
+//! `CLAWHDF5_REQUIRE_INTEROP=1`; `CLAWHDF5_PYTHON` picks the interpreter.
+
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+fn python() -> String {
+ std::env::var("CLAWHDF5_PYTHON").unwrap_or_else(|_| "python3".to_string())
+}
+
+fn interop_required() -> bool {
+ std::env::var("CLAWHDF5_REQUIRE_INTEROP").is_ok_and(|v| v == "1")
+}
+
+fn python_available() -> bool {
+ Command::new(python())
+ .args(["-c", "import h5py, numpy"])
+ .output()
+ .map(|o| o.status.success())
+ .unwrap_or(false)
+}
+
+fn scripts() -> PathBuf {
+ Path::new(env!("CARGO_MANIFEST_DIR")).join("scripts")
+}
+
+fn run(cmd: &mut Command) -> String {
+ let out = cmd.output().expect("spawn");
+ assert!(
+ out.status.success(),
+ "{cmd:?} failed\nSTDOUT:\n{}\nSTDERR:\n{}",
+ String::from_utf8_lossy(&out.stdout),
+ String::from_utf8_lossy(&out.stderr)
+ );
+ String::from_utf8_lossy(&out.stdout).into_owned()
+}
+
+const SMALL: [&str; 8] = [
+ "--threads",
+ "1,2",
+ "--slabs",
+ "8",
+ "--reps",
+ "1",
+ "--slab",
+ "64",
+];
+
+fn results(path: &Path) -> serde_json::Value {
+ serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
+}
+
+#[test]
+fn harnesses_run_end_to_end_on_tiny_files() {
+ let dir = tempfile::TempDir::new().unwrap();
+ let data = dir.path().join("data");
+ let claw = dir.path().join("claw.json");
+
+ let bin = env!("CARGO_BIN_EXE_concurrent_read");
+ run(Command::new(bin)
+ .arg("--dir")
+ .arg(&data)
+ .args(["--datasets", "3", "--mib", "1"])
+ .args(SMALL)
+ .arg("--json")
+ .arg(&claw));
+ // Second run reuses the files (and exercises --cold).
+ let out = Command::new(bin)
+ .arg("--dir")
+ .arg(&data)
+ .args(["--datasets", "3", "--mib", "1", "--cold"])
+ .args(SMALL)
+ .output()
+ .unwrap();
+ assert!(out.status.success());
+ assert!(String::from_utf8_lossy(&out.stderr).contains("reusing"));
+
+ let doc = results(&claw);
+ assert_eq!(doc["tool"], "clawhdf5");
+ // 2 layouts x 2 modes x 2 thread counts.
+ assert_eq!(doc["results"].as_array().unwrap().len(), 8);
+ for r in doc["results"].as_array().unwrap() {
+ assert!(r["mb_s"].as_f64().unwrap() > 0.0, "{r}");
+ }
+
+ if !python_available() {
+ assert!(
+ !interop_required(),
+ "CLAWHDF5_REQUIRE_INTEROP=1 but {} has no h5py",
+ python()
+ );
+ eprintln!("skipping the h5py half: no h5py in {}", python());
+ return;
+ }
+ let mut jsons = vec![claw];
+ for executor in ["threads", "processes"] {
+ let out = dir.path().join(format!("h5py-{executor}.json"));
+ run(Command::new(python())
+ .arg(scripts().join("concurrent_read_h5py.py"))
+ .arg("--dir")
+ .arg(&data)
+ .args(["--executor", executor])
+ .args(SMALL)
+ .arg("--json")
+ .arg(&out));
+ let doc = results(&out);
+ assert_eq!(doc["tool"], format!("h5py-{executor}"));
+ assert_eq!(doc["results"].as_array().unwrap().len(), 8);
+ jsons.push(out);
+ }
+ let table = run(Command::new(python())
+ .arg(scripts().join("compare_concurrent_read.py"))
+ .args(&jsons));
+ assert!(table.contains("| deflate | same | 2 |"), "{table}");
+ assert!(table.contains("clawhdf5 / h5py-processes"), "{table}");
+
+ // A different workload must not be compared.
+ let other = dir.path().join("other.json");
+ run(Command::new(python())
+ .arg(scripts().join("concurrent_read_h5py.py"))
+ .arg("--dir")
+ .arg(&data)
+ .args([
+ "--threads",
+ "1",
+ "--slabs",
+ "4",
+ "--reps",
+ "1",
+ "--slab",
+ "64",
+ ])
+ .arg("--json")
+ .arg(&other));
+ let out = Command::new(python())
+ .arg(scripts().join("compare_concurrent_read.py"))
+ .arg(&jsons[0])
+ .arg(&other)
+ .output()
+ .unwrap();
+ assert!(!out.status.success());
+ assert!(String::from_utf8_lossy(&out.stderr).contains("slabs"));
+}
diff --git a/crates/clawhdf5-format/Cargo.toml b/crates/clawhdf5-format/Cargo.toml
index a88b734..75fd233 100644
--- a/crates/clawhdf5-format/Cargo.toml
+++ b/crates/clawhdf5-format/Cargo.toml
@@ -22,6 +22,13 @@ zstd = { version = "0.13", optional = true }
blake3 = { version = "1", optional = true }
libaec-sys = { path = "../libaec-sys", version = "0.1", optional = true }
pco = { version = "1.0", optional = true }
+# Pure-Rust Zstandard, for the plugin filters that embed zstd (bitshuffle,
+# blosc). The `zstd` feature (filter 32015) links libzstd instead.
+ruzstd = { version = "0.9", optional = true }
+# bzip2 with its default backend, libbz2-rs-sys: a pure-Rust port of
+# libbzip2 (no C is compiled, despite the -sys name).
+bzip2 = { version = "0.6", optional = true }
+snap = { version = "1", optional = true }
[dev-dependencies]
half = { workspace = true }
@@ -37,7 +44,7 @@ harness = false
# Deflate backend: `zlib-rs` (pure Rust) by default. `fast-deflate` selects
# zlib-ng instead (C, built with cmake); flate2 prefers a C zlib whenever one
# is enabled, so turning it on anywhere in the build overrides the default.
-default = ["std", "checksum", "deflate", "provenance", "zlib-rs", "system-zlib-decompress"]
+default = ["std", "checksum", "deflate", "provenance", "zlib-rs", "system-zlib-decompress", "lzf"]
std = []
checksum = []
deflate = ["flate2"]
@@ -56,6 +63,17 @@ zstd = ["dep:zstd"]
blake3_hash = ["blake3"]
szip = ["libaec-sys"]
pcodec = ["dep:pco"]
+# Plugin filters, pure Rust. LZF (32000) is h5py's built-in compression; it
+# has no dependencies, so it is on by default.
+lzf = []
+# Bitshuffle (32008), with its LZ4 and Zstandard modes.
+bitshuffle = ["lz4_flex", "ruzstd"]
+# bzip2 (307).
+bzip2 = ["dep:bzip2", "std"]
+# Blosc 1 (32001) with its BloscLZ, LZ4, Snappy, Zlib and Zstandard codecs.
+blosc = ["lz4_flex", "ruzstd", "snap", "deflate", "std"]
+# Every plugin filter above.
+plugin-filters = ["lzf", "bitshuffle", "bzip2", "blosc"]
[[bench]]
name = "parallel_decompress_bench"
diff --git a/crates/clawhdf5-format/src/attribute.rs b/crates/clawhdf5-format/src/attribute.rs
index c844c3c..11bde3e 100644
--- a/crates/clawhdf5-format/src/attribute.rs
+++ b/crates/clawhdf5-format/src/attribute.rs
@@ -362,6 +362,18 @@ fn extract_name(bytes: &[u8]) -> String {
String::from_utf8_lossy(&bytes[..end]).into_owned()
}
+/// An attribute's datatype gets libhdf5's extra check for a header without
+/// a checksum (see [`Datatype::check_unused_bits`]).
+fn check_in_header(
+ attr: AttributeMessage,
+ header: &ObjectHeader,
+) -> Result {
+ if header.version == 1 {
+ attr.datatype.check_unused_bits()?;
+ }
+ Ok(attr)
+}
+
/// Extract all attribute messages from an object header.
pub fn extract_attributes(
header: &ObjectHeader,
@@ -371,7 +383,7 @@ pub fn extract_attributes(
for msg in &header.messages {
if msg.msg_type == MessageType::Attribute {
let attr = AttributeMessage::parse(&msg.data, length_size)?;
- attrs.push(attr);
+ attrs.push(check_in_header(attr, header)?);
}
}
Ok(attrs)
@@ -465,6 +477,7 @@ fn extract_attributes_with(
} else {
AttributeMessage::parse_in_file(&msg.data, file_data, offset_size, length_size)
};
+ let attr = attr.and_then(|a| check_in_header(a, header));
match attr {
Ok(attr) => attrs.push(attr),
Err(e) => on_error(e)?,
@@ -573,7 +586,8 @@ mod tests {
/// Build an f64 LE datatype message.
fn build_f64_dt() -> Vec {
- let mut buf = build_dt_header(1, 1, [0x00, 0x00, 0x02], 8);
+ // Sign bit 63 (bits 8-15 of the class bits).
+ let mut buf = build_dt_header(1, 1, [0x20, 63, 0x00], 8);
let mut props = [0u8; 12];
props[2..4].copy_from_slice(&64u16.to_le_bytes()); // bit_precision
props[4] = 52; // exp_location
diff --git a/crates/clawhdf5-format/src/chunked_read.rs b/crates/clawhdf5-format/src/chunked_read.rs
index 3a3dc05..cbbaa69 100644
--- a/crates/clawhdf5-format/src/chunked_read.rs
+++ b/crates/clawhdf5-format/src/chunked_read.rs
@@ -15,7 +15,7 @@ use crate::datatype::Datatype;
use crate::error::FormatError;
use crate::extensible_array::{ExtensibleArrayHeader, read_extensible_array_chunks};
use crate::filter_pipeline::FilterPipeline;
-use crate::filters::{all_filters_skipped, decompress_chunk_masked};
+use crate::filters::{all_filters_skipped, decompress_chunk_exact};
use crate::fixed_array::{FixedArrayHeader, read_fixed_array_chunks};
#[cfg(feature = "std")]
use std::sync::Arc;
@@ -65,12 +65,13 @@ fn decompress_all_chunks(
let raw_chunk = &file_data[c_addr..c_addr + size];
let decompressed = if let Some(pl) = pipeline {
- decompress_chunk_masked(
+ decompress_chunk_exact(
raw_chunk,
pl,
chunk_total_bytes,
element_size,
chunk_info.filter_mask,
+ &chunk_info.offsets,
)?
} else {
raw_chunk.to_vec()
@@ -148,6 +149,96 @@ pub(crate) fn checked_byte_len(elements: u64, elem_size: usize) -> Result Result<(usize, Vec), FormatError> {
+ let rank = chunk_dimensions.len().checked_sub(1).ok_or_else(|| {
+ FormatError::InvalidChunkDimensions("chunked layout has no dimensions".into())
+ })?;
+ if dataspace.dimensions.len() != rank {
+ return Err(FormatError::InvalidChunkDimensions(format!(
+ "dimensionality of chunks doesn't match the dataspace (chunk rank {rank}, \
+ dataspace rank {})",
+ dataspace.dimensions.len()
+ )));
+ }
+ let spatial = &chunk_dimensions[..rank];
+ if let Some(d) = spatial.iter().position(|&c| c == 0) {
+ return Err(FormatError::InvalidChunkDimensions(format!(
+ "chunk size must be > 0, dim = {d}"
+ )));
+ }
+ let bytes = spatial
+ .iter()
+ .fold(elem_size as u128, |acc, &c| acc * u128::from(c));
+ if layout_version < 4 && bytes > u128::from(u32::MAX) {
+ return Err(FormatError::InvalidChunkDimensions(format!(
+ "chunk size must be < 4GB with v1 b-tree index (chunk {spatial:?} of {elem_size}-byte elements)"
+ )));
+ }
+ Ok((rank, spatial.iter().map(|&c| c as usize).collect()))
+}
+
+/// The size of one element of `dt` as stored in the file: a
+/// variable-length element is its length (4), a global heap address
+/// (`offset_size`) and an index (4), not the 16 of [`Datatype::type_size`].
+fn stored_element_size(dt: &Datatype, offset_size: u8) -> u64 {
+ match dt {
+ Datatype::VariableLength { .. } => 8 + u64::from(offset_size),
+ Datatype::Array {
+ base_type,
+ dimensions,
+ } => dimensions
+ .iter()
+ .fold(stored_element_size(base_type, offset_size), |acc, &d| {
+ acc.saturating_mul(u64::from(d))
+ }),
+ _ => u64::from(dt.type_size()),
+ }
+}
+
+/// A chunked layout records the element size as its last dimension, and
+/// libhdf5 refuses a dataset whose datatype has another size
+/// (`H5D__chunk_set_sizes`: "stored datatype size in chunk layout does not
+/// match datatype description"). Reading it anyway laid the chunks out with
+/// the wrong element size.
+pub(crate) fn check_chunk_element_size(
+ layout: &DataLayout,
+ datatype: &Datatype,
+ offset_size: u8,
+) -> Result<(), FormatError> {
+ let DataLayout::Chunked {
+ chunk_dimensions, ..
+ } = layout
+ else {
+ return Ok(());
+ };
+ let Some(&stored) = chunk_dimensions.last() else {
+ return Ok(());
+ };
+ let expected = stored_element_size(datatype, offset_size);
+ if u64::from(stored) != expected {
+ return Err(FormatError::InvalidChunkDimensions(format!(
+ "stored datatype size in chunk layout does not match datatype description \
+ (layout {stored} bytes, datatype {expected})"
+ )));
+ }
+ Ok(())
+}
+
/// Product of chunk dimensions times the element size, overflow-checked.
pub(crate) fn checked_chunk_byte_len(
chunk_dims: &[usize],
@@ -222,7 +313,76 @@ pub fn collect_chunk_info(
offset_size: u8,
length_size: u8,
) -> Result, FormatError> {
- collect_chunk_info_inner(file_data, btree_address, ndims, offset_size, length_size, 0)
+ collect_chunk_info_inner(
+ file_data,
+ btree_address,
+ ndims,
+ None,
+ offset_size,
+ length_size,
+ 0,
+ )
+}
+
+/// [`collect_chunk_info`] for a layout with these `chunk_dimensions` (the
+/// layout message's list, element size last), checking every key of the
+/// B-tree as libhdf5 does (`H5D__btree_decode_key`): each coordinate offset
+/// must be a multiple of its chunk dimension. That includes the keys that
+/// only bound a node (internal-node keys and each node's final key), which
+/// is where a corrupt chunk dimension shows when the chunks themselves all
+/// start at offset 0 in that dimension (`cve-2018-11205`). A key that fails
+/// ("bad coordinate offset") means a corrupt index or chunk dimension; the
+/// chunks were read at the wrong place, or the dataset read as fill values.
+pub fn collect_chunk_info_checked(
+ file_data: &[u8],
+ btree_address: u64,
+ chunk_dimensions: &[u32],
+ offset_size: u8,
+ length_size: u8,
+) -> Result, FormatError> {
+ collect_chunk_info_inner(
+ file_data,
+ btree_address,
+ chunk_dimensions.len(),
+ Some(chunk_dimensions),
+ offset_size,
+ length_size,
+ 0,
+ )
+}
+
+/// Check one v1 B-tree chunk key's offsets (see
+/// [`collect_chunk_info_checked`]).
+fn check_key_offsets(offsets: &[u64], chunk_dimensions: &[u32]) -> Result<(), FormatError> {
+ for (&offset, &dim) in offsets.iter().zip(chunk_dimensions) {
+ if dim == 0 || offset % u64::from(dim) != 0 {
+ return Err(FormatError::ChunkedReadError(format!(
+ "bad coordinate offset {offsets:?} for chunk dimensions {chunk_dimensions:?}"
+ )));
+ }
+ }
+ Ok(())
+}
+
+/// Read the `ndims` 8-byte offsets of the chunk key at `pos` (after its
+/// chunk size and filter mask) and check them when `chunk_dimensions` is
+/// given.
+fn read_key_offsets(
+ file_data: &[u8],
+ pos: usize,
+ ndims: usize,
+ chunk_dimensions: Option<&[u32]>,
+) -> Result, FormatError> {
+ let mut offsets = Vec::with_capacity(ndims);
+ let mut kp = pos + 8;
+ for _ in 0..ndims {
+ offsets.push(read_offset(file_data, kp, CHUNK_KEY_OFFSET_SIZE)?);
+ kp += CHUNK_KEY_OFFSET_SIZE as usize;
+ }
+ if let Some(dims) = chunk_dimensions {
+ check_key_offsets(&offsets, dims)?;
+ }
+ Ok(offsets)
}
/// Width of each chunk offset in a v1 chunk B-tree key, independent of the
@@ -237,6 +397,7 @@ fn collect_chunk_info_inner(
file_data: &[u8],
btree_address: u64,
ndims: usize,
+ chunk_dimensions: Option<&[u32]>,
offset_size: u8,
_length_size: u8,
depth: usize,
@@ -296,12 +457,7 @@ fn collect_chunk_info_inner(
file_data[pos + 6],
file_data[pos + 7],
]);
- let mut offsets = Vec::with_capacity(ndims);
- let mut kp = pos + 8;
- for _ in 0..ndims {
- offsets.push(read_offset(file_data, kp, CHUNK_KEY_OFFSET_SIZE)?);
- kp += CHUNK_KEY_OFFSET_SIZE as usize;
- }
+ let offsets = read_key_offsets(file_data, pos, ndims, chunk_dimensions)?;
pos += key_size;
// Parse child address
@@ -315,7 +471,8 @@ fn collect_chunk_info_inner(
address,
});
}
- // Skip final key
+ // The final key only bounds the node; libhdf5 still checks it.
+ read_key_offsets(file_data, pos, ndims, chunk_dimensions)?;
Ok(chunks)
} else {
// Internal node: recurse into children
@@ -324,11 +481,13 @@ fn collect_chunk_info_inner(
let mut child_addrs = Vec::with_capacity(entries_used);
for _ in 0..entries_used {
- pos += key_size; // skip key
+ read_key_offsets(file_data, pos, ndims, chunk_dimensions)?;
+ pos += key_size;
let child_addr = read_offset(file_data, pos, offset_size)?;
child_addrs.push(child_addr);
pos += os;
}
+ read_key_offsets(file_data, pos, ndims, chunk_dimensions)?;
let mut all_chunks = Vec::new();
for child_addr in child_addrs {
@@ -336,6 +495,7 @@ fn collect_chunk_info_inner(
file_data,
child_addr,
ndims,
+ chunk_dimensions,
offset_size,
_length_size,
depth + 1,
@@ -549,30 +709,13 @@ pub fn list_chunks(
.ok_or_else(|| FormatError::ChunkedReadError("no address for chunked layout".into()))?;
// Both v3 and v4 include element size as last dim (rank+1)
- let ndims = chunk_dimensions.len();
- let rank = ndims
- .checked_sub(1)
- .ok_or_else(|| FormatError::ChunkedReadError("chunked layout has no dimensions".into()))?;
- let chunk_dims: Vec = chunk_dimensions[..rank]
- .iter()
- .map(|&d| d as usize)
- .collect();
-
+ let (rank, chunk_dims) = chunk_geometry(chunk_dimensions, version, dataspace, elem_size)?;
let ds_dims: Vec = dataspace.dimensions.iter().map(|&d| d as usize).collect();
- if ds_dims.len() != rank {
- return Err(FormatError::ChunkedReadError(format!(
- "rank mismatch: dataspace has {} dims, layout has {} chunk dims (rank={})",
- ds_dims.len(),
- chunk_dimensions.len(),
- rank
- )));
- }
// Collect chunks based on version and index type
let mut chunks = match (version, chunk_index_type) {
(3, _) => {
- let ndims = chunk_dimensions.len(); // rank+1
- collect_chunk_info(file_data, addr, ndims, offset_size, length_size)?
+ collect_chunk_info_checked(file_data, addr, chunk_dimensions, offset_size, length_size)?
}
(4, Some(1)) => {
// Single chunk — one chunk covering the entire dataset
@@ -679,6 +822,7 @@ pub fn read_chunked_data(
offset_size: u8,
length_size: u8,
) -> Result, FormatError> {
+ check_chunk_element_size(layout, datatype, offset_size)?;
let elem_size = datatype.type_size() as usize;
let (chunks, chunk_dims) = list_chunks(
file_data,
@@ -802,12 +946,13 @@ pub fn read_chunked_data_cached(
length_size: u8,
cache: &ChunkCache,
) -> Result, FormatError> {
- let (chunk_dimensions, addr_opt) = match layout {
+ let (chunk_dimensions, version, addr_opt) = match layout {
DataLayout::Chunked {
chunk_dimensions,
+ version,
btree_address,
..
- } => (chunk_dimensions, *btree_address),
+ } => (chunk_dimensions, *version, *btree_address),
_ => {
return Err(FormatError::ChunkedReadError(
"expected chunked layout".into(),
@@ -818,25 +963,10 @@ pub fn read_chunked_data_cached(
let addr = addr_opt
.ok_or_else(|| FormatError::ChunkedReadError("no address for chunked layout".into()))?;
+ check_chunk_element_size(layout, datatype, offset_size)?;
let elem_size = datatype.type_size() as usize;
- let ndims = chunk_dimensions.len();
- let rank = ndims
- .checked_sub(1)
- .ok_or_else(|| FormatError::ChunkedReadError("chunked layout has no dimensions".into()))?;
- let chunk_dims: Vec = chunk_dimensions[..rank]
- .iter()
- .map(|&d| d as usize)
- .collect();
-
+ let (rank, chunk_dims) = chunk_geometry(chunk_dimensions, version, dataspace, elem_size)?;
let ds_dims: Vec = dataspace.dimensions.iter().map(|&d| d as usize).collect();
- if ds_dims.len() != rank {
- return Err(FormatError::ChunkedReadError(format!(
- "rank mismatch: dataspace has {} dims, layout has {} chunk dims (rank={})",
- ds_dims.len(),
- chunk_dimensions.len(),
- rank
- )));
- }
// The per-file cache is shared across datasets (and threads); every
// lookup is keyed by this dataset's chunk-index address, so another
@@ -932,12 +1062,13 @@ pub fn read_chunked_data_cached(
let cache_them = total_bytes <= cache.max_bytes();
if let Some(pl) = pipeline {
let decode = |c: &&ChunkInfo| -> Result, FormatError> {
- decompress_chunk_masked(
+ decompress_chunk_exact(
raw_bytes(c)?,
pl,
chunk_total_bytes,
elem_size as u32,
c.filter_mask,
+ &c.offsets,
)
};
for batch in misses.chunks(DECODE_BATCH) {
@@ -1123,12 +1254,13 @@ pub fn read_chunked_data_sweep(
cache: &ChunkCache,
sweep: &mut SweepContext,
) -> Result, FormatError> {
- let (chunk_dimensions, addr_opt) = match layout {
+ let (chunk_dimensions, version, addr_opt) = match layout {
DataLayout::Chunked {
chunk_dimensions,
+ version,
btree_address,
..
- } => (chunk_dimensions, *btree_address),
+ } => (chunk_dimensions, *version, *btree_address),
_ => {
return Err(FormatError::ChunkedReadError(
"expected chunked layout".into(),
@@ -1139,25 +1271,10 @@ pub fn read_chunked_data_sweep(
let addr = addr_opt
.ok_or_else(|| FormatError::ChunkedReadError("no address for chunked layout".into()))?;
+ check_chunk_element_size(layout, datatype, offset_size)?;
let elem_size = datatype.type_size() as usize;
- let ndims = chunk_dimensions.len();
- let rank = ndims
- .checked_sub(1)
- .ok_or_else(|| FormatError::ChunkedReadError("chunked layout has no dimensions".into()))?;
- let chunk_dims: Vec = chunk_dimensions[..rank]
- .iter()
- .map(|&d| d as usize)
- .collect();
-
+ let (rank, chunk_dims) = chunk_geometry(chunk_dimensions, version, dataspace, elem_size)?;
let ds_dims: Vec = dataspace.dimensions.iter().map(|&d| d as usize).collect();
- if ds_dims.len() != rank {
- return Err(FormatError::ChunkedReadError(format!(
- "rank mismatch: dataspace has {} dims, layout has {} chunk dims (rank={})",
- ds_dims.len(),
- chunk_dimensions.len(),
- rank
- )));
- }
// The per-file cache is shared across datasets (and threads); every
// lookup is keyed by this dataset's chunk-index address, so another
@@ -1217,12 +1334,13 @@ pub fn read_chunked_data_sweep(
ensure_len(file_data, c_addr, size)?;
let raw_chunk = &file_data[c_addr..c_addr + size];
let dec = if let Some(pl) = pipeline {
- decompress_chunk_masked(
+ decompress_chunk_exact(
raw_chunk,
pl,
chunk_total_bytes,
elem_size as u32,
chunk_info.filter_mask,
+ &coord,
)?
} else {
raw_chunk.to_vec()
@@ -1275,12 +1393,13 @@ pub fn read_chunked_data_indexed(
length_size: u8,
cache: &ChunkCache,
) -> Result, FormatError> {
- let (chunk_dimensions, addr_opt) = match layout {
+ let (chunk_dimensions, version, addr_opt) = match layout {
DataLayout::Chunked {
chunk_dimensions,
+ version,
btree_address,
..
- } => (chunk_dimensions, *btree_address),
+ } => (chunk_dimensions, *version, *btree_address),
_ => {
return Err(FormatError::ChunkedReadError(
"expected chunked layout".into(),
@@ -1291,25 +1410,10 @@ pub fn read_chunked_data_indexed(
let addr = addr_opt
.ok_or_else(|| FormatError::ChunkedReadError("no address for chunked layout".into()))?;
+ check_chunk_element_size(layout, datatype, offset_size)?;
let elem_size = datatype.type_size() as usize;
- let ndims = chunk_dimensions.len();
- let rank = ndims
- .checked_sub(1)
- .ok_or_else(|| FormatError::ChunkedReadError("chunked layout has no dimensions".into()))?;
- let chunk_dims: Vec = chunk_dimensions[..rank]
- .iter()
- .map(|&d| d as usize)
- .collect();
-
+ let (rank, chunk_dims) = chunk_geometry(chunk_dimensions, version, dataspace, elem_size)?;
let ds_dims: Vec = dataspace.dimensions.iter().map(|&d| d as usize).collect();
- if ds_dims.len() != rank {
- return Err(FormatError::ChunkedReadError(format!(
- "rank mismatch: dataspace has {} dims, layout has {} chunk dims (rank={})",
- ds_dims.len(),
- chunk_dimensions.len(),
- rank
- )));
- }
// Chunk index and assembly plan for this dataset, built on first access
// and kept per dataset (keyed by chunk-index address) in the shared cache.
@@ -1346,12 +1450,13 @@ pub fn read_chunked_data_indexed(
ensure_len(file_data, c_addr, size)?;
let raw_chunk = &file_data[c_addr..c_addr + size];
let decompressed = if let Some(pl) = pipeline {
- decompress_chunk_masked(
+ decompress_chunk_exact(
raw_chunk,
pl,
chunk_total_bytes,
elem_size as u32,
*filter_mask,
+ coord,
)?
} else {
raw_chunk.to_vec()
@@ -1620,11 +1725,12 @@ mod tests {
write_offset(&mut buf, chunk.address, offset_size);
}
- // Final key (dummy)
+ // Final key (its offsets must be on the chunk grid, as libhdf5
+ // checks; 0 always is)
buf.extend_from_slice(&0u32.to_le_bytes()); // chunk_size
buf.extend_from_slice(&0u32.to_le_bytes()); // filter_mask
for _ in 0..ndims {
- write_offset(&mut buf, u64::MAX, 8);
+ write_offset(&mut buf, 0, 8);
}
buf
@@ -1632,6 +1738,48 @@ mod tests {
// --- ChunkInfo collection tests ---
+ #[test]
+ fn checked_collection_refuses_keys_off_the_chunk_grid() {
+ let chunk = |offsets: Vec, address| ChunkInfo {
+ chunk_size: 80,
+ filter_mask: 0,
+ offsets,
+ address,
+ };
+ let good =
+ build_chunk_btree_leaf(&[chunk(vec![0, 0], 0x100), chunk(vec![10, 0], 0x200)], 2, 8);
+ assert_eq!(
+ collect_chunk_info_checked(&good, 0, &[10, 8], 8, 8)
+ .unwrap()
+ .len(),
+ 2
+ );
+ // A chunk key off the grid.
+ let bad =
+ build_chunk_btree_leaf(&[chunk(vec![0, 0], 0x100), chunk(vec![7, 0], 0x200)], 2, 8);
+ assert!(collect_chunk_info(&bad, 0, 2, 8, 8).is_ok());
+ assert!(matches!(
+ collect_chunk_info_checked(&bad, 0, &[10, 8], 8, 8),
+ Err(FormatError::ChunkedReadError(m)) if m.starts_with("bad coordinate offset")
+ ));
+ // cve-2018-11205: the chunks all start at 0 in dimension 1, and only
+ // the node's final key shows the chunk dimension is wrong.
+ let mut two_d = build_chunk_btree_leaf(
+ &[chunk(vec![0, 0, 0], 0x100), chunk(vec![10, 0, 0], 0x200)],
+ 3,
+ 8,
+ );
+ // Final key: (20, 20, 0), the end of a 20 x 20 dataset.
+ let final_key = two_d.len() - 24;
+ two_d[final_key..final_key + 8].copy_from_slice(&20u64.to_le_bytes());
+ two_d[final_key + 8..final_key + 16].copy_from_slice(&20u64.to_le_bytes());
+ assert!(collect_chunk_info_checked(&two_d, 0, &[10, 20, 4], 8, 8).is_ok());
+ assert!(matches!(
+ collect_chunk_info_checked(&two_d, 0, &[10, 32788, 4], 8, 8),
+ Err(FormatError::ChunkedReadError(m)) if m.starts_with("bad coordinate offset [20, 20, 0]")
+ ));
+ }
+
#[test]
fn collect_two_chunks_from_leaf() {
let ndims = 2; // rank+1 for 1D dataset
@@ -1750,6 +1898,51 @@ mod tests {
use crate::dataspace::{Dataspace, DataspaceType};
use crate::datatype::{Datatype, DatatypeByteOrder};
+ #[test]
+ fn chunk_geometry_matches_libhdf5_open_checks() {
+ let space = |dims: &[u64]| Dataspace {
+ space_type: DataspaceType::Simple,
+ rank: dims.len() as u8,
+ dimensions: dims.to_vec(),
+ max_dimensions: None,
+ };
+ for v in [3, 4] {
+ assert_eq!(
+ chunk_geometry(&[4, 5, 8], v, &space(&[10, 10]), 8).unwrap(),
+ (2, vec![4, 5])
+ );
+ // Rank mismatch.
+ assert!(matches!(
+ chunk_geometry(&[4, 8], v, &space(&[10, 10]), 8),
+ Err(FormatError::InvalidChunkDimensions(m)) if m.contains("doesn't match")
+ ));
+ // Zero dimension (a layout built in memory, bypassing the parser).
+ assert!(matches!(
+ chunk_geometry(&[4, 0, 8], v, &space(&[10, 10]), 8),
+ Err(FormatError::InvalidChunkDimensions(m)) if m.contains("must be > 0")
+ ));
+ assert!(chunk_geometry(&[0xFFFF_FFFF, 1], v, &space(&[10]), 1).is_ok());
+ }
+ // With a v1 B-tree index (layout version 3) the largest chunk is
+ // 4 GiB - 1 bytes: 0x80000000 x 4-byte elements (8 GiB) is refused.
+ // These dims used to hang the reader.
+ assert!(matches!(
+ chunk_geometry(&[0x8000_0000, 4], 3, &space(&[10]), 4),
+ Err(FormatError::InvalidChunkDimensions(m)) if m.contains("4GB with v1 b-tree")
+ ));
+ assert!(matches!(
+ chunk_geometry(&[0xFFFF_FFFF, 0xFFFF_FFFF, 1], 3, &space(&[10, 10]), 1),
+ Err(FormatError::InvalidChunkDimensions(m)) if m.contains("4GB with v1 b-tree")
+ ));
+ // The other chunk indexes (layout version 4, and 5, which is read as
+ // 4) allow chunks of 4 GiB and more; HDF5 2.0 writes them.
+ assert_eq!(
+ chunk_geometry(&[0x2000_0001, 8], 4, &space(&[10]), 8).unwrap(),
+ (1, vec![0x2000_0001])
+ );
+ assert!(chunk_geometry(&[0xFFFF_FFFF, 0xFFFF_FFFF, 1], 4, &space(&[10, 10]), 1).is_ok());
+ }
+
fn make_f64_type() -> Datatype {
Datatype::FloatingPoint {
size: 8,
@@ -1863,8 +2056,8 @@ mod tests {
let file_data = vec![0u8; 64];
let result = read_chunked_data(&file_data, &layout, &dataspace, &datatype, None, 8, 8);
assert!(
- matches!(result, Err(FormatError::ChunkedReadError(_))),
- "expected a clean ChunkedReadError, got {result:?}"
+ matches!(result, Err(FormatError::InvalidChunkDimensions(_))),
+ "expected a clean InvalidChunkDimensions, got {result:?}"
);
}
diff --git a/crates/clawhdf5-format/src/chunked_write.rs b/crates/clawhdf5-format/src/chunked_write.rs
index ff5081e..05383a6 100644
--- a/crates/clawhdf5-format/src/chunked_write.rs
+++ b/crates/clawhdf5-format/src/chunked_write.rs
@@ -12,8 +12,9 @@ use crate::chunk_grid::ChunkGrid;
use crate::ea_writer;
use crate::error::FormatError;
use crate::filter_pipeline::{
- FILTER_DEFLATE, FILTER_FLETCHER32, FILTER_LZ4, FILTER_PCODEC, FILTER_PCODEC_NAME,
- FILTER_SHUFFLE, FILTER_ZSTD, FilterDescription, FilterPipeline,
+ FILTER_BITSHUFFLE, FILTER_BLOSC, FILTER_BZIP2, FILTER_DEFLATE, FILTER_FLETCHER32, FILTER_LZ4,
+ FILTER_LZF, FILTER_PCODEC, FILTER_PCODEC_NAME, FILTER_SHUFFLE, FILTER_ZSTD, FilterDescription,
+ FilterPipeline,
};
use crate::filters::compress_chunk;
/// Round a file offset up to the next cache-line boundary.
@@ -48,6 +49,167 @@ pub struct ChunkOptions {
/// Pcodec lossless numerical compression. Private, unregistered filter
/// ID [`FILTER_PCODEC`] (480): only clawhdf5 can read it.
pub pcodec: bool,
+ /// A plugin compression filter (LZF, ...). Takes priority over the
+ /// codecs above. Each needs its cargo feature to be written.
+ pub plugin: Option,
+}
+
+/// A compression filter from the common HDF5 plugin set, written in the
+/// format the libhdf5 plugin (h5py / hdf5plugin) reads.
+#[derive(Debug, Clone, PartialEq, Eq)]
+#[non_exhaustive]
+pub enum PluginFilter {
+ /// LZF (filter 32000), h5py's built-in `compression="lzf"`. Needs the
+ /// `lzf` feature.
+ Lzf,
+ /// Bitshuffle (filter 32008): a bit transpose of each block of
+ /// `block_size` elements (0 = bitshuffle's default, else a multiple of
+ /// 8), optionally compressed. Needs the `bitshuffle` feature.
+ Bitshuffle {
+ /// Block size in elements; 0 for the default.
+ block_size: u32,
+ /// Compression after the transpose.
+ compression: BitshuffleCompression,
+ },
+ /// bzip2 (filter 307) at block size `level` (1-9). Needs the `bzip2`
+ /// feature.
+ Bzip2 {
+ /// Block size 1-9 (9 = hdf5plugin's default).
+ level: u32,
+ },
+ /// Blosc 1 (filter 32001): `codec` at `level` (0-9; 0 stores), after
+ /// `shuffle`. Needs the `blosc` feature.
+ Blosc {
+ /// The codec inside the Blosc frame.
+ codec: BloscCodec,
+ /// Compression level 0-9 (0 stores the data uncompressed).
+ level: u32,
+ /// The shuffle Blosc applies first.
+ shuffle: BloscShuffle,
+ },
+}
+
+/// The codec inside a Blosc frame that clawhdf5 can write. (It reads
+/// BloscLZ too, but cannot write it.)
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum BloscCodec {
+ /// LZ4.
+ Lz4,
+ /// Snappy.
+ Snappy,
+ /// Zlib, at the Blosc level.
+ Zlib,
+ /// Zstandard (clawhdf5's pure-Rust encoder has one level, about zstd 1).
+ Zstd,
+}
+
+/// The shuffle Blosc applies before compressing.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum BloscShuffle {
+ /// None.
+ None,
+ /// Byte shuffle (Blosc's default).
+ Byte,
+ /// Bit shuffle.
+ Bit,
+}
+
+/// What bitshuffle compresses its blocks with.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum BitshuffleCompression {
+ /// Transpose only.
+ None,
+ /// LZ4 (bitshuffle's `cname="lz4"`, the common choice).
+ Lz4,
+ /// Zstandard. clawhdf5's pure-Rust encoder has a single level (about
+ /// zstd's level 1); `level` is recorded in the file for other writers.
+ Zstd {
+ /// Level recorded in `cd_values[5]`.
+ level: u32,
+ },
+}
+
+impl PluginFilter {
+ /// Whether the filter reorders bytes itself, so the automatic shuffle
+ /// pre-filter would only get in its way.
+ fn shuffles_itself(&self) -> bool {
+ match self {
+ PluginFilter::Lzf => false,
+ PluginFilter::Bitshuffle { .. } => true,
+ PluginFilter::Bzip2 { .. } => false,
+ PluginFilter::Blosc { .. } => true,
+ }
+ }
+
+ /// The pipeline entry for this filter. `chunk_bytes` is one chunk's
+ /// uncompressed size (0 if unknown).
+ fn description(&self, element_size: u32, chunk_bytes: u32) -> FilterDescription {
+ match self {
+ // h5py's lzf_set_local: filter version, liblzf version, chunk
+ // size in bytes. Optional, as h5py flags it: a chunk the filter
+ // cannot shrink may then be stored unfiltered.
+ PluginFilter::Lzf => FilterDescription {
+ filter_id: FILTER_LZF,
+ name: Some("lzf".into()),
+ flags: 1,
+ client_data: vec![4, 0x0105, chunk_bytes],
+ },
+ // bshuf_h5_set_local: version 0.4, element size, block size,
+ // compression (0 none, 2 LZ4, 3 Zstandard), Zstandard level.
+ // hdf5-blosc's blosc_set_local: filter revision 2, Blosc format
+ // 2, type size, chunk size, then level, shuffle, compressor.
+ PluginFilter::Blosc {
+ codec,
+ level,
+ shuffle,
+ } => FilterDescription {
+ filter_id: FILTER_BLOSC,
+ name: Some("blosc".into()),
+ flags: 1,
+ client_data: vec![
+ 2,
+ 2,
+ element_size,
+ chunk_bytes,
+ (*level).min(9),
+ match shuffle {
+ BloscShuffle::None => 0,
+ BloscShuffle::Byte => 1,
+ BloscShuffle::Bit => 2,
+ },
+ match codec {
+ BloscCodec::Lz4 => 1,
+ BloscCodec::Snappy => 3,
+ BloscCodec::Zlib => 4,
+ BloscCodec::Zstd => 5,
+ },
+ ],
+ },
+ PluginFilter::Bzip2 { level } => FilterDescription {
+ filter_id: FILTER_BZIP2,
+ name: Some("bzip2".into()),
+ flags: 1,
+ client_data: vec![(*level).clamp(1, 9)],
+ },
+ PluginFilter::Bitshuffle {
+ block_size,
+ compression,
+ } => {
+ let mut cd = vec![0, 4, element_size, *block_size];
+ match compression {
+ BitshuffleCompression::None => cd.push(0),
+ BitshuffleCompression::Lz4 => cd.push(2),
+ BitshuffleCompression::Zstd { level } => cd.extend([3, *level]),
+ }
+ FilterDescription {
+ filter_id: FILTER_BITSHUFFLE,
+ name: Some("bitshuffle; see https://github.com/kiyo-masui/bitshuffle".into()),
+ flags: 1,
+ client_data: cd,
+ }
+ }
+ }
+ }
}
/// Largest chunk the automatic choice produces, in bytes.
@@ -92,14 +254,33 @@ impl ChunkOptions {
|| self.lz4
|| self.zstd_level.is_some()
|| self.pcodec
+ || self.plugin.is_some()
}
/// Build a FilterPipeline from the options.
pub fn build_pipeline(&self, element_size: u32) -> Option {
+ self.build_pipeline_for_chunk(element_size, 0)
+ }
+
+ /// Build a FilterPipeline for chunks of `chunk_bytes` uncompressed bytes
+ /// (0 if unknown). Some plugin filters record the chunk size in their
+ /// client data.
+ pub fn build_pipeline_for_chunk(
+ &self,
+ element_size: u32,
+ chunk_bytes: u32,
+ ) -> Option {
let mut filters = Vec::new();
- let has_compression =
- self.deflate_level.is_some() || self.zstd_level.is_some() || self.lz4 || self.pcodec;
+ let plugin_shuffles = self
+ .plugin
+ .as_ref()
+ .is_some_and(PluginFilter::shuffles_itself);
+ let has_compression = self.deflate_level.is_some()
+ || self.zstd_level.is_some()
+ || self.lz4
+ || self.pcodec
+ || (self.plugin.is_some() && !plugin_shuffles);
// Shuffle before compression. Applied if explicitly requested OR if compression
// is active and the caller hasn't disabled it — matches h5py default behavior
@@ -113,8 +294,11 @@ impl ChunkOptions {
});
}
- // Compression filters (mutually exclusive, priority: pcodec > zstd > lz4 > deflate)
- if self.pcodec {
+ // Compression filters (mutually exclusive, priority: plugin > pcodec >
+ // zstd > lz4 > deflate)
+ if let Some(plugin) = &self.plugin {
+ filters.push(plugin.description(element_size, chunk_bytes));
+ } else if self.pcodec {
filters.push(FilterDescription {
filter_id: FILTER_PCODEC,
name: Some(FILTER_PCODEC_NAME.into()),
@@ -383,39 +567,7 @@ fn serialize_v4_single_chunk(
let ndims = chunk_dims.len() as u8 + 1;
buf.push(ndims);
- // dim_size_encoded_length: how many bytes per dimension
- // We need to figure out the minimum encoding width
- let max_dim = chunk_dims
- .iter()
- .map(|&d| d as u64)
- .chain(core::iter::once(element_size as u64))
- .max()
- .unwrap_or(1);
- let dim_encoded_len: u8 = if max_dim <= 0xFF {
- 1
- } else if max_dim <= 0xFFFF {
- 2
- } else {
- 4
- };
- buf.push(dim_encoded_len);
-
- // dimension sizes (chunk dims + element size)
- for &d in chunk_dims {
- match dim_encoded_len {
- 1 => buf.push(d as u8),
- 2 => buf.extend_from_slice(&(d as u16).to_le_bytes()),
- 4 => buf.extend_from_slice(&d.to_le_bytes()),
- _ => {}
- }
- }
- // Element size dimension
- match dim_encoded_len {
- 1 => buf.push(element_size as u8),
- 2 => buf.extend_from_slice(&(element_size as u16).to_le_bytes()),
- 4 => buf.extend_from_slice(&element_size.to_le_bytes()),
- _ => {}
- }
+ push_v4_chunk_dims(&mut buf, chunk_dims, element_size);
// chunk index type = 1 (single chunk)
buf.push(1);
@@ -465,6 +617,25 @@ fn serialize_v4_fixed_array(
/// The part of a v4 chunked layout message before the chunk index type:
/// version, class, flags and the chunk dimensions (plus the element size).
+/// Append a v4 layout's dimension width and its dimensions (the chunk
+/// dimensions, then the element size). Each takes the fewest bytes that hold
+/// the largest, as libhdf5 computes it (`H5D__chunk_set_sizes`:
+/// `(log2(dim) + 8) / 8`); HDF5 2.0.0 refuses any other width.
+pub(crate) fn push_v4_chunk_dims(buf: &mut Vec, chunk_dims: &[u32], element_size: u32) {
+ let max_dim = chunk_dims
+ .iter()
+ .copied()
+ .chain(core::iter::once(element_size))
+ .max()
+ .unwrap_or(1)
+ .max(1);
+ let width = (32 - max_dim.leading_zeros()).div_ceil(8) as usize;
+ buf.push(width as u8);
+ for &d in chunk_dims.iter().chain(core::iter::once(&element_size)) {
+ buf.extend_from_slice(&d.to_le_bytes()[..width]);
+ }
+}
+
fn layout_v4_chunked_prefix(chunk_dims: &[u32], element_size: u32) -> Vec {
let mut buf = Vec::new();
buf.push(4); // version
@@ -476,35 +647,7 @@ fn layout_v4_chunked_prefix(chunk_dims: &[u32], element_size: u32) -> Vec {
let ndims = chunk_dims.len() as u8 + 1;
buf.push(ndims);
- let max_dim = chunk_dims
- .iter()
- .map(|&d| d as u64)
- .chain(core::iter::once(element_size as u64))
- .max()
- .unwrap_or(1);
- let dim_encoded_len: u8 = if max_dim <= 0xFF {
- 1
- } else if max_dim <= 0xFFFF {
- 2
- } else {
- 4
- };
- buf.push(dim_encoded_len);
-
- for &d in chunk_dims {
- match dim_encoded_len {
- 1 => buf.push(d as u8),
- 2 => buf.extend_from_slice(&(d as u16).to_le_bytes()),
- 4 => buf.extend_from_slice(&d.to_le_bytes()),
- _ => {}
- }
- }
- match dim_encoded_len {
- 1 => buf.push(element_size as u8),
- 2 => buf.extend_from_slice(&(element_size as u16).to_le_bytes()),
- 4 => buf.extend_from_slice(&element_size.to_le_bytes()),
- _ => {}
- }
+ push_v4_chunk_dims(&mut buf, chunk_dims, element_size);
buf
}
@@ -675,7 +818,12 @@ pub fn precompress_chunks(
element_size: usize,
options: &ChunkOptions,
) -> Result {
- let pipeline = options.build_pipeline(element_size as u32);
+ let chunk_bytes = chunk_dims
+ .iter()
+ .try_fold(element_size as u64, |acc, &d| acc.checked_mul(d))
+ .and_then(|b| u32::try_from(b).ok())
+ .unwrap_or(0);
+ let pipeline = options.build_pipeline_for_chunk(element_size as u32, chunk_bytes);
let has_filters = pipeline.is_some();
let pipeline_message = pipeline.as_ref().map(|pl| pl.serialize());
@@ -1569,6 +1717,35 @@ mod tests {
assert_eq!(pl.filters[1].client_data, vec![3]);
}
+ #[test]
+ fn chunk_options_pipeline_lzf() {
+ let options = ChunkOptions {
+ plugin: Some(PluginFilter::Lzf),
+ ..Default::default()
+ };
+ assert!(options.is_chunked());
+ let pl = options.build_pipeline_for_chunk(8, 800).unwrap();
+ assert_eq!(pl.filters.len(), 2);
+ assert_eq!(pl.filters[0].filter_id, FILTER_SHUFFLE);
+ assert_eq!(pl.filters[1].filter_id, FILTER_LZF);
+ assert_eq!(pl.filters[1].client_data, vec![4, 0x0105, 800]);
+ }
+
+ #[test]
+ fn chunk_options_pipeline_bitshuffle_has_no_auto_shuffle() {
+ let options = ChunkOptions {
+ plugin: Some(PluginFilter::Bitshuffle {
+ block_size: 0,
+ compression: BitshuffleCompression::Zstd { level: 5 },
+ }),
+ ..Default::default()
+ };
+ let pl = options.build_pipeline(4).unwrap();
+ assert_eq!(pl.filters.len(), 1);
+ assert_eq!(pl.filters[0].filter_id, FILTER_BITSHUFFLE);
+ assert_eq!(pl.filters[0].client_data, vec![0, 4, 4, 0, 3, 5]);
+ }
+
#[test]
fn chunk_options_zstd_priority_over_deflate() {
let options = ChunkOptions {
diff --git a/crates/clawhdf5-format/src/data_layout.rs b/crates/clawhdf5-format/src/data_layout.rs
index 60a8243..59a9065 100644
--- a/crates/clawhdf5-format/src/data_layout.rs
+++ b/crates/clawhdf5-format/src/data_layout.rs
@@ -24,6 +24,34 @@ pub struct VdsMapping {
pub virtual_selection: Vec,
}
+/// Most dimensions a layout message can list (libhdf5 `H5O_LAYOUT_NDIMS`):
+/// 32 dataspace dimensions plus the element size.
+const MAX_LAYOUT_NDIMS: usize = 33;
+
+/// libhdf5's checks on a chunked layout message's dimensions
+/// (`H5O__layout_decode`): at most [`MAX_LAYOUT_NDIMS`], no dimension 0, and
+/// before version 4 at least one dataspace dimension plus the element size.
+/// A zero chunk dimension used to read the dataset as all fill values.
+fn check_chunk_dims(dims: Vec, layout_version: u8) -> Result, FormatError> {
+ if dims.len() > MAX_LAYOUT_NDIMS {
+ return Err(FormatError::InvalidChunkDimensions(
+ "dimensionality is too large".into(),
+ ));
+ }
+ if layout_version < 4 && dims.len() < 2 {
+ return Err(FormatError::InvalidChunkDimensions(
+ "bad dimensions for chunked storage".into(),
+ ));
+ }
+ if let Some(u) = dims.iter().position(|&d| d == 0) {
+ return Err(FormatError::InvalidChunkDimensions(format!(
+ "bad chunk dimension value when parsing layout message - chunk dimension must be \
+ positive: mesg->u.chunk.dim[{u}] = 0"
+ )));
+ }
+ Ok(dims)
+}
+
/// Parsed HDF5 data layout message.
#[derive(Debug, Clone, PartialEq)]
pub enum DataLayout {
@@ -394,7 +422,7 @@ impl DataLayout {
Ok(DataLayout::Contiguous { address, size })
}
_ => Ok(DataLayout::Chunked {
- chunk_dimensions: dims,
+ chunk_dimensions: check_chunk_dims(dims, 2)?,
btree_address: address,
version: 3,
chunk_index_type: None,
@@ -457,7 +485,7 @@ impl DataLayout {
p += 4;
}
Ok(DataLayout::Chunked {
- chunk_dimensions,
+ chunk_dimensions: check_chunk_dims(chunk_dimensions, 3)?,
btree_address,
version: 3,
chunk_index_type: None,
@@ -506,47 +534,40 @@ impl DataLayout {
let dimensionality = data[pos + 1] as usize;
let dim_size_encoded_length = data[pos + 2] as usize;
let mut p = pos + 3;
+ if dimensionality > MAX_LAYOUT_NDIMS {
+ return Err(FormatError::InvalidChunkDimensions(
+ "dimensionality is too large".into(),
+ ));
+ }
- // dimension sizes
+ // Each dimension takes 1 to 8 bytes (libhdf5 writes the
+ // fewest that hold the largest one, so 3, 5, 6 and 7 occur:
+ // a chunk dimension of 70 000 takes 3). libhdf5 refuses 0
+ // and more than 8.
+ if dim_size_encoded_length == 0 || dim_size_encoded_length > 8 {
+ return Err(FormatError::InvalidChunkDimensions(
+ "encoded chunk dimension size is too large".into(),
+ ));
+ }
ensure_len(data, p, dimensionality * dim_size_encoded_length)?;
let mut chunk_dimensions = Vec::with_capacity(dimensionality);
for _ in 0..dimensionality {
- let val = match dim_size_encoded_length {
- 1 => data[p] as u32,
- 2 => u16::from_le_bytes([data[p], data[p + 1]]) as u32,
- 4 => u32::from_le_bytes([data[p], data[p + 1], data[p + 2], data[p + 3]]),
- 8 => {
- // V4 chunked encodes dimension sizes as 8 bytes, but
- // our ChunkedStorageV4 stores them as u32. We read only
- // the low 4 bytes (little-endian). This silently
- // truncates dimensions > 4 GiB, which are not expected
- // in practice (HDF5 chunk dimensions are always small).
- // If the high bytes are non-zero, the file is malformed
- // or uses dimensions we cannot represent.
- let high = u32::from_le_bytes([
- data[p + 4],
- data[p + 5],
- data[p + 6],
- data[p + 7],
- ]);
- if high != 0 {
- return Err(FormatError::UnexpectedEof {
- expected: p + 8,
- available: data.len(),
- });
- }
- u32::from_le_bytes([data[p], data[p + 1], data[p + 2], data[p + 3]])
- }
- _ => {
- return Err(FormatError::UnexpectedEof {
- expected: p + dim_size_encoded_length,
- available: data.len(),
- });
- }
- };
+ let val = data[p..p + dim_size_encoded_length]
+ .iter()
+ .rev()
+ .fold(0u64, |acc, &b| (acc << 8) | u64::from(b));
+ // Chunk dimensions are held as u32; HDF5 2.0 can write
+ // larger ones (layout version 5), which are refused
+ // rather than truncated.
+ let val = u32::try_from(val).map_err(|_| {
+ FormatError::InvalidChunkDimensions(format!(
+ "chunk dimension {val} is larger than 2^32 - 1, which is not supported"
+ ))
+ })?;
chunk_dimensions.push(val);
p += dim_size_encoded_length;
}
+ let chunk_dimensions = check_chunk_dims(chunk_dimensions, 4)?;
// chunk index type
ensure_len(data, p, 1)?;
@@ -755,6 +776,100 @@ mod tests {
);
}
+ /// A v3 chunked layout message with these dims (element size last).
+ fn v3_chunked_msg(dims: &[u32]) -> Vec {
+ let mut buf = vec![3u8, 2, dims.len() as u8];
+ buf.extend_from_slice(&0x1000u64.to_le_bytes());
+ for d in dims {
+ buf.extend_from_slice(&d.to_le_bytes());
+ }
+ buf
+ }
+
+ #[test]
+ fn chunk_dimensions_are_checked_when_the_layout_is_parsed() {
+ assert!(DataLayout::parse(&v3_chunked_msg(&[4, 4, 8]), 8, 8).is_ok());
+ // A zero chunk dimension used to read as all fill values.
+ let err = DataLayout::parse(&v3_chunked_msg(&[4, 0, 8]), 8, 8).unwrap_err();
+ assert!(
+ matches!(&err, FormatError::InvalidChunkDimensions(m) if m.contains("dim[1] = 0")),
+ "{err:?}"
+ );
+ // Only the element-size dimension: libhdf5 "bad dimensions".
+ assert_eq!(
+ DataLayout::parse(&v3_chunked_msg(&[8]), 8, 8).unwrap_err(),
+ FormatError::InvalidChunkDimensions("bad dimensions for chunked storage".into())
+ );
+ assert_eq!(
+ DataLayout::parse(&v3_chunked_msg(&[1; 34]), 8, 8).unwrap_err(),
+ FormatError::InvalidChunkDimensions("dimensionality is too large".into())
+ );
+ // v1/v2 and v4 messages get the zero check too.
+ let mut v1 = v1v2_header(1, 2, 2);
+ v1.extend_from_slice(&0x1000u64.to_le_bytes());
+ v1.extend_from_slice(&0u32.to_le_bytes());
+ v1.extend_from_slice(&8u32.to_le_bytes());
+ assert!(matches!(
+ DataLayout::parse(&v1, 8, 8),
+ Err(FormatError::InvalidChunkDimensions(_))
+ ));
+ let mut v4 = vec![4u8, 2, 0, 2, 4];
+ v4.extend_from_slice(&0u32.to_le_bytes());
+ v4.extend_from_slice(&8u32.to_le_bytes());
+ v4.push(3); // fixed array index
+ v4.push(0); // page bits
+ v4.extend_from_slice(&0x1000u64.to_le_bytes());
+ assert!(matches!(
+ DataLayout::parse(&v4, 8, 8),
+ Err(FormatError::InvalidChunkDimensions(_))
+ ));
+ }
+
+ /// A v4 chunked layout (fixed array index) whose `dims` are each
+ /// encoded in `width` bytes.
+ fn v4_chunked_msg(width: u8, dims: &[u64]) -> Vec {
+ let mut m = vec![4u8, 2, 0, dims.len() as u8, width];
+ for &d in dims {
+ m.extend_from_slice(&d.to_le_bytes()[..width.min(8) as usize]);
+ }
+ m.push(3); // fixed array index
+ m.push(0); // page bits
+ m.extend_from_slice(&0x1000u64.to_le_bytes());
+ m
+ }
+
+ #[test]
+ fn v4_chunk_dimensions_take_1_to_8_bytes() {
+ // libhdf5 encodes each dimension in the fewest bytes that hold the
+ // largest: a chunk dimension of 70 000 takes 3, and 3, 5, 6 and 7
+ // were refused ("UnexpectedEof").
+ for width in 1..=8u8 {
+ let dims = [if width >= 3 { 70_000 } else { 200 }, 8];
+ let layout = DataLayout::parse(&v4_chunked_msg(width, &dims), 8, 8)
+ .unwrap_or_else(|e| panic!("width {width}: {e:?}"));
+ assert!(
+ matches!(&layout, DataLayout::Chunked { chunk_dimensions, .. }
+ if chunk_dimensions.iter().map(|&d| u64::from(d)).eq(dims)),
+ "width {width}: {layout:?}"
+ );
+ }
+ // libhdf5 refuses 0 and more than 8 bytes.
+ for width in [0u8, 9] {
+ assert_eq!(
+ DataLayout::parse(&v4_chunked_msg(width, &[4, 8]), 8, 8).unwrap_err(),
+ FormatError::InvalidChunkDimensions(
+ "encoded chunk dimension size is too large".into()
+ )
+ );
+ }
+ // A dimension past u32 cannot be represented and is refused, not
+ // truncated.
+ assert!(matches!(
+ DataLayout::parse(&v4_chunked_msg(5, &[1 << 32, 8]), 8, 8),
+ Err(FormatError::InvalidChunkDimensions(m)) if m.contains("2^32")
+ ));
+ }
+
#[test]
fn v1v2_rejects_bad_class_dimensionality_and_truncation() {
assert_eq!(
diff --git a/crates/clawhdf5-format/src/data_read.rs b/crates/clawhdf5-format/src/data_read.rs
index 76c2b0e..b8f2ffe 100644
--- a/crates/clawhdf5-format/src/data_read.rs
+++ b/crates/clawhdf5-format/src/data_read.rs
@@ -303,6 +303,7 @@ pub fn read_raw_data_selection(
use crate::selection::Selection;
crate::partial_read::validate(selection, &dataspace.dimensions)?;
+ crate::chunked_read::check_chunk_element_size(layout, datatype, offset_size)?;
// Read only what the selection's bounding box touches when that is
// possible; everything below is the decode-everything-then-pick path,
@@ -360,6 +361,7 @@ pub fn read_raw_data_selection(
chunk_index_type,
..
} => {
+ crate::chunked_read::chunk_geometry(chunk_dimensions, *version, dataspace, elem_size)?;
// For chunked data, only read chunks that intersect the selection
let chunk_dims: Vec = chunk_dimensions.iter().map(|&d| d as u64).collect();
let rank = dims.len();
@@ -400,10 +402,10 @@ pub fn read_raw_data_selection(
} else {
// v3: B-tree v1
if let Some(addr) = btree_address {
- crate::chunked_read::collect_chunk_info(
+ crate::chunked_read::collect_chunk_info_checked(
file_data,
*addr,
- rank + 1,
+ chunk_dimensions,
offset_size,
length_size,
)?
diff --git a/crates/clawhdf5-format/src/datatype.rs b/crates/clawhdf5-format/src/datatype.rs
index ba85afa..12e427f 100644
--- a/crates/clawhdf5-format/src/datatype.rs
+++ b/crates/clawhdf5-format/src/datatype.rs
@@ -208,6 +208,31 @@ fn offset_bytes_for_size(compound_size: u32) -> usize {
}
/// Read an unsigned integer of 1, 2, 4, or 8 bytes (LE).
+/// The size field of the datatype message at `pos`, as stored (a
+/// variable-length type's stored size is not modelled in [`Datatype`]).
+fn stored_type_size(data: &[u8], pos: usize) -> Result {
+ ensure_len(data, pos, 8)?;
+ Ok(LittleEndian::read_u32(&data[pos + 4..pos + 8]))
+}
+
+/// libhdf5 refuses an array type of more than `H5S_MAX_RANK` (32)
+/// dimensions.
+fn check_array_rank(ndims: usize) -> Result<(), FormatError> {
+ if ndims > 32 {
+ return Err(invalid("too many dimensions for array datatype"));
+ }
+ Ok(())
+}
+
+/// A zero-sized array dimension makes a zero-sized type, which libhdf5
+/// cannot open ("unable to retrieve size of datatype").
+fn check_array_dims(dims: &[u32]) -> Result<(), FormatError> {
+ if dims.contains(&0) {
+ return Err(invalid("zero-sized dimension specified"));
+ }
+ Ok(())
+}
+
fn read_uint(data: &[u8], offset: usize, nbytes: usize) -> Result {
ensure_len(data, offset, nbytes)?;
let slice = &data[offset..offset + nbytes];
@@ -232,10 +257,104 @@ fn read_uint(data: &[u8], offset: usize, nbytes: usize) -> Result) -> FormatError {
+ FormatError::InvalidDatatype(why.into())
+}
+
+/// libhdf5's bounds checks on an integer type's bit offset and precision
+/// (`H5O__dtype_decode_helper`): both must lie inside the type. (Newer
+/// libhdf5 checks bit fields the same way; HDF5 2.0, which h5py 3.16 ships,
+/// does not, and opens such a type.)
+fn check_integer_bits(size: u32, bit_offset: u16, bit_precision: u16) -> Result<(), FormatError> {
+ let bits = u64::from(size) * 8;
+ if u64::from(bit_offset) >= bits {
+ return Err(invalid("integer offset out of bounds"));
+ }
+ if bit_precision == 0 {
+ return Err(invalid("precision is zero"));
+ }
+ if u64::from(bit_offset) + u64::from(bit_precision) > bits {
+ return Err(invalid("integer offset+precision out of bounds"));
+ }
+ Ok(())
+}
+
+/// Whether the closed bit ranges `[a0, a1]` and `[b0, b1]` share a bit.
+fn ranges_overlap(a0: u64, a1: u64, b0: u64, b1: u64) -> bool {
+ a0 <= b1 && b0 <= a1
+}
+
+/// libhdf5's checks on a floating-point type's fields: exponent and mantissa
+/// must lie inside the type, be non-empty, and not overlap each other or the
+/// sign bit. (libhdf5 does not check a float's bit offset and precision.)
+///
+/// One libhdf5 check is left out on purpose: a sign bit position outside the
+/// type ("sign bit position out of bounds"). clawhdf5 up to v2.7.0 wrote 63
+/// there for every float, so every `f32` it wrote (every agent store's
+/// embeddings) would stop opening. The position is not used to decode an
+/// IEEE float, so reading such a type returns the right values.
+fn check_float_fields(
+ size: u32,
+ sign: u8,
+ epos: u8,
+ esize: u8,
+ mpos: u8,
+ msize: u8,
+) -> Result<(), FormatError> {
+ let bits = u64::from(size) * 8;
+ let (sign, epos, esize, mpos, msize) = (
+ u64::from(sign),
+ u64::from(epos),
+ u64::from(esize),
+ u64::from(mpos),
+ u64::from(msize),
+ );
+ if esize == 0 {
+ return Err(invalid("exponent size can't be zero"));
+ }
+ if epos >= bits {
+ return Err(invalid("exponent starting position out of bounds"));
+ }
+ if epos + esize > bits {
+ return Err(invalid("exponent range out of bounds"));
+ }
+ if msize == 0 {
+ return Err(invalid("mantissa size can't be zero"));
+ }
+ if mpos >= bits {
+ return Err(invalid("mantissa starting position out of bounds"));
+ }
+ if mpos + msize > bits {
+ return Err(invalid("mantissa range out of bounds"));
+ }
+ let (e_end, m_end) = (epos + esize - 1, mpos + msize - 1);
+ if ranges_overlap(sign, sign, epos, e_end) {
+ return Err(invalid("exponent and sign positions overlap"));
+ }
+ if ranges_overlap(sign, sign, mpos, m_end) {
+ return Err(invalid("mantissa and sign positions overlap"));
+ }
+ if ranges_overlap(epos, e_end, mpos, m_end) {
+ return Err(invalid("mantissa and exponent positions overlap"));
+ }
+ Ok(())
+}
+
impl Datatype {
/// Parse a datatype message from raw bytes.
///
/// Returns `(Datatype, bytes_consumed)` for recursive parsing.
+ ///
+ /// A type libhdf5 refuses to decode is refused here too, with
+ /// [`FormatError::InvalidDatatype`] carrying libhdf5's reason: size 0,
+ /// integer/bit-field/float bit fields outside the type or overlapping,
+ /// a compound with no members, a member outside its compound, a
+ /// duplicate or overlapping member, an enum whose size differs from its
+ /// base type's or with an empty name, an array of more than 32
+ /// dimensions or a zero-sized one, an unaligned opaque tag length.
+ /// Reading such a type used to return data from a corrupt file. Checks
+ /// newer libhdf5 releases add but HDF5 2.0 (h5py 3.16) lacks are left
+ /// out, so a file h5py opens still opens here.
pub fn parse(data: &[u8]) -> Result<(Datatype, usize), FormatError> {
Self::parse_with_depth(data, 0)
}
@@ -259,6 +378,14 @@ impl Datatype {
let size = LittleEndian::read_u32(&data[4..8]);
let mut pos = 8;
+ // libhdf5 refuses size 0 for every class. A fixed-length string is
+ // exempt: clawhdf5 up to v2.7.0 wrote an empty-string attribute
+ // with a size-0 string type, and refusing it would fail every
+ // attribute of such objects, while reading it (an empty string) is
+ // harmless.
+ if size == 0 && class_id != 3 {
+ return Err(invalid("invalid datatype size"));
+ }
match class_id {
0 => {
@@ -272,6 +399,7 @@ impl Datatype {
let signed = (bf0 >> 3) & 0x01 == 1;
let bit_offset = LittleEndian::read_u16(&data[pos..pos + 2]);
let bit_precision = LittleEndian::read_u16(&data[pos + 2..pos + 4]);
+ check_integer_bits(size, bit_offset, bit_precision)?;
pos += 4;
Ok((
Datatype::FixedPoint {
@@ -289,13 +417,23 @@ impl Datatype {
ensure_len(data, pos, 12)?;
let bo_low = bf0 & 0x01;
let bo_high = (bf0 >> 6) & 0x01;
+ // Bit 6 (with bit 0) is VAX order, defined by version 3; libhdf5
+ // ignores bit 6 in older versions, which this read as VAX,
+ // byte-swapping a little-endian float.
+ let bo_high = if version >= 3 { bo_high } else { 0 };
let byte_order = match (bo_high, bo_low) {
(0, 0) => DatatypeByteOrder::LittleEndian,
(0, 1) => DatatypeByteOrder::BigEndian,
- (1, 0) => DatatypeByteOrder::Vax,
+ (1, 0) => {
+ return Err(invalid("bad byte order for datatype message"));
+ }
(1, 1) => DatatypeByteOrder::Vax,
_ => unreachable!(),
};
+ // Bits 4-5: mantissa normalization; 3 is undefined.
+ if (bf0 >> 4) & 0x03 == 3 {
+ return Err(invalid("unknown floating-point normalization"));
+ }
let bit_offset = LittleEndian::read_u16(&data[pos..pos + 2]);
let bit_precision = LittleEndian::read_u16(&data[pos + 2..pos + 4]);
let exponent_location = data[pos + 4];
@@ -303,6 +441,14 @@ impl Datatype {
let mantissa_location = data[pos + 6];
let mantissa_size = data[pos + 7];
let exponent_bias = LittleEndian::read_u32(&data[pos + 8..pos + 12]);
+ check_float_fields(
+ size,
+ bf1,
+ exponent_location,
+ exponent_size,
+ mantissa_location,
+ mantissa_size,
+ )?;
pos += 12;
Ok((
Datatype::FloatingPoint {
@@ -371,6 +517,10 @@ impl Datatype {
5 => {
// Opaque
let tag_len = bf0 as usize;
+ // libhdf5 writes the NUL-padded length, a multiple of 8.
+ if !tag_len.is_multiple_of(8) {
+ return Err(invalid("opaque flag field must be aligned"));
+ }
ensure_len(data, pos, tag_len)?;
// The stored tag is NUL-padded to a multiple of 8 bytes; the
// tag itself ends at the first NUL (libhdf5 reads it with
@@ -384,7 +534,45 @@ impl Datatype {
6 => {
// Compound
let num_members = (bf0 as u16) | ((bf1 as u16) << 8);
- let mut members = Vec::with_capacity(num_members as usize);
+ if num_members == 0 {
+ return Err(invalid("invalid number of members: 0"));
+ }
+ let mut members: Vec = Vec::with_capacity(num_members as usize);
+ // Each member's size in the compound as libhdf5 decodes it:
+ // its stored size, times a v1 member's array dimensions. A
+ // variable-length member takes 4 + offset size + 4 bytes on
+ // disk, not the 16 of `Datatype::type_size`.
+ let mut member_sizes: Vec = Vec::with_capacity(num_members as usize);
+ // libhdf5 checks each member as it is decoded: it must fit in
+ // the compound (by its own stored size, before a v1 member's
+ // array dimensions are applied), and must not repeat a name
+ // or overlap an earlier member (by its final size).
+ let check_member = |members: &[CompoundMember],
+ member_sizes: &[u64],
+ name: &str,
+ byte_offset: u64,
+ stored_size: u32,
+ final_size: u64|
+ -> Result<(), FormatError> {
+ if byte_offset + u64::from(stored_size) > u64::from(size) {
+ return Err(invalid(
+ "member type extends outside its parent compound type",
+ ));
+ }
+ if let Some(j) = members.iter().position(|m| m.name == name) {
+ return Err(invalid(format!(
+ "duplicated compound field name '{name}', for fields {j} and {}",
+ members.len()
+ )));
+ }
+ let end = byte_offset + final_size;
+ if members.iter().zip(member_sizes).any(|(m, &m_size)| {
+ byte_offset < m.byte_offset + m_size && m.byte_offset < end
+ }) {
+ return Err(invalid("member overlaps with previous member"));
+ }
+ Ok(())
+ };
if (3..=5).contains(&version) {
// v3, v4 and v5 share the compact member encoding (name,
@@ -396,9 +584,20 @@ impl Datatype {
pos += name_len;
let byte_offset = read_uint(data, pos, ob)?;
pos += ob;
+ let stored_size = stored_type_size(data, pos)?;
let (member_dt, consumed) =
Self::parse_with_depth(&data[pos..], depth + 1)?;
pos += consumed;
+ let final_size = u64::from(stored_size);
+ check_member(
+ &members,
+ &member_sizes,
+ &name,
+ byte_offset,
+ stored_size,
+ final_size,
+ )?;
+ member_sizes.push(final_size);
members.push(CompoundMember {
name,
byte_offset,
@@ -438,11 +637,11 @@ impl Datatype {
let at = pos + 12 + 4 * j;
LittleEndian::read_u32(&data[at..at + 4]) == 0
});
- if ndims > 4 || zero_dim {
- return Err(FormatError::InvalidDatatypeVersion {
- class: class_id,
- version,
- });
+ if ndims > 4 {
+ return Err(invalid("invalid number of dimensions for array"));
+ }
+ if zero_dim {
+ return Err(invalid("zero-sized dimension specified"));
}
array_dims = (0..ndims)
.map(|j| {
@@ -452,15 +651,28 @@ impl Datatype {
.collect();
pos += 28;
}
+ let stored_size = stored_type_size(data, pos)?;
let (mut member_dt, consumed) =
Self::parse_with_depth(&data[pos..], depth + 1)?;
pos += consumed;
+ let final_size = array_dims.iter().fold(u64::from(stored_size), |a, &d| {
+ a.saturating_mul(u64::from(d))
+ });
if !array_dims.is_empty() {
member_dt = Datatype::Array {
base_type: Box::new(member_dt),
dimensions: array_dims,
};
}
+ check_member(
+ &members,
+ &member_sizes,
+ &name,
+ byte_offset,
+ stored_size,
+ final_size,
+ )?;
+ member_sizes.push(final_size);
members.push(CompoundMember {
name,
byte_offset,
@@ -499,6 +711,9 @@ impl Datatype {
let (base_type, base_consumed) = Self::parse_with_depth(&data[pos..], depth + 1)?;
pos += base_consumed;
let base_size = base_type.type_size();
+ if base_size != size {
+ return Err(invalid("ENUM datatype size does not match parent"));
+ }
let mut members = Vec::with_capacity(num_members as usize);
// Enum layout: base_type, then all names (null-terminated), then all values
// v1/v2: names are padded to 8-byte boundaries
@@ -506,6 +721,9 @@ impl Datatype {
let mut member_names = Vec::with_capacity(num_members as usize);
for _ in 0..num_members {
let (name, name_len) = read_null_terminated_string(data, pos)?;
+ if name.is_empty() {
+ return Err(invalid("0 length enum name"));
+ }
if version < 3 {
let padded = (name_len + 7) & !7;
pos += padded;
@@ -566,6 +784,7 @@ impl Datatype {
if version == 2 {
ensure_len(data, pos, 4)?;
let ndims = data[pos] as usize;
+ check_array_rank(ndims)?;
pos += 4; // ndims(1) + reserved(3)
ensure_len(data, pos, ndims * 4 + ndims * 4)?;
let mut dimensions = Vec::with_capacity(ndims);
@@ -573,6 +792,7 @@ impl Datatype {
dimensions.push(LittleEndian::read_u32(&data[pos..pos + 4]));
pos += 4;
}
+ check_array_dims(&dimensions)?;
// skip permutation indices
pos += ndims * 4;
let (base_type, consumed) = Self::parse_with_depth(&data[pos..], depth + 1)?;
@@ -589,6 +809,7 @@ impl Datatype {
// type); HDF5 1.14+/2.0 with `libver=latest` emits v5.
ensure_len(data, pos, 1)?;
let ndims = data[pos] as usize;
+ check_array_rank(ndims)?;
pos += 1;
ensure_len(data, pos, ndims * 4)?;
let mut dimensions = Vec::with_capacity(ndims);
@@ -596,6 +817,7 @@ impl Datatype {
dimensions.push(LittleEndian::read_u32(&data[pos..pos + 4]));
pos += 4;
}
+ check_array_dims(&dimensions)?;
let (base_type, consumed) = Self::parse_with_depth(&data[pos..], depth + 1)?;
pos += consumed;
Ok((
@@ -652,6 +874,70 @@ impl Datatype {
}
}
+ /// [`Self::parse`] for the datatype message of an object whose header
+ /// has version `header_version`: a version-1 header, which has no
+ /// checksum, additionally gets [`Self::check_unused_bits`], as libhdf5
+ /// does. Use this wherever the header is at hand.
+ pub fn parse_in_header(
+ data: &[u8],
+ header_version: u8,
+ ) -> Result<(Datatype, usize), FormatError> {
+ let parsed = Self::parse(data)?;
+ if header_version == 1 {
+ parsed.0.check_unused_bits()?;
+ }
+ Ok(parsed)
+ }
+
+ /// libhdf5's guard against a corrupt numeric type in a header without
+ /// a checksum (`H5T_is_numeric_with_unusual_unused_bits`, HDF5 1.14.4+):
+ /// an integer, float or bit field wider than a byte whose precision and
+ /// offset leave more than half its bits unused is taken for corruption
+ /// (e.g. a 3-bit integer in 4 bytes, `cve-2024-29162`, or a 32-bit float
+ /// in 65525 bytes, `cve-2024-32614`), anywhere in the type. libhdf5
+ /// skips the check for checksummed (version-2) headers and when the
+ /// file is opened with `H5Pset_relax_file_integrity_checks`; so does
+ /// [`Self::parse_in_header`], which has no such option.
+ pub fn check_unused_bits(&self) -> Result<(), FormatError> {
+ match self {
+ Datatype::FixedPoint {
+ size,
+ bit_offset,
+ bit_precision,
+ ..
+ }
+ | Datatype::FloatingPoint {
+ size,
+ bit_offset,
+ bit_precision,
+ ..
+ }
+ | Datatype::BitField {
+ size,
+ bit_offset,
+ bit_precision,
+ ..
+ } => {
+ let bits = u64::from(*size) * 8;
+ let prec = u64::from(*bit_precision);
+ if *size > 1 && prec < bits && bits > 2 * (prec + u64::from(*bit_offset)) {
+ return Err(invalid(format!(
+ "datatype has unusually large # of unused bits (prec = {prec} bits, \
+ size = {size} bytes), possibly corrupted file"
+ )));
+ }
+ Ok(())
+ }
+ Datatype::Compound { members, .. } => members
+ .iter()
+ .try_for_each(|m| m.datatype.check_unused_bits()),
+ Datatype::Enumeration { base_type, .. }
+ | Datatype::VariableLength { base_type, .. }
+ | Datatype::Array { base_type, .. } => base_type.check_unused_bits(),
+ _ => Ok(()),
+ }
+ }
+
/// Serialize datatype to HDF5 message bytes.
pub fn serialize(&self) -> Vec {
match self {
@@ -863,9 +1149,23 @@ impl Datatype {
}
/// Check that this datatype can be written: every part of it has an
- /// on-disk encoding. [`Self::serialize`] cannot report errors, so the
- /// writer calls this first.
+ /// on-disk encoding, and the encoding is one the reader (and libhdf5)
+ /// accepts. [`Self::serialize`] cannot report errors, so the writer calls
+ /// this first. A compound with no fields or a repeated field name, or an
+ /// enum member with an empty name, is refused here: libhdf5 and h5py
+ /// refuse such types, and so does [`Self::parse`], so writing one made a
+ /// file that could not be read back.
pub fn check_encodable(&self) -> Result<(), FormatError> {
+ self.check_encodable_parts()?;
+ Self::parse(&self.serialize()).map_err(|e| {
+ FormatError::SerializationError(format!(
+ "datatype cannot be written: HDF5 readers refuse it ({e})"
+ ))
+ })?;
+ Ok(())
+ }
+
+ fn check_encodable_parts(&self) -> Result<(), FormatError> {
match self {
Datatype::Opaque { tag, .. } if opaque_tag_text(tag).len() > MAX_OPAQUE_TAG_LEN => {
Err(FormatError::SerializationError(format!(
@@ -878,10 +1178,10 @@ impl Datatype {
)),
Datatype::Compound { members, .. } => members
.iter()
- .try_for_each(|m| m.datatype.check_encodable()),
+ .try_for_each(|m| m.datatype.check_encodable_parts()),
Datatype::Enumeration { base_type, .. }
| Datatype::VariableLength { base_type, .. }
- | Datatype::Array { base_type, .. } => base_type.check_encodable(),
+ | Datatype::Array { base_type, .. } => base_type.check_encodable_parts(),
_ => Ok(()),
}
}
@@ -985,7 +1285,8 @@ mod tests {
) -> Vec {
// LE byte order: bo_low=0, bo_high=0
let bf0 = 0x00u8;
- let bf1 = 0x00u8;
+ // Sign bit: the top bit.
+ let bf1 = (size * 8 - 1) as u8;
// mantissa norm = 2 (MSB not stored) in bits 24-31... wait, that's bf2
let bf2 = 0x02u8; // norm = 2
let mut buf = build_dt_header(1, 1, [bf0, bf1, bf2], size);
@@ -1012,7 +1313,7 @@ mod tests {
let levels = MAX_DATATYPE_DEPTH as usize + 10;
let mut data = Vec::new();
for _ in 0..levels {
- data.extend_from_slice(&build_dt_header(9, 3, [0, 0, 0], 0));
+ data.extend_from_slice(&build_dt_header(9, 3, [0, 0, 0], 16));
}
data.extend_from_slice(&build_fixed_point(4, false, false, 0, 32));
@@ -1026,7 +1327,7 @@ mod tests {
let levels = MAX_DATATYPE_DEPTH as usize - 1;
let mut data = Vec::new();
for _ in 0..levels {
- data.extend_from_slice(&build_dt_header(9, 3, [0, 0, 0], 0));
+ data.extend_from_slice(&build_dt_header(9, 3, [0, 0, 0], 16));
}
data.extend_from_slice(&build_fixed_point(4, false, false, 0, 32));
@@ -1176,8 +1477,8 @@ mod tests {
#[test]
fn test_opaque() {
- // tag_len = 4, tag = "BLOB"
- let mut buf = build_dt_header(5, 1, [4, 0, 0], 64);
+ // tag = "BLOB"; the stored length is the NUL-padded length, 8
+ let mut buf = build_dt_header(5, 1, [8, 0, 0], 64);
buf.extend_from_slice(b"BLOB");
// Pad to 8 bytes
buf.extend_from_slice(&[0, 0, 0, 0]);
@@ -2049,4 +2350,273 @@ mod tests {
};
assert_eq!(dt.type_size(), 48);
}
+ /// Every check here mirrors one in libhdf5's `H5O__dtype_decode_helper`;
+ /// the error text is libhdf5's.
+ fn invalid_reason(data: &[u8]) -> String {
+ match Datatype::parse(data) {
+ Err(FormatError::InvalidDatatype(why)) => why,
+ other => panic!("expected InvalidDatatype, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn size_zero_is_refused() {
+ // cve-2017-17508: a variable-length string member of stored size 0.
+ let mut data = build_dt_header(9, 1, [1, 0, 0], 0);
+ data.extend_from_slice(&build_fixed_point(1, false, false, 0, 8));
+ assert_eq!(invalid_reason(&data), "invalid datatype size");
+ // Except a fixed-length string, which clawhdf5 <= v2.7.0 wrote for an
+ // empty-string attribute.
+ assert!(Datatype::parse(&build_dt_header(3, 1, [0, 0, 0], 0)).is_ok());
+ assert_eq!(
+ invalid_reason(&build_fixed_point(0, false, false, 0, 0)),
+ "invalid datatype size"
+ );
+ }
+
+ #[test]
+ fn integer_bits_must_lie_inside_the_type() {
+ assert_eq!(
+ invalid_reason(&build_fixed_point(4, false, false, 32, 1)),
+ "integer offset out of bounds"
+ );
+ assert_eq!(
+ invalid_reason(&build_fixed_point(4, false, false, 0, 0)),
+ "precision is zero"
+ );
+ assert_eq!(
+ invalid_reason(&build_fixed_point(4, false, false, 8, 25)),
+ "integer offset+precision out of bounds"
+ );
+ // A partial-precision integer inside its bytes is fine.
+ assert!(Datatype::parse(&build_fixed_point(4, false, false, 12, 8)).is_ok());
+ }
+
+ #[test]
+ fn float_fields_must_lie_inside_the_type_and_not_overlap() {
+ // (sign, epos, esize, mpos, msize) on an f32
+ let f32_with = |sign: u8, epos: u8, esize: u8, mpos: u8, msize: u8| {
+ let mut data = build_dt_header(1, 1, [0x20, sign, 0], 4);
+ data.extend_from_slice(&0u16.to_le_bytes());
+ data.extend_from_slice(&32u16.to_le_bytes());
+ data.extend_from_slice(&[epos, esize, mpos, msize]);
+ data.extend_from_slice(&127u32.to_le_bytes());
+ data
+ };
+ assert!(Datatype::parse(&f32_with(31, 23, 8, 0, 23)).is_ok());
+ for (fields, why) in [
+ ((31, 23, 0, 0, 23), "exponent size can't be zero"),
+ (
+ (31, 32, 8, 0, 23),
+ "exponent starting position out of bounds",
+ ),
+ ((31, 30, 8, 0, 23), "exponent range out of bounds"),
+ ((31, 23, 8, 0, 0), "mantissa size can't be zero"),
+ (
+ (31, 23, 8, 40, 1),
+ "mantissa starting position out of bounds",
+ ),
+ // cve-2024-29163: a 128-bit mantissa in a 4-byte float.
+ ((31, 23, 8, 0, 128), "mantissa range out of bounds"),
+ ((23, 23, 8, 0, 23), "exponent and sign positions overlap"),
+ ((0, 23, 8, 0, 23), "mantissa and sign positions overlap"),
+ // cve-2026-34734.
+ (
+ (31, 20, 8, 0, 23),
+ "mantissa and exponent positions overlap",
+ ),
+ ] {
+ let (sign, epos, esize, mpos, msize) = fields;
+ assert_eq!(
+ invalid_reason(&f32_with(sign, epos, esize, mpos, msize)),
+ why,
+ "{fields:?}"
+ );
+ }
+ // Normalization 3 is undefined; bit 6 (VAX) needs bit 0 from v3.
+ let mut data = f32_with(31, 23, 8, 0, 23);
+ data[1] = 0x30;
+ assert_eq!(
+ invalid_reason(&data),
+ "unknown floating-point normalization"
+ );
+ let mut data = f32_with(31, 23, 8, 0, 23);
+ data[0] = 0x31; // version 3
+ data[1] = 0x60;
+ assert_eq!(invalid_reason(&data), "bad byte order for datatype message");
+ }
+
+ #[test]
+ fn unusual_unused_bits_are_refused_in_version_1_headers_only() {
+ // cve-2024-29162: a 3-bit integer in 4 bytes.
+ let data = build_fixed_point(4, false, true, 0, 3);
+ assert!(Datatype::parse_in_header(&data, 2).is_ok());
+ assert_eq!(
+ match Datatype::parse_in_header(&data, 1) {
+ Err(FormatError::InvalidDatatype(why)) => why,
+ other => panic!("{other:?}"),
+ },
+ "datatype has unusually large # of unused bits (prec = 3 bits, size = 4 bytes), \
+ possibly corrupted file"
+ );
+ // Half the bits used (with the offset) is not unusual; nor is a
+ // 1-byte type; nor a full-precision one.
+ for (size, offset, prec) in [(4u32, 0u16, 16u16), (4, 8, 8), (1, 0, 1), (8, 0, 64)] {
+ let data = build_fixed_point(size, false, true, offset, prec);
+ assert!(
+ Datatype::parse_in_header(&data, 1).is_ok(),
+ "{size} {offset} {prec}"
+ );
+ }
+ // Nested: a compound member's type is checked too.
+ let member = build_fixed_point(4, false, true, 0, 15);
+ let data = compound_v3(4, &[("a", 0, member)]);
+ assert!(Datatype::parse_in_header(&data, 2).is_ok());
+ assert!(Datatype::parse_in_header(&data, 1).is_err());
+ }
+
+ #[test]
+ fn f32_written_by_clawhdf5_up_to_2_7_0_still_parses() {
+ // Those versions put the sign bit at 63 whatever the float's size;
+ // libhdf5 refuses it ("sign bit position out of bounds").
+ let mut data = build_dt_header(1, 1, [0x20, 63, 0], 4);
+ data.extend_from_slice(&0u16.to_le_bytes());
+ data.extend_from_slice(&32u16.to_le_bytes());
+ data.extend_from_slice(&[23, 8, 0, 23]);
+ data.extend_from_slice(&127u32.to_le_bytes());
+ assert!(Datatype::parse(&data).is_ok());
+ }
+
+ #[test]
+ fn float_bit_6_is_vax_order_only_from_version_3() {
+ // h5py opens a v1 float with bit 6 set as an ordinary little-endian
+ // float; it used to be read as VAX order.
+ let mut data = build_float(4, 23, 8, 0, 23, 127);
+ data[1] |= 0x40;
+ match Datatype::parse(&data).unwrap().0 {
+ Datatype::FloatingPoint { byte_order, .. } => {
+ assert_eq!(byte_order, DatatypeByteOrder::LittleEndian)
+ }
+ other => panic!("{other:?}"),
+ }
+ data[0] = 0x31;
+ data[1] |= 0x01;
+ match Datatype::parse(&data).unwrap().0 {
+ Datatype::FloatingPoint { byte_order, .. } => {
+ assert_eq!(byte_order, DatatypeByteOrder::Vax)
+ }
+ other => panic!("{other:?}"),
+ }
+ }
+
+ #[test]
+ fn opaque_tag_length_must_be_padded() {
+ let mut data = build_dt_header(5, 1, [4, 0, 0], 4);
+ data.extend_from_slice(b"BLOB");
+ assert_eq!(invalid_reason(&data), "opaque flag field must be aligned");
+ }
+
+ /// A v3 compound of `size` bytes with `(name, offset, member)` members.
+ fn compound_v3(size: u32, members: &[(&str, u8, Vec)]) -> Vec {
+ let n = members.len() as u8;
+ let mut data = build_dt_header(6, 3, [n, 0, 0], size);
+ for (name, off, dt) in members {
+ data.extend_from_slice(name.as_bytes());
+ data.push(0);
+ data.push(*off);
+ data.extend_from_slice(dt);
+ }
+ data
+ }
+
+ #[test]
+ fn compound_members_are_checked() {
+ let i4 = build_fixed_point(4, false, true, 0, 32);
+ // cve-2016-4332: no members.
+ assert_eq!(
+ invalid_reason(&compound_v3(8, &[])),
+ "invalid number of members: 0"
+ );
+ assert_eq!(
+ invalid_reason(&compound_v3(
+ 8,
+ &[("a", 0, i4.clone()), ("b", 6, i4.clone())]
+ )),
+ "member type extends outside its parent compound type"
+ );
+ assert_eq!(
+ invalid_reason(&compound_v3(
+ 8,
+ &[("a", 0, i4.clone()), ("a", 4, i4.clone())]
+ )),
+ "duplicated compound field name 'a', for fields 0 and 1"
+ );
+ assert_eq!(
+ invalid_reason(&compound_v3(
+ 8,
+ &[("a", 0, i4.clone()), ("b", 2, i4.clone())]
+ )),
+ "member overlaps with previous member"
+ );
+ assert_eq!(
+ invalid_reason(&compound_v3(
+ 8,
+ &[("b", 4, i4.clone()), ("a", 2, i4.clone())]
+ )),
+ "member overlaps with previous member"
+ );
+ // Members out of offset order, and gaps, are fine.
+ assert!(Datatype::parse(&compound_v3(12, &[("b", 8, i4.clone()), ("a", 0, i4)])).is_ok());
+ }
+
+ #[test]
+ fn enum_is_checked() {
+ let base = build_fixed_point(4, false, true, 0, 32);
+ let enum_of = |size: u32, names: &[&str]| {
+ let mut data = build_dt_header(8, 3, [names.len() as u8, 0, 0], size);
+ data.extend_from_slice(&base);
+ for n in names {
+ data.extend_from_slice(n.as_bytes());
+ data.push(0);
+ }
+ for i in 0..names.len() as u32 {
+ data.extend_from_slice(&i.to_le_bytes());
+ }
+ data
+ };
+ assert!(Datatype::parse(&enum_of(4, &["RED", "GREEN"])).is_ok());
+ // cve-2024-32618.
+ assert_eq!(
+ invalid_reason(&enum_of(4, &["", "GREEN"])),
+ "0 length enum name"
+ );
+ assert_eq!(
+ invalid_reason(&enum_of(2, &["RED"])),
+ "ENUM datatype size does not match parent"
+ );
+ }
+
+ #[test]
+ fn array_dimensions_are_checked() {
+ let base = build_fixed_point(4, false, true, 0, 32);
+ let array_v3 = |dims: &[u32]| {
+ let n = dims.iter().product::().max(1);
+ let mut data = build_dt_header(10, 3, [0, 0, 0], 4 * n);
+ data.push(dims.len() as u8);
+ for d in dims {
+ data.extend_from_slice(&d.to_le_bytes());
+ }
+ data.extend_from_slice(&base);
+ data
+ };
+ assert!(Datatype::parse(&array_v3(&[2, 3])).is_ok());
+ assert_eq!(
+ invalid_reason(&array_v3(&[2, 0])),
+ "zero-sized dimension specified"
+ );
+ assert_eq!(
+ invalid_reason(&array_v3(&[1; 33])),
+ "too many dimensions for array datatype"
+ );
+ }
}
diff --git a/crates/clawhdf5-format/src/ea_writer.rs b/crates/clawhdf5-format/src/ea_writer.rs
index e0f0375..f669534 100644
--- a/crates/clawhdf5-format/src/ea_writer.rs
+++ b/crates/clawhdf5-format/src/ea_writer.rs
@@ -7,7 +7,9 @@ extern crate alloc;
use alloc::{vec, vec::Vec};
use crate::checksum::jenkins_lookup3;
-use crate::chunked_write::{WrittenChunk, filtered_chunk_size_len, push_addr, push_index_element};
+use crate::chunked_write::{
+ WrittenChunk, filtered_chunk_size_len, push_addr, push_index_element, push_v4_chunk_dims,
+};
/// Serialize a v4 Extensible Array layout message.
pub(crate) fn serialize_v4_extensible_array(
@@ -24,35 +26,7 @@ pub(crate) fn serialize_v4_extensible_array(
let ndims = chunk_dims.len() as u8 + 1;
buf.push(ndims);
- let max_dim = chunk_dims
- .iter()
- .map(|&d| d as u64)
- .chain(core::iter::once(element_size as u64))
- .max()
- .unwrap_or(1);
- let dim_encoded_len: u8 = if max_dim <= 0xFF {
- 1
- } else if max_dim <= 0xFFFF {
- 2
- } else {
- 4
- };
- buf.push(dim_encoded_len);
-
- for &d in chunk_dims {
- match dim_encoded_len {
- 1 => buf.push(d as u8),
- 2 => buf.extend_from_slice(&(d as u16).to_le_bytes()),
- 4 => buf.extend_from_slice(&d.to_le_bytes()),
- _ => unreachable!("unexpected dim_encoded_len: {dim_encoded_len}"),
- }
- }
- match dim_encoded_len {
- 1 => buf.push(element_size as u8),
- 2 => buf.extend_from_slice(&(element_size as u16).to_le_bytes()),
- 4 => buf.extend_from_slice(&element_size.to_le_bytes()),
- _ => unreachable!("unexpected dim_encoded_len: {dim_encoded_len}"),
- }
+ push_v4_chunk_dims(&mut buf, chunk_dims, element_size);
// chunk index type = 4 (Extensible Array)
buf.push(4);
diff --git a/crates/clawhdf5-format/src/error.rs b/crates/clawhdf5-format/src/error.rs
index bb81939..0056b14 100644
--- a/crates/clawhdf5-format/src/error.rs
+++ b/crates/clawhdf5-format/src/error.rs
@@ -201,6 +201,28 @@ pub enum FormatError {
DuplicateDatasetName(String),
/// Integer overflow in size computation (malformed data protection).
Overflow(String),
+ /// An object header that libhdf5 refuses to load (the reason is
+ /// libhdf5's own error text): a misaligned or overrunning message, a
+ /// wrong message count, contradictory message flags, a message of a
+ /// class that cannot be shared flagged shareable, …
+ InvalidObjectHeader(&'static str),
+ /// A datatype message libhdf5 refuses to decode (the reason is
+ /// libhdf5's own error text): size 0, bit fields outside the type,
+ /// an empty enum name, a compound member outside its compound, …
+ InvalidDatatype(String),
+ /// A chunked layout whose chunk dimensions libhdf5 refuses: a zero
+ /// dimension, a rank that does not match the dataspace, an element size
+ /// that is not the datatype's, or a chunk of 4 GiB or more indexed by a
+ /// version-1 B-tree.
+ InvalidChunkDimensions(String),
+ /// The superblock's end-of-file address lies past the end of the file:
+ /// the file was truncated (libhdf5 refuses to open it).
+ TruncatedFile {
+ /// End of file recorded in the superblock (relative to byte 0).
+ stored_eof: u64,
+ /// The file's actual length in bytes.
+ actual_len: u64,
+ },
}
impl fmt::Display for FormatError {
@@ -406,9 +428,17 @@ impl fmt::Display for FormatError {
FormatError::InvalidFilterPipelineVersion(v) => {
write!(f, "invalid filter pipeline version: {v}")
}
- FormatError::UnsupportedFilter(id) => {
- write!(f, "unsupported filter: {id}")
- }
+ FormatError::UnsupportedFilter(id) => match crate::filter_registry::known_filter(*id) {
+ Some((name, Some(feature))) => write!(
+ f,
+ "unsupported filter: {id} ({name}; this build lacks the `{feature}` feature)"
+ ),
+ Some((name, None)) => write!(
+ f,
+ "unsupported filter: {id} ({name}, not implemented by clawhdf5)"
+ ),
+ None => write!(f, "unsupported filter: {id}"),
+ },
FormatError::FilterError(msg) => {
write!(f, "filter error: {msg}")
}
@@ -445,6 +475,25 @@ impl fmt::Display for FormatError {
FormatError::Overflow(msg) => {
write!(f, "integer overflow: {msg}")
}
+ FormatError::InvalidObjectHeader(why) => {
+ write!(f, "corrupt object header: {why}")
+ }
+ FormatError::InvalidDatatype(why) => {
+ write!(f, "invalid datatype: {why}")
+ }
+ FormatError::InvalidChunkDimensions(why) => {
+ write!(f, "invalid chunk dimensions: {why}")
+ }
+ FormatError::TruncatedFile {
+ stored_eof,
+ actual_len,
+ } => {
+ write!(
+ f,
+ "truncated file: the superblock records end of file {stored_eof}, \
+ but the file is {actual_len} bytes"
+ )
+ }
}
}
}
diff --git a/crates/clawhdf5-format/src/filter_pipeline.rs b/crates/clawhdf5-format/src/filter_pipeline.rs
index 74bc727..13ed69f 100644
--- a/crates/clawhdf5-format/src/filter_pipeline.rs
+++ b/crates/clawhdf5-format/src/filter_pipeline.rs
@@ -19,6 +19,18 @@ pub const FILTER_SCALEOFFSET: u16 = 6;
pub const FILTER_LZ4: u16 = 32004;
/// Zstandard compression.
pub const FILTER_ZSTD: u16 = 32015;
+/// bzip2 (registered by PyTables; hdf5plugin's `BZip2`).
+pub const FILTER_BZIP2: u16 = 307;
+/// LZF — h5py's built-in `compression="lzf"`.
+pub const FILTER_LZF: u16 = 32000;
+/// Blosc 1 (hdf5-blosc; hdf5plugin's `Blosc`).
+pub const FILTER_BLOSC: u16 = 32001;
+/// Bitshuffle, optionally with LZ4 or Zstandard (hdf5plugin's `Bitshuffle`).
+pub const FILTER_BITSHUFFLE: u16 = 32008;
+/// ZFP lossy floating-point compression (hdf5plugin's `Zfp`). Not supported.
+pub const FILTER_ZFP: u16 = 32013;
+/// Blosc 2 (hdf5plugin's `Blosc2`).
+pub const FILTER_BLOSC2: u16 = 32026;
/// Pcodec lossless numerical codec — a **private, unregistered** clawhdf5
/// filter. Pcodec has no ID in the HDF Group's filter registry (checked
/// 2026-09-25, `hdf5_plugins/docs/RegisteredFilterPlugins.md`), so it uses an
diff --git a/crates/clawhdf5-format/src/filter_registry.rs b/crates/clawhdf5-format/src/filter_registry.rs
new file mode 100644
index 0000000..6d39613
--- /dev/null
+++ b/crates/clawhdf5-format/src/filter_registry.rs
@@ -0,0 +1,477 @@
+//! Filter registry: every filter is looked up here by its HDF5 filter ID.
+//!
+//! Two tiers:
+//!
+//! * **Built-in filters** — a static table of the filters compiled into this
+//! build: the HDF5 standard filters (deflate, shuffle, Fletcher32, szip,
+//! N-Bit, scale-offset) and the plugin filters whose cargo features are
+//! enabled (LZ4, Zstandard, pcodec, LZF, bitshuffle, bzip2, blosc).
+//! [`builtin_filters`] lists them.
+//! * **Registered filters** (`std` only) — codecs the application supplies
+//! for any other ID with [`register_filter`] (a [`FilterCodec`], or just a
+//! decoding closure). A registered codec cannot shadow a built-in one,
+//! except under 32023: that ID belongs to Granular BitRound, and the
+//! built-in entry there only reads the pcodec chunks clawhdf5 <= 2.7.0
+//! wrote (filter name `"pcodec"`), so a codec registered for 32023 handles
+//! every other chunk with that ID, and writes.
+//!
+//! An ID in neither tier fails with [`FormatError::UnsupportedFilter`], as it
+//! always has.
+//!
+//! ```
+//! # #[cfg(feature = "std")] {
+//! use clawhdf5_format::filter_registry::{self, FilterContext};
+//! use clawhdf5_format::error::FormatError;
+//!
+//! // A toy filter in the private-use range: every byte XORed with 0x5A.
+//! filter_registry::register_filter(300, |input: &[u8], _ctx: &FilterContext<'_>| {
+//! Ok::<_, FormatError>(input.iter().map(|b| b ^ 0x5A).collect())
+//! })
+//! .unwrap();
+//! assert!(filter_registry::is_filter_available(300));
+//! filter_registry::unregister_filter(300);
+//! # }
+//! ```
+
+#[cfg(not(feature = "std"))]
+extern crate alloc;
+
+#[cfg(not(feature = "std"))]
+use alloc::vec::Vec;
+
+use crate::error::FormatError;
+use crate::filter_pipeline::FilterDescription;
+
+/// What a codec is told about the filter it is applying.
+#[derive(Debug, Clone, Copy)]
+pub struct FilterContext<'a> {
+ /// The filter as recorded in the dataset's filter pipeline: its ID, name,
+ /// flags and client data (`cd_values`).
+ pub filter: &'a FilterDescription,
+ /// Size in bytes of one dataset element (the datatype's size).
+ pub element_size: usize,
+ /// Decoding only: the most bytes this stage may produce — what entered
+ /// the filter when the chunk was written. 0 means unknown; a decoder then
+ /// falls back to a fixed ceiling. Always 0 when encoding.
+ pub max_output: usize,
+}
+
+impl FilterContext<'_> {
+ /// The filter's client data (`cd_values`).
+ pub fn client_data(&self) -> &[u32] {
+ &self.filter.client_data
+ }
+
+ /// The largest output a decoder should allow: [`Self::max_output`], or
+ /// 256 MiB when that is unknown.
+ pub fn output_limit(&self) -> usize {
+ if self.max_output != 0 {
+ self.max_output
+ } else {
+ crate::filters::MAX_DECOMPRESS_SIZE
+ }
+ }
+}
+
+/// A filter implementation.
+///
+/// `decode` undoes the filter (the read direction). `encode` applies it (the
+/// write direction); the default refuses with
+/// [`FormatError::UnsupportedFilter`], which is right for a read-only codec.
+pub trait FilterCodec: Send + Sync {
+ /// Undo the filter on one chunk. The output must not exceed
+ /// [`FilterContext::output_limit`]; the pipeline rejects a larger one.
+ fn decode(&self, input: &[u8], ctx: &FilterContext<'_>) -> Result, FormatError>;
+
+ /// Apply the filter to one chunk.
+ fn encode(&self, input: &[u8], ctx: &FilterContext<'_>) -> Result, FormatError> {
+ let _ = input;
+ Err(FormatError::UnsupportedFilter(ctx.filter.filter_id))
+ }
+}
+
+/// Any `Fn(&[u8], &FilterContext) -> Result, FormatError>` is a
+/// decode-only codec.
+impl FilterCodec for F
+where
+ F: Fn(&[u8], &FilterContext<'_>) -> Result, FormatError> + Send + Sync,
+{
+ fn decode(&self, input: &[u8], ctx: &FilterContext<'_>) -> Result, FormatError> {
+ self(input, ctx)
+ }
+}
+
+/// Signature of a built-in filter's decoder or encoder.
+pub type BuiltinFn = fn(&[u8], &FilterContext<'_>) -> Result, FormatError>;
+
+/// A filter compiled into this build.
+#[derive(Debug, Clone, Copy)]
+pub struct BuiltinFilter {
+ /// HDF5 filter ID.
+ pub id: u16,
+ /// Human-readable name.
+ pub name: &'static str,
+ /// Decoder.
+ pub(crate) decode: BuiltinFn,
+ /// Encoder, if this build can write the filter.
+ pub(crate) encode: Option,
+}
+
+impl BuiltinFilter {
+ /// Whether this build can write the filter as well as read it.
+ pub fn can_encode(&self) -> bool {
+ self.encode.is_some()
+ }
+
+ /// Whether the built-in entry only borrows its ID for some chunks, so a
+ /// registered codec may take the rest: the legacy pcodec entry under
+ /// Granular BitRound's 32023, which claims only chunks named `"pcodec"`.
+ fn is_shared(&self) -> bool {
+ self.id == crate::filter_pipeline::FILTER_PCODEC_LEGACY
+ }
+
+ /// Whether this entry decodes chunks written with `filter`.
+ fn claims(&self, filter: &crate::filter_pipeline::FilterDescription) -> bool {
+ !self.is_shared()
+ || filter.name.as_deref() == Some(crate::filter_pipeline::FILTER_PCODEC_LEGACY_NAME)
+ }
+}
+
+/// The filters compiled into this build, in ID order.
+pub fn builtin_filters() -> &'static [BuiltinFilter] {
+ crate::filters::BUILTIN_FILTERS
+}
+
+/// The built-in filter with this ID, if it is compiled in.
+pub fn builtin_filter(id: u16) -> Option<&'static BuiltinFilter> {
+ builtin_filters().iter().find(|f| f.id == id)
+}
+
+/// Why a filter ID may be missing from this build: the filter's name, and
+/// the cargo feature that provides it (`None`: clawhdf5 does not implement
+/// it — register a codec for it with [`register_filter`]). `None` for an ID
+/// clawhdf5 knows nothing about.
+pub fn known_filter(id: u16) -> Option<(&'static str, Option<&'static str>)> {
+ Some(match id {
+ 1 => ("deflate", Some("deflate")),
+ 4 => ("SZIP", Some("szip")),
+ 307 => ("bzip2", Some("bzip2")),
+ 480 => ("pcodec", Some("pcodec")),
+ 32000 => ("LZF", Some("lzf")),
+ 32001 => ("Blosc", Some("blosc")),
+ 32004 => ("LZ4", Some("lz4")),
+ 32008 => ("bitshuffle", Some("bitshuffle")),
+ 32013 => ("ZFP", None),
+ 32015 => ("Zstandard", Some("zstd")),
+ 32019 => ("JPEG", None),
+ 32022 => ("BitGroom", None),
+ 32023 => ("Granular BitRound", None),
+ 32026 => ("Blosc2", None),
+ _ => return None,
+ })
+}
+
+/// Whether a chunk filtered with `id` can be decoded: a built-in filter or a
+/// registered one.
+pub fn is_filter_available(id: u16) -> bool {
+ if builtin_filter(id).is_some() {
+ return true;
+ }
+ #[cfg(feature = "std")]
+ {
+ registered(id).is_some()
+ }
+ #[cfg(not(feature = "std"))]
+ {
+ false
+ }
+}
+
+#[cfg(feature = "std")]
+mod custom {
+ use super::FilterCodec;
+ use std::collections::BTreeMap;
+ use std::sync::{Arc, PoisonError, RwLock};
+
+ pub(super) type Registry = BTreeMap>;
+
+ static REGISTRY: RwLock = RwLock::new(BTreeMap::new());
+
+ pub(super) fn with_read(f: impl FnOnce(&Registry) -> R) -> R {
+ // A panic while holding the lock cannot leave the map half-updated
+ // (every update is a single insert/remove), so poisoning is ignored.
+ f(®ISTRY.read().unwrap_or_else(PoisonError::into_inner))
+ }
+
+ pub(super) fn with_write(f: impl FnOnce(&mut Registry) -> R) -> R {
+ f(&mut REGISTRY.write().unwrap_or_else(PoisonError::into_inner))
+ }
+}
+
+/// Register a codec for filter `id`, process-wide. It is used for every
+/// chunk read (and, if it implements [`FilterCodec::encode`], written) with
+/// that filter ID, by every file.
+///
+/// A plain closure `Fn(&[u8], &FilterContext) -> Result, FormatError>`
+/// registers a decoder. Replaces (and returns) an earlier registration for
+/// the same ID. Fails with [`FormatError::FilterError`] if `id` is a built-in
+/// filter of this build: those cannot be overridden. The exception is 32023
+/// (Granular BitRound): with the `pcodec` feature the built-in entry there
+/// reads only chunks whose filter is named `"pcodec"` (clawhdf5 <= 2.7.0's
+/// files); a codec registered for 32023 decodes every other chunk with that
+/// ID and does all the writing.
+#[cfg(feature = "std")]
+pub fn register_filter(
+ id: u16,
+ codec: C,
+) -> Result