Compare commits

...
Author SHA1 Message Date
osobhandClaude Opus 5.5 63648c7000 bench: concurrent-read results on tank, including where we lose
h5py threads stay flat (global lock); clawhdf5 hyperslab reads of deflate
data scale to 1244 MB/s at 16 threads (9.7x h5py threads, 0.89x h5py
processes). Two deficits recorded as open issues: full chunked reads stop
scaling at ~4 threads (chunk cache suspected), and contiguous reads are
4x (full) to 8x (hyperslab) slower than h5py single-threaded.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 02:02:17 -05:00
osobhandClaude Opus 5.5 91644d8aaf docs: conformance report with header checks and plugin filters (575 of 697 ok)
Regenerated on tank: ok 569 -> 575 (h5ex_d_blosc, h5ex_d_bshuf,
h5ex_d_bzip2, h5ex_d_lzf; h5clear_fsm_persist_less, h5stat_err_refcount),
our-error 14 -> 10, mismatch 22 -> 20, no panics, hangs, crashes or OOM.
Baseline raised.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:50:02 -05:00
osobhandClaude Opus 5.5 72306c6013 fix(tools): h5rs check says why the library refused a file
With the header checks merged, the library refuses truncated files and
misaligned chunk-index keys itself, so check reported only "file cannot
be opened" for a truncated file. It now reports the truncation (stored
end of file vs file length) or the library's error, and the misaligned
chunk test accepts the library's refusal of the key.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:46:48 -05:00
osobhandClaude Opus 5.5 e60bde3579 docs: h5rs check CVE counts measured with the header checks merged
Measured on tank on the 150 cvefiles/ and fuzzerfiles/ of cve_hdf5 (the
earlier text said 180): check --data passes 16 (was 28), and h5dump
1.14.6 rejects 9 of those (was 21). It still flags none of the 418
conformance files both readers read in full.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:41:39 -05:00
osobhandClaude Opus 5.5 c85a8222cc test: compile the facade's parallel tests, and build them in CI
parallel_integration.rs declared `_sequential` and used `sequential`
under the parallel feature, which nothing in CI enabled for the facade.
ci-test.sh now lints and tests the facade with parallel on.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:38:38 -05:00
osobh 2b68791f6a Merge branch 'feat/p1-concurrency-bench' into feat/p1-proof 2026-09-26 01:38:17 -05:00
osobh f7c362cef5 Merge branch 'feat/p1-wasm' into feat/p1-proof
# Conflicts:
#	Cargo.toml
#	scripts/ci-test.sh
2026-09-26 01:38:17 -05:00
osobh 13c095a3da Merge branch 'feat/p1-h5-tools' into feat/p1-proof
# Conflicts:
#	docs/known-issues.md
2026-09-26 01:38:06 -05:00
osobh 591aa71d12 Merge branch 'feat/p1-plugin-filters' into feat/p1-proof
# Conflicts:
#	crates/clawhdf5/tests/h5py_chunked_read_tests.rs
#	docs/known-issues.md
2026-09-26 01:37:58 -05:00
osobh b9a2ce3077 Merge branch 'fix/p1-header-hardening' into feat/p1-proof 2026-09-26 01:37:43 -05:00
osobhandClaude Opus 5.5 743c32b512 docs: record the chunk dimension width libhdf5 2.0.0 refuses and we read
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:32:09 -05:00
osobhandClaude Opus 5.5 993214723e test: a v2 header message running into the checksum is refused, as in libhdf5
The review read libhdf5's H5O__chunk_deserialize as accepting a v2
message that runs up to 4 bytes into the chunk's checksum, since it
bounds message bodies by the whole chunk buffer. It does not accept it:
the message loop stops at the checksum, and the checksum read that
follows starts past it and overruns the chunk ("ran off end of input
buffer while decoding"). h5py refuses such files whether the message
runs 1, 4 or 5 bytes in, and so does clawhdf5, with its own error text.
No code change; the test pins the agreement and a comment records why.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:32:00 -05:00
osobhandClaude Opus 5.5 3938f7f8a2 fix(io): refuse truncated files in the VOL, async and MPI readers
The truncated-file check and the end-of-file clamp reached File,
LazyFile and MmapFile but not clawhdf5-io's readers, which still opened
truncated files and read past the recorded end of file. NativeVol
(open, and read_dataset for from_bytes), AsyncHDF5File::from_bytes and
MpiVol's collective read now view the file through the new
vol::hdf5_view: from the superblock to Superblock::data_end, refusing a
file shorter than that.

MpiVol's read is compiled only with the mpi-io feature, which needs an
MPI installation; it was not built here. The edit there only swaps its
two-line superblock setup for hdf5_view.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:30:56 -05:00
osobhandClaude Opus 5.5 dd40bea467 fix(format): refuse a chunk layout whose element size is not the datatype's
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"). clawhdf5 ignored the recorded size and
read the chunks anyway, for v3 and v4 layouts. The check runs on every
chunked read (read_chunked_data*, read_raw_data_selection) and compares
against the stored size: a variable-length element is 4 + offset size
+ 4 bytes, not Datatype::type_size's 16.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:29:26 -05:00
osobhandClaude Opus 5.5 afae86f3ea fix(format): write layout v4 chunk dimensions in the fewest bytes
libhdf5 encodes a version-4 layout's chunk dimensions in (log2(max) +
8) / 8 bytes, and HDF5 2.0.0 (h5py 3.16) refuses any other width:
"stored chunk dimension encoding length does not match value calculated
from chunk dimensions". The writer rounded 3 bytes up to 4, so h5py
could not open a dataset we wrote with a chunk dimension from 65 536 to
16 777 215, for every chunk index (single chunk, fixed and extensible
array, v2 B-tree). The three encoders now share push_v4_chunk_dims,
which writes the exact width.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:27:22 -05:00
osobhandClaude Opus 5.5 f713847e65 fix(format): read layout v4 chunk dimensions of any width from 1 to 8 bytes
A version-4 layout stores every chunk dimension in the fewest bytes that
hold the largest one (H5D__chunk_set_sizes: (log2(dim) + 8) / 8), so a
chunk dimension of 65 536 to 16 777 215 takes 3 bytes. Only widths 1, 2,
4 and 8 were decoded; an h5py file with chunks=(70000,) and
libver='latest' failed with UnexpectedEof. Widths 1-8 are decoded now;
0 and more than 8 are refused with libhdf5's "encoded chunk dimension
size is too large", and a dimension past u32 is refused, not truncated.

The review asked for libhdf5's check that the stored width matches the
one computed from the dimensions. HDF5 2.0.0 (h5py 3.16) refuses any
mismatch, but HDFGroup/hdf5@e124c36 ("Allow reading of files with chunk
dimensions encoded using more bytes than necessary", 2026-06-05) relaxed
it to refusing only a width too small for the dimensions, which cannot
happen once the dimensions have been decoded from that width. Follow
current libhdf5: a wider-than-needed encoding is read. clawhdf5's own
writer produces such layouts (the next commit fixes that).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:25:43 -05:00
osobhandClaude Opus 5.5 17fc8b1964 docs: changelog and known issues for the plugin-filter review fixes
The short-decoding chunk (wrong data, pre-existing), the Blosc header
underflow (crash) and filter 32023 registration, each with its date and
what it changes; the conformance count is unchanged at 573 of 697.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:24:18 -05:00
osobhandClaude Opus 5.5 9238605661 fix(format): measure compound members by their stored size
The compound overlap check measured each earlier member with
Datatype::type_size, which is a fixed 16 for a variable-length type. On
disk a VL member takes 4 + offset size + 4 bytes, 12 in a file with
4-byte offsets, so a member right after one was refused as "member
overlaps with previous member" (and with the type, every attribute of
the object). libhdf5 measures members by their decoded, stored size
(times a v1 member's array dimensions); so does this now.

Reading VL values in such files is a separate, older gap, now recorded
in known-issues.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:24:17 -05:00
osobhandClaude Opus 5.5 738b9491b2 fix(format): a codec can be registered for filter 32023 (Granular BitRound)
With the pcodec feature, 32023 was a built-in entry (the legacy reader for
the pcodec chunks clawhdf5 <= 2.7.0 wrote under that ID), so
register_filter(32023, ...) was refused as "built in", although
UnsupportedFilter(32023) names Granular BitRound as not implemented and
the registry docs point to register_filter for such IDs.

That entry is now shared: it claims only chunks whose filter is named
"pcodec"; any other chunk with ID 32023 goes to the registered codec (or,
with none registered, gets UnsupportedFilter as before), and writing
32023 uses the registered codec. Every other built-in ID still refuses
registration.

Test: a_codec_can_be_registered_for_granular_bitround (registers, round-
trips chunks with no name and other names, still reads a legacy "pcodec"
chunk with the built-in reader, and after unregistering reads nothing).
It fails without the change ("filter 32023 ... is built in and cannot be
re-registered"). It and the existing legacy-pcodec test share a lock,
since the registry is process-wide.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:23:58 -05:00
osobhandClaude Opus 5.5 e10df68ed8 ci: show h5dump's version; h5rs dump output checked against 1.14.5
dump_matches_h5dump requires byte identity with the h5dump on PATH, and
CI's rust:latest (Debian 13.7) installs hdf5-tools 1.14.5, not the 1.14.6
the test was written against. Ran the whole clawhdf5-tools suite in
rust:latest with Debian's hdf5-tools and pip h5py 3.16.0 (HDF5 2.0.0), as
CI sets it up, with CLAWHDF5_REQUIRE_INTEROP=1: 18 of 18 pass, so the
comparison needs no loosening. The version is now printed in the CI log
so a future Debian update that changes the output is easy to spot.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:23:52 -05:00
osobhandClaude Opus 5.5 c4d96c1390 fix(tools): h5rs dump shows NUL padding in nested strings, like h5dump
A null-padded fixed string inside a compound or an array member printed
trimmed ("" for three NULs, "a" for "a\0b"), where h5dump prints every
byte ("\000\000\000", "a\000b"); only top-level strings were shown in
full. DATA blocks now render elements through one function that keeps
the padding at any depth.

The README now lists the remaining known differences from h5dump:
nested compounds print inline, and long double values are printed as
errors (exit 1) with the datatype as an H5T_FLOAT block.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:23:32 -05:00
osobhandClaude Opus 5.5 b8492bd28d fix(tools): h5rs check --data follows VL data into the global heap
The README said check skips only "global heap collections other than
those a value read touches", but read_dataset returns the raw heap IDs,
so no collection was ever read: a file whose global heap collection
claims a 4 GiB object passed `check --data` with no problems, while
h5dump (and h5rs dump/diff) fail on it.

With --data, every variable-length element (strings and sequences, also
inside compounds, arrays and nested sequences) of every dataset and
attribute is followed into its collection. A collection that does not
parse, a missing heap object, or a sequence longer than its heap object
is a problem at the collection's address, once per object; the summary
counts the collections read.

Measured on tank, 2026-09-26: the 418 fully-read conformance ok files
still pass (scripts/h5rs-check-ok-files.sh --data, 0 flagged), and
`check --data` now flags 152 of the 180 CVE-corpus files (was 147); of
the 28 it passes, h5dump 1.14.6 rejects 21 (was 26 of 33).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:22:27 -05:00
osobhandClaude Opus 5.5 a5bd70216c fix(format): a chunk that decodes short is an error, not zero-filled
HDF5 stores every chunk at the full chunk size (edge chunks are padded
before filtering, and with "don't filter partial edge chunks" they are
stored raw at full size), so a filter pipeline that decodes to fewer bytes
means a corrupt chunk. Every chunk reader padded it with zeros and
returned it as data. libhdf5 returns the rest uninitialised, or fails when
the filter checks (Blosc with nbytes = 0).

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:22:26 -05:00
osobhandClaude Opus 5.5 17f09375ad fix(format): refuse to write datatypes the reader refuses
The reader now refuses a compound with a repeated field name or no
fields and an enum member with an empty name, as libhdf5 does, but the
writer still wrote them: CompoundTypeBuilder and EnumTypeBuilder build
them without complaint, so clawhdf5 wrote files it could not read back.
They were never valid HDF5; h5py refuses them.

Datatype::check_encodable, which FileWriter::finish runs on every
dataset and attribute type, now parses the type's own encoding back and
refuses one the reader refuses, with the reader's reason. That keeps the
writer in step with every reader check, not only these three.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:21:20 -05:00
osobhandClaude Opus 5.5 386bd1d41e fix(tools): h5rs diff names its options as h5diff does
-c meant "list at most N differences" in h5rs, but in h5diff -c is
--compare (a flag) and the count is -n/--count=N, so a script moved over
from h5diff behaved differently: `h5diff -r -c 2 A B` exits 2 (the 2 is
taken as a file name) while h5rs exited 1.

The count is now -n/--count, -c/--compare is accepted (h5rs always lists
objects that are not comparable), and the --count=N, --delta=D,
--relative=R forms are accepted; exit codes equal h5diff's on 7 cases.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:18:56 -05:00
osobhandClaude Opus 5.5 b5e43bacd7 fix(tools): h5rs diff compares soft links by target, like h5diff
An OBJ that was a soft link was resolved and its target object compared,
so two files whose /g/s both point at /z differed when /z did: exit 1,
where h5diff (without --follow-symlinks) compares the links' target paths
and exits 0.

A soft link is now compared as a link wherever it is, OBJ included.
--follow-symlinks compares the objects soft links lead to instead, walks
into soft-linked groups, resolves relative targets against the link's
group, and treats two dangling links as the same; exit codes equal
h5diff's on 14 cases. External links are never followed (documented).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:18:13 -05:00
osobhandClaude Opus 5.5 a14ccc36bf fix(format): limit chunks to 4 GiB only under a v1 B-tree index
libhdf5 refuses a chunk of 4 GiB or more only when a version-1 B-tree
indexes it (H5D__chunk_init: "chunk size must be < 4GB with v1 b-tree
index"). HDF5 2.0 writes larger chunks with layout version 5, and h5py
reads them; these were refused. chunk_geometry now takes the layout
version and applies the limit to layout version 3 and earlier only.

The interop test is ignored by default: h5py writes a 4 GiB chunk and
both libraries hold it in memory.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:17:36 -05:00
osobhandClaude Opus 5.5 7f52a6f3ba test(format): fuzz every plugin-filter decoder for panics
Audited LZF, bitshuffle, bzip2 and Blosc/BloscLZ for arithmetic on
header fields and unchecked slicing. The only live bug was the Blosc
frame-size underflow fixed in the previous commit; bzip2's output-growth
step now uses a saturating subtraction as well (the allocator may hand
back more capacity than asked for).

src/test_fuzz.rs (tests only) feeds each decoder random bytes, truncated
seeds and one-to-four-edit mutations of valid frames, biased towards
edge-case u32 values in size and offset fields, and asserts no panic and
no output over the limit (tests build with overflow checks and debug
assertions). Per decoder: LZF, bzip2, bitshuffle in all six mode/block
settings plus hostile cd_values, Blosc across four codecs, three shuffles,
stored frames and a hand-built BloscLZ frame, and BloscLZ streams alone.
With the previous commit's check removed, fuzzed_frames_never_panic panics
at the same subtraction. A 100x-iteration soak (different seed) found no
other panic.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:16:44 -05:00
osobhandClaude Opus 5.5 f325d111f3 fix(tools): h5rs diff compares integers exactly under -d/-p
With a tolerance, integers were converted to f64 before comparing, so
int64/uint64 values above 2^53 that differ compared equal: -d 0 on 2^60
and 2^60 + 1 exited 0, where h5diff exits 1. Integer pairs are now
compared in i128 (the delta against floor(D), the relative quotient from
an exact difference), and the report prints the exact difference.

h5diff compares exactly when -p is below the f64 epsilon (2^60 and
2^60 + 1 differ at -p 1e-18, and nextafter(2, 0) and 2 at -p 1.5e-16);
h5rs now does the same.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:16:09 -05:00
osobhandClaude Opus 5.5 699ee9c447 fix(tools): h5rs diff compares every name of a hard-linked object
The path walk skipped the second hard link to an object, so a file that
shares one dataset between /x and /y differed from a file holding two
identical copies: "</y> exists only in <B>", exit 1, where h5diff exits 0.
For a hard-linked group every member was reported the same way.

diff now enumerates every path below the start object (a hard link back
to an ancestor is recorded but not descended into), so each name is
compared. A group whose links cannot be read is now an error instead of
an empty group.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:14:53 -05:00
osobhandClaude Opus 5.5 9416c58723 fix(format): a Blosc frame shorter than its header is an error, not a panic
A hostile chunk whose header gave a compressed size below 16 bytes, not
stored raw, made the block-table check subtract past zero: a panic in any
build with overflow checks (cargo test, maturin develop, debug CLI). The
frame size is now checked against the header size, and the stream-length
read no longer adds to an untrusted offset.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 01:14:33 -05:00
osobhandClaude Opus 5.5 6a8ee3ec7f docs: changelog and known issues for the header hardening
CHANGELOG (Correctness): the new header, datatype, chunk and truncation
checks, what is left out on purpose (checks HDF5 2.0 lacks; the two
v2.7.0 writer quirks), the conformance numbers and the new FormatError
variants. known-issues: the "Header checks" audit gap is fixed, with the
one CVE object and two CVE files libhdf5 still refuses and we read.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 00:36:49 -05:00
osobhandClaude Opus 5.5 a59d83d47d test: compare header and datatype damage with what h5py refuses
h5py writes a dataset (libver earliest, so version-1 object headers) and
the script damages one field of a copy: a layout message flagged
shareable, a message size that is not a multiple of 8, a compound field
that repeats an earlier name or overlaps it, an empty enum member name, a
float exponent overlapping the mantissa. h5py refuses every damaged copy,
and clawhdf5 must refuse exactly those and read the valid files. All of
them but the unaligned one (then an UnexpectedEof) read before this
branch. The helper now reads any datatype
(File::read_multi) rather than only integers.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 00:35:41 -05:00
osobhandClaude Opus 5.5 bb39be7f24 fix(format): keep reading the floats and empty strings clawhdf5 v2.7.0 wrote
Two of the datatype checks added on this branch refused files clawhdf5
itself wrote up to v2.7.0: it put the sign bit of every float at
position 63 (so every f32 it wrote failed "sign bit position out of
bounds", including every agent store's embeddings), and wrote an
empty-string attribute with a size-0 string type ("invalid datatype
size", failing every attribute of the object). libhdf5 refuses both, but
neither decodes to wrong values (an IEEE float's sign position is not
used; a size-0 string is empty), so this reader keeps accepting them.

New fixtures written by clawhdf5 v2.7.0 (FileBuilder with every datatype,
layout and attribute kind it could write, and a FileWriter paged file)
and legacy_writer_files.rs, which reads every object of them. The agent's
v2.5.0 store fixture (float16_store) passes again.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 00:34:44 -05:00
osobhandClaude Opus 5.5 845a9d0125 docs: h5rs check inherits the library's header-check gap
Measured on the CVE corpus: check --data passes 33 of 180 files, and
h5dump 1.14.6 rejects 26 of those. Recorded under the open "Header checks"
gap and in the crate README.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 00:33:06 -05:00
osobhandClaude Opus 5.5 7d7a7e75d4 fix: refuse truncated files and read nothing past the recorded end of file
The superblock records where the file's data ends. libhdf5 refuses to
open a file shorter than that ("truncated file", H5F__super_read) and
fails any read past it ("addr overflow" / "address plus size exceeds
file eoa"). clawhdf5 read whatever was left of a truncated file, and read
bytes after the recorded end as if they belonged to the file.

New Superblock::data_end: FormatError::TruncatedFile for a file shorter
than its recorded end, otherwise where the HDF5 data ends. As in libhdf5,
the recorded end moves with the superblock when its recorded base address
is not where it is (a user block added afterwards; cve-2021-36977, which
h5py reads, depends on it), and the check is skipped for a v3 superblock
still being written in SWMR mode. File, LazyFile, MmapFile and the
conformance probe refuse a truncated file and parse only up to the end.

Files clawhdf5 writes record their true length, and the v2.5.0 agent-store
fixture and files written by v2.7.0 (plain, paged, user-block free) pass
the check.

Interop test: h5py writes a file; a copy missing its last 8 bytes must be
refused by both, a copy with bytes appended and one moved behind a new
512-byte user block must read in both.

Conformance (cached corpus, tank): 570 -> 571 ok
(h5clear_fsm_persist_less.h5, whose data past the recorded end was being
read); ten files h5py refuses as truncated (cve-2018-13874,
cve-2018-13876, the family/multi/subfiling members, h5clear_fsm_persist_
greater/user_greater) are now refused at open instead of read.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 00:30:40 -05:00
osobhandClaude Opus 5.5 0685037593 docs: h5rs in the changelog and the crate table
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 00:29:38 -05:00
osobhandClaude Opus 5.5 e92faa23a6 ci: install hdf5-tools for the h5rs interop tests
The clawhdf5-tools interop tests compare h5rs with h5ls, h5stat, h5dump and
h5diff, and CLAWHDF5_REQUIRE_INTEROP=1 turns a missing tool into a failure.
Also hold clawhdf5-tools to the no-C-in-the-default-build check.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 00:29:38 -05:00
osobhandClaude Opus 5.5 40968b3578 test(tools): h5rs sweeps over the conformance corpora
scripts/h5rs-fuzz.sh runs every h5rs subcommand over every file of a corpus
(default: the HDF Group's CVE reproducers), optionally with byte-flipped
copies (MUTATE=N), under a timeout and a memory limit, with a debug build so
integer overflow panics instead of wrapping; any exit status above 2 (a
caught panic, a timeout, a signal) fails it. It found size*8 overflows in
the datatype names on cve-2021-46244.h5, cve-2024-29161.h5 and unknown-1.h5
(fixed in the crate before it landed).

scripts/h5rs-check-ok-files.sh runs check (--data) over the conformance
files that clawhdf5 and h5py both read in full; none may be flagged.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 00:29:34 -05:00
osobhandClaude Opus 5.5 310448bfcb feat(tools): h5rs, pure-Rust HDF5 tools (ls, dump, stat, diff, check)
New workspace crate clawhdf5-tools with one binary, h5rs, built only on the
clawhdf5 facade and clawhdf5-format (no libhdf5, no C):

- ls [-r] [-v] FILE[/path]: h5ls's listing (same text in its first two
  columns) plus the datatype; -v adds address, link count, layout and chunk
  index, chunk size, storage, filters, datatype and attributes.
- dump [--json] [-A] [-p] [-d PATH] FILE: h5dump DDL (byte-identical to
  h5dump 1.14.6 on the test files) or hdf5-json.
- stat FILE: h5stat's object/link/rank/layout/filter/attribute counts, raw
  data and total size.
- diff [-r] [-q] [-d D] [-p R] A B [OBJ1 [OBJ2]]: structural and value
  differences, exit 0/1/2 like h5diff.
- check [--data] FILE: walks every object, parses every message, verifies
  the checksums of every v2+ structure (including the fractal heap blocks
  the library never checks), checks chunk indexes against their datasets
  and raw data for out-of-file or overlapping extents; every problem with
  its address.

Values over --max-bytes are reported, not read; dense-storage heaps are
verified before objects are read from them; panics are caught (exit 3).
Tests compare with h5ls, h5stat, h5dump and h5diff and with h5py's values,
and flip the checksum of every checksummed structure in a v1.14-format file.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 00:29:28 -05:00
osobhandClaude Opus 5.5 e73ac2af09 fix(format): validate chunk dimensions and chunk index offsets like libhdf5
A chunk dimension of 0 read a dataset as all fill values, 0x80000000 made
an 8 GiB chunk, and a chunk dimension the chunk index's offsets are not
multiples of read chunks at the wrong place (cve-2018-11205). libhdf5
refuses all of these; now so does clawhdf5:

- DataLayout::parse (H5O__layout_decode): no chunk dimension 0 ("bad chunk
  dimension value"), at most 33 dimensions, and before layout v4 at least
  2 ("bad dimensions for chunked storage"). New
  FormatError::InvalidChunkDimensions.
- Reading a chunked dataset (H5D__chunk_init / H5D__chunk_set_sizes): the
  chunk rank must match the dataspace's and a chunk must be under 4 GiB.
  One chunked_read::chunk_geometry replaces the four copies of the rank
  check.
- v1 B-tree chunk index (H5D__btree_decode_key): every key's offsets must
  be multiples of the chunk dimensions, including the keys that only bound
  a node, which is where cve-2018-11205's bad dimension shows. New
  chunked_read::collect_chunk_info_checked; the chunked read and selection
  paths use it.

New interop test header_validation_interop.rs: h5py writes chunked files
(layout v3 and v4), the script corrupts the chunk dimension, and
clawhdf5 must read exactly the copies h5py reads.

Conformance (cached corpus, tank): 570 ok, unchanged; cve-2018-11205 now
refuses the dataset h5py refuses; six more objects that already failed now
fail with libhdf5's reason.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 00:21:58 -05:00
osobhandClaude Opus 5.5 e01160299a docs: plugin filters and the filter registry
CHANGELOG (Unreleased): LZF, bitshuffle, bzip2 and Blosc read and write
in pure Rust, their features, the ChunkOptions::plugin field (breaking for
struct-literal construction), the filter registry, the named
UnsupportedFilter message, and Blosc2/ZFP still unimplemented.
README: the clawhdf5-format feature table gains lzf (default),
bitshuffle, bzip2, blosc and plugin-filters, with how to write them and
what is not implemented; no speed claims. docs/known-issues.md: the audit's
filter gap is marked fixed 2026-09-26 for LZF/bitshuffle/bzip2/Blosc,
Blosc2 and ZFP still open. CLAUDE.md: the clawhdf5-filters row no longer
says "No Blosc". clawhdf5-format's crate docs list the new features.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 00:14:47 -05:00
osobhandClaude Opus 5.5 5461a13984 ci: js-sys is not C in the no-C check
The check matches any *-sys crate, and clawhdf5-wasm pulls in js-sys,
wasm-bindgen's bindings to JavaScript, which compiles no C. Exempt it
by name so the check keeps catching real C for the wasm crate.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 00:14:30 -05:00
osobhandClaude Opus 5.5 056092b082 ci: lint and test the plugin filters; the conformance probe reads them
scripts/ci-test.sh: the format feature matrix (clippy and tests) adds
plugin-filters; bitshuffle, bzip2 and blosc are each linted alone (blosc
and bitshuffle share code); the facade is linted with plugin-filters; and
the interop section runs tests/plugin_filters_interop.rs with it, against
h5py + hdf5plugin (CI's venv already installs hdf5plugin).

conformance/probe enables plugin-filters. Sweep (tank, 2026-09-26,
conformance/run.sh --no-fetch against the cached corpus): 573 of 697 ok
(baseline 569), no regressions; newly ok: h5ex_d_blosc.h5,
h5ex_d_bshuf.h5, h5ex_d_bzip2.h5, h5ex_d_lzf.h5. h5ex_d_blosc2.h5 and
h5ex_d_zfp.h5 remain UnsupportedFilter.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 00:14:28 -05:00
osobhandClaude Opus 5.5 a5ca970015 fix: refuse numeric types with unusually many unused bits in v1 headers
libhdf5 1.14.4+ treats an integer, float or bit field wider than a byte
whose precision and offset leave more than half its bits unused as
corruption when the type sits in a header without a checksum (version 1),
unless the file is opened with H5Pset_relax_file_integrity_checks
(H5T_is_numeric_with_unusual_unused_bits). clawhdf5 read such types,
e.g. a 3-bit integer in 4 bytes (cve-2024-29162) or a 32-bit float in
65525 bytes (cve-2024-32614, tmisc38a.h5).

New Datatype::check_unused_bits (recursive) and Datatype::parse_in_header,
which applies it for version-1 headers. Dataset datatypes (facade File,
LazyFile, MmapFile; clawhdf5-io VOL, MPI VOL, async reader; the
conformance probe) and compact attributes in version-1 headers use it.

Conformance (cached corpus, tank): 570 ok, unchanged; cve-2024-29162,
cve-2024-32614 and tmisc38a.h5 now refuse the object h5py refuses, and
tmisc38b.h5 / unknown-1.h5 now fail with libhdf5's reason.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 00:12:04 -05:00
osobhandClaude Opus 5.5 e7a7951f1e feat(format): name the filter in UnsupportedFilter errors; Blosc2/ZFP stay errors
Blosc2 (32026) is out of reach for now: hdf5plugin's Blosc2 filter stores
each HDF5 chunk as a Blosc2 super-chunk frame (msgpack header, a compressed
chunk-offset index, trailer metalayers) and, for 2-D and larger chunks, as
a B2ND array whose n-D blocks have to be reassembled - on top of the Blosc2
chunk format itself (extended header, filter pipeline, special-value
chunks). ZFP (32013) is out of scope. Both keep failing with
UnsupportedFilter, and the message now says what the ID is:
"unsupported filter: 32026 (Blosc2, not implemented by clawhdf5)", or,
for a filter this build left out, "... (Blosc; this build lacks the
`blosc` feature)". filter_registry::known_filter exposes the table.

tests/plugin_filters_interop.rs: hdf5plugin's Blosc2 and ZFP datasets
read as an error naming the filter, never as data.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 00:11:57 -05:00
osobhandClaude Opus 5.5 1f71f3bcbc feat(format): Blosc filter (32001), read and write, pure Rust
hdf5plugin's Blosc (hdf5-blosc) failed with UnsupportedFilter(32001). The
new `blosc` feature decodes the Blosc 1 frame c-blosc 1.x writes: the
16-byte header, raw ("memcpyed") frames, the block table, blocks split
into one stream per byte plane (and the "do not split" flag), streams
stored raw, the byte shuffle and bit shuffle (whole 8-element groups, the
rest copied) - and every codec hdf5plugin offers: BloscLZ (implemented
here from c-blosc 1.21's blosclz_decompress, including its rejection of
malformed and truncated streams), LZ4/LZ4HC (lz4_flex), Snappy (snap),
Zlib (flate2) and Zstandard (ruzstd). Every stream must decode to exactly
its size and the frame to at most the chunk size; a frame of another
format version (Blosc 2) is a clear error.

It also encodes (DatasetBuilder::with_blosc(codec, level, shuffle)):
LZ4, Snappy, Zlib or Zstandard, with c-blosc's split rule, raw streams
where compression does not pay, and a stored frame for level 0 or
incompressible data. It cannot write BloscLZ (asking for it is an
error). `plugin-filters` enables LZF, bitshuffle, bzip2 and Blosc.

Interop: hdf5plugin writes all six codecs x {no, byte, bit} shuffle at
levels 5/9/1, plus level 0, over the 12-case matrix, read byte for byte;
our four codecs x four shuffle/level settings read back through
hdf5plugin. Both fail with the decoder removed. `cargo tree` with
`plugin-filters` has no -sys crate other than libbz2-rs-sys (pure Rust),
no cc and no cmake.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 00:10:05 -05:00
osobhandClaude Opus 5.5 3cf8cd86f2 fix(format): refuse datatypes libhdf5 refuses to decode
Datatype::parse now makes the checks of libhdf5's H5O__dtype_decode_helper
and fails with InvalidDatatype (libhdf5's own error text) instead of
decoding a corrupt type:

- size 0 ("invalid datatype size"), for every class;
- integer bit offset/precision outside the type, or precision 0;
- float sign/exponent/mantissa outside the type, empty, or overlapping;
  normalization 3; bit 6 without bit 0 from version 3;
- compound with no members, a member outside the compound, a duplicate
  name, or a member overlapping an earlier one;
- enum whose size differs from its base type's, or an empty member name;
- array of more than 32 dimensions or with a zero-sized one (v1 compound
  array members now say so rather than InvalidDatatypeVersion);
- opaque tag length that is not a multiple of 8.

Bit 6 of a version-1/2 float's class bits used to be read as VAX order,
byte-swapping values; libhdf5 ignores it before version 3, and so does
this now.

Only checks HDF5 2.0 (h5py 3.16) makes are added: newer libhdf5 also
checks bit fields, the variable-length kind and array sizes, but h5py
opens files that fail those, so they are left out. Each check was
confirmed against h5py by corrupting a file it wrote.

The conformance probe now decodes committed datatypes, as h5py's f[name]
does. Conformance (cached corpus, tank): 570 ok, unchanged. Objects
libhdf5 refuses that clawhdf5 used to read: cve-2016-4332-mtime (/cmpnd),
cve-2017-17508, cve-2024-32616 (/type1), cve-2024-32618, cve-2026-34734,
bad_compound.h5 (/cmpnd, /dataset); eight more that already failed now
fail with libhdf5's reason (e.g. cve-2024-29163 "mantissa range out of
bounds").

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 00:08:58 -05:00
osobhandClaude Opus 5.5 34987ec194 docs: clawhdf5-wasm in the changelog, known issues and CLAUDE.md
Changelog entry with the sizes measured on tank on 2026-09-26 (and the
h5wasm 0.10.3 comparison), the browser build's limits as a known-issues
entry, and where its tests run.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 00:06:58 -05:00
osobhandClaude Opus 5.5 b58d61cfb7 test(wasm): accept a zstd read when the build has the filter
cargo test --workspace unifies clawhdf5-format/zstd on (another member
enables it), so the native interop test read the Zstd dataset that the
wasm build refuses. The fixture now records its values plus the error
the wasm build must give; the native test accepts either, the Node test
of the real wasm package still requires the error.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 00:06:26 -05:00
osobhandClaude Opus 5.5 1abd93e0f8 feat(wasm): examples/wasm-viewer, an HDF5/NetCDF-4 viewer page
Drop a file (or pass ?file=<url>&path=<object>), browse the tree lazily,
see a dataset's type, shape, max shape and attributes, and page through
its values as 50x12 hyperslab windows (leading dims of 3-D+ data held
at chosen indices). build.sh produces pkg/ (not committed) with
wasm-bindgen --target web and checks the CLI matches the crate version.

test/run.sh builds it and runs test.mjs under Node against the h5py/
netCDF4 fixture (250 checks: every dataset whole and as a strided
hyperslab, listings, attributes, error paths, the page's DOM-free
helpers), then browser.sh renders the page in headless Chromium for
eight objects and checks the DOM. The fixture gains LZ4 (read) and Zstd
(refused: links C) datasets and a compound attribute (value null plus
its type). ci-test.sh runs it when node and wasm-bindgen exist; the CI
container has neither, so CI relies on the native h5py_interop test.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 00:05:34 -05:00
osobhandClaude Opus 5.5 6dfd239011 feat(format): bzip2 filter (307), read and write, pure Rust
hdf5plugin's BZip2 failed with UnsupportedFilter(307). The new `bzip2`
feature decodes the single bzip2 stream H5Zbzip2.c stores, bounded by the
chunk size (a truncated stream is an error, not short data), and encodes
at block size cd_values[0] (DatasetBuilder::with_bzip2(level)). It uses the
bzip2 crate's default backend, libbz2-rs-sys, a pure-Rust port of
libbzip2: `cargo tree` shows no cc/cmake, and nothing is compiled from C.

Interop: hdf5plugin writes block sizes 9, 1 and 5+shuffle over the
12-case matrix, read byte for byte; ours at 9 (shuffled) and 1 (not)
reads back through hdf5plugin. Both fail with the decoder removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 00:05:26 -05:00
osobhandClaude Opus 5.5 07094e34a9 feat(format): bitshuffle filter (32008), with its LZ4 and Zstandard modes
hdf5plugin's Bitshuffle failed with UnsupportedFilter(32008). The new
`bitshuffle` feature (pure Rust: lz4_flex, and ruzstd for Zstandard — the
`zstd` feature's libzstd is not needed) decodes all three modes of
bshuf_h5filter.c — transpose only, LZ4 and Zstandard blocks behind the
12-byte header — including the default and explicit block sizes, the
shorter last block rounded down to a multiple of 8 elements, and the
untransposed trailing elements. Sizes read from the chunk are bounded by
the chunk size.

It also encodes: DatasetBuilder::with_bitshuffle(BitshuffleCompression)
or PluginFilter::Bitshuffle { block_size, compression } writes the filter
with hdf5plugin's cd_values and no automatic byte shuffle. ruzstd has one
compression level (about zstd's 1); the requested level is recorded.

The bit transpose is checked bit for bit against a one-bit-at-a-time model
(which matched hdf5plugin's output) and is shared with blosc next.
Interop: hdf5plugin writes none/LZ4/Zstandard at default and explicit
block sizes and levels over the 12-case matrix, read byte for byte; our
three modes at two block sizes read back through hdf5plugin. Both fail
with the decoder removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 00:04:05 -05:00
osobhandClaude Opus 5.5 f4dee1cd08 fix(format): refuse object headers libhdf5 refuses to load
ObjectHeader::parse now checks each header message the way libhdf5's
H5O__chunk_deserialize does, and fails with InvalidObjectHeader (libhdf5's
own error text) instead of reading objects out of a corrupt header:

- v1: every message in chunk 0 is read (not just the prefix's count) and
  more messages than the prefix claims is "bad object header message
  count"; message sizes must be multiples of 8; leftover bytes are a gap,
  which only v2 allows; the prefix's chunk size must fit its count.
- v1 and v2: a message running past its chunk is an error (it used to end
  the chunk quietly, dropping it and everything after); contradictory
  message flags; a message of a class that cannot be shared flagged
  shared/shareable; a reference-count message in a v1 header; malformed
  continuation, reference-count and modification-time messages (libhdf5
  decodes these while loading the header).
- v2: unknown header status flags, max_compact < min_dense, a chunk 0
  smaller than a message header, a gap in a chunk that has NIL messages.

Conformance (cached corpus, tank): 569 -> 570 ok (h5stat_err_refcount.h5).
Objects libhdf5 refuses that clawhdf5 used to read: cve-2016-4332-mtime
(/dataset), cve-2016-4332-mtime-new, cve-2018-11204, cve-2018-13873,
cve-2024-32619, cve-2024-33873, cve-2024-33874, gh-4433-poc-08; seven more
CVE objects that already failed now fail with libhdf5's reason.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 00:02:49 -05:00
osobhandClaude Opus 5.5 e38f9123db feat(format): LZF filter (32000), read and write, pure Rust
h5py's built-in compression="lzf" failed with UnsupportedFilter(32000). The
new `lzf` feature (no dependencies, on by default in clawhdf5-format and
the facade) decodes the raw liblzf stream h5py's filter stores, bounded by
the chunk size, and encodes it: DatasetBuilder::with_lzf() (or
with_plugin_filter(PluginFilter::Lzf)) writes the filter with h5py's
cd_values (filter version 4, liblzf 0x0105, chunk size in bytes), flagged
optional as h5py does. ChunkOptions gains a `plugin` field for the plugin
filters; build_pipeline_for_chunk passes the chunk size to filters that
record it.

tests/plugin_filters_interop.rs: h5py writes LZF (alone, with shuffle, with
shuffle+fletcher32) over 12 dtype/shape/chunk/data cases with partial edge
chunks and incompressible data, and every dataset reads byte for byte equal
to its unfiltered twin; our LZF output (1-D and 2-D, edge chunks, with and
without shuffle) reads back in h5py. Both fail with the decoder removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 00:01:22 -05:00
osobhandClaude Opus 5.5 a42b646689 feat(wasm): clawhdf5-wasm, the reader for JavaScript via wasm-bindgen
open(bytes) -> H5File with kind/list/info/attrs/attrErrors/read/
readHyperslab. Numeric data comes back in the typed array of the
stored width (Int16Array for i16, BigInt64Array for i64, Float32Array
for f32/f16, ...), strings and enum names as string arrays, array
datatypes flattened with their dims appended to the shape. Compound,
reference, opaque and VL-sequence datasets are refused with an error
naming the type; nothing is returned as reinterpreted bytes.

The logic is in a plain-Rust core module, tested natively: unit tests,
and h5py_interop, which compares every dataset, hyperslab, listing and
attribute of an h5py- and a netCDF4-written file with what libhdf5
reads back (generator shared with the Node test of the built package).

No mmap, no threads; lz4 is on, zstd/szip (C) are not. A
wasm-release profile (opt-level s, LTO) serves the browser build.
ci-test.sh lints the crate for wasm32 and checks it builds no C.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 00:00:46 -05:00
osobhandClaude Opus 5.5 74f9f50086 ci: build the clawhdf5 read path for wasm32-unknown-unknown
The facade already builds for the browser target without mmap (and
with it: memmap2 compiles there and File::open just fails, as std::fs
does). Nothing needed gating; keep it that way with a ci-test.sh step,
and install the target in the CI container.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 00:00:11 -05:00
osobhandClaude Opus 5.5 d16544b928 feat(format): a filter registry — filters are looked up by ID
decompress_chunk_masked and compress_chunk matched on the filter ID. They
now look the ID up in filter_registry: a static table of the built-in
filters compiled into this build (a filter whose cargo feature is off is
simply absent), then the codecs an application registered at run time with
register_filter (a FilterCodec, or a plain decoding closure). Registered
codecs cannot shadow a built-in one, and their output is held to the same
per-stage bound as the built-in decoders. An ID in neither tier still fails
with UnsupportedFilter(id).

The "feature off" stub functions that returned UnsupportedFilter are gone:
the table leaves those filters out instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 23:56:37 -05:00
osobhandClaude Opus 5.5 3b24e6753b bench: concurrent-read harness against h5py threads and processes
concurrent_read reads one shared File from 1-16 threads: every dataset
in full (distinct datasets per thread) and random hyperslabs of one
dataset, over a deflate and a contiguous file it generates (or reuses
while manifest.json matches). It reports decoded MB/s and scaling
efficiency, warm or --cold (posix_fadvise) page cache, sizes the decode
pool with --decode-threads, and writes JSON.

scripts/concurrent_read_h5py.py runs the same workload on the same files
with h5py threads or spawned processes (same splitmix64 data and slab
stream, checked at spot elements), and compare_concurrent_read.py prints
one table and refuses runs with different workloads. A smoke test runs
all three end to end on tiny files (h5py half honours CLAWHDF5_PYTHON /
CLAWHDF5_REQUIRE_INTEROP).

BENCHMARKS.md gets a "Concurrent reads" section with the commands, marked
not yet measured.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 23:56:36 -05:00
osobhandClaude Opus 5.5 e815eb922f feat(facade): Dataset::raw_datatype returns the full stored datatype
dtype() simplifies the type (no byte order, string padding or member
offsets), so callers could not decode read_selection's bytes for types
the typed read_* methods skip. raw_datatype() returns the parsed
Datatype, committed types resolved, for use with data_read.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 23:51:20 -05:00
osobhandClaude Opus 5.5 bb78d70b99 docs: changelog for the review follow-up fixes
CI / test-arm64 (pull_request) Successful in 1m7s
CI / test (pull_request) Successful in 5m31s
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 22:46:44 -05:00
osobhandClaude Opus 5.5 a7de15534c docs: conformance report after the read-gap fixes (569 of 697 ok)
Regenerated on tank at 10d1029: ok 467 -> 569, our-error 123 -> 14,
mismatch 15 -> 22 (six user-defined-link files moved from our-error to a
listing difference), no panics, hangs, crashes or OOM. Baseline raised.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 22:46:37 -05:00
osobhandClaude Opus 5.5 10d1029ead conformance: probe files with a user block and VDS the library's way
Superblock::parse now refuses a user-block offset, and the raw read path
no longer guesses a VDS fill value. The probe looks at the file from the
superblock on and reads virtual datasets with vds::read_virtual_dataset,
the dataset's fill value and its source-derived extent.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 22:46:08 -05:00
osobhandClaude Opus 5.5 883980f2bd test: compare h5py's v1 compound field names with what clawhdf5 reads
The test compared h5py against its own expected table, so it passed with
the fix reverted. Found by the adversarial review.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 22:44:05 -05:00
osobhandClaude Opus 5.5 d6e426e6d5 fix(agent): fail to open a store whose /meta has an unreadable attribute
Group::attrs now leaves out an attribute it cannot decode. The agent read
its settings through it, so a store whose float16 (or compression,
quantized_index, WAL mark, signature...) attribute could not be decoded
opened with the default in its place, and no error. /meta is now read
with attrs_with_errors and any unreadable attribute is a Schema error,
as it was before attrs became tolerant. Found by the adversarial review.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 22:43:53 -05:00
osobhandClaude Opus 5.5 256e7b89e4 fix(format): refuse variable-length and reference VDS data from another file
Their elements are global-heap IDs and object addresses in the source
file. The VDS reader copied them raw, so anything decoding them against
the virtual dataset's file got another object's data with no error.
Same-file sources are unaffected. Found by the adversarial review.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 22:42:57 -05:00
osobh f2e704abf3 Merge branch 'fix/p1-vds' into fix/p1-read-gaps
# Conflicts:
#	CHANGELOG.md
#	crates/clawhdf5-format/src/data_read.rs
2026-09-25 22:42:04 -05:00
osobh 61f36516d7 Merge branch 'fix/p1-attrs-links' into fix/p1-read-gaps
# Conflicts:
#	crates/clawhdf5-format/src/attribute.rs
#	crates/clawhdf5-format/src/group_v1.rs
#	crates/clawhdf5/src/lazy.rs
#	crates/clawhdf5/src/mmap_file.rs
#	docs/known-issues.md
2026-09-25 22:41:33 -05:00
osobh 45720fe5a6 Merge branch 'fix/p1-userblock-shared' into fix/p1-read-gaps
# Conflicts:
#	crates/clawhdf5-format/src/attribute.rs
#	crates/clawhdf5-format/src/datatype.rs
#	crates/clawhdf5-format/src/shared_message.rs
#	docs/known-issues.md
2026-09-25 22:40:55 -05:00
osobh adf961c883 Merge branch 'feat/p1-layout-v1v2' into fix/p1-read-gaps 2026-09-25 22:40:26 -05:00
osobhandClaude Opus 5.5 b4a44a2e66 feat(format): read unlimited and printf-style VDS mappings like libhdf5
Unlimited VDS mappings were refused, and printf-style source names
("f-%b.h5") were not expanded, so those regions read as fill (read-matrix
case 0470: 29 of 30 values wrong). All 7 virtual datasets in the libhdf5
test set use such mappings.

Implement H5Dvirtual.c's semantics in the vds module:
- %b is the block number, %% a literal %, other specifiers are an error;
  block j of the virtual selection comes from the source named with j,
  probing from 0 to the first missing source (printf gap 0);
- unlimited source/virtual selections are clipped to what the source's
  current extent fills (H5S_hyper_get_clip_extent_match, partial last
  block included);
- the extent is recomputed as H5Dget_space does (view "last available":
  the largest clip, never below what limited mappings need), exposed as
  vds::virtual_dataset_extent and used by Dataset::shape();
- a source in the other byte order is byte-swapped; other conversions stay
  an error.

Tests: vds_interop::vds_printf_source_names,
vds_unlimited_mappings_follow_source_extents (h5py low-level API, earliest
and latest format) and vds_libhdf5_test_files (vds-eiger, 4_vds and
vds-percival-unlim-maxmin from HDF5's tools/test/testfiles/vds, committed
as fixtures) all compare shape and values with h5py; unit tests for the
clip arithmetic, name parsing and mapping rules.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 22:10:09 -05:00
osobhandClaude Opus 5.5 17fa783dce fix(format): resolve SOHM-shared messages on every path
A message shared through the file's SOHM heap (H5Pset_shared_mesg_index)
is referenced by heap ID, which needs the SOHM table from the superblock
extension. Only message_data_with_sohm (used for fill values) loaded it;
resolve_shared_message passed no table, so a SOHM-shared datatype,
dataspace, filter pipeline or attribute failed with "invalid shared
message version: 2" and the dataset or attribute could not be read.
resolve_shared_message now loads the table when the reference carries a
heap ID.

Found while making attrs() tolerant: SOHM attributes turned from an
error into missing keys in the audit read matrix. With this fix all 36
SOHM cases there match h5py (datasets, fill values and attributes, every
shareable message type, libver earliest and latest).

Regression test: sohm_shared_messages_resolve (h5py writes files sharing
each message type on its own and all of them; values and attributes
checked).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 22:08:45 -05:00
osobhandClaude Opus 5.5 90e050944f fix(format): refuse a local heap whose free list leaves the heap
libhdf5 walks a local heap's free list when it loads the heap's data and
refuses the heap ("bad heap free list") when a free block starts or ends
outside the data segment, or links to offset 0. We never looked at the
free list, so a damaged old-style group listed names read from the broken
heap: once the user block of cve-2021-36977.h5 was applied, its root
listed eight garbage names where libhdf5 fails.

LocalHeap::validate_free_list (new) mirrors H5HL__fl_deserialize, with a
cycle bound, and accepts H5HL_FREE_NULL (1) or an all-ones head as the
end of the list. Like libhdf5 it runs when the first name is needed, not
on parse, so an empty group with a damaged heap still lists as empty
(cve-2018-13871.h5, cve-2024-29166.h5, gh-4431-poc-03.h5 keep matching
h5py).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 22:07:50 -05:00
osobhandClaude Opus 5.5 a6e90f3ee3 fix(format): apply the base address of files with a user block
A file may start with a user block (h5py userblock_size, h5jam), putting
the superblock at 512, 1024, ...; every address in the file is then
relative to the superblock. The signature search found it, but every
reader passed the whole file to the parsers, so addresses landed
userblock bytes early and the root group failed with
InvalidObjectHeaderVersion (twithub.h5, twithub513.h5,
h5clear_fsm_persist_user_*.h5).

Readers now view the file from the superblock on, taking the signature's
position as the base address as libhdf5 does: File (mmap, buffered,
from_bytes), MmapFile, LazyFile, AsyncHDF5File, the VOL and MPI VOL
readers, the HNSW loader and external VDS source files. File, MmapFile
and LazyFile gain user_block_size(). The new signature::split_user_block
returns the two parts, and Superblock::parse refuses a non-zero offset
(UserBlockNotStripped) so a format-level caller cannot silently apply
superblock-relative addresses to the whole file.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 22:07:50 -05:00
osobhandClaude Opus 5.5 0555794850 fix(format): read version-1 shared message addresses after the heap offset
A version-1 shared message (HDF5 1.6) embeds the target as a symbol-table
entry: after six reserved bytes comes a length-sized local-heap offset,
then the object header address. We read the heap offset as the address,
so datasets using a committed datatype in 1.6-era files (tcompound.h5,
tcompound2.h5) failed with InvalidObjectHeaderVersion. parse_shared_ref
now takes length_size and skips the offset, as libhdf5 does.

Resolving a reference also no longer falls back to the first message of
any type in the target header: a missing target message is
SharedMessageTargetMissing instead of garbage.

Fixture: tcompound.h5 from libhdf5's tools/test/testfiles (8 KiB).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 22:07:50 -05:00
osobhandClaude Opus 5.5 efc2dc53c9 fix(format): read array members of version-1 compound datatypes
HDF5 1.6 encoded a compound member that is a fixed-size array through
legacy per-member fields (dimensionality, permutation, four dimension
sizes) that the v1 decoder skipped, so a [4] i32 member read as one i32
with the wrong size. Build the array type from those fields as libhdf5
does (ignoring the permutation) and refuse more than four dimensions.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 22:07:50 -05:00
osobhandClaude Opus 5.5 5c2f656fe7 docs: first conformance report and baseline (42b81d9, tank)
CI / test-arm64 (pull_request) Successful in 1m6s
CI / test (pull_request) Successful in 5m0s
467 of 697 files read identically to h5py 3.16 / HDF5 2.0, 123 our-error,
15 mismatch (2 an h5py big-endian VL bug), 92 libhdf5 cannot read; no
panics, hangs, crashes or OOM.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 22:06:00 -05:00
osobhandClaude Opus 5.5 945b13a1f1 ci: nightly conformance sweep
Runs conformance/run.sh in rust:latest on a schedule and on demand, with its
own venv (pinned h5py/numpy/hdf5plugin/netCDF4) and hdf5-tools. Fails on any
panic, hang, crash or OOM in clawhdf5 and on a drop against
conformance/baseline.json; prints CONFORMANCE.md into the job log and
uploads nothing. Plain git checkout, no JavaScript actions.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 22:06:00 -05:00
osobhandClaude Opus 5.5 9179aa356e feat(conformance): in-repo, reproducible conformance sweep
conformance/run.sh fetches eight public HDF5 corpora pinned by commit
(conformance/corpus.txt) into a gitignored cache, reads every file with
clawhdf5 (conformance/probe, a crate outside the workspace) and with
h5py/libhdf5 (ref.py), and the CVE files with h5dump, each under a timeout
and an address-space limit; compare.py classifies the files, report.py
writes CONFORMANCE.md and check.py gates on panics/hangs/crashes/OOM and on
regressions against conformance/baseline.json. ~25 s once cached.

Changes from the ad-hoc audit harness:
- the probe compares non-IEEE-layout floats (N-Bit) and integers with a bit
  offset or reduced precision as the values libhdf5 converts them to, not
  raw file bytes: 8 files that showed as mismatches now read identically;
- ref.py exits without tearing down h5py objects: libhdf5 2.0 aborts while
  freeing them for two files about half the time, which flipped them
  between ok and h5py-cannot-read from run to run;
- the file list is defined (list_files.py): netCDF classic files are left
  out, 11 HDF5 files the ad-hoc sweep missed are in.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 22:06:00 -05:00
osobhandClaude Opus 5.5 e94a52a88b fix(format): read unmapped VDS elements as the virtual dataset's fill value
Elements of a virtual dataset that no mapping supplies (unmapped regions,
a missing source file, a missing source dataset) read as 0 instead of the
fill value libhdf5 returns — silent wrong data for any VDS created with a
non-zero fillvalue (read-matrix cases 0471/0472: -1 and 7 read as 0). A
missing source dataset was an error; libhdf5 reads it as fill.

Move VDS assembly into a new vds module following H5Dvirtual.c:
vds::read_virtual_dataset takes the dataset's fill value and a
VdsFileResolver that can refuse a name, and reports how many elements were
unmapped. Sources are read with their own fill value, and a source whose
datatype differs from the virtual dataset's is an error (libhdf5 converts).
File passes the dataset's fill value, resolves source names against the
virtual file's directory, and refuses names that leave it with an error
instead of reading them as fill. read_selection on a VDS goes through the
same fill-aware path.

The raw-read API (read_raw_data_full*) has no fill value, so it now errors
for a VDS with unmapped elements instead of guessing zeros.

Tests: vds_interop::vds_unmapped_regions_read_as_fill_value (external,
same-file, missing file/dataset, sparse source with its own fill, int
fill; earliest and latest format) and
vds_source_outside_directory_is_an_error_not_fill, both against h5py;
integration_test::v4_virtual_dataset_raw_api_refuses_to_guess_the_fill_value.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 22:03:47 -05:00
osobhandClaude Opus 5.5 d54a0f4737 feat(format): read the other attributes when one cannot be read
attrs() read every attribute of an object through extract_attributes_full,
so one attribute it could not read (a corrupt or unsupported attribute
message, or a heap object it could not locate) failed all of them — the
same shape as the huge-object bug, where one 8 KiB attribute hid every
attribute on a NetCDF file's root group.

- clawhdf5-format: new attribute::extract_attributes_tolerant returns the
  attributes it could read plus one error per attribute it could not.
  Errors in the attribute index itself (Attribute Info message, dense
  heap header, B-tree) still fail, since then it is unknown which
  attributes exist. extract_attributes_full is unchanged (strict); both
  share one implementation.
- clawhdf5: attrs() on Group/Dataset, MmapGroup/MmapDataset and
  LazyGroup/LazyDataset leaves an unreadable attribute out (documented),
  and the new attrs_with_errors() returns the map with the per-attribute
  errors. A value is either returned complete or not at all.

Regression test: one_unreadable_attribute_does_not_hide_the_others (h5py
writes 11 dense attributes; one message's version byte is corrupted;
before: attrs() failed with InvalidAttributeVersion(127), after: the 10
others come back with their values and one error is reported).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 22:03:09 -05:00
osobhandClaude Opus 5.5 aadfd18d4c fix(format): list soft links as their targets, like h5py
Group::datasets()/groups() (and the Mmap/Lazy handles) listed only hard
links, so a soft link to a dataset or group was missing, and dataset(name)
/ group(name) on a group handle could not open one. In old-style (symbol
table) groups a soft link's entry has no object header address, and the
listing failed outright trying to parse one.

The three facade handles each had their own copy of the child-listing
code; they now share group_v2::resolve_group_children, which returns hard
links plus soft links resolved to their targets (relative targets from
the group holding the link, via the new resolve_path_from). A dangling or
cyclic soft link, an external link and a user-defined link are left out —
h5py lists their names but cannot open them. Any other error met while
resolving is returned, not hidden.

Path resolution now walks a relative soft link's target from the group
holding it instead of rebuilding the path from the root (same result,
one less re-walk), and ignores "." components.

Regression test: soft_links_are_listed_as_their_targets (h5py writes
absolute, relative, group, dangling, cyclic and external links with
libver latest and earliest; listings compared with h5py for File,
MmapFile and LazyFile).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 22:00:46 -05:00
osobhandClaude Opus 5.5 2c6c6c176e fix(format): decode the version-1 VDS mapping list HDF5 2.0 writes
With a 2.0 low version bound, libhdf5 stores the VDS mapping list as heap
block version 1: every entry starts with a flags byte (0x04 same file, no
file name; 0x01/0x02 file/dataset name shared with an earlier entry, whose
index is stored in place of the name). The parser treated only a leading
0x04 byte as special, so a 0x00 flags byte read as an empty (same-file)
name and shared names were read as garbage.

Decode it as H5D__virtual_load_layout does, refusing unknown flags,
forward references and block versions above 1.

Test: vds_interop::vds_mapping_block_version1_shared_names (h5py
libver=("v200","v200") with repeated long names; failed before with
"unknown dataspace selection type") plus the exact heap block as a unit
test.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:59:15 -05:00
osobhandClaude Opus 5.5 190918a478 feat(format): decode hyperslab selection versions 1 and 2 in VDS mappings
libhdf5 serializes a VDS hyperslab as version 1 (irregular, 4-byte block
corners) for the default format bounds, and as version 2 (regular, 8-byte)
for unlimited selections in the 1.10 format. Only version 3 was accepted,
so every h5py VDS written with default libver failed with "only version-3
hyperslab selections are supported" (5 libhdf5 test files in the sweep).

Decode all three versions following H5S__hyper_deserialize, including
irregular hyperslabs (a union of blocks, enumerated in row-major order as
libhdf5 iterates them) and the all-ones "unlimited" count/block marker.
SerializedSelection exposes the raw form for unlimited-mapping support.

Test: vds_interop::vds_version1_irregular_hyperslab_selections compares
default-libver h5py VDS reads (contiguous, strided and 2-D block mappings)
with libhdf5's values.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:57:32 -05:00
osobhandClaude Opus 5.5 38d0d4de02 fix(format): skip user-defined links instead of failing the group
Link types 65-255 are user-defined: their target is only meaningful to
the application that registered the link class. LinkMessage::parse
rejects them with InvalidLinkType, and group traversal propagated that,
so one such link made the whole group unlistable and every path through
it unresolvable (libhdf5's tall.h5 and tudlink.h5, class 187).

Group traversal (compact and dense) now leaves user-defined links out,
the way h5py leaves out links it cannot open; reserved types (2-63) are
still an error.

Regression test: user_defined_links_do_not_break_the_listing, on
libhdf5's own tools/test/testfiles tall.h5 and tudlink.h5 (BSD-style
HDF5 licence, 10 KB and 1 KB), committed as fixtures.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:57:18 -05:00
osobhandClaude Opus 5.5 1c85986079 fix(format): read huge, tiny and filtered fractal heap objects
A heap ID's type is in bits 4-5 of its first byte (H5HF_ID_TYPE_MASK
0x30); bits 6-7 are the ID version. The reader took the type from bits
6-7, so every huge object ID (0x10) was decoded as a managed one and
failed — and since dense attributes are read all at once, one attribute
over the heap's 4 KiB managed limit made every attribute on its object
unreadable (netcdf4-python's issue671.nc / issue672.nc).

- Huge objects (type 1): located directly from the ID when address and
  length fit in it, otherwise through the huge-object v2 B-tree (record
  types 1 and 2); filtered huge objects are decoded with the heap's
  pipeline and their filter mask.
- Tiny objects (type 2): read from the ID itself.
- Filtered heaps: the header's pipeline is parsed (it was skipped short,
  so the header checksum was read from the wrong place), indirect-block
  entries for direct blocks carry their filtered size and mask, and
  direct blocks are decoded before objects are read from them.
- An unknown ID version is an error.

FractalHeapHeader gains huge_btree_address, filter_pipeline,
root_direct_block_filtered_size, root_direct_block_filter_mask,
offset_size and length_size; read_managed_object now accepts any ID type.

Regression tests (h5py-written, compared with h5py):
dense_attribute_stored_as_a_huge_heap_object, dense_group_with_a_huge_link,
dense_group_with_a_filtered_link_heap; unit tests
tiny_object_is_read_from_the_id, huge_object_with_a_direct_id,
unknown_heap_id_version_is_refused.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:55:51 -05:00
osobhandClaude Opus 5.5 c7092722aa fix(format): locate the address in version-1 shared messages
A version-1 shared message reference is version, type, six reserved bytes
and then an old-style symbol table entry: link-name offset (length size),
object header address, cache type, reserved, scratch. We read the address
straight after the reserved bytes, i.e. the link-name offset, and the
committed datatype lookup failed with InvalidObjectHeaderVersion (the bytes
checked in tcompound.h5: name offset 0x10, then 0x590 = /type1). Datasets
of 1.4/1.6-era files that use a committed datatype were unreadable.

Skip the name offset. parse_shared_ref has no length size, so add
parse_shared_ref_sized and use it in every internal caller;
parse_shared_ref keeps its signature and assumes length size == offset
size. The old parse_v1_ref unit test encoded the wrong layout and now uses
the real bytes.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:55:31 -05:00
osobhandClaude Opus 5.5 36356ba8a1 fix(format): keep the array dimensions of compound v1 members
Compound datatype version 1 carries, per member, a dimensionality and four
dimension sizes (HDF5 before 1.4 had no array class). The parser skipped
those 28 bytes, so a member such as `f: f32[4]` came back as a single f32
at the member's offset: the compound's size was right but its members were
wrong. libhdf5 wraps such a member in an array type of the first
`dimensionality` sizes and ignores the permutation; do the same, and
reject a dimensionality above 4 as libhdf5 does.

Only files old enough to also use layout message v1 have these, so this
became reachable with the previous commit (tarrold.h5, tcompound.h5).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:55:31 -05:00
osobhandClaude Opus 5.5 85eb7f5ce2 feat(format): read Data Layout message versions 1 and 2
HDF5 1.4/1.6-era files store the layout as version 1 or 2: version,
dimensionality, class, 5 reserved bytes, an address (contiguous and chunked
only), dimensionality 32-bit sizes (with the trailing element-size
dimension) and, for compact storage, a 32-bit size and the raw data. They
failed with InvalidLayoutVersion — 84 of the 686 files in the audit sweep,
205 datasets.

Map them onto the existing variants: chunked uses the same version-1
B-tree chunk index as version 3 and is reported as version 3, so every
chunked read path (filters, selections, caches) applies unchanged.
Contiguous size is the product of the stored dimensions, which is what
libhdf5 computes from the dataspace; a disagreement fails the reader's size
check instead of returning wrong data.

Fixtures are HDF5's own deflate.h5 (v1, chunked + deflate) and
h5ex_g_iterate.h5 (v2, contiguous, one unallocated dataset); the new
interop test compares every dataset byte for byte against h5py.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:55:31 -05:00
osobhandClaude Opus 5.5 8196fab72a fix(format): read v2 B-tree internal nodes with libhdf5's pointer widths
An internal node's child pointer is an address, the child's record count
and (below the first internal level) the child subtree's total record
count. libhdf5 (H5B2__hdr_init) encodes the record count in the width of
a leaf's maximum and the subtree total in the width of cum_max_nrec for
that depth, computed level by level from the node size. The reader
guessed 2 * leaf_max and leaf_max^depth, which agree at depth 2 but not
at depth 3: a 24 000-link group's name index has depth 3, its root's
pointers were read 3 bytes wide instead of 2, and listing failed with a
garbage heap offset.

Regression tests: dense_group_with_a_three_level_name_index (h5py writes
24 000 links; listing compared with h5py) and
subtree_capacity_matches_libhdf5.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:53:00 -05:00
osobhandClaude Opus 5.5 8ebd488d9e fix(format): size fractal heap child indirect blocks by their row's span
A child indirect block in row r of a fractal heap's doubling table spans
that row's block size of heap space, so it has
log2(size) - log2(start_block_size * width) + 1 rows (libhdf5's
H5HF__dtable_size_to_rows). The reader used row - first_indirect_row + 1,
which undercounts, so every object stored past the root block's direct
rows (512 KiB with libhdf5's defaults) was unreachable: dense groups with
a few thousand long link names, or ~20 000 short ones, could not be listed.

Regression test: dense_group_whose_heap_outgrows_the_root_direct_rows
(h5py writes 2 500 links with 248-byte names; listing compared with h5py).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:52:07 -05:00
osobh 42b81d9f1c Merge pull request 'Fix silent wrong data and libhdf5 interop found by the HDF5 audit' (#11) from fix/phase0-correctness into main
CI / test-arm64 (push) Successful in 1m7s
CI / test (push) Successful in 5m50s
Reviewed-on: #11
2026-09-26 02:42:53 +00:00
osobhandClaude Opus 5.5 72b9cfb1e1 docs: record the 2026-09-25 HDF5 audit fixes and open gaps
CI / test-arm64 (pull_request) Successful in 1m21s
CI / test (pull_request) Successful in 5m59s
CHANGELOG: upgrade notes (changed read results for max-shape files,
saturating conversions, new writer errors, format-crate API changes) and
the reader/writer correctness fixes. known-issues: the silent-wrong-data
table with before/after sweep numbers, the gaps still open, and a
correction to the Extensible Array entry, which said files we wrote were
unaffected. CLAUDE.md: clawhdf5-gpu is vector distance computation, not
I/O, and clawhdf5-filters holds only deflate backends (no Blosc).

Also a facade test that libhdf5's 20-bit N-Bit float test data reads as
libhdf5's values.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:26:56 -05:00
osobhandClaude Opus 5.5 650f355219 ci: run the hdf5plugin LZ4/Zstd interop tests
The interop step built writer_h5py_tests without the lz4/zstd features,
so the hdf5plugin round-trips added with the registered LZ4 framing and
the Zstd content-size fix never compiled in CI, and CI never installed
hdf5plugin.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:24:50 -05:00
osobh 7f5cfee281 Merge branch 'fix/p0-filters' into fix/phase0-correctness
# Conflicts:
#	crates/clawhdf5-format/src/filters.rs
2026-09-25 21:21:19 -05:00
osobh c5302e587e Merge branch 'fix/p0-writer-meta' into fix/phase0-correctness 2026-09-25 21:20:59 -05:00
osobh e1115bc92a Merge branch 'fix/p0-reader-numeric' into fix/phase0-correctness 2026-09-25 21:20:59 -05:00
osobh 36d7a6f234 Merge branch 'fix/p0-chunked-read' into fix/phase0-correctness 2026-09-25 21:20:59 -05:00
osobh 4b23ad697c Merge branch 'fix/p0-chunk-index' into fix/phase0-correctness 2026-09-25 21:20:59 -05:00
osobhandClaude Opus 5.5 e7f2d8575d fix(format): import format! for the no_std chunk index planner
The maxshape checks added to chunked_write use format!, which a no_std
build has to import from alloc (scripts/check-nostd.sh).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:18:13 -05:00
osobhandClaude Opus 5.5 7c1968a34a fix(format): resolve shared fill value messages instead of zero-filling
dataset_fill_value treated a shared Fill Value message as "no fill
value", so unwritten storage of a dataset whose fill value lives in the
file's shared-message (SOHM) heap read as zeros rather than its fill
value. libhdf5 shares fill values whenever the file has a SOHM index for
them.

- fill_value::dataset_fill_value_in follows the reference (another object
  header, or the SOHM heap); read_full_with_fill and the facade's
  selection read use it.
- dataset_fill_value, which has no file to follow a reference into, now
  returns UnresolvedSharedMessage for a shared message instead of None.
- shared_message::load_sohm_table / message_data_with_sohm load the SOHM
  table from the superblock extension on demand.
- parse_sohm_table skipped each index's leading version byte, reading
  every field one byte off; SOHM references could never resolve.

Fixture shared_fill_value.h5 (HDF5 2.0, gen_shared_fill.py): sohm_b read
[0,1,2,3,0,0,0,0] and now reads [0,1,2,3,-7,-7,-7,-7], as h5py does.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:18:12 -05:00
osobhandClaude Opus 5.5 6db13c60b8 docs: changelog for the filter interop fixes
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:17:08 -05:00
osobhandClaude Opus 5.5 d99426be94 fix(format): allow Fletcher32 ahead of a compressor in the pipeline
libhdf5 applies filters in pipeline order, so with Fletcher32 before
deflate (h5repack_filters.h5 /dset_all: shuffle, fletcher32, deflate; or
h5py's set_fletcher32() then set_deflate()) the compressor holds the
chunk plus a 4-byte checksum. decompress_chunk bounded every stage by the
chunk size and rejected it: "deflate: output exceeds size limit". Bound
each stage by the chunk size plus 4 bytes per Fletcher32 that precedes
it in the pipeline.

Test: fletcher32_before_deflate_decodes (h5py-written chunk, and our own
shuffle + fletcher32 + deflate round trip); failed before.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:16:48 -05:00
osobhandClaude Opus 5.5 f5505fb03d fix(format): keep maxshape == shape datasets contiguous
Any maxshape forced chunked storage, even one equal to the shape, which
cannot grow. h5py and the library store such a dataset contiguously; we
now do too unless chunks (or a filter) are requested.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:15:55 -05:00
osobhandClaude Opus 5.5 95dcb04454 fix(format): scale-offset float decode with libhdf5's arithmetic
D-scale floats were rebuilt as `minval + code / 10^D` in f64 and then
rounded to f32 once, but libhdf5 (H5Z_scaleoffset_modify_3/4 with
`float`/`powf`) computes `(float)(int)code / powf(10, D) + min` in single
precision. The two differ by 1 ULP for some values: le_data.h5
/Scale_offset_float_data_{le,be} gave 1.6663332 (0x3fd54a69) where
libhdf5 gives 1.6663333 (0x3fd54a6a). Use f32 arithmetic for 4-byte
floats and `(double)(long)code / pow(10, D) + min` for 8-byte ones.

Test: scaleoffset_float_dscale_matches_libhdf5_bits (le_data.h5 float
LE/BE and double chunks, bit-exact against h5py); failed before.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:15:49 -05:00
osobhandClaude Opus 5.5 1dba7b465a fix(format): index datasets with several unlimited dims by B-tree v2
A dataset with more than one unlimited dimension got an Extensible Array
index, which libhdf5 refuses ("already found unlimited dimension"), so
the whole file failed to open in h5py and h5dump. The previous commit
turned that into a write error; this one writes what the library itself
uses there: a version-2 B-tree chunk index (record type 10/11), as a
single leaf of the library's 2048-byte node size, or a larger leaf when
the records do not fit. The root's record count is 16-bit, so more than
65535 chunks is still refused rather than written wrong.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:14:47 -05:00
osobhandClaude Opus 5.5 57e938c438 fix(format): honour unknown-message flags the way libhdf5 does
The object header parser failed on an unknown message with flag bit 3
set and ignored bit 7. Per the spec, bit 3 means "fail if unknown and
the file is opened for writing" and bit 7 "fail if unknown, always".
The parser only reads, so it now ignores bit 3 (as libhdf5 does for a
read-only open) and refuses bit 7, in v1 headers, v2 headers and their
continuation chunks.

On libhdf5's conformance file tbogus.h5 (added as a fixture) we used to
refuse Dataset2 and open Dataset3; we now match libhdf5: Dataset1, 2, 4
and 5 open, Dataset3 is refused.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:14:30 -05:00
osobhandClaude Opus 5.5 3000b40cf3 fix(format): N-Bit pass-through flag and no-op (enum) members
- libhdf5 sets cd_values[1] ("need not compress") when every field is
  already full width and then stores the chunk unchanged
  (H5Z__filter_nbit: `if (cd_values[1]) HGOTO_DONE`). We ignored it and
  tried to unpack, so tfilters.h5 / h5stat_filters.h5 `/all` (shuffle +
  szip + deflate + fletcher32 + N-Bit) failed with "nbit: packed data too
  short". A type with no N-Bit parameters (cd = [3, 1, nelmts]) is now
  accepted the same way.
- Class 4 (H5Z_NBIT_NOOPTYPE: enum, string, opaque, ... members) is
  stored whole, 8 bits per byte; it was UnsupportedFilter(5)
  (h5repack_nested_8bit_enum_deflated.h5).

N-Bit on floats was not wrong in the filter: for le_data.h5 /
Nbit_float_data_* our output equals libhdf5's decoded bytes in the file
datatype (a 20-bit float, offset 7, bias 31). h5py's values differ
because libhdf5 then converts that custom float layout to IEEE, which
our datatype reader does not do; nbit_float_matches_libhdf5_file_type_bytes
pins the filter output and the doc comment says where conversion belongs.

Tests: nbit_need_not_compress_is_passthrough,
nbit_in_multi_filter_pipeline_matches_libhdf5 (tfilters.h5 chunk, szip
feature), nbit_compound_with_enum_member_matches_libhdf5 all failed
before; nbit_float_matches_libhdf5_file_type_bytes (guard).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:14:14 -05:00
osobhandClaude Opus 5.5 bc820fbd8c fix(format): refuse path-like group and dataset names
FileWriter writes the root group plus one level of groups; it has no way
to create intermediate groups. create_group("a/b") therefore stored a
single link literally named "a/b", which no HDF5 reader can resolve
(h5py: "component not found"). Nesting would mean restructuring the
writer's layout around a group tree, so for now finish() rejects any
group, dataset or external-link name that is empty, "." or contains '/'.
Attribute names may still contain '/'.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:13:08 -05:00
osobhandClaude Opus 5.5 9066d34eaa fix(format): key the shared chunk cache by dataset
A File is Send + Sync and keeps one ChunkCache for all its datasets.
The cached readers bound that cache to "the current dataset" with
ensure_dataset(addr), then checked, built and read its index and its
decompressed chunks in separate lock acquisitions. Two threads reading
two chunked datasets interleaved those steps, so one could store its
chunk index under the other's binding, or get the other's decompressed
chunk for the same coordinate: wrong data, or an index-out-of-bounds
panic when the ranks differed (16 threads x 40 reads over 24 datasets
panicked on every run).

The cache now keeps per-dataset state keyed by chunk-index address:
the chunk index, ChunkIndex and ChunkLayout per dataset (held as Arcs,
built outside the lock, first writer wins), and decompressed chunks
keyed by (address, coordinate). The chunked readers use the new
addr-taking methods (chunks_for, chunk_layout_for, get/put_decompressed_in,
prefetch_hint_in) exclusively. Memory stays bounded: decompressed data by
the existing byte/slot budget across datasets, indexes by at most 64
datasets and 2^20 index entries in total, dropping the least recently
used dataset's index first. Switching datasets no longer throws away the
other datasets' cached chunks.

The address-less methods remain and act on the dataset last bound with
ensure_dataset; they are documented as not for concurrent readers.

Regression: threads_reading_different_datasets_get_their_own_chunks
(crates/clawhdf5/tests/concurrent_chunk_cache.rs), plus cache unit tests
datasets_sharing_coordinates_stay_separate, dataset_indexes_are_bounded
and concurrent_readers_of_different_datasets_see_their_own_chunks.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:12:52 -05:00
osobhandClaude Opus 5.5 540fa08907 fix(format): write chunk indexes over the max extent, swizzled for EA
The writer indexed chunks by their position in the current shape, the
same mistake the reader had. With a finite maxshape larger than the shape
the Fixed Array was sized for the shape, so libhdf5 looked up chunks past
its end ("addr overflow"); with the unlimited dimension anywhere but first,
e.g. maxshape (20, None), libhdf5 swizzles that dimension to the slowest
position and read our Extensible Array scrambled. Two unlimited dimensions
produced a file libhdf5 refused to open ("already found unlimited
dimension").

Chunks are now placed with the shared chunk_grid linearisation: Fixed
Array slots cover every chunk of the maximum extent (unwritten ones
undefined), Extensible Array indexes are swizzled, Single Chunk is only
used when the maximum extent is one chunk, and a maxshape that is smaller
than the shape, has more than one unlimited dimension, or would need an
absurd Fixed Array is an error instead of a bad file.
build_chunked_data_from_precompressed now returns a Result.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:12:47 -05:00
osobhandClaude Opus 5.5 14876b8ae5 fix(format): give empty string attributes a 1-byte type
An empty AttrValue::String (or a StringArray of empty strings) was
written with a size-0 fixed-length string type. libhdf5 rejects that
("invalid datatype size"), and the failure takes every attribute on the
object with it. Strings are now at least 1 byte, NUL-padded, which is
how h5py stores "" and reads back as "" in both h5py and our reader.
check_encodable also refuses a size-0 string type passed in directly.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:12:12 -05:00
osobhandClaude Opus 5.5 5935e13866 fix(format): decode SZIP chunks the way libhdf5 does
SZIP-filtered datasets from libhdf5 came back as garbage or zeros with no
error (ref_szip.h5, h5repack_szip.h5, noencoder.h5, le_data/be_data
Szip_float_data_*), and 64-bit ones failed with "invalid bits per
sample" (h5wasm compressed.h5). The decoder called aec_buffer_decode
directly, but libhdf5 goes through szlib's SZ_BufftoBuffDecompress
(H5Zszip.c), which libaec implements with reshaping (sz_compat.c).
Differences, all fixed:

- H5Zszip.c prefixes the stream with the 4-byte LE uncompressed size; it
  was fed to libaec as data.
- 32- and 64-bit samples are coded as byte planes of 8-bit samples and
  must be de-interleaved.
- The reference sample interval is ceil(pixels_per_scanline /
  pixels_per_block), not a fixed 128.
- Scanlines that are not a whole number of blocks are padded and must be
  unpadded.
- Byte order comes from the MSB option bit; LE data was decoded as MSB.

Test: szip_decodes_libhdf5_chunks_exactly compares chunks from HDF Group
test files (noencoder.h5, le_data.h5) and an h5py-written file (64-bit,
16-bit, padded scanlines, NN and EC) byte for byte with h5py's values;
it failed before on the first case.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:11:24 -05:00
osobhandClaude Opus 5.5 8c3ef996ea fix(format): write fill times with libhdf5's codes; add fill values
FillTime::to_byte had the fill-time field rotated against libhdf5
(H5D_FILL_TIME_ALLOC = 0, NEVER = 1, IFSET = 2): Never was written as
ALLOC, Alloc as IFSET and IfSet as NEVER, as h5py reported. The flags
byte is now late allocation plus the right code, and FillTime::from_byte
decodes it.

The default becomes IfSet, which is libhdf5's default and exactly the
byte (0x0a) every dataset was already written with, so default output
does not change; `Alloc` was documented as the C library's default but
never was. DatasetCreateProps follows.

DatasetBuilder::with_fill_value sets a user-defined fill value (one
element's stored bytes, checked against the datatype size), written as a
defined value in the fill value message. h5py reports it, and extending
the dataset in h5py fills the new elements with it.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:11:07 -05:00
osobhandClaude Opus 5.5 74fdf0582b fix(format): write paged files libhdf5 can open
FileWriter::with_page_size wrote a "version 4" superblock with an extra
page-size field. HDF5 has no superblock version 4, so libhdf5 refused
every such file ("bad superblock version number").

A paged file is now what libhdf5 itself writes for fs_strategy="page":
a v3 superblock whose extension object header holds a File Space Info
message (strategy PAGE, the page size, free space not persisted; same
bytes and flags as HDF5 2.0), with the file padded to a whole page.
h5py opens it, reports the strategy and page size, and can modify it in
r+ mode. Page sizes outside libhdf5's 512 B..1 GiB are an error.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:09:31 -05:00
osobhandClaude Opus 5.5 44f5f8b5c5 fix(format): index every Extensible Array chunk, not just the first 244
The Extensible Array writer only filled the index block's 4 inline
elements and the 6 data blocks it addresses directly (240 elements); its
super block addresses were always undefined. Chunks from index 244 on were
written to the file but never indexed, so they read back as fill values in
our reader and in libhdf5, without an error.

The writer now lays out data blocks and super blocks for any element
count as H5EA__hdr_init sizes them, pages data blocks larger than 1024
elements (page-init bits in the owning super block), leaves blocks with no
defined element unallocated, and records real header statistics
(max_idx_set is one past the highest defined index).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:09:29 -05:00
osobhandClaude Opus 5.5 2f252df084 fix(format): return whole VL sequences from read_vl_bytes
read_vl_bytes cut each element to the reference's length field, which
counts sequence elements, not bytes: a VL int32 [1, 2, 3] came back as
3 bytes. Return the whole global-heap object, which is element count x
base size bytes. No in-tree caller depended on the old behaviour.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:08:37 -05:00
osobhandClaude Opus 5.5 c8c2930fc0 fix(format): read enum and bool datasets through their base integer type
read_i64/read_u64/read_i32/read_f64/read_f32 refused enumeration
datatypes, including h5py's bool (an enum of int8), with a type
mismatch. Read them as their base type's integer values, the way array
datatypes already read through theirs.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:08:37 -05:00
osobhandClaude Opus 5.5 417c9516ca fix(format): decode floats by their datatype fields, not their size
Every 2-byte float was decoded as IEEE half, so bfloat16 (HDF5 2.0's
H5T_FLOAT_BFLOAT16*, or any custom 8-bit-exponent type) read wrong:
1.5 as 1.9375, +inf as NaN. 1-byte FP8 floats were refused.

Read the exponent/mantissa location and size and the bias from the
datatype message: IEEE half/single/double keep their existing paths
(half still through clawhdf5_format::float16), any other IEEE-style
layout up to 64 bits whose values fit f64 (bfloat16, FP8 E4M3/E5M2, ...)
is decoded generically, and the bulk-copy and zero-copy fast paths now
require the IEEE layout rather than just the size. Datatypes with fields
that describe no float still fall back to IEEE by size; layouts that
cannot be represented in f64 (x87 80-bit, binary128) remain an error.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:08:37 -05:00
osobhandClaude Opus 5.5 53dbddb07b fix(format): saturate out-of-range integer reads instead of truncating
Reading wider or differently-signed integers kept the low bits: i64
2^40+5 read as i32 was 5, u64::MAX read as i64 was -1, and -1 read as
u64 was 4294967295. u32 data read as i32 also took the bulk-copy fast
path meant for i32. Saturate at the target range like libhdf5's hard
conversions (a negative value read as unsigned is 0), and keep the i32
fast path to signed data.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:08:37 -05:00
osobhandClaude Opus 5.5 081341b433 fix(format): convert float data read as integers instead of returning bit patterns
read_i32/read_i64/read_u64 on a floating-point dataset reinterpreted the
IEEE bits (1.5 read as i64 was 4609434218613702656). Convert like
libhdf5's hard conversions instead: truncate toward zero and saturate at
the target range; NaN reads as 0.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:08:37 -05:00
osobhandClaude Opus 5.5 d074385944 fix(format): read partial edge chunks stored unfiltered
Layout message v4 flag bit 0 (H5D_CHUNK_DONT_FILTER_PARTIAL_CHUNKS, set
with H5Pset_chunk_opts) makes libhdf5 store every chunk that extends past
the dataset's extent without the filter pipeline, while its filter mask
still reads 0. The parser ignored the flag, so readers tried to inflate
raw bytes: libhdf5's own h5fc_edge_v3.h5 failed with "deflate: ...
unknown compression method".

DataLayout::Chunked gains dont_filter_partial_edge_chunks (always false
for v3), and list_chunks — the one place every read path gets its chunk
list from — marks such partial chunks as having skipped every filter, so
the full, cached, indexed, parallel and selection readers all copy them
as-is. chunked_write.rs gets `..` in one exhaustive test pattern for the
new field.

Regression: libhdf5_edge_chunk_fixture_reads (h5fc_edge_v3.h5 from the
HDF5 tools test files, committed as a 2.5 KB fixture), and
h5py_unfiltered_partial_edge_chunks_read (the flag set through h5py's
bundled libhdf5 via ctypes, as h5py has no binding for it: fixed array,
extensible array and B-tree v2 indexes, 1-D and 2-D, plus a hyperslab
of the last chunk), and v4_chunked_dont_filter_partial_edge_chunks_flag.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:08:36 -05:00
osobhandClaude Opus 5.5 4a1876faf2 fix(format): page Fixed Array data blocks past 1024 chunks
The Fixed Array writer always packed every element into one data block
behind one checksum. Past 2^10 elements libhdf5 (and our reader) expect a
paged block: a page-init bitmap after the prefix, then one checksummed page
per 1024 elements. Any dataset with more than 1024 chunks and no unlimited
dimension failed with "incorrect metadata checksum" in h5py, h5dump and
our own reader.

build_fixed_array_at now takes one Option<WrittenChunk> per array slot so
later fixes can leave unallocated slots.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:07:08 -05:00
osobhandClaude Opus 5.5 be88e3fec7 fix(format): encode Time, BitField, Opaque and Reference datatypes
Datatype::serialize returned an empty message for these four classes, so
any dataset or attribute of them (including a Raw attribute copied from
another file) was unreadable by libhdf5 ("ran off end of input buffer
while decoding"). They now encode exactly as libhdf5 does: legacy object
and region references as datatype version 1, H5T_STD_REF kinds as version
4 with their encoding version, opaque tags NUL-padded to 8 bytes.

Parsing an opaque tag now stops at its first NUL, so libhdf5's padding
no longer becomes part of the tag. Datatype::check_encodable rejects
what has no encoding (an opaque tag over 248 bytes); FileWriter::finish
calls it for every dataset and attribute type.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:06:51 -05:00
osobhandClaude Opus 5.5 e162c013fd fix(format): bound each filter stage by what the stages before it produce
Every decode stage was capped at the chunk's decoded size. That holds
only when every filter ahead of the codec preserves size; Fletcher32
does not (it appends a 4-byte checksum), so a pipeline with Fletcher32
before deflate (NetCDF-4's fletcher32 -> shuffle -> deflate ordering,
h5repack's "all filters") failed with "deflate: output exceeds size
limit" on every chunk.

decompress_chunk_masked now computes each stage's bound by running the
chunk size forward through the filters that precede it in write order
(and that the chunk's mask did not skip): shuffle keeps the size,
Fletcher32 adds 4, any codec adds at most n/8 + 64. The cap is still a
small constant factor of the chunk, so a decompression bomb is rejected
as before (tested).

Shuffle also had to learn libhdf5's handling of a length that is not a
whole number of elements (chunk + checksum): shuffle the whole elements
and leave the trailing bytes in place, in both directions. It used to
refuse such data.

Regression: h5py_fletcher32_before_deflate_reads (fletcher->shuffle->
gzip, fletcher->gzip, shuffle->fletcher->gzip, and a 2-D i32 grid),
fletcher32_ahead_of_deflate_stays_bounded and
shuffle_leaves_a_partial_trailing_element_in_place.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:06:22 -05:00
osobhandClaude Opus 5.5 06dda26d85 fix(format): stop writing pcodec under Granular BitRound's filter ID
Pcodec chunks were written as filter 32023, which the HDF Group registry
assigns to Granular BitRound (GBR). Pcodec has no registered ID (checked
2026-09-25 against hdf5_plugins/docs/RegisteredFilterPlugins.md, which
ends at 32033 with no pcodec entry). GBR's decode is a pass-through, so
libhdf5 with that plugin loaded would have returned the compressed bytes
as the dataset's values.

Write pcodec as 480, from the registry's testing/private range (256-511),
named "pcodec (clawhdf5 private)", and document it as non-interoperable:
only clawhdf5 with the `pcodec` feature reads it. Chunks under 32023 are
still read as pcodec when the filter is named exactly "pcodec" (what
clawhdf5 <= 2.7.0 wrote); any other 32023 is UnsupportedFilter.

Test: pcodec_uses_private_id_and_reads_legacy_32023.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:06:07 -05:00
osobhandClaude Opus 5.5 585e14d5e2 fix(format): honour each bit of a chunk's filter mask
A chunk's filter mask has one bit per pipeline filter; bit i set means
filter i was not applied to that chunk (an optional filter that
declined, or a direct chunk write). Every read path treated any nonzero
mask as "no filters applied" and returned the stored bytes, so a chunk
that skipped only gzip in a shuffle+gzip pipeline came back still
shuffled (h5py write_direct_chunk with filter_mask=0b10: 8 of 32 values
wrong).

decompress_chunk_masked undoes the filters the mask leaves set and skips
the rest; an unsupported filter is no longer an error when the chunk
skipped it. The full, cached, sweep, indexed, parallel and selection
(partial_read) paths all use it, and a chunk is copied straight from the
file only when every filter was skipped. decompress_chunk is the mask-0
case.

Regression: h5py_partial_filter_mask_skips_only_masked_filters (1-D
shuffle+gzip with masks 0, 0b10 and 0b11; 2-D with 0b01; full and
hyperslab reads) and filter_mask_skips_only_the_masked_filters.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:05:07 -05:00
osobhandClaude Opus 5.5 183d96ee26 fix(format): record the content size in zstd frames
Filter 32015 chunks were written with the streaming encoder
(zstd::encode_all), whose frames carry no content size. The registered
HDF5 Zstandard filter (H5Zzstd.c, libhdf5 + hdf5plugin) sizes its output
from ZSTD_getFrameContentSize and fails on such frames, so h5py could not
read our zstd datasets ("filter returned failure during read"). Compress
with the one-shot API, which records the size.

Tests: zstd_frames_record_content_size (content size was None before),
hdf5plugin_reads_our_zstd (ignored interop test; failed before).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:04:29 -05:00
osobhandClaude Opus 5.5 bba1560416 fix(format): lay Fixed/Extensible Array chunk indexes out by max dims
Both indexes place each chunk at a linear index computed from the
dataset's maximum dimensions (libhdf5's max_down_chunks), and the
Extensible Array first swizzles its unlimited dimension to the slowest
position. We linearised by the current dimensions, so any dataset whose
shape was smaller than its maxshape, or whose unlimited dimension was not
the first, read back scrambled without an error: h5py libver="latest"
files with maxshape (10, None) or (20, 10), and the libhdf5 test files
h5fc_ext*.h5 and test_ld.h5.

The linearisation now lives in chunk_grid (shared with the writers), and
slots beyond the current extent are ignored as the library does.
read_fixed_array_chunks / read_extensible_array_chunks take the
dataspace's max dimensions.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:04:19 -05:00
osobhandClaude Opus 5.5 b36998ef01 fix(format): refuse object header messages over 64 KiB
A v2 object header message has a 2-byte size field. The writer truncated
larger sizes to 16 bits, so an attribute over ~64 KiB (or a compact
dataset of 65532-65535 bytes, whose layout message adds 4 bytes) produced
a file libhdf5 rejects ("message of unshareable class flagged as
shareable", "bad flag combination").

ObjectHeaderWriter::serialize now returns a Result and fails on any message
over MAX_MESSAGE_SIZE; FileWriter::finish propagates it. Compact storage
falls back to contiguous above 65531 bytes, the real limit. Dense storage
for large attributes remains future work.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:04:07 -05:00
osobhandClaude Opus 5.5 aef8e766ae fix(format): write and read the registered HDF5 LZ4 filter format
Filter 32004 chunks were framed as a 4-byte little-endian size plus one
LZ4 block. That is not the registered HDF5 LZ4 format (H5Zlz4.c: 8-byte
big-endian total size, 4-byte big-endian block size, then per block a
4-byte big-endian compressed length and the block, stored raw when the
length equals the block size), so libhdf5 + hdf5plugin could not read
our LZ4 datasets and we could not read theirs (h5ex_d_lz4.h5:
"lz4: 0 is not a valid match offset").

Write the registered format (cd_values[0] is honoured as the block size,
default 1 GiB like the plugin) and read it, multi-block and raw blocks
included. Chunks in the old framing stay readable: an HDF5 chunk is under
4 GiB, so a registered chunk always starts with four zero bytes and is at
least 12 bytes long, while an old one starts with four zero bytes only
when empty (5 bytes).

Tests: lz4_reads_registered_hdf5_format (chunk of the HDF Group's
h5ex_d_lz4.h5, block size 3), lz4_writes_registered_hdf5_format,
lz4_reads_legacy_clawhdf5_format, and hdf5plugin_reads_our_lz4 (ignored
interop test; failed before with "filter returned failure during read").

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:03:49 -05:00
osobhandClaude Opus 5.5 9ea44d473d fix(format): read v1 chunk B-tree key offsets as 8 bytes
A type-1 (raw data chunk) B-tree key holds the chunk size, the filter
mask and one offset per dimension, and those offsets are always 8 bytes:
they are dataset coordinates, not file addresses. The reader used the
superblock's size-of-offsets for them, so in a file with 4-byte offsets
every key was misparsed. Unfiltered chunked datasets read as zeros (with
stray bytes where a misread address landed on data) and filtered ones
failed with "deflate: truncated stream".

Only the sibling and child addresses follow size-of-offsets now. The
unit-test B-tree builder wrote keys the same wrong way, which is why its
tests passed; it now matches the format.

Regression: h5py_four_byte_offsets_chunked_reads (h5py, set_sizes(4, 4)
and (4, 8); 1-D and 2-D, unfiltered and gzip) and the unit test
collect_chunks_with_four_byte_addresses.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:01:57 -05:00
osobhandClaude Opus 5.5 46203ea761 test(format): keep the 2026-09-20 B-tree v2 fuzz crash as a regression
An 82-byte fuzz_btree_v2 crash input from 2026-09-20 was left untracked
in fuzz/artifacts. Replayed today it runs cleanly: the depth cap and
record budget added to B-tree v2 traversal that day fixed it. It is now
in the committed fuzz corpus, and a robustness test replays the fuzz
target's exact code path on it so a regression fails CI rather than
waiting for someone to run the fuzzer.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 20:38:34 -05:00
osobh 75bdb53342 Merge pull request 'Withdraw the ZeroClaw integration claims' (#10) from docs/withdraw-zeroclaw into main
CI / test-arm64 (push) Successful in 1m16s
CI / test (push) Successful in 4m55s
Reviewed-on: #10
2026-09-25 19:25:18 +00:00
osobhandClaude Opus 5.5 dd5b3f6633 docs: ClawBrainHub is the one verified consumer
CI / test-arm64 (pull_request) Successful in 1m3s
CI / test (pull_request) Successful in 5m10s
The previous commit said clawhdf5 has no integration at all. ClawBrainHub
(clawverse/clawbrainhub) does use it: cbh-core reads and writes .brain
files through the facade, cbh-scanner uses the facade, and cbh-cli uses
clawhdf5_agent::bm25::BM25Index, all via path dependencies on this repo.
Checked on 2026-09-25 against main: it builds on its pinned toolchain and
its 204 tests pass. CLAUDE.md now records that, and that path
dependencies mean API changes here reach it directly.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 11:18:14 -05:00
osobh 79dfa78e8f Merge pull request 'Withdraw the OpenClaw integration claims' (#9) from docs/withdraw-openclaw into main
CI / test-arm64 (push) Successful in 1m18s
CI / test (push) Successful in 5m40s
Reviewed-on: #9
2026-09-25 16:14:36 +00:00
osobhandClaude Opus 5.5 87d64588e5 docs: withdraw the ZeroClaw integration claims
CI / test-arm64 (pull_request) Successful in 1m5s
CI / test (pull_request) Successful in 5m34s
CLAUDE.md said ZeroClaw "imports this as a Cargo feature (clawhdf5
feature flag)" and uses clawhdf5 as its memory backend; the agent crate
called itself the "ZeroClaw agent memory HDF5 backend"; the migrator
claimed to read "the ZeroClaw layout". Checked on 2026-09-25 against
ZeroClaw v0.8.5 (its latest release), the osobh/zeroclaw fork (on
v0.8.5) and both histories back to February 2026:

- no `clawhdf5` feature, dependency or memory backend has ever existed
  in ZeroClaw; its backends are sqlite, lucid, postgres, qdrant,
  markdown and none, behind its own `Memory` trait;
- ZeroClaw's SQLite schema is a single `memories` table (id, key,
  content, category, embedding, created_at, updated_at); the
  migrator's memory_chunks/sessions/entities/relations layout never
  existed in ZeroClaw, so it cannot read a ZeroClaw database.

Decision: withdraw the claims (as with OpenClaw); clawhdf5 is a
standalone library with no framework integration. The migrator's
default layout is documented as its own. ZEROCLAW_VERSION keeps its name
and value (it is the persisted `edgehdf5_version` writer tag) with a
doc comment saying it is unrelated to ZeroClaw.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 11:08:41 -05:00
osobh bdadf3447c Merge pull request 'Ed25519-signed checkpoints; remove the no-op agent feature' (#8) from feat/signed-checkpoints into main
CI / test-arm64 (push) Successful in 1m16s
CI / test (push) Successful in 5m42s
Reviewed-on: #8
2026-09-25 15:58:19 +00:00
osobhandClaude Opus 5.5 0c65a27b00 docs: withdraw the OpenClaw integration claims
CI / test-arm64 (pull_request) Successful in 1m3s
CI / test (pull_request) Successful in 5m48s
The docs described a "drop-in" OpenClaw memory backend enabled with
`memory.backend = "clawhdf5"`. Checked against OpenClaw's source and
docs (v2026.2.26 through v2026.9.6): that config was never valid —
v2026.2-v2026.7 accepted only "builtin"/"qmd" and rejected unknown
keys, so a Gateway given it refuses to start, and v2026.8.1 (OpenClaw
2.0) removed the key. No plugin was ever built (no manifest, no
registration, no tools), nothing was tested against OpenClaw, the
linked github.com/redclawsystems/openclaw is a 404, and
@redclaw/clawhdf5 was never published.

Decision (2026-09-25): not pursuing an OpenClaw plugin for now; ZeroClaw
is the integration target.

- Remove openclaw-integration.md, openclaw-config.md and
  migration-guide.md; add docs/openclaw.md: the status, what a memory
  plugin needs against v2026.9.6 (plugins.slots.memory, manifest with
  kind "memory", registerMemoryCapability / MemorySearchManager,
  prebuilt native packages), and what this repo has as building blocks.
- README, QUICKSTART, USE_CASES, ROADMAP (Track 7 withdrawn), CLAUDE.md
  and the `openclaw` module docs describe ClawhdfBackend as what it is:
  a Markdown-oriented library backend, not an OpenClaw plugin. The
  QUICKSTART example is corrected (the old one called a three-argument
  create that does not exist) and states its limits.
- packages/clawhdf5-node: marked unpublished and broken, "private": true
  so it cannot be published by accident; its bugs (snake_case vs
  camelCase fields, wrong addon path, no way to store an embedding,
  wrong WAL name) are recorded in docs/known-issues.md.
- Two broken rustdoc links fixed along the way.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 10:27:54 -05:00
osobhandClaude Opus 5.5 db9af7972c feat(agent): Ed25519-signed checkpoints
CI / test-arm64 (pull_request) Successful in 1m5s
CI / test (pull_request) Successful in 4m50s
Makes the README's "cryptographically verifiable memory" true.

With HDF5Memory::set_signing_key(key), every checkpoint stores a signed
manifest of the store: a SHA-256 per memory record (text, embedding as
stored, channel, timestamp, session, tags, deleted flag, activation) in
a Merkle tree, plus hashes of the settings (and WAL mark), sessions and
knowledge graph. The signature, public key and manifest hashes go in
/meta; the per-record hashes in /integrity/record_hashes, so
HDF5Memory::verify(path, &public_key) can say which records changed, not
just that something did. A forged manifest fails the signature.

Decisions, as agreed:
- the key is set on the open store and never persisted;
- a signed store refuses to checkpoint without its key
  (MemoryError::SigningKeyRequired); remove_signature() is the
  deliberate way back to unsigned;
- checkpoints only: saves still in the WAL are not covered, and verify
  reports how many there are.

The hashes cover exactly what the file persists, in the form the loader
returns it (strings lose trailing NULs; an empty WAL mark is not
written), so untouched stores verify across any number of reopen and
checkpoint cycles. MemoryError becomes #[non_exhaustive] (it already
gains variants in this unreleased version).

CLI: keygen (owner-only key file), --signing-key / CLAWHDF5_SIGNING_KEY
on writing commands (create signs immediately), verify --public-key
(JSON; exit 2 if not valid), `signed` in create/stats output.

Tests: reopen/checkpoint cycles with awkward strings (f16 and f32),
refusal without the key, wrong and rotated keys, eight kinds of edit
each detected and located, a forged manifest, unsigned stores, NULs in
text, and an edit made in place with h5py that verify pinpoints.

Cost on tank (search_harness --signing-study --full, 3 runs): ~20% of a
checkpoint (+9 ms at 10K, +89-112 ms at 100K), verify 18.6 ms / 247 ms,
32 bytes per record in the file. New deps ed25519-dalek, sha2,
rand_core: pure Rust, the no-C check passes.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 10:13:34 -05:00
osobh 7706697feb Merge pull request 'Complete the consolidation benchmark: cheaper novelty scoring' (#7) from feat/consolidation-scaling into main
CI / test-arm64 (push) Successful in 1m7s
CI / test (push) Successful in 5m28s
Reviewed-on: #7
2026-09-25 15:05:58 +00:00
osobh c0f704c381 Merge pull request 'clawhdf5-migrate writes real agent stores; knowledge-graph fix; dated benchmark re-run' (#6) from feat/migrate-and-benchmarks into main
CI / test-arm64 (push) Successful in 1m19s
CI / test (push) Successful in 5m34s
Reviewed-on: #6
2026-09-25 14:52:35 +00:00
osobh a7920bd4b3 Merge pull request 'Search options (source filters, re-ranking, confidence); float16 default' (#5) from feat/search-options into main
CI / test-arm64 (push) Successful in 1m8s
CI / test (push) Successful in 5m40s
Reviewed-on: #5
2026-09-25 12:44:15 +00:00
osobh 7e43b5366c Merge branch 'main' into feat/search-options
CI / test-arm64 (pull_request) Successful in 54s
CI / test (pull_request) Successful in 4m35s
2026-09-25 12:32:00 +00:00
osobh 73bb068264 Merge pull request 'Files open in h5py again; float16 embedding storage' (#4) from feat/float16-embeddings into main
CI / test-arm64 (push) Successful in 1m17s
CI / test (push) Successful in 5m10s
Reviewed-on: #4
2026-09-25 03:18:07 +00:00
195 changed files with 34034 additions and 3367 deletions
+12 -3
View File
@@ -22,6 +22,9 @@ jobs:
run: rustup component add rustfmt clippy run: rustup component add rustfmt clippy
- name: Install thumbv7em-none-eabihf target - name: Install thumbv7em-none-eabihf target
run: rustup target add thumbv7em-none-eabihf 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 - name: Install Python interop dependencies
# The interop suites used to skip silently when python3/h5py were # The interop suites used to skip silently when python3/h5py were
# missing, so they never ran in CI. Install them and make a missing # 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) # 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 # steps in ci-test.sh; rust:latest does not ship it. The default
# build (pure-Rust zlib-rs) does not need it. # 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 python3 -m venv /opt/interop
/opt/interop/bin/pip install --no-cache-dir h5py numpy netCDF4 xarray /opt/interop/bin/pip install --no-cache-dir h5py numpy netCDF4 xarray hdf5plugin
echo "/opt/interop/bin" >> "$GITHUB_PATH" echo "/opt/interop/bin" >> "$GITHUB_PATH"
- name: Show interop library versions - name: Show interop library versions
run: /opt/interop/bin/python -c "import h5py, netCDF4; print('h5py', h5py.__version__, 'HDF5', h5py.version.hdf5_version, 'netCDF4', netCDF4.__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 - name: Run CI script
env: env:
# Name the interpreter outright rather than relying on $GITHUB_PATH # Name the interpreter outright rather than relying on $GITHUB_PATH
+56
View File
@@ -0,0 +1,56 @@
name: Conformance
# Nightly: read every file of the pinned public HDF5 corpora with clawhdf5 and
# with h5py/libhdf5 and compare (conformance/run.sh; CONFORMANCE.md explains
# the method). Fails on any panic, hang, crash or out-of-memory in clawhdf5,
# and when the ok count drops below conformance/baseline.json or a file the
# baseline lists as ok stops being ok. The report is printed into the job log;
# nothing is uploaded (artifact actions are JavaScript, which rust:latest
# cannot run — see CLAUDE.md).
on:
schedule:
- cron: "17 3 * * *"
workflow_dispatch:
jobs:
conformance:
runs-on: ubuntu-latest
container: rust:latest
timeout-minutes: 60
env:
CARGO_NET_RETRY: "10"
steps:
# Plain git, not actions/checkout (a JavaScript action; see ci.yml).
- name: Check out
run: |
git init -q .
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
for i in 1 2 3; do git fetch -q --depth 1 origin "${GITHUB_SHA}" && break; sleep 5; done
git checkout -q FETCH_HEAD
- name: Install h5py, h5dump and the probe's codec libraries
# hdf5-tools: h5dump for the CVE-corpus comparison. libaec-dev and
# pkg-config: the probe builds clawhdf5-format with `szip` (the core
# crates' default build needs neither).
run: |
apt-get update
apt-get install -y --no-install-recommends python3 python3-venv hdf5-tools libaec-dev pkg-config
python3 -m venv /opt/conformance
/opt/conformance/bin/pip install --no-cache-dir -r conformance/requirements.txt
/opt/conformance/bin/python -c "import h5py, hdf5plugin; print('h5py', h5py.__version__, 'HDF5', h5py.version.hdf5_version, 'hdf5plugin', hdf5plugin.version)"
h5dump --version
- name: Probe unit tests
run: cargo test --release --manifest-path conformance/probe/Cargo.toml
env:
CARGO_TARGET_DIR: conformance/.cache/target
- name: Sweep
# The corpora come from GitHub (pinned commits, conformance/corpus.txt),
# so this job needs a runner that reaches github.com.
env:
CLAWHDF5_PYTHON: /opt/conformance/bin/python
run: bash conformance/run.sh
- name: Report
if: always()
run: |
if [ -f CONFORMANCE.md ]; then cat CONFORMANCE.md; else echo "no report was generated"; fi
if [ -f conformance/.cache/results/summary.md ]; then
echo; echo "---- per-file detail (conformance/.cache/results/summary.md) ----"
cat conformance/.cache/results/summary.md
fi
+149
View File
@@ -243,6 +243,30 @@ index asked for ~16 000 candidates, where scanning the few hundred or thousand
allowed records is exact and cheap. Re-ranking a 3k candidate pool and allowed records is exact and cheap. Re-ranking a 3k candidate pool and
confidence rejection add about 3%. confidence rejection add about 3%.
### Signed checkpoints
Measured 2026-09-25 on tank (AMD Ryzen 7 7800X3D). A default store (float16,
int8 index), 384-dim; each checkpoint rewrites the whole file, as every
checkpoint does. Medians of five checkpoints and three verifies; three runs
agreed to within the ranges shown.
```bash
cargo run --release -p clawhdf5-bench --bin search_harness -- --signing-study --full
```
| N | checkpoint, unsigned | checkpoint, signed | signing adds | `verify` | file size added |
|---:|---:|---:|---:|---:|---:|
| 1 000 | 5.4 ms | 6.4 ms | 0.7–1.0 ms | 2.1 ms | 0.03 MiB |
| 10 000 | 46 ms | 55 ms | 8.1–9.4 ms | 18.6 ms | 0.31 MiB |
| 100 000 | 495 ms | 598 ms | 89–112 ms | 247 ms | 3.05 MiB |
Signing costs about 20% of a checkpoint: every record is rehashed (SHA-256)
and the Merkle root recomputed each time; the Ed25519 signature itself is
microseconds. Caching per-record hashes between checkpoints would cut this to
the records that changed. The per-record hashes stored for locating edits are
32 bytes each (4% of a 100K float16 store). `verify` reads and rehashes the
whole checkpoint.
### float16 embedding storage (`MemoryConfig::float16`) ### float16 embedding storage (`MemoryConfig::float16`)
Measured 2026-09-23 on tank (AMD Ryzen 7 7800X3D). The same clustered Measured 2026-09-23 on tank (AMD Ryzen 7 7800X3D). The same clustered
@@ -458,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 column 0.45 -> 0.49 ms, contiguous column 2.55 -> 2.61 ms). This run does not
explain the slower windows. 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): `<dir>/deflate.h5` and `<dir>/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) ## Search harness baseline (v2.3.0)
Produced by `cargo run --release -p clawhdf5-bench --bin search_harness -- --full` Produced by `cargo run --release -p clawhdf5-bench --bin search_harness -- --full`
+555
View File
@@ -2,7 +2,110 @@
## Unreleased ## 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 ### 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,
netcdf4-python, h5wasm, h5py and xarray corpora), a 567-case read matrix and
a 96-case write matrix against HDF5 1.10–2.0 found bugs that returned wrong
values with no error, and files we wrote that libhdf5 rejects. The fixes are
listed under Correctness and Interop. What changes for callers:
- **Chunked datasets whose max shape is larger than their current shape**,
or whose unlimited dimension is not the first, were indexed by the current
shape instead of the max shape, both when read and when written. Files from
libhdf5 now read correctly. Files clawhdf5 wrote with such a max shape were
laid out wrongly and now read the way libhdf5 always read them — rewrite
them. Agent stores and ClawBrainHub files have no max shape and are
unaffected.
- Integer reads (`read_i32`/`read_i64`/`read_u64`/...) of float data now
convert (truncate toward zero, saturate at the type's range, NaN reads as
0) instead of returning the IEEE bit pattern, and out-of-range integers
saturate instead of keeping the low bits.
- `FileWriter::finish()` now returns an error instead of writing a corrupt
file for: a header message over 64 KiB (e.g. an attribute larger than
~64 KiB), a group/dataset/link name that is empty, `.` or contains `/`
(nested paths were written as one literal link), a max shape smaller than
the shape, a page size outside 512 B–1 GiB, and more than 65 535 chunks in
a dataset with several unlimited dimensions.
- **Breaking (format crate):** `ObjectHeaderWriter::serialize`,
`BatchObjectHeaderWriter::compute_sizes`/`serialize_all` and
`build_chunked_data_from_precompressed` return `Result`;
`read_fixed_array_chunks`/`read_extensible_array_chunks` take `max_dims`;
`build_fixed_array_at`/`ea_writer::build_extensible_array_at` take one
`Option<WrittenChunk>` per index slot; `fill_value::dataset_fill_value`
returns `UnresolvedSharedMessage` for a shared message it cannot resolve
instead of `None`. `FillTime::default()` is `IfSet` (libhdf5's default;
default files are byte-identical).
- **ZeroClaw does not use clawhdf5.** The project described itself as
ZeroClaw's memory backend ("imported as a `clawhdf5` Cargo feature"). Checked
against ZeroClaw v0.8.5 (the latest release), the `osobh/zeroclaw` fork and
their full history: no such feature or backend has ever existed. And
`clawhdf5-migrate`'s "ZeroClaw layout" (`memory_chunks`, `sessions`,
`entities`, `relations`) is not ZeroClaw's schema — ZeroClaw uses a single
`memories` table — so the migrator cannot read a ZeroClaw database. The
claims are withdrawn; the migrator's layout is documented as its own.
- **OpenClaw is not supported, and never was.** The docs described a
"drop-in" OpenClaw memory backend enabled with `memory.backend = "clawhdf5"`.
That config was never valid in any OpenClaw release (v2026.2–v2026.7
accepted only `builtin`/`qmd` and rejected unknown keys, so a Gateway given
it refuses to start; OpenClaw 2.0 removed the key), no plugin was ever built,
and `@redclaw/clawhdf5` was never published. The integration docs
(`openclaw-integration.md`, `openclaw-config.md`, `migration-guide.md`) are
removed; `docs/openclaw.md` explains the status and what a real plugin would
need against OpenClaw v2026.9.6. `ClawhdfBackend` stays as a library API.
- **Breaking:** `MemoryError` is now `#[non_exhaustive]` and gained
`SigningKeyRequired`; a `match` on it needs a wildcard arm. Future variants
will no longer be breaking.
- **Breaking:** `clawhdf5-agent`'s `agent` feature is removed. It enabled - **Breaking:** `clawhdf5-agent`'s `agent` feature is removed. It enabled
nothing — the agent layer is always built — but the README and guides told nothing — the agent layer is always built — but the README and guides told
people to pass it; drop `agent` from `features = [...]`. people to pass it; drop `agent` from `features = [...]`.
@@ -60,6 +163,88 @@
`quantized_index = false`, or pass `create --f32-index` to the CLI, to opt `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. 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
`HDF5Memory::set_signing_key(key)`, every checkpoint stores a signed
manifest: a SHA-256 per record (text, embedding as stored, channel,
timestamp, session, tags, deleted flag, activation) in a Merkle tree, plus
hashes of the settings (and WAL mark), sessions and knowledge graph, with
the per-record hashes in `/integrity/record_hashes`.
`HDF5Memory::verify(path, &public_key)` recomputes everything from the file
and reports which part changed and which records (`changed_records`); a
forged manifest fails the signature. The key is never persisted; a signed
store refuses to checkpoint without it (`MemoryError::SigningKeyRequired`),
and `remove_signature()` is the deliberate way back to unsigned. Saves still
in the WAL are not covered (`wal_entries_unsigned`). Tests include every
kind of edit, and an edit made with h5py in place, which verify pinpoints.
Cost: ~20% of a checkpoint, 32 bytes per record (`BENCHMARKS.md`, "Signed
checkpoints"). New dependencies `ed25519-dalek`, `sha2`, `rand_core` — pure
Rust; the no-C check still passes.
- `clawhdf5-cli`: `keygen --out <file>` (owner-only key file),
`--signing-key <file>` / `CLAWHDF5_SIGNING_KEY` on writing commands
(`create` signs immediately), `verify --public-key <hex|file>` (JSON report;
exit status 2 if not valid), and `signed` in `create`/`stats` output.
### Migration ### Migration
- `clawhdf5-migrate`: writes through the agent's own API (`HDF5Memory::create` - `clawhdf5-migrate`: writes through the agent's own API (`HDF5Memory::create`
/ `open`, `save_batch`, the session cache and knowledge graph), so there is / `open`, `save_batch`, the session cache and knowledge graph), so there is
@@ -100,6 +285,13 @@
activation of the `k` results it returns, not of the whole `3k` candidate activation of the `k` results it returns, not of the whole `3k` candidate
pool it re-ranks. pool it re-ranks.
### Documentation
- OpenClaw claims withdrawn across the README, QUICKSTART, USE_CASES, ROADMAP
(Track 7 marked withdrawn) and the `openclaw` module docs; the dead
`github.com/redclawsystems/openclaw` link is gone. The Node package is
marked unpublished and broken (now `"private": true` so it cannot be
published by accident), with its bugs recorded in `docs/known-issues.md`.
### Benchmarks ### Benchmarks
- Every undated or pre-September section of `BENCHMARKS.md` re-run on one - Every undated or pre-September section of `BENCHMARKS.md` re-run on one
machine on one day (tank, 2026-09-24, commit 5c8323c), with the command for machine on one day (tank, 2026-09-24, commit 5c8323c), with the command for
@@ -113,8 +305,39 @@
README claimed but nothing measured. README claimed but nothing measured.
- `footprint_bench` reports whether it built `float16` or `f32` stores and - `footprint_bench` reports whether it built `float16` or `f32` stores and
takes `--f32`; it had kept printing "f32" after the default changed. 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 ### 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,
pyfive, netcdf-c, netcdf4-python, h5wasm, h5py, xarray-data) into a
gitignored cache, reads every file with clawhdf5 and with h5py/libhdf5 (and
the CVE files with h5dump) under a timeout and memory limit, compares them
object by object and regenerates the report — about 30 s once the corpus is
cached. A nightly Gitea job (`.gitea/workflows/conformance.yml`) runs it and
fails on any panic, hang, crash or out-of-memory, or when a file in
`conformance/baseline.json` stops reading identically. First report, on
42b81d9: 467 of 697 files identical to h5py, 123 our-error, 15 mismatch
(2 of them an h5py bug), 92 that libhdf5 cannot read, no panics, hangs or
crashes. Compared with the ad-hoc audit sweep, the probe now compares
N-Bit floats (and integers with a bit offset) as the values libhdf5
converts them to rather than raw file bytes — 8 files that were reported as
mismatches read identically — and the reference side no longer flips
between runs when libhdf5 aborts while freeing h5py objects.
- `clawhdf5-format`: **every `f32` dataset was unreadable by h5py and - `clawhdf5-format`: **every `f32` dataset was unreadable by h5py and
libhdf5.** The float datatype encoder hard-coded the sign bit's position to libhdf5.** The float datatype encoder hard-coded the sign bit's position to
63, correct only for `f64`; libhdf5 validates it and refused the dataset. It 63, correct only for `f64`; libhdf5 validates it and refused the dataset. It
@@ -129,6 +352,37 @@
`float16` rounding matches numpy's bit for bit on 4 020 probe values, `float16` rounding matches numpy's bit for bit on 4 020 probe values,
including ties, subnormals and the overflow boundary), and an agent store — including ties, subnormals and the overflow boundary), and an agent store —
`f32` and `float16` — opened by h5py with every dataset decoded. `f32` and `float16` — opened by h5py with every dataset decoded.
- `clawhdf5-format` filters, checked against libhdf5 + hdf5plugin:
- **LZ4 (32004) now uses the registered HDF5 LZ4 format** (8-byte BE size,
4-byte BE block size, BE-length-prefixed blocks). Our old framing (4-byte
LE size + one block) was readable only by clawhdf5, and we could not read
libhdf5's (`h5ex_d_lz4.h5`). Old clawhdf5 LZ4 chunks still read; they are
told apart unambiguously (a registered chunk starts with four zero bytes).
- **Zstd (32015) frames now record the content size**, which libhdf5's zstd
plugin needs; h5py could not read our zstd datasets.
- **Pcodec moved from filter ID 32023 to 480.** 32023 is registered to
Granular BitRound, whose decode is a pass-through — libhdf5 with that
plugin would have returned compressed bytes as data. Pcodec has no
registered ID; 480 is in the registry's private range (256–511) and only
clawhdf5 can read it. Chunks written under 32023 with the filter name
`pcodec` (clawhdf5 ≤ 2.7.0) still read.
- **SZIP decode matches libhdf5.** It returned garbage or zeros with no
error for libhdf5-written files (the 4-byte size prefix, 32/64-bit
byte-plane interleaving, reference interval, scanline padding and byte
order were all handled wrongly) and rejected 64-bit data.
- N-Bit honours libhdf5's "need not compress" flag (multi-filter pipelines
such as `tfilters.h5` failed) and reads enum/no-op members.
- Scale-offset `float` decode uses libhdf5's single-precision arithmetic
(was 1 ULP off for some values).
- A pipeline with Fletcher32 ahead of the compressor (h5py
`set_fletcher32()` then `set_deflate()`) no longer fails with "deflate:
output exceeds size limit".
- `clawhdf5-format`: **HDF5 1.4/1.6-era files are readable.** Data Layout
message versions 1 and 2 (compact, contiguous, and chunked through the
version-1 B-tree) failed with `InvalidLayoutVersion` — 84 of the 686 files in
the 2026-09-25 audit sweep, 205 datasets. They now read as libhdf5 does;
checked byte for byte against h5py on HDF5's own test files
(`tests/legacy_format_interop.rs`).
### Storage ### Storage
- `clawhdf5-format`: **half-precision datasets.** - `clawhdf5-format`: **half-precision datasets.**
@@ -150,6 +404,32 @@
infinity; batches are all or nothing. CLI: `create --float16`. See infinity; batches are all or nothing. CLI: `create --float16`. See
`BENCHMARKS.md`, "float16 embedding storage". `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 ### Build
- **Pure-Rust default.** `clawhdf5-format`, `clawhdf5-filters` and the - **Pure-Rust default.** `clawhdf5-format`, `clawhdf5-filters` and the
`clawhdf5` facade default to the `zlib-rs` deflate backend; `fast-deflate` `clawhdf5` facade default to the `zlib-rs` deflate backend; `fast-deflate`
@@ -166,6 +446,281 @@
- CI keeps zlib-ng building and tested; the arm64 job no longer needs cmake. - CI keeps zlib-ng building and tested; the arm64 job no longer needs cmake.
### Correctness ### 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
be decoded against the wrong file and name another object.
- `clawhdf5-agent`: a store whose `/meta` has an attribute that cannot be
decoded fails to open (`MemoryError::Schema`). With `attrs()` now leaving
unreadable attributes out, it would otherwise have opened with defaults in
place of its settings (`float16`, `compression`, the WAL mark, ...).
- `clawhdf5-format` reader: an old-style group whose local heap has a free
list pointing outside the heap was listed with names read from the broken
heap (garbage names on `cve-2021-36977.h5` once its user block was
applied). libhdf5 refuses such a heap ("bad heap free list"); so do we now,
with `FormatError::InvalidLocalHeapFreeList`. As in libhdf5 the free list
is checked when the first name is read (`LocalHeap::validate_free_list`,
new), so an empty group with a damaged heap still lists as empty.
- **Files with a user block** (`h5py.File(..., userblock_size=N)`, `h5jam`;
the superblock at 512, 1024, …) could not be read: every address in the
file is relative to the superblock, but it was applied from byte 0
(`InvalidObjectHeaderVersion` on the root group). `File` (mmap, buffered,
`from_bytes`), `MmapFile`, `LazyFile`, `AsyncHDF5File`, the VOL readers,
the HNSW loader and external VDS sources now view the file from the
superblock on, using the signature's position as the base address as
libhdf5 does; `user_block_size()` reports the user block (h5py's
`userblock_size`), and `as_bytes()` returns the bytes from the superblock
on. **Breaking (format crate):** `Superblock::parse` refuses a non-zero
signature offset with `FormatError::UserBlockNotStripped`, since the
addresses it returns would be applied to the wrong bytes; pass the slice
from `signature::split_user_block` (new) and parse at offset 0.
- `clawhdf5-format` reader: version-1 shared messages (HDF5 1.6-era files,
e.g. a dataset using a committed datatype in libhdf5's `tcompound.h5`)
read the heap-offset field of the embedded symbol-table entry as the
target address and failed with `InvalidObjectHeaderVersion`. The address
is now read after it, as libhdf5 does. **Breaking (format crate):**
`shared_message::parse_shared_ref` takes `length_size`. A reference whose
target header has no message of the referenced type is now
`FormatError::SharedMessageTargetMissing` instead of returning the first
other message found there (which decoded as garbage).
- `clawhdf5-format` reader: array members of version-1 compound datatypes
(HDF5 1.6-era files, e.g. libhdf5's `tcompound.h5`) were read as a single
element: a `[4] i32` member came back as one `i32`, with the wrong size.
The legacy per-member dimension fields are now decoded into an array type,
as libhdf5 does; more than four dimensions, or a zero-sized one, is an
error.
- `clawhdf5-format` virtual datasets (VDS), checked against HDF5 2.0 through
h5py (`crates/clawhdf5/tests/vds_interop.rs`):
- **Wrong data:** elements no mapping supplies — unmapped regions, and
mappings whose source file or dataset is missing — read as 0 instead of
the virtual dataset's fill value (e.g. h5py `fillvalue=-1`). Assembly moved
to the new `vds` module: `vds::read_virtual_dataset` takes the fill value
and a resolver that can refuse a name (`VdsFileResolver`), and `File`
passes the dataset's fill value. A missing source *dataset* read as an
error; it is fill now, as in libhdf5. Source datasets are read with their
own fill value for unallocated chunks, and a source whose datatype differs
from the virtual dataset's is an error (libhdf5 converts; we do not).
`File` now refuses a source name that leaves the virtual file's directory
(`../x.h5`, absolute paths), or any external source of a `File::from_bytes`
file, with an error — these used to read as fill.
**Behaviour change:** the raw-read API (`read_raw_data_full*`), which has
no fill value, now returns an error for a virtual dataset with unmapped
elements instead of zeros.
- Unlimited and printf-style mappings are supported (all 7 VDS files in the
libhdf5 test set are such mappings, e.g. Eiger/Percival detector layouts).
`%b` in a source file or dataset name is the block number and `%%` a
literal `%` (other `%` sequences are an error, as in libhdf5); block `j`
is read from the source named with `j`, probing from 0 up to the first
missing source. Unlimited source/virtual selections cover as much as the
source's current extent fills, including a partial last block. As
libhdf5 does on `H5Dget_space`, the extent is recomputed from the sources
present (default "last available" view, printf gap 0) —
`vds::virtual_dataset_extent`, used by `Dataset::shape()` — so e.g.
`vds-eiger.h5` is `[5, 10, 10]`, not its stored `[20, 10, 10]`. A source
stored in the other byte order is byte-swapped (libhdf5 converts);
other type conversions remain an error.
- Hyperslab selection versions 1 and 2 were refused ("only version-3
hyperslab selections are supported"). Version 1 is what libhdf5 writes for
every VDS created with the default format bounds (h5py's default), so
those could not be read at all; version 2 is its encoding of an unlimited
selection. Both are decoded now, as are irregular hyperslabs (a union of
blocks, read in row-major order as libhdf5 iterates them).
`SerializedSelection` exposes the raw form, including unlimited counts.
- The version-1 mapping list HDF5 2.0 writes (low version bound 2.0) was
misparsed: each entry's flags byte was read as the start of the source
file name, and names shared with an earlier entry (stored as that entry's
index) were not followed. Now decoded as `H5D__virtual_load_layout` does.
- `clawhdf5-format` reader — **values returned wrong with no error:**
- Fixed Array and Extensible Array chunk indexes were laid out by the
dataset's current shape instead of its max shape (23 libhdf5 test files,
and any h5py file with e.g. `maxshape=(10, None)` or `(20, 10)` under
`libver='latest'`).
- Files with 4-byte offsets: unfiltered chunked datasets read as zeros.
Chunk B-tree keys store offsets in 8 bytes whatever the file's offset
size.
- A chunk's filter mask skipped the whole pipeline when any bit was set;
only the flagged filters are skipped now.
- Float data read as an integer returned the bit pattern; narrowing integer
reads kept the low bits; bfloat16 was decoded as IEEE half. Floats are now
decoded from their datatype fields (bf16, FP8 E4M3/E5M2, IEEE half, single
and double).
- `vl_data::read_vl_bytes` truncated sequences of non-byte base types.
- A shared fill-value message read as zero fill; it is resolved now,
including from the file's shared-message (SOHM) table, which could never
resolve because its index version byte was skipped.
- Two threads reading two chunked datasets through one `File` could get each
other's chunks (the shared chunk cache was switched between datasets
across separate lock acquisitions). The cache is now keyed by dataset.
- Compound datatype version 1 members with legacy array dimensions (HDF5
before 1.4, which had no array class) were read as a single scalar at
the member's offset; they are now array members, as in libhdf5
(`tarrold.h5`, `tcompound.h5`). Only reachable once layout versions 1/2
were readable, since the files that use it are that old.
- `clawhdf5-format` reader — errors on valid files: a version-1 shared
message (a committed datatype in HDF5 1.4/1.6-era files) was read as if the
object header address followed the reserved bytes; it follows a link-name
offset (the reference is an old-style symbol table entry), so the reader
followed the name offset and failed with `InvalidObjectHeaderVersion`
(`tcompound.h5`). New `shared_message::parse_shared_ref_sized` takes the
superblock's length size; `parse_shared_ref` assumes it equals the offset
size.
- `clawhdf5-format` reader — errors on valid files: enum and bool datasets
through the numeric readers; the "don't filter partial edge chunks" layout
flag; Fletcher32 ahead of deflate (NetCDF-4's order). Unknown-message flags
follow libhdf5 (`tbogus.h5`): "fail if unknown" is refused, "fail if unknown
and writing" is ignored by a reader.
- `clawhdf5-format` reader — dense groups and attributes (links or
attributes kept in a fractal heap indexed by a v2 B-tree):
- A link heap larger than the root indirect block's direct rows (512 KiB
with libhdf5's defaults: a few thousand long link names, or ~20 000 short
ones) could not be listed: child indirect blocks were given the wrong
number of rows, so every link stored in one was unreachable.
- v2 B-trees of depth 3 or more (a dense group of ~22 000+ links) were
misparsed: internal-node child pointers were read with widths from an
estimate instead of libhdf5's per-depth record capacities, and the
listing failed. The same B-tree code indexes dense attributes, shared
messages and chunks.
- Fractal-heap "huge" objects (larger than the heap's managed-object
limit, 4 KiB by default — e.g. an 8 KiB dense attribute or a link with a
very long name) and "tiny" objects are now read; the ID type was taken
from the wrong bits (6-7, the version, instead of 4-5), so a huge object
failed and took every attribute on its object down with it (NetCDF-4
files such as netcdf4-python's `issue671.nc`). Huge objects are found
directly from the ID or through the huge-object v2 B-tree, filtered or
not.
- Heaps with an I/O filter pipeline (a group created with a filter on its
creation property list compresses its link heap) are now read: the
header's pipeline was skipped with the wrong size, so its checksum was
looked for in the wrong place, and filtered direct blocks were read raw.
- A user-defined link (link class 65-255, e.g. 187 in libhdf5's
`tall.h5`/`tudlink.h5`) made its whole group unlistable. Such links
cannot be followed without the application that registered the class, so
they are now left out of `datasets()`/`groups()` and path lookup, as h5py
leaves out links it cannot open; reserved link types are still an error.
- `clawhdf5` — soft links are listed, as h5py lists them: `datasets()` and
`groups()` on `Group`/`MmapGroup`/`LazyGroup` include each soft link under
its own name as the kind of object it resolves to, and `dataset(name)` /
`group(name)` open through it. Relative targets resolve from the group
holding the link. Dangling or cyclic soft links, external links and
user-defined links are left out (h5py lists their names but cannot open
them). Previously soft links were missing from the listings, and in
old-style (symbol table) groups a soft link made the listing fail. New
`group_v2::resolve_group_children` / `resolve_path_from` and
`group_v1::v1_soft_links` in `clawhdf5-format`.
- `clawhdf5` — one unreadable attribute no longer fails `attrs()` for every
attribute on its object: it is left out of the map, and the new
`attrs_with_errors()` (on every group and dataset handle) returns the map
plus one error per attribute left out. Returned values are always complete.
An error in the attribute index itself (attribute info message, dense heap
header or B-tree) still fails the call. `clawhdf5-format` gains
`attribute::extract_attributes_tolerant`; `extract_attributes_full` stays
strict.
- `clawhdf5-format` reader — files with shared object header messages
(SOHM, `H5Pset_shared_mesg_index`): a datatype, dataspace, filter pipeline
or attribute stored in the file's SOHM heap failed with "invalid shared
message version: 2" — only shared fill values loaded the SOHM table — so
such files' datasets and attributes could not be read.
`shared_message::resolve_shared_message` now loads the table when a
reference needs it (36 cases of the audit's read matrix).
- `clawhdf5-format` writer — **files libhdf5 rejects or reads wrong:**
- Extensible Array (one unlimited dimension): chunks from index 244 on were
written but never indexed and read as 0, by libhdf5 and by us.
- Fixed Array: more than 1 024 chunks gave checksum errors (data blocks
were never paged).
- A finite max shape larger than the shape gave libhdf5 "addr overflow"; an
unlimited dimension that is not the first scrambled the data; several
unlimited dimensions (`(None, None)`) broke the whole file. These now
write the index libhdf5 writes (swizzled Extensible Array, or a B-tree v2
index for several unlimited dimensions).
- Header messages over 64 KiB (the size field is 16 bits) and compact
datasets at 65 534–65 535 bytes produced corrupt files.
- Reference, Opaque, BitField and Time datatypes were written as empty
messages; they now encode as HDF5 2.0 does.
- `with_page_size` wrote a nonexistent superblock version 4; it now writes
the v3 superblock and File Space Info message libhdf5 writes.
- `FillTime` values were rotated on disk (NEVER was written as ALLOC, and so
on). New `DatasetBuilder::with_fill_value`.
- An empty-string attribute got a zero-size datatype, which made every
attribute on the object unreadable in libhdf5.
- `maxshape` equal to the shape no longer forces chunked layout.
- `clawhdf5-format`: **a truncated deflate chunk read back short, with no - `clawhdf5-format`: **a truncated deflate chunk read back short, with no
error.** The deflate filter used flate2's streaming reader, which returns the error.** The deflate filter used flate2's streaming reader, which returns the
bytes it has when the input runs out before the end-of-stream marker. It now bytes it has when the input runs out before the end-of-stream marker. It now
+60 -9
View File
@@ -1,29 +1,31 @@
# clawhdf5 # clawhdf5
## Purpose ## Purpose
Pure-Rust HDF5 format implementation with HNSW vector search, WAL-backed persistence, agent memory storage, and GPU-accelerated I/O. Used by ZeroClaw as its persistent memory and knowledge graph backend. Pure-Rust HDF5 format implementation with HNSW vector search, WAL-backed persistence, agent memory storage, and GPU-accelerated vector search. A standalone library. Its one verified consumer is ClawBrainHub (`.brain` files); no agent framework integrates it (OpenClaw and ZeroClaw claims were withdrawn on 2026-09-25 — neither was ever true).
## Architecture ## 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 | | Crate | Role |
|-------|------| |-------|------|
| `clawhdf5-format` | HDF5 binary spec parser (superblock, B-tree, heap) — also holds shared type definitions and physical constants | | `clawhdf5-format` | HDF5 binary spec parser (superblock, B-tree, heap) — also holds shared type definitions and physical constants |
| `clawhdf5-io` | Read/write implementation | | `clawhdf5-io` | Read/write implementation |
| `clawhdf5-filters` | Compression filters (gzip, LZ4, Zstd, 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-derive` | Proc-macro derive for HDF5-serializable structs |
| `clawhdf5` | Main facade crate | | `clawhdf5` | Main facade crate |
| `clawhdf5-netcdf4` | NetCDF-4 compatibility layer | | `clawhdf5-netcdf4` | NetCDF-4 compatibility layer |
| `clawhdf5-ann` | HNSW approximate nearest-neighbor vector index | | `clawhdf5-ann` | HNSW approximate nearest-neighbor vector index |
| `clawhdf5-agent` | Agent memory, session history, knowledge graph storage | | `clawhdf5-agent` | Agent memory, session history, knowledge graph storage |
| `clawhdf5-gpu` | GPU-accelerated I/O via wgpu (hand-written WGSL compute shaders) | | `clawhdf5-gpu` | GPU vector distance computation via wgpu (hand-written WGSL compute shaders) — not dataset I/O |
| `clawhdf5-accel` | CPU SIMD acceleration path | | `clawhdf5-accel` | CPU SIMD acceleration path |
| `clawhdf5-migrate` | SQLite → HDF5 agent-memory migration | | `clawhdf5-migrate` | SQLite → HDF5 agent-memory migration |
| `clawhdf5-android` | Android JNI bindings | | `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-napi` | Node.js native addon bindings |
| `clawhdf5-py` | PyO3 Python bindings | | `clawhdf5-py` | PyO3 Python bindings |
| `clawhdf5-wasm` | WebAssembly (wasm-bindgen) reader for the browser; demo in `examples/wasm-viewer/` |
| `clawhdf5-bench` | Benchmark suite | | `clawhdf5-bench` | Benchmark suite |
## Key Features ## Key Features
@@ -104,11 +106,34 @@ Cargo workspace with 16 crates under `crates/` (plus `libaec-sys`, an internal F
the allowed records whenever cheaper than `pool × M` index distance the allowed records whenever cheaper than `pool × M` index distance
evaluations, and as the fallback when the pool comes back short), fusion, evaluations, and as the fallback when the pool comes back short), fusion,
activation scaling, optional re-ranking and confidence rejection. activation scaling, optional re-ranking and confidence rejection.
`hybrid_search`/`hybrid_search_with` are thin wrappers; the OpenClaw `hybrid_search`/`hybrid_search_with` are thin wrappers; `ClawhdfBackend`
backend is `search` with re-rank + confidence on. Measure changes with (the `openclaw` module) is `search` with re-rank + confidence on.
- **OpenClaw is not supported** (decided 2026-09-25): clawhdf5 is not an
OpenClaw memory plugin and never was — the old `memory.backend = "clawhdf5"`
config was never valid. Don't reintroduce OpenClaw claims; `docs/openclaw.md`
records what a real plugin would need.
- **ZeroClaw does not use clawhdf5** (checked 2026-09-25 against upstream
v0.8.5 and the `osobh/zeroclaw` fork, and their full history): no
`clawhdf5` feature or backend exists; ZeroClaw's memory backends are
sqlite/lucid/postgres/qdrant/markdown/none behind its own `Memory` trait.
`clawhdf5-migrate`'s default SQLite layout (`memory_chunks`, `sessions`,
`entities`, `relations`) is not ZeroClaw's schema either (ZeroClaw's is a
`memories` table). Don't reintroduce integration claims without an
integration and a test against the real consumer. Measure changes with
`search_harness --options-study`. `search_harness --options-study`.
- `MemoryConfig::compression` is off by default; when on, embeddings are - `MemoryConfig::compression` is off by default; when on, embeddings are
deflate-compressed, or Zstd with the agent's `zstd` feature (links libzstd). deflate-compressed, or Zstd with the agent's `zstd` feature (links libzstd).
- Signed checkpoints (`clawhdf5-agent` `signing` module): with
`HDF5Memory::set_signing_key` every checkpoint stores an Ed25519-signed
manifest (SHA-256 per record in a Merkle tree + settings/sessions/graph
hashes; per-record hashes in `/integrity/record_hashes`);
`HDF5Memory::verify(path, &pk)` locates edits. The hashes must cover exactly
what the file persists in the form the loader returns it (strings lose
trailing NULs; an empty WAL mark is not written) or untouched stores stop
verifying — `tests/signed_store.rs` round-trips awkward strings. The key is
never persisted; a signed store refuses to checkpoint without it
(`MemoryError::SigningKeyRequired`, and `MemoryError` is `#[non_exhaustive]`).
WAL entries after the checkpoint are not covered.
- `Dataset::verify_provenance()` (clawhdf5 facade, `provenance` feature, on by - `Dataset::verify_provenance()` (clawhdf5 facade, `provenance` feature, on by
default) recomputes a dataset's SHA-256 and compares it against the default) recomputes a dataset's SHA-256 and compares it against the
`_provenance_sha256` attribute written automatically on save when `_provenance_sha256` attribute written automatically on save when
@@ -125,7 +150,15 @@ Cargo workspace with 16 crates under `crates/` (plus `libaec-sys`, an internal F
Alerts never block a save — drain them with `HDF5Memory::take_anomaly_alerts`. Alerts never block a save — drain them with `HDF5Memory::take_anomaly_alerts`.
`MemorySource` for this bookkeeping is inferred from the caller-supplied `MemorySource` for this bookkeeping is inferred from the caller-supplied
`source_channel` string (a heuristic, not an authenticated trust boundary). `source_channel` string (a heuristic, not an authenticated trust boundary).
- GPU-accelerated batch I/O for large dataset processing - 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 - Python and Node.js bindings for cross-language use
- NetCDF-4 compatibility for scientific data interop - NetCDF-4 compatibility for scientific data interop
@@ -165,6 +198,16 @@ cargo run -p clawhdf5-cli -- --help
# create, save, search, recall, stats, flush-wal, agents-md, export, snapshot subcommands # 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 ### Python bindings
```bash ```bash
cd crates/clawhdf5-py cd crates/clawhdf5-py
@@ -173,4 +216,12 @@ python -c "import clawhdf5; print(clawhdf5.__version__)"
``` ```
## Integration ## Integration
ZeroClaw imports this as a Cargo feature (`clawhdf5` feature flag) to persist agent memory with HNSW vector search for context retrieval. - **ClawBrainHub** (`clawverse/clawbrainhub` on git.redclaw.dev) is the one
verified consumer: `cbh-core` reads and writes `.brain` files through the
facade (`File`, `FileBuilder`, `AttrValue`, `Selection`), `cbh-scanner`
uses the facade, and `cbh-cli` uses `clawhdf5_agent::bm25::BM25Index`. It
depends on this repo by path (`../clawhdf5`), so it builds against whatever
is checked out — changes to those APIs reach it directly. Verified
2026-09-25 against main: builds, and its 204 tests pass.
- OpenClaw and ZeroClaw were both described as consumers; neither integrates
clawhdf5 (see Key Features and `docs/openclaw.md`).
+298
View File
@@ -0,0 +1,298 @@
# clawhdf5 conformance report
Every HDF5 file of eight public corpora (pinned by commit) is read twice — by
clawhdf5 (`conformance/probe`, the same `clawhdf5-format` calls the facade
makes) and by h5py/libhdf5 (`conformance/ref.py`) — and the two readings are
compared object by object: the set of hard-linked objects, each dataset's and
attribute's shape, and a SHA-256 of its values in a canonical encoding. The
CVE corpus is also run through `h5dump`. Each side runs under a timeout and an
address-space limit, so a hang, crash or runaway allocation is recorded, not
fatal. This file is generated by `conformance/run.sh`; do not edit it by hand.
## Run
| | |
|---|---|
| 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) |
| reference | h5py 3.16.0, HDF5 2.0.0, numpy 2.5.3, hdf5plugin 7.1.0, Python 3.14.4 |
| h5dump | Version 1.14.6 (CVE corpus only) |
| limits | 20 s timeout (SIGKILL), 4096 MiB address space, per process; 16 files in parallel |
| runtime | 22 s probing + comparing (0 s fetch/build before it) |
## Results
A file's class is the first that applies:
- **panic / hang / crash / oom** — clawhdf5 panicked (caught per object or not), hit the timeout, died on a signal, or failed an allocation. The CI gate fails on any of these.
- **h5py-cannot-read** — libhdf5 could not open the file (or itself crashed or hung). Nothing to compare against; most are the deliberately malformed CVE reproducers.
- **our-error** — clawhdf5 returned an error for something h5py reads.
- **mismatch** — both read it, but the shapes, values, object set or attribute set differ.
- **ok** — every object h5py reads, clawhdf5 reads identically.
| corpus | files | ok | our-error | mismatch | h5py-cannot-read | panic | hang | crash | oom |
|---|---|---|---|---|---|---|---|---|---|
| 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 | 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** | **575** | **10** | **20** | **92** | **0** | **0** | **0** | **0** |
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/`):
| corpus | source | commit |
|---|---|---|
| hdf5 | https://github.com/HDFGroup/hdf5 | `a3cf1ea82cc7` |
| cve_hdf5 | https://github.com/HDFGroup/cve_hdf5 | `3fd1f5ae3869` |
| netcdf-c | https://github.com/Unidata/netcdf-c | `beb7b9585273` |
| NCAS-CMS_pyfive | https://github.com/NCAS-CMS/pyfive | `8cf07b874913` |
| usnistgov_h5wasm | https://github.com/usnistgov/h5wasm | `02f6336527d2` |
| netcdf4-python | https://github.com/Unidata/netcdf4-python | `6e67576d39ae` |
| xarray-data | https://github.com/pydata/xarray-data | `a35297e9da2c` |
| h5py_data | https://github.com/h5py/h5py (`h5py/tests/data_files`) | `b2f0347c4200` |
## Panics, hangs, crashes, out-of-memory
None.
## Our-error root causes
Grouped by normalised error message. *files* counts files whose class this cause affects.
| files | objects | error | examples |
|---:|---:|---|---|
| 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` |
## Mismatch root causes
| 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) |
| 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=<f4 h5py=float32 layout=chunked filters=-` | `cve_hdf5/cvefiles/cve-2025-44904.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` |
| 1 | 1 | `values: ours=<f4 h5py=float32 layout=chunked filters=[2]` | `cve_hdf5/cvefiles/cve-2025-44905.h5` |
| 1 | 1 | `values: ours=((<i4)[6, 3])[4] h5py=(('<i4', (6, 3)), (4,)) layout=contiguous filters=-` | `hdf5/tools/test/testfiles/tarray3.h5` |
| 1 | 1 | `values: ours=vlen({r:>f4,i:>f4}8) h5py=object layout=contiguous filters=-` | `hdf5/tools/test/testfiles/tcomplex_be.h5` |
## CVE corpus: clawhdf5 vs h5dump vs h5py
The 147 files of [HDFGroup/cve_hdf5](https://github.com/HDFGroup/cve_hdf5) — reproducers for
published libhdf5 CVEs and fuzzer finds. *read* = produced output (possibly with per-object
errors), *error* = refused cleanly. h5dump exits non-zero on any error anywhere in a file, so
its read/error split is not comparable with the other two rows; the panic, crash, hang and oom
columns are.
| tool | read | error | panic | crash | hang | oom |
|---|---:|---:|---:|---:|---:|---:|
| 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 |
<details><summary>Per-file outcomes</summary>
| file | h5dump | h5py | clawhdf5 | class |
|---|---|---|---|---|
| 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, 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, 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, 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 |
| cvefiles/cve-2018-13866.h5 | error exit | open error | open error | h5py-cannot-read |
| cvefiles/cve-2018-13867.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
| cvefiles/cve-2018-13868.h5 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
| cvefiles/cve-2018-13869.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
| 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, 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 | 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 |
| cvefiles/cve-2018-14035.h5 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
| cvefiles/cve-2018-14460.h5 | error exit | read 3 obj, 2 errors | read 3 obj, 2 errors | ok |
| cvefiles/cve-2018-15671.h5 | ok | read 1 obj | read 1 obj | ok |
| cvefiles/cve-2018-15672.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
| cvefiles/cve-2018-16438.h5 | error exit | read 1 obj, 1 errors | read 1 obj | ok |
| cvefiles/cve-2018-17233.h5 | error exit | read 6 obj, 1 errors | read 6 obj, 1 errors | ok |
| cvefiles/cve-2018-17234.h5 | error exit | read 6 obj, 1 errors | read 6 obj, 1 errors | ok |
| cvefiles/cve-2018-17237.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
| cvefiles/cve-2018-17432.h5 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
| cvefiles/cve-2018-17433 | error exit | open error | open error | h5py-cannot-read |
| cvefiles/cve-2018-17434.h5 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
| cvefiles/cve-2018-17435.h5 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
| cvefiles/cve-2018-17436 | error exit | open error | open error | h5py-cannot-read |
| cvefiles/cve-2018-17437.h5 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
| cvefiles/cve-2018-17438 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | mismatch |
| cvefiles/cve-2018-17439 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | mismatch |
| cvefiles/cve-2019-8396.h5 | error exit | read 3 obj, 2 errors | read 3 obj, 2 errors | ok |
| cvefiles/cve-2019-8397.h5 | error exit | read 3 obj, 2 errors | read 2 obj, 1 errors | mismatch |
| cvefiles/cve-2019-8398.h5 | error exit | read 3 obj, 2 errors | read 2 obj, 1 errors | mismatch |
| cvefiles/cve-2019-9151.h5 | error exit | read 3 obj, 1 errors | read 3 obj, 2 errors | our-error |
| cvefiles/cve-2019-9152.h5 | error exit | read 3 obj, 1 errors | read 3 obj, 1 errors | ok |
| cvefiles/cve-2020-10809 | error exit | open error | open error | h5py-cannot-read |
| cvefiles/cve-2020-10810.h5 | error exit | open error | read 2 obj | h5py-cannot-read |
| cvefiles/cve-2020-10811.h5 | error exit | read 25 obj, 1 errors | read 25 obj, 1 errors | ok |
| cvefiles/cve-2020-10812.h5 | error exit | open error | read 2 obj | h5py-cannot-read |
| cvefiles/cve-2020-18232.h5 | error exit | read 3 obj, 2 errors | read 3 obj, 2 errors | ok |
| cvefiles/cve-2020-18494.h5 | ok | read 2 obj | read 2 obj, 1 errors | our-error |
| cvefiles/cve-2021-36977.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
| cvefiles/cve-2021-37501.h5 | error exit | read 18 obj, 1 errors | read 18 obj, 1 errors | ok |
| cvefiles/cve-2021-45829.h5 | error exit | read 1 obj, 2 errors | read 1 obj, 1 errors | ok |
| cvefiles/cve-2021-45830.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
| 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, 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, 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 |
| cvefiles/cve-2024-29166.h5 | error exit | read 17 obj, 2 errors | read 17 obj | ok |
| cvefiles/cve-2024-32605.h5 | ok | read 6 obj, 1 errors | read 6 obj, 1 errors | ok |
| cvefiles/cve-2024-32606.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
| cvefiles/cve-2024-32607-1.h5 | ok | read 10 obj | read 10 obj | ok |
| cvefiles/cve-2024-32607-2.h5 | error exit | read 9 obj, 1 errors | read 9 obj, 1 errors | ok |
| cvefiles/cve-2024-32608.h5 | error exit | read 6 obj, 1 errors | read 6 obj | ok |
| cvefiles/cve-2024-32609.h5 | error exit | SIGSEGV | read 3 obj, 1 errors | h5py-cannot-read |
| cvefiles/cve-2024-32610.h5 | error exit | read 2 obj, 1 errors | read 2 obj, 1 errors | ok |
| 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, 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, 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, 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, 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 |
| cvefiles/cve-2025-2153.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
| cvefiles/cve-2025-2308.h5 | error exit | read 25 obj, 1 errors | read 25 obj, 2 errors | our-error |
| cvefiles/cve-2025-2309.h5 | ok | read 6 obj, 1 errors | read 6 obj, 1 errors | our-error |
| cvefiles/cve-2025-2310.h5 | error exit | read 24 obj, 8 errors | read 24 obj, 8 errors | ok |
| cvefiles/cve-2025-2912.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
| cvefiles/cve-2025-2913.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
| cvefiles/cve-2025-2914.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
| cvefiles/cve-2025-2915.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
| cvefiles/cve-2025-2923.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
| cvefiles/cve-2025-2924.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
| cvefiles/cve-2025-2925.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
| cvefiles/cve-2025-2926.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
| cvefiles/cve-2025-44904.h5 | error exit | read 25 obj, 1 errors | read 25 obj, 1 errors | mismatch |
| cvefiles/cve-2025-44905.h5 | error exit | read 25 obj, 3 errors | read 25 obj, 3 errors | mismatch |
| cvefiles/cve-2025-6269-1.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
| cvefiles/cve-2025-6269-2.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
| cvefiles/cve-2025-6269-3.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
| cvefiles/cve-2025-6269-4.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
| cvefiles/cve-2025-6270-1.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
| cvefiles/cve-2025-6270-2.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
| cvefiles/cve-2025-6270-3.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
| cvefiles/cve-2025-6516.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
| cvefiles/cve-2025-6750.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
| cvefiles/cve-2025-6816.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
| cvefiles/cve-2025-6817.h5 | error exit | open error | read 1 obj | h5py-cannot-read |
| cvefiles/cve-2025-6818.h5 | error exit | open error | read 1 obj | h5py-cannot-read |
| cvefiles/cve-2025-6856.h5 | error exit | open error | read 1 obj, 1 errors | h5py-cannot-read |
| cvefiles/cve-2025-6857.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
| cvefiles/cve-2025-6858.h5 | SIGSEGV | open error | read 1 obj, 1 errors | h5py-cannot-read |
| cvefiles/cve-2025-7067.h5 | error exit | read 1 obj, 1 errors | read 1 obj, 1 errors | ok |
| 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, 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, 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 |
| fuzzerfiles/gh_2649_flawed.h5 | error exit | read 9 obj, 1 errors | read 9 obj, 1 errors | ok |
| fuzzerfiles/gh_2649_plain_model.h5 | ok | read 10 obj | read 10 obj | ok |
</details>
## Known not-our-bug
- **h5py big-endian variable-length sequences.** h5py returns the elements of a VL sequence
whose base type is big-endian with the file's big-endian bytes but a native (little-endian)
numpy dtype, so the values it reports are byte-swapped garbage; `h5dump` prints the values
clawhdf5 reads. Reproducer: `h5py.vlen_dtype(np.dtype('>f4'))` dataset holding `[1.0, 2.0]`
reads back in h5py as `[4.6e-41, 9.0e-44]`. Affected here: `NCAS-CMS_pyfive/tests/data/attr_datatypes.hdf5`, `hdf5/tools/test/testfiles/tcomplex_be.h5`.
- **Non-IEEE floats and partial-precision integers (N-Bit).** libhdf5 converts a float whose
bit layout is not IEEE (e.g. `H5Tset_precision` for the N-Bit filter) or an integer with a
bit offset / reduced precision into the plain numpy type of the same size. The probe
compares such values as converted numbers, not raw file bytes (before 2026-09-25 it compared
raw bytes, which reported every N-Bit float dataset as a mismatch).
- **Types h5py widens.** Where h5py reads a type into a numpy type of a different size
(FP8 -> float16, bfloat16 -> float32, x87 long double -> float128) the values are not
compared (shape and presence still are): dataset file type size 1 -> numpy float16 (2) (15x), attr file type size 1 -> numpy float16 (2) (15x), dataset file type size 2 -> numpy float32 (4) (2x), dataset file type size 8 -> numpy float128 (16) (1x), dataset file type size 12 -> numpy float128 (16) (1x), attr file type size 2 -> numpy float32 (4) (1x), dataset file type size 2 -> numpy >f4 (4) (1x), attr file type size 2 -> numpy >f4 (4) (1x).
- **References** are compared by presence only (`R`), not by target.
## Objects h5py fails on but clawhdf5 reads
- 19 x `OSError: Can't synchronously read data (no appropriate function for conversion path)`
- 1 x `TypeError: unhandled dtype kind M (dtype('…'))`
- 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)`
## Reproduce
```sh
# needs: Rust, python3 with h5py numpy hdf5plugin (conformance/requirements.txt), h5dump (hdf5-tools), git
CLAWHDF5_PYTHON=/path/to/venv/bin/python conformance/run.sh
```
The corpus (about 450 MB of sparse checkouts) is cached in `conformance/.cache/`; results for
every file, both sides' raw JSON and stderr, are in `conformance/.cache/results/`.
`conformance/baseline.json` holds the ok files the nightly CI job (`.gitea/workflows/conformance.yml`)
must keep; `conformance/run.sh --update-baseline` rewrites it.
+11
View File
@@ -16,6 +16,8 @@ members = [
"crates/clawhdf5-cli", "crates/clawhdf5-cli",
"crates/clawhdf5-napi", "crates/clawhdf5-napi",
"crates/clawhdf5-bench", "crates/clawhdf5-bench",
"crates/clawhdf5-tools",
"crates/clawhdf5-wasm",
"crates/libaec-sys", "crates/libaec-sys",
] ]
resolver = "2" resolver = "2"
@@ -34,3 +36,12 @@ tempfile = "3"
criterion = { version = "0.5", features = ["html_reports"] } criterion = { version = "0.5", features = ["html_reports"] }
half = "2.7" half = "2.7"
serde = { version = "1", features = ["derive"] } 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"
+73 -18
View File
@@ -8,7 +8,7 @@
[![LongMemEval](https://img.shields.io/badge/LongMemEval__s-Turn--Level%20Hit@5%2081.4%25%20hybrid-blue.svg)](BENCHMARKS.md#longmemeval-results) [![LongMemEval](https://img.shields.io/badge/LongMemEval__s-Turn--Level%20Hit@5%2081.4%25%20hybrid-blue.svg)](BENCHMARKS.md#longmemeval-results)
[![Footprint](https://img.shields.io/badge/on--disk-~820%20B%2Frecord%20float16%2C%20synthetic%20text-lightgrey.svg)](BENCHMARKS.md#memory-footprint-1) [![Footprint](https://img.shields.io/badge/on--disk-~820%20B%2Frecord%20float16%2C%20synthetic%20text-lightgrey.svg)](BENCHMARKS.md#memory-footprint-1)
ClawHDF5 is a pure-Rust HDF5 implementation combined with a research-grade agent memory engine. It gives AI agents persistent, searchable, integrity-checked memory — all stored in a single portable file. ClawHDF5 is a pure-Rust HDF5 implementation combined with a research-grade agent memory engine. It gives AI agents persistent, searchable, cryptographically verifiable memory (Ed25519-signed checkpoints) — all stored in a single portable file.
> **Two things live here:** > **Two things live here:**
> - **A general-purpose, pure-Rust HDF5 library** — zero C dependencies, NetCDF-4 support, SIMD/GPU acceleration. See the **[Crate Map](#crate-map)** and **[BENCHMARKS.md](BENCHMARKS.md)** for the libhdf5 head-to-head numbers. > - **A general-purpose, pure-Rust HDF5 library** — zero C dependencies, NetCDF-4 support, SIMD/GPU acceleration. See the **[Crate Map](#crate-map)** and **[BENCHMARKS.md](BENCHMARKS.md)** for the libhdf5 head-to-head numbers.
@@ -71,7 +71,7 @@ breaking change, are in [CHANGELOG.md](CHANGELOG.md).
100K). It no longer rebuilds BM25 or rewrites the store per query, and the 100K). It no longer rebuilds BM25 or rewrites the store per query, and the
HNSW graph is persisted (v2.4.0). HNSW graph is persisted (v2.4.0).
- Default fusion weights are now the measured 0.4 / 0.6 (v2.5.0). Re-ranking had - Default fusion weights are now the measured 0.4 / 0.6 (v2.5.0). Re-ranking had
been discarding the retrieval score, costing the OpenClaw backend 40.6pp of been discarding the retrieval score, costing the Markdown backend 40.6pp of
Hit@1; fixed in v2.6.0. Hit@1; fixed in v2.6.0.
- Selection reads decode only the chunks they touch (a 64×64 window: 105 ms to - Selection reads decode only the chunks they touch (a 64×64 window: 105 ms to
0.39 ms), and full reads are 1.2–1.9× faster (v2.5.0). 0.39 ms), and full reads are 1.2–1.9× faster (v2.5.0).
@@ -93,7 +93,7 @@ breaking change, are in [CHANGELOG.md](CHANGELOG.md).
identical LongMemEval retrieval on real embeddings. identical LongMemEval retrieval on real embeddings.
- `HDF5Memory::search` with `SearchOptions`: filter by source channel (exact - `HDF5Memory::search` with `SearchOptions`: filter by source channel (exact
filtered top-k, never slower than unfiltered), and opt-in re-ranking and filtered top-k, never slower than unfiltered), and opt-in re-ranking and
confidence rejection, which used to be OpenClaw-only. confidence rejection, which used to be reachable only through `ClawhdfBackend`.
**Tooling** **Tooling**
- CI now runs the h5py/netCDF4 interop suites for real (they had been skipping - CI now runs the h5py/netCDF4 interop suites for real (they had been skipping
@@ -113,7 +113,7 @@ Every AI agent needs memory. Today that means scattered Markdown files, SQLite d
| Memory consolidation | Manual pruning | Hippocampal-inspired automatic tiers | | Memory consolidation | Manual pruning | Hippocampal-inspired automatic tiers |
| Temporal queries | Custom code | Native temporal index (622 ns range query over 10K) | | Temporal queries | Custom code | Native temporal index (622 ns range query over 10K) |
| Multi-modal | Multiple stores | Unified cross-modal search (exact scan: 842 µs over 1K records) | | Multi-modal | Multiple stores | Unified cross-modal search (exact scan: 842 µs over 1K records) |
| Integrity | Hope for the best | Chained-CRC WAL, checksummed chunk indexes, write-anomaly alerts, opt-in SHA-256 dataset provenance | | Integrity | Hope for the best | Ed25519-signed checkpoints that pinpoint any edited record, chained-CRC WAL, checksummed chunk indexes, write-anomaly alerts |
| Portability | Config + DB + files | **One `.h5` file. Copy it anywhere.** | | Portability | Config + DB + files | **One `.h5` file. Copy it anywhere.** |
--- ---
@@ -228,7 +228,7 @@ is for. The weights matter more than the stages: a sweep of `vector_weight` from
0.0 to 1.0 found the old `0.7/0.3` default is **strictly dominated** by 0.0 to 1.0 found the old `0.7/0.3` default is **strictly dominated** by
`0.4/0.6` — better on Hit@1, Hit@5, Hit@10 and MRR at both granularities. Since `0.4/0.6` — better on Hit@1, Hit@5, Hit@10 and MRR at both granularities. Since
v2.5.0 `0.4/0.6` is the default (`hybrid::DEFAULT_FUSION`, used by v2.5.0 `0.4/0.6` is the default (`hybrid::DEFAULT_FUSION`, used by
`unified_search`, `hybrid_search_with` and the OpenClaw backend); callers that `unified_search`, `hybrid_search_with` and `ClawhdfBackend`); callers that
pass weights to `hybrid_search` explicitly choose their own. Use `0.3/0.7` if pass weights to `hybrid_search` explicitly choose their own. Use `0.3/0.7` if
rank-1 precision matters most. Reciprocal rank fusion is selectable rank-1 precision matters most. Reciprocal rank fusion is selectable
(`hybrid::Fusion::Rrf`) but measured worse than the weighted sum. See (`hybrid::Fusion::Rrf`) but measured worse than the weighted sum. See
@@ -329,7 +329,7 @@ ClawhDF5's agent memory engine draws on 15+ recent papers on agentic memory syst
│ × √(Hebbian activation) │ │ × √(Hebbian activation) │
└─────────────────┬──────────────────┘ └─────────────────┬──────────────────┘
│ opt-in (SearchOptions); │ opt-in (SearchOptions);
│ the OpenClaw backend turns both on │ ClawhdfBackend turns both on
┌─────────────────▼──────────────────┐ ┌─────────────────▼──────────────────┐
│ Multi-factor re-ranking │ │ Multi-factor re-ranking │
│ relevance · recency · authority · │ │ relevance · recency · authority · │
@@ -365,13 +365,14 @@ directly; the store persists the records, sessions and graph they work over.
| **`knowledge`** | Entity/relation graph with BFS traversal, spreading activation, fuzzy (Levenshtein) entity resolution | | **`knowledge`** | Entity/relation graph with BFS traversal, spreading activation, fuzzy (Levenshtein) entity resolution |
| **`consolidation`** | Three-tier memory (Working → Episodic → Semantic) with importance scoring, novelty, and time-decay | | **`consolidation`** | Three-tier memory (Working → Episodic → Semantic) with importance scoring, novelty, and time-decay |
| **`hybrid`** | Vector + BM25 fusion. Default is a min-max-normalised weighted sum, vector 0.4 / keyword 0.6 (`hybrid::DEFAULT_FUSION`, tuned on LongMemEval); RRF is available via `Fusion::Rrf` / `hybrid_search_with`. The vector stage uses the HNSW index by default (`hnsw` feature); disable with `--no-default-features --features float16` for an exact linear scan | | **`hybrid`** | Vector + BM25 fusion. Default is a min-max-normalised weighted sum, vector 0.4 / keyword 0.6 (`hybrid::DEFAULT_FUSION`, tuned on LongMemEval); RRF is available via `Fusion::Rrf` / `hybrid_search_with`. The vector stage uses the HNSW index by default (`hnsw` feature); disable with `--no-default-features --features float16` for an exact linear scan |
| **`reranker`** | Multi-factor re-ranking: retrieval relevance (leads, weight 1.0), temporal recency, source authority, activation weight. Opt-in via `SearchOptions::with_rerank`; on in the OpenClaw backend | | **`reranker`** | Multi-factor re-ranking: retrieval relevance (leads, weight 1.0), temporal recency, source authority, activation weight. Opt-in via `SearchOptions::with_rerank`; on in `ClawhdfBackend` |
| **`confidence`** | Low-confidence rejection — suppresses spurious recalls when nothing matches. Opt-in via `SearchOptions::with_confidence`; on in the OpenClaw backend | | **`confidence`** | Low-confidence rejection — suppresses spurious recalls when nothing matches. Opt-in via `SearchOptions::with_confidence`; on in `ClawhdfBackend` |
| **`temporal`** | Sorted timestamp index, session DAG, entity timeline, temporal query hints | | **`temporal`** | Sorted timestamp index, session DAG, entity timeline, temporal query hints |
| **`multimodal`** | Cross-modal search across text/image/audio/video embeddings | | **`multimodal`** | Cross-modal search across text/image/audio/video embeddings |
| **`signing`** | Ed25519-signed checkpoints: SHA-256 per record in a Merkle tree, plus hashes of settings, sessions and the knowledge graph; `HDF5Memory::verify` names any edited record |
| **`provenance`** | Source attribution and an unkeyed FNV-1a content hash per record, held in memory for the session, for detecting accidental corruption (not tamper-proof) | | **`provenance`** | Source attribution and an unkeyed FNV-1a content hash per record, held in memory for the session, for detecting accidental corruption (not tamper-proof) |
| **`anomaly`** | Write rate limiting, 15 injection-pattern detectors, source-distribution analysis. Alerts never block a save; drain them with `take_anomaly_alerts` | | **`anomaly`** | Write rate limiting, 15 injection-pattern detectors, source-distribution analysis. Alerts never block a save; drain them with `take_anomaly_alerts` |
| **`openclaw`** | OpenClaw integration: MemoryBackend trait, Markdown ↔ HDF5 conversion | | **`openclaw`** | `ClawhdfBackend`: a Markdown-oriented backend (ingest by section, search, read back by path, export). Named for OpenClaw, but **not an OpenClaw plugin** — see [docs/openclaw.md](docs/openclaw.md) |
| **`vector_search`** | Flat cosine, pre-normed, SIMD, BLAS, GPU, parallel search paths | | **`vector_search`** | Flat cosine, pre-normed, SIMD, BLAS, GPU, parallel search paths |
| **`ivf` / `pq`** | Standalone IVF and IVF-PQ indexes (benchmarked to 100K vectors); not used by `HDF5Memory`, whose ANN index is HNSW | | **`ivf` / `pq`** | Standalone IVF and IVF-PQ indexes (benchmarked to 100K vectors); not used by `HDF5Memory`, whose ANN index is HNSW |
| **`bm25`** | Incremental Okapi BM25 inverted index, kept for the life of the store; optional stemming | | **`bm25`** | Incremental Okapi BM25 inverted index, kept for the life of the store; optional stemming |
@@ -447,7 +448,7 @@ let work = memory.search(
); );
// Re-rank by relevance, recency, source authority and activation, then drop // Re-rank by relevance, recency, source authority and activation, then drop
// low-confidence results — the pipeline the OpenClaw backend runs. // low-confidence results — the pipeline ClawhdfBackend runs.
let careful = memory.search( let careful = memory.search(
&query_embedding, &query_embedding,
"user preferences", "user preferences",
@@ -457,6 +458,35 @@ let careful = memory.search(
); );
``` ```
### Signed Checkpoints
```rust
use clawhdf5_agent::signing;
// Once, somewhere safe: keep the secret key, publish the public key.
let key = signing::generate_key();
let public = key.verifying_key();
// Every checkpoint is signed from now on. The key is never written to disk;
// a signed store refuses to checkpoint without it.
memory.set_signing_key(key);
memory.flush_wal()?;
// Anyone holding the public key can check the file, e.g. after copying it.
let report = HDF5Memory::verify(std::path::Path::new("agent.h5"), &public)?;
assert!(report.is_valid());
// On a tampered file: report.changed_records lists the records that differ.
```
The signature covers every record (text, embedding as stored, channel,
timestamp, session, tags, deleted flag, activation), the store's settings,
its sessions and its knowledge graph — a change made with any tool is caught.
It covers checkpoints, not saves still in the WAL
(`report.wal_entries_unsigned` counts those). CLI: `clawhdf5-cli keygen`,
`--signing-key <file>` on writing commands, and `verify --public-key`.
Signing adds about 20% to a checkpoint and 32 bytes per record to the file
([BENCHMARKS.md § Signed checkpoints](BENCHMARKS.md#signed-checkpoints)).
### Knowledge Graph ### Knowledge Graph
```rust ```rust
@@ -527,7 +557,13 @@ let ids = index.range_query(1700000000.0, 1700010800.0);
let recent = index.latest(10); let recent = index.latest(10);
``` ```
### OpenClaw Integration ### Markdown Backend
`ClawhdfBackend` ingests Markdown by section and searches it with the full
pipeline. It is a library API — clawhdf5 is **not** an OpenClaw memory plugin
([docs/openclaw.md](docs/openclaw.md)). Sections stored this way carry no
embedding, so their search is keyword-only unless you save records with
vectors through `save_entry`.
```rust ```rust
use clawhdf5_agent::openclaw::*; use clawhdf5_agent::openclaw::*;
@@ -551,14 +587,14 @@ let exported = backend.export_markdown("MEMORY.md")?;
## Crate Map ## 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 and benches; plus libaec-sys, an internal FFI bindings
crate for the optional szip feature) crate for the optional szip feature)
│ │
├── Core HDF5 ├── Core HDF5
│ ├── clawhdf5-format — Binary parser/writer (no_std-capable), shared type definitions │ ├── 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-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-derive — Proc macros
│ ├── clawhdf5 — High-level API │ ├── clawhdf5 — High-level API
│ ├── clawhdf5-netcdf4 — NetCDF-4 support │ ├── clawhdf5-netcdf4 — NetCDF-4 support
@@ -574,7 +610,8 @@ clawhdf5 workspace (16 crates, ~86K lines of Rust in src/, ~104K with tests
│ │
├── Bindings ├── Bindings
│ ├── clawhdf5-py — Python (PyO3) │ ├── 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 └── Tooling
└── clawhdf5-bench — Benchmark suite └── clawhdf5-bench — Benchmark suite
@@ -660,10 +697,26 @@ stores keep their setting. Opt out with `float16 = false` or
| `fast-checksum` | no | crc32fast-accelerated checksums | | `fast-checksum` | no | crc32fast-accelerated checksums |
| `lz4` | no | LZ4 block compression filter (id 32004) | | `lz4` | no | LZ4 block compression filter (id 32004) |
| `zstd` | no | Zstandard compression filter (id 32015) | | `zstd` | no | Zstandard compression filter (id 32015) |
| `pcodec` | no | Pcodec lossless numerical codec (id 32023, via `pco` crate) | | `pcodec` | no | Pcodec lossless numerical codec (via `pco` crate). Private, unregistered filter id 480: **only clawhdf5 can read these datasets** (h5py/libhdf5 cannot). Files from clawhdf5 <= 2.7.0 used id 32023, which is registered to Granular BitRound; they still read. |
| `system-zlib` | no | System zlib backend for deflate (C) | | `system-zlib` | no | System zlib backend for deflate (C) |
| `blake3_hash` | no | BLAKE3 content hashing for provenance | | `blake3_hash` | no | BLAKE3 content hashing for provenance |
| `szip` | no | SZIP filter (id 4) via libaec (C, through the internal `libaec-sys` crate) | | `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` ### `clawhdf5-ann`
@@ -782,7 +835,9 @@ clawhdf5-migrate --sqlite old.db --hdf5 memory.h5 --agent-id my-agent --embedder
The output is an ordinary `clawhdf5-agent` store, written through the agent's The output is an ordinary `clawhdf5-agent` store, written through the agent's
own API: open it with `HDF5Memory::open` (or `clawhdf5-cli --path memory.h5 …`) own API: open it with `HDF5Memory::open` (or `clawhdf5-cli --path memory.h5 …`)
and search it straight away. What carries over from the ZeroClaw tables: and search it straight away. The source must use the `memory_chunks` / `sessions` / `entities` / `relations` layout (names are
configurable with `--*-table`); note that this is not ZeroClaw's schema, and
ZeroClaw does not use clawhdf5. What carries over:
| SQLite | Agent store | | SQLite | Agent store |
|--------|-------------| |--------|-------------|
@@ -821,10 +876,10 @@ See [ROADMAP.md](ROADMAP.md) for the full implementation tracker.
- ✅ Temporal reasoning with sub-µs queries - ✅ Temporal reasoning with sub-µs queries
- ✅ Memory security + anomaly detection - ✅ Memory security + anomaly detection
- ✅ Multi-modal memory (text/image/audio/video) - ✅ Multi-modal memory (text/image/audio/video)
- ✅ OpenClaw integration layer - ✅ Markdown ingest/export backend (`ClawhdfBackend`); an OpenClaw plugin was never built — see [docs/openclaw.md](docs/openclaw.md)
- ✅ Comprehensive Criterion benchmarks - ✅ Comprehensive Criterion benchmarks
**Phase 2** — MemoryArena and LongMemEval academic benchmarks are done (see [BENCHMARKS.md](BENCHMARKS.md), reproduced on a second machine); remaining: publish the OpenClaw TypeScript bridge to npm, crates.io/PyPI publishing. **Phase 2** — MemoryArena and LongMemEval academic benchmarks are done (see [BENCHMARKS.md](BENCHMARKS.md), reproduced on a second machine); remaining: crates.io/PyPI publishing. The Node bindings are unpublished and known to be broken ([known issues](docs/known-issues.md)).
--- ---
+14 -8
View File
@@ -105,24 +105,30 @@
--- ---
## Track 7: OpenClaw Integration ## Track 7: OpenClaw Integration — withdrawn (2026-09-25)
**Status:** 🟢 Complete **Status:** ⚪ Withdrawn (the items below were library work; no OpenClaw integration shipped)
**Priority:** Critical (for adoption) **Priority:** Critical (for adoption)
**Crates:** `clawhdf5-agent`, `clawhdf5-napi` **Crates:** `clawhdf5-agent`, `clawhdf5-napi`
- [x] **7.1** Memory backend trait — MemoryBackend with search/get/write/ingest/export/stats - [x] **7.1** Memory backend trait — MemoryBackend with search/get/write/ingest/export/stats
- [x] **7.2** Hybrid retrieval pipeline — ClawhdfBackend wires RRF → reranker → confidence rejection - [x] **7.2** Hybrid retrieval pipeline — ClawhdfBackend wires RRF → reranker → confidence rejection
- [x] **7.3** Markdown import/export — MarkdownParser + MarkdownExporter with line tracking + metadata - [x] **7.3** Markdown import/export — MarkdownParser + MarkdownExporter with line tracking + metadata
- [x] **7.4** memory_search tool — backed by full hybrid retrieval pipeline - [x] **7.4** `search()` — backed by the full hybrid retrieval pipeline (a Rust method; no OpenClaw tool was ever registered)
- [x] **7.5** memory_get tool — get() with path + line range support - [x] **7.5** `get()` — read back by path, with a line slice (not an OpenClaw tool either)
- [x] **7.6** Compaction integration — run_compaction() (decay + compact + WAL flush), run_consolidation() (hippocampal engine), tick_session(), flush_wal() - [x] **7.6** Compaction integration — run_compaction() (decay + compact + WAL flush), run_consolidation() (hippocampal engine), tick_session(), flush_wal()
- [x] **7.7** Config surface — `memory.backend = "clawhdf5"` schema documented in docs/openclaw-config.md - [ ] **7.7** ~~Config surface — `memory.backend = "clawhdf5"`~~ — never valid OpenClaw config; docs removed
- [x] **7.8** Documentation + migration guide — docs/migration-guide.md, docs/openclaw-integration.md (architecture, full API reference, code patterns) - [ ] **7.8** ~~Documentation + migration guide~~ — removed: they described an integration that never worked
**Node.js bridge:** `clawhdf5-napi` (napi-rs) → `@redclaw/clawhdf5` npm package with full TypeScript types. **Node.js bridge:** `clawhdf5-napi` (napi-rs) and a TypeScript wrapper in `packages/clawhdf5-node` exist but are unpublished, untested in CI and known to be broken (docs/known-issues.md).
--- ---
> **Withdrawn.** None of this track produced a working OpenClaw integration: no
> plugin was built, the documented `memory.backend = "clawhdf5"` config was never
> valid in any OpenClaw release, and the Node package was never published. The
> Rust `ClawhdfBackend` remains as a library API. Not pursued for now; see
> [docs/openclaw.md](docs/openclaw.md) for what a plugin would need today.
## Track 8: Benchmarking & Validation ## Track 8: Benchmarking & Validation
**Status:** 🟢 Complete **Status:** 🟢 Complete
**Priority:** High **Priority:** High
@@ -142,7 +148,7 @@
**Phase 1:** ~~Tracks 1, 2, 3 — core memory intelligence~~ 🟢 Complete **Phase 1:** ~~Tracks 1, 2, 3 — core memory intelligence~~ 🟢 Complete
**Phase 2:** ~~Track 4 (temporal) + Track 5 (security)~~ 🟢 Complete **Phase 2:** ~~Track 4 (temporal) + Track 5 (security)~~ 🟢 Complete
**Phase 3:** ~~Track 6 (multi-modal) + Track 7 (OpenClaw integration)~~ 🟢 Complete **Phase 3:** ~~Track 6 (multi-modal)~~ 🟢 Complete; Track 7 (OpenClaw integration) withdrawn
**Phase 4:** ~~Track 8 (benchmarking + validation)~~ 🟢 Complete **Phase 4:** ~~Track 8 (benchmarking + validation)~~ 🟢 Complete
All 8 tracks delivered. 1,650+ tests passing, zero clippy warnings. All 8 tracks delivered. 1,650+ tests passing, zero clippy warnings.
+3
View File
@@ -0,0 +1,3 @@
/.cache/
# pin the probe's dependencies (the workspace lock is not committed)
!/probe/Cargo.lock
+39
View File
@@ -0,0 +1,39 @@
# Conformance sweep
Reads every HDF5 file of eight public corpora with clawhdf5 and with
h5py/libhdf5, compares the two readings object by object, and writes
[`CONFORMANCE.md`](../CONFORMANCE.md).
```sh
CLAWHDF5_PYTHON=/path/to/venv/bin/python conformance/run.sh # ~30 s once the corpus is cached
conformance/run.sh --update-baseline # after an intended change in results
```
Needs Rust, `git`, `h5dump` (Debian/Ubuntu `hdf5-tools`), `libaec` (for the
probe's `szip` feature; `libaec-dev`), and a Python with the packages in
`requirements.txt`. The first run downloads about 450 MB of sparse checkouts.
| file | role |
|---|---|
| `corpus.txt` | the corpora: git URL, pinned commit, swept root, sparse-checkout patterns |
| `fetch-corpus.sh` | shallow, sparse, blob-filtered checkout of each pinned commit into `.cache/src/` (gitignored); no-op when already there |
| `list_files.py` | which files are probed (HDF5/netCDF-4 extensions minus netCDF classic, plus the CVE reproducers) |
| `probe/` | the clawhdf5 side: a standalone crate (outside the workspace, so `cargo test --workspace` never builds it) that walks a file with `clawhdf5-format` and prints canonical JSON |
| `ref.py` | the h5py side: the same JSON from h5py |
| `run_one.sh` | runs both sides on one file (and `h5dump` on the CVE corpus) under a timeout and an address-space limit |
| `compare.py` | classifies each file (ok / our-error / mismatch / h5py-cannot-read / panic / hang / crash / oom) and groups root causes |
| `report.py` | writes `CONFORMANCE.md` |
| `check.py` | the gate: fails on any panic/hang/crash/oom, on an ok count below `baseline.json`, or on a baseline-ok file that is no longer ok |
| `baseline.json` | the ok files the gate holds the line on |
| `requirements.txt` | pinned h5py / numpy / hdf5plugin / netCDF4 |
Results for every file (both sides' JSON and stderr, `results.csv`,
`results.json`, `summary.md`) are left in `.cache/results/`.
The nightly job is `.gitea/workflows/conformance.yml`; it prints the report
into the job log.
The canonical value encoding both sides hash is documented at the top of
`probe/src/main.rs`. Values are compared as libhdf5 presents them: a float
with a non-IEEE bit layout (N-Bit) or an integer with a bit offset is compared
as the converted number, not as raw file bytes.
+624
View File
@@ -0,0 +1,624 @@
{
"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": "72306c601399748616bc9d061be2ebc4c1bea9e0",
"date": "2026-09-26 06:50 UTC",
"reference": "h5py 3.16.0 / HDF5 2.0.0",
"files": 697,
"ok": 575,
"counts": {
"h5py-cannot-read": 92,
"mismatch": 20,
"ok": 575,
"our-error": 10
},
"per_corpus": {
"NCAS-CMS_pyfive": {
"mismatch": 1,
"ok": 32
},
"cve_hdf5": {
"h5py-cannot-read": 32,
"mismatch": 9,
"ok": 100,
"our-error": 6
},
"h5py_data": {
"ok": 4
},
"hdf5": {
"h5py-cannot-read": 60,
"mismatch": 10,
"ok": 392,
"our-error": 4
},
"netcdf-c": {
"ok": 20
},
"netcdf4-python": {
"ok": 18
},
"usnistgov_h5wasm": {
"ok": 5
},
"xarray-data": {
"ok": 4
}
},
"ok_files": [
"NCAS-CMS_pyfive/tests/compact.hdf5",
"NCAS-CMS_pyfive/tests/data/btreev2.hdf5",
"NCAS-CMS_pyfive/tests/data/chunked.hdf5",
"NCAS-CMS_pyfive/tests/data/cmip_bad_eg.nc",
"NCAS-CMS_pyfive/tests/data/compressed.hdf5",
"NCAS-CMS_pyfive/tests/data/compressed_v1.hdf5",
"NCAS-CMS_pyfive/tests/data/dataset_datatypes.hdf5",
"NCAS-CMS_pyfive/tests/data/dataset_multidim.hdf5",
"NCAS-CMS_pyfive/tests/data/dim_scales.hdf5",
"NCAS-CMS_pyfive/tests/data/earliest.hdf5",
"NCAS-CMS_pyfive/tests/data/enum_h5variable.hdf5",
"NCAS-CMS_pyfive/tests/data/enum_variable.hdf5",
"NCAS-CMS_pyfive/tests/data/enum_variable.nc",
"NCAS-CMS_pyfive/tests/data/enums_from_netcdf.nc",
"NCAS-CMS_pyfive/tests/data/fillvalue_earliest.hdf5",
"NCAS-CMS_pyfive/tests/data/fillvalue_latest.hdf5",
"NCAS-CMS_pyfive/tests/data/filter_pipeline_v2.hdf5",
"NCAS-CMS_pyfive/tests/data/fletcher32.hdf5",
"NCAS-CMS_pyfive/tests/data/fractal_heap_no_mci_rlat.nc",
"NCAS-CMS_pyfive/tests/data/groups.hdf5",
"NCAS-CMS_pyfive/tests/data/h5netcdf_test.hdf5",
"NCAS-CMS_pyfive/tests/data/issue23_A.nc",
"NCAS-CMS_pyfive/tests/data/issue23_A_contiguous.nc",
"NCAS-CMS_pyfive/tests/data/issue23_B.nc",
"NCAS-CMS_pyfive/tests/data/latest.hdf5",
"NCAS-CMS_pyfive/tests/data/netcdf4_classic.nc",
"NCAS-CMS_pyfive/tests/data/new_style_groups.hdf5",
"NCAS-CMS_pyfive/tests/data/noy_AERmonZ_UKESM1-0-LL_piControl_r1i1p1f2_gnz_200001-200012.nc",
"NCAS-CMS_pyfive/tests/data/references.hdf5",
"NCAS-CMS_pyfive/tests/data/resizable.hdf5",
"NCAS-CMS_pyfive/tests/opaque_datetime.hdf5",
"NCAS-CMS_pyfive/tests/opaque_fixed.hdf5",
"cve_hdf5/cvefiles/cve-2016-4330.h5",
"cve_hdf5/cvefiles/cve-2016-4331.h5",
"cve_hdf5/cvefiles/cve-2016-4332-mtime-new.h5",
"cve_hdf5/cvefiles/cve-2016-4332-mtime.h5",
"cve_hdf5/cvefiles/cve-2016-4333.h5",
"cve_hdf5/cvefiles/cve-2017-17505.h5",
"cve_hdf5/cvefiles/cve-2017-17506.h5",
"cve_hdf5/cvefiles/cve-2017-17507.h5",
"cve_hdf5/cvefiles/cve-2017-17508.h5",
"cve_hdf5/cvefiles/cve-2017-17509.h5",
"cve_hdf5/cvefiles/cve-2018-11202.h5",
"cve_hdf5/cvefiles/cve-2018-11203.h5",
"cve_hdf5/cvefiles/cve-2018-11204.h5",
"cve_hdf5/cvefiles/cve-2018-11205.h5",
"cve_hdf5/cvefiles/cve-2018-11206-new.h5",
"cve_hdf5/cvefiles/cve-2018-11206-old.h5",
"cve_hdf5/cvefiles/cve-2018-11207.h5",
"cve_hdf5/cvefiles/cve-2018-13867.h5",
"cve_hdf5/cvefiles/cve-2018-13868.h5",
"cve_hdf5/cvefiles/cve-2018-13869.h5",
"cve_hdf5/cvefiles/cve-2018-13870.h5",
"cve_hdf5/cvefiles/cve-2018-13871.h5",
"cve_hdf5/cvefiles/cve-2018-13872.h5",
"cve_hdf5/cvefiles/cve-2018-13873.h5",
"cve_hdf5/cvefiles/cve-2018-13875.h5",
"cve_hdf5/cvefiles/cve-2018-14031.h5",
"cve_hdf5/cvefiles/cve-2018-14033.h5",
"cve_hdf5/cvefiles/cve-2018-14034.h5",
"cve_hdf5/cvefiles/cve-2018-14035.h5",
"cve_hdf5/cvefiles/cve-2018-14460.h5",
"cve_hdf5/cvefiles/cve-2018-15671.h5",
"cve_hdf5/cvefiles/cve-2018-15672.h5",
"cve_hdf5/cvefiles/cve-2018-16438.h5",
"cve_hdf5/cvefiles/cve-2018-17233.h5",
"cve_hdf5/cvefiles/cve-2018-17234.h5",
"cve_hdf5/cvefiles/cve-2018-17237.h5",
"cve_hdf5/cvefiles/cve-2018-17432.h5",
"cve_hdf5/cvefiles/cve-2018-17434.h5",
"cve_hdf5/cvefiles/cve-2018-17435.h5",
"cve_hdf5/cvefiles/cve-2018-17437.h5",
"cve_hdf5/cvefiles/cve-2019-8396.h5",
"cve_hdf5/cvefiles/cve-2019-9152.h5",
"cve_hdf5/cvefiles/cve-2020-10811.h5",
"cve_hdf5/cvefiles/cve-2020-18232.h5",
"cve_hdf5/cvefiles/cve-2021-36977.h5",
"cve_hdf5/cvefiles/cve-2021-37501.h5",
"cve_hdf5/cvefiles/cve-2021-45829.h5",
"cve_hdf5/cvefiles/cve-2021-45833.h5",
"cve_hdf5/cvefiles/cve-2024-29157.h5",
"cve_hdf5/cvefiles/cve-2024-29158.h5",
"cve_hdf5/cvefiles/cve-2024-29159.h5",
"cve_hdf5/cvefiles/cve-2024-29160.h5",
"cve_hdf5/cvefiles/cve-2024-29161.h5",
"cve_hdf5/cvefiles/cve-2024-29162.h5",
"cve_hdf5/cvefiles/cve-2024-29163.h5",
"cve_hdf5/cvefiles/cve-2024-29164.h5",
"cve_hdf5/cvefiles/cve-2024-29165.h5",
"cve_hdf5/cvefiles/cve-2024-29166.h5",
"cve_hdf5/cvefiles/cve-2024-32605.h5",
"cve_hdf5/cvefiles/cve-2024-32606.h5",
"cve_hdf5/cvefiles/cve-2024-32607-1.h5",
"cve_hdf5/cvefiles/cve-2024-32607-2.h5",
"cve_hdf5/cvefiles/cve-2024-32608.h5",
"cve_hdf5/cvefiles/cve-2024-32610.h5",
"cve_hdf5/cvefiles/cve-2024-32611.h5",
"cve_hdf5/cvefiles/cve-2024-32612.h5",
"cve_hdf5/cvefiles/cve-2024-32613.h5",
"cve_hdf5/cvefiles/cve-2024-32614.h5",
"cve_hdf5/cvefiles/cve-2024-32615.h5",
"cve_hdf5/cvefiles/cve-2024-32616.h5",
"cve_hdf5/cvefiles/cve-2024-32617.h5",
"cve_hdf5/cvefiles/cve-2024-32619.h5",
"cve_hdf5/cvefiles/cve-2024-32620.h5",
"cve_hdf5/cvefiles/cve-2024-32621.h5",
"cve_hdf5/cvefiles/cve-2024-32622.h5",
"cve_hdf5/cvefiles/cve-2024-32624.h5",
"cve_hdf5/cvefiles/cve-2024-33873.h5",
"cve_hdf5/cvefiles/cve-2024-33875.h5",
"cve_hdf5/cvefiles/cve-2024-33876.h5",
"cve_hdf5/cvefiles/cve-2024-33877.h5",
"cve_hdf5/cvefiles/cve-2025-2310.h5",
"cve_hdf5/cvefiles/cve-2025-2924.h5",
"cve_hdf5/cvefiles/cve-2025-2925.h5",
"cve_hdf5/cvefiles/cve-2025-6269-1.h5",
"cve_hdf5/cvefiles/cve-2025-6269-2.h5",
"cve_hdf5/cvefiles/cve-2025-6269-3.h5",
"cve_hdf5/cvefiles/cve-2025-6269-4.h5",
"cve_hdf5/cvefiles/cve-2025-6516.h5",
"cve_hdf5/cvefiles/cve-2025-6857.h5",
"cve_hdf5/cvefiles/cve-2025-7067.h5",
"cve_hdf5/cvefiles/cve-2026-26200.h5",
"cve_hdf5/cvefiles/cve-2026-34734.h5",
"cve_hdf5/cvefiles/cve-2026-92627.h5",
"cve_hdf5/cvefiles/unknown-1.h5",
"cve_hdf5/fuzzerfiles/gh-4431-poc-03.h5",
"cve_hdf5/fuzzerfiles/gh-4432-poc-05.h5",
"cve_hdf5/fuzzerfiles/gh-4433-poc-08.h5",
"cve_hdf5/fuzzerfiles/gh-4435-poc-10.h5",
"cve_hdf5/fuzzerfiles/gh_2649_flawed.h5",
"cve_hdf5/fuzzerfiles/gh_2649_plain_model.h5",
"h5py_data/compound-dtype-complex.h5",
"h5py_data/vlen_string_dset.h5",
"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",
"hdf5/HDF5Examples/C/H5G/h5ex_g_iterate.h5",
"hdf5/HDF5Examples/C/H5G/h5ex_g_traverse.h5",
"hdf5/HDF5Examples/C/H5G/h5ex_g_visit.h5",
"hdf5/HDF5Examples/FORTRAN/H5G/h5ex_g_iterate.h5",
"hdf5/HDF5Examples/FORTRAN/H5G/h5ex_g_traverse.h5",
"hdf5/HDF5Examples/FORTRAN/H5G/h5ex_g_visit.h5",
"hdf5/HDF5Examples/JAVA/H5G/h5ex_g_iterate.h5",
"hdf5/HDF5Examples/JAVA/H5G/h5ex_g_visit.h5",
"hdf5/HDF5Examples/JAVA/compat/H5G/110/h5ex_g_iterate.h5",
"hdf5/HDF5Examples/JAVA/compat/H5G/110/h5ex_g_visit.h5",
"hdf5/HDF5Examples/JAVA/compat/H5G/h5ex_g_iterate.h5",
"hdf5/HDF5Examples/JAVA/compat/H5G/h5ex_g_visit.h5",
"hdf5/c++/test/th5s.h5",
"hdf5/hl/test/testfiles/test_ds_be.h5",
"hdf5/hl/test/testfiles/test_ds_be_new_ref-32bit.h5",
"hdf5/hl/test/testfiles/test_ds_be_new_ref.h5",
"hdf5/hl/test/testfiles/test_ds_le.h5",
"hdf5/hl/test/testfiles/test_ds_le_new_ref.h5",
"hdf5/hl/test/testfiles/test_ld.h5",
"hdf5/hl/test/testfiles/test_table_be.h5",
"hdf5/hl/test/testfiles/test_table_cray.h5",
"hdf5/hl/test/testfiles/test_table_le.h5",
"hdf5/test/testfiles/aggr.h5",
"hdf5/test/testfiles/bad_chunk_ndims.h5",
"hdf5/test/testfiles/bad_compound.h5",
"hdf5/test/testfiles/bad_offset.h5",
"hdf5/test/testfiles/be_data.h5",
"hdf5/test/testfiles/be_extlink1.h5",
"hdf5/test/testfiles/be_extlink2.h5",
"hdf5/test/testfiles/btree_idx_1_6.h5",
"hdf5/test/testfiles/btree_idx_1_8.h5",
"hdf5/test/testfiles/charsets.h5",
"hdf5/test/testfiles/corrupt_stab_msg.h5",
"hdf5/test/testfiles/deflate.h5",
"hdf5/test/testfiles/file_image_core_test.h5",
"hdf5/test/testfiles/filespace_1_6.h5",
"hdf5/test/testfiles/filespace_1_8.h5",
"hdf5/test/testfiles/fill18.h5",
"hdf5/test/testfiles/fill_old.h5",
"hdf5/test/testfiles/filter_error.h5",
"hdf5/test/testfiles/fsm_aggr_nopersist.h5",
"hdf5/test/testfiles/fsm_aggr_persist.h5",
"hdf5/test/testfiles/group_old.h5",
"hdf5/test/testfiles/h5fc_ext1_f.h5",
"hdf5/test/testfiles/h5fc_ext1_i.h5",
"hdf5/test/testfiles/h5fc_ext2_if.h5",
"hdf5/test/testfiles/h5fc_ext2_sf.h5",
"hdf5/test/testfiles/h5fc_ext3_isf.h5",
"hdf5/test/testfiles/h5fc_ext_none.h5",
"hdf5/test/testfiles/le_data.h5",
"hdf5/test/testfiles/le_extlink1.h5",
"hdf5/test/testfiles/le_extlink2.h5",
"hdf5/test/testfiles/memleak_H5O_dtype_decode_helper_H5Odtype.h5",
"hdf5/test/testfiles/mergemsg.h5",
"hdf5/test/testfiles/noencoder.h5",
"hdf5/test/testfiles/none.h5",
"hdf5/test/testfiles/paged_nopersist.h5",
"hdf5/test/testfiles/paged_persist.h5",
"hdf5/test/testfiles/specmetaread.h5",
"hdf5/test/testfiles/tarrold.h5",
"hdf5/test/testfiles/tbad_msg_count.h5",
"hdf5/test/testfiles/tbogus.h5",
"hdf5/test/testfiles/test_filters_be.h5",
"hdf5/test/testfiles/test_filters_le.h5",
"hdf5/test/testfiles/th5s.h5",
"hdf5/test/testfiles/tlayouto.h5",
"hdf5/test/testfiles/tmisc38a.h5",
"hdf5/test/testfiles/tmisc38b.h5",
"hdf5/test/testfiles/tmtimen.h5",
"hdf5/test/testfiles/tmtimeo.h5",
"hdf5/test/testfiles/tnullspace.h5",
"hdf5/test/testfiles/tsizeslheap.h5",
"hdf5/tools/test/testfiles/bigendian/tdset2.h5",
"hdf5/tools/test/testfiles/binfp64.h5",
"hdf5/tools/test/testfiles/binin16.h5",
"hdf5/tools/test/testfiles/binin32.h5",
"hdf5/tools/test/testfiles/binin8.h5",
"hdf5/tools/test/testfiles/binin8w.h5",
"hdf5/tools/test/testfiles/binuin16.h5",
"hdf5/tools/test/testfiles/binuin32.h5",
"hdf5/tools/test/testfiles/bounds_latest_latest.h5",
"hdf5/tools/test/testfiles/charsets.h5",
"hdf5/tools/test/testfiles/compounds_array_vlen1.h5",
"hdf5/tools/test/testfiles/compounds_array_vlen2.h5",
"hdf5/tools/test/testfiles/err_attr_dspace.h5",
"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",
"hdf5/tools/test/testfiles/h5clear_sec2_v0.h5",
"hdf5/tools/test/testfiles/h5clear_sec2_v2.h5",
"hdf5/tools/test/testfiles/h5copy_extlinks_src.h5",
"hdf5/tools/test/testfiles/h5copy_extlinks_trg.h5",
"hdf5/tools/test/testfiles/h5copy_ref.h5",
"hdf5/tools/test/testfiles/h5copytst.h5",
"hdf5/tools/test/testfiles/h5copytst_new.h5",
"hdf5/tools/test/testfiles/h5diff_attr1.h5",
"hdf5/tools/test/testfiles/h5diff_attr2.h5",
"hdf5/tools/test/testfiles/h5diff_attr3.h5",
"hdf5/tools/test/testfiles/h5diff_attr_v_level1.h5",
"hdf5/tools/test/testfiles/h5diff_attr_v_level2.h5",
"hdf5/tools/test/testfiles/h5diff_basic1.h5",
"hdf5/tools/test/testfiles/h5diff_basic2.h5",
"hdf5/tools/test/testfiles/h5diff_comp_vl_strs.h5",
"hdf5/tools/test/testfiles/h5diff_danglelinks1.h5",
"hdf5/tools/test/testfiles/h5diff_danglelinks2.h5",
"hdf5/tools/test/testfiles/h5diff_dset1.h5",
"hdf5/tools/test/testfiles/h5diff_dset2.h5",
"hdf5/tools/test/testfiles/h5diff_dset3.h5",
"hdf5/tools/test/testfiles/h5diff_dset_zero_dim_size1.h5",
"hdf5/tools/test/testfiles/h5diff_dset_zero_dim_size2.h5",
"hdf5/tools/test/testfiles/h5diff_dtypes.h5",
"hdf5/tools/test/testfiles/h5diff_empty.h5",
"hdf5/tools/test/testfiles/h5diff_enum_invalid_values.h5",
"hdf5/tools/test/testfiles/h5diff_eps1.h5",
"hdf5/tools/test/testfiles/h5diff_eps2.h5",
"hdf5/tools/test/testfiles/h5diff_exclude1-1.h5",
"hdf5/tools/test/testfiles/h5diff_exclude1-2.h5",
"hdf5/tools/test/testfiles/h5diff_exclude2-1.h5",
"hdf5/tools/test/testfiles/h5diff_exclude2-2.h5",
"hdf5/tools/test/testfiles/h5diff_exclude3-1.h5",
"hdf5/tools/test/testfiles/h5diff_exclude3-2.h5",
"hdf5/tools/test/testfiles/h5diff_ext2softlink_src.h5",
"hdf5/tools/test/testfiles/h5diff_ext2softlink_trg.h5",
"hdf5/tools/test/testfiles/h5diff_extlink_src.h5",
"hdf5/tools/test/testfiles/h5diff_extlink_trg.h5",
"hdf5/tools/test/testfiles/h5diff_grp_recurse1.h5",
"hdf5/tools/test/testfiles/h5diff_grp_recurse2.h5",
"hdf5/tools/test/testfiles/h5diff_grp_recurse_ext1.h5",
"hdf5/tools/test/testfiles/h5diff_grp_recurse_ext2-1.h5",
"hdf5/tools/test/testfiles/h5diff_grp_recurse_ext2-2.h5",
"hdf5/tools/test/testfiles/h5diff_grp_recurse_ext2-3.h5",
"hdf5/tools/test/testfiles/h5diff_hyper1.h5",
"hdf5/tools/test/testfiles/h5diff_hyper2.h5",
"hdf5/tools/test/testfiles/h5diff_linked_softlink.h5",
"hdf5/tools/test/testfiles/h5diff_links.h5",
"hdf5/tools/test/testfiles/h5diff_onion_dset_1d.h5",
"hdf5/tools/test/testfiles/h5diff_onion_dset_ext.h5",
"hdf5/tools/test/testfiles/h5diff_onion_objs.h5",
"hdf5/tools/test/testfiles/h5diff_softlinks.h5",
"hdf5/tools/test/testfiles/h5diff_strings1.h5",
"hdf5/tools/test/testfiles/h5diff_strings2.h5",
"hdf5/tools/test/testfiles/h5fc_edge_v3.h5",
"hdf5/tools/test/testfiles/h5fc_err_level.h5",
"hdf5/tools/test/testfiles/h5fc_ext1_f.h5",
"hdf5/tools/test/testfiles/h5fc_ext1_i.h5",
"hdf5/tools/test/testfiles/h5fc_ext1_s.h5",
"hdf5/tools/test/testfiles/h5fc_ext2_if.h5",
"hdf5/tools/test/testfiles/h5fc_ext2_is.h5",
"hdf5/tools/test/testfiles/h5fc_ext2_sf.h5",
"hdf5/tools/test/testfiles/h5fc_ext3_isf.h5",
"hdf5/tools/test/testfiles/h5fc_ext_none.h5",
"hdf5/tools/test/testfiles/h5fc_non_v3.h5",
"hdf5/tools/test/testfiles/h5repack_CVE-2018-14460.h5",
"hdf5/tools/test/testfiles/h5repack_CVE-2018-17432.h5",
"hdf5/tools/test/testfiles/h5repack_aggr.h5",
"hdf5/tools/test/testfiles/h5repack_attr.h5",
"hdf5/tools/test/testfiles/h5repack_attr_refs.h5",
"hdf5/tools/test/testfiles/h5repack_deflate.h5",
"hdf5/tools/test/testfiles/h5repack_early.h5",
"hdf5/tools/test/testfiles/h5repack_ext.h5",
"hdf5/tools/test/testfiles/h5repack_f32le.h5",
"hdf5/tools/test/testfiles/h5repack_f32le_ex.h5",
"hdf5/tools/test/testfiles/h5repack_fill.h5",
"hdf5/tools/test/testfiles/h5repack_filters.h5",
"hdf5/tools/test/testfiles/h5repack_fletcher.h5",
"hdf5/tools/test/testfiles/h5repack_fsm_aggr_nopersist.h5",
"hdf5/tools/test/testfiles/h5repack_fsm_aggr_persist.h5",
"hdf5/tools/test/testfiles/h5repack_hlink.h5",
"hdf5/tools/test/testfiles/h5repack_int32le_1d.h5",
"hdf5/tools/test/testfiles/h5repack_int32le_1d_ex.h5",
"hdf5/tools/test/testfiles/h5repack_int32le_2d.h5",
"hdf5/tools/test/testfiles/h5repack_int32le_2d_ex.h5",
"hdf5/tools/test/testfiles/h5repack_int32le_3d.h5",
"hdf5/tools/test/testfiles/h5repack_int32le_3d_ex.h5",
"hdf5/tools/test/testfiles/h5repack_layout.UD.h5",
"hdf5/tools/test/testfiles/h5repack_layout.h5",
"hdf5/tools/test/testfiles/h5repack_layout2.h5",
"hdf5/tools/test/testfiles/h5repack_layout3.h5",
"hdf5/tools/test/testfiles/h5repack_layouto.h5",
"hdf5/tools/test/testfiles/h5repack_named_dtypes.h5",
"hdf5/tools/test/testfiles/h5repack_nbit.h5",
"hdf5/tools/test/testfiles/h5repack_nested_8bit_enum.h5",
"hdf5/tools/test/testfiles/h5repack_nested_8bit_enum_deflated.h5",
"hdf5/tools/test/testfiles/h5repack_none.h5",
"hdf5/tools/test/testfiles/h5repack_objs.h5",
"hdf5/tools/test/testfiles/h5repack_paged_nopersist.h5",
"hdf5/tools/test/testfiles/h5repack_paged_persist.h5",
"hdf5/tools/test/testfiles/h5repack_refs.h5",
"hdf5/tools/test/testfiles/h5repack_shuffle.h5",
"hdf5/tools/test/testfiles/h5repack_soffset.h5",
"hdf5/tools/test/testfiles/h5repack_szip.h5",
"hdf5/tools/test/testfiles/h5repack_uint8be.h5",
"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",
"hdf5/tools/test/testfiles/h5stat_threshold.h5",
"hdf5/tools/test/testfiles/h5stat_tsohm.h5",
"hdf5/tools/test/testfiles/mod_h5clear_mdc_image.h5",
"hdf5/tools/test/testfiles/non_comparables1.h5",
"hdf5/tools/test/testfiles/non_comparables2.h5",
"hdf5/tools/test/testfiles/old_h5fc_ext1_f.h5",
"hdf5/tools/test/testfiles/old_h5fc_ext1_i.h5",
"hdf5/tools/test/testfiles/old_h5fc_ext1_s.h5",
"hdf5/tools/test/testfiles/old_h5fc_ext2_if.h5",
"hdf5/tools/test/testfiles/old_h5fc_ext2_is.h5",
"hdf5/tools/test/testfiles/old_h5fc_ext2_sf.h5",
"hdf5/tools/test/testfiles/old_h5fc_ext3_isf.h5",
"hdf5/tools/test/testfiles/old_h5fc_ext_none.h5",
"hdf5/tools/test/testfiles/packedbits.h5",
"hdf5/tools/test/testfiles/t128bit_float.h5",
"hdf5/tools/test/testfiles/tCVE-2021-37501_attr_decode.h5",
"hdf5/tools/test/testfiles/tCVE_2018_11206_fill_new.h5",
"hdf5/tools/test/testfiles/tCVE_2018_11206_fill_old.h5",
"hdf5/tools/test/testfiles/taindices.h5",
"hdf5/tools/test/testfiles/tarray1.h5",
"hdf5/tools/test/testfiles/tarray1_big.h5",
"hdf5/tools/test/testfiles/tarray2.h5",
"hdf5/tools/test/testfiles/tarray4.h5",
"hdf5/tools/test/testfiles/tarray5.h5",
"hdf5/tools/test/testfiles/tarray8.h5",
"hdf5/tools/test/testfiles/tattr.h5",
"hdf5/tools/test/testfiles/tattr2.h5",
"hdf5/tools/test/testfiles/tattr4_be.h5",
"hdf5/tools/test/testfiles/tattrintsize.h5",
"hdf5/tools/test/testfiles/tattrreg.h5",
"hdf5/tools/test/testfiles/tbfloat16.h5",
"hdf5/tools/test/testfiles/tbfloat16_be.h5",
"hdf5/tools/test/testfiles/tbigdims.h5",
"hdf5/tools/test/testfiles/tbinary.h5",
"hdf5/tools/test/testfiles/tbitnopaque.h5",
"hdf5/tools/test/testfiles/tchar.h5",
"hdf5/tools/test/testfiles/tcmpdattrintsize.h5",
"hdf5/tools/test/testfiles/tcmpdintarray.h5",
"hdf5/tools/test/testfiles/tcmpdints.h5",
"hdf5/tools/test/testfiles/tcmpdintsize.h5",
"hdf5/tools/test/testfiles/tcomplex.h5",
"hdf5/tools/test/testfiles/tcompound.h5",
"hdf5/tools/test/testfiles/tcompound_complex.h5",
"hdf5/tools/test/testfiles/tcompound_complex2.h5",
"hdf5/tools/test/testfiles/tdatareg.h5",
"hdf5/tools/test/testfiles/tdset.h5",
"hdf5/tools/test/testfiles/tdset2.h5",
"hdf5/tools/test/testfiles/tdset_idx.h5",
"hdf5/tools/test/testfiles/tempty.h5",
"hdf5/tools/test/testfiles/textlink.h5",
"hdf5/tools/test/testfiles/textlinkfar.h5",
"hdf5/tools/test/testfiles/textlinksrc.h5",
"hdf5/tools/test/testfiles/textlinktar.h5",
"hdf5/tools/test/testfiles/textpfe.h5",
"hdf5/tools/test/testfiles/tfcontents2.h5",
"hdf5/tools/test/testfiles/tfilters.h5",
"hdf5/tools/test/testfiles/tfloat16.h5",
"hdf5/tools/test/testfiles/tfloat16_be.h5",
"hdf5/tools/test/testfiles/tfloat4.h5",
"hdf5/tools/test/testfiles/tfloat6.h5",
"hdf5/tools/test/testfiles/tfloat8.h5",
"hdf5/tools/test/testfiles/tfloatsattrs.h5",
"hdf5/tools/test/testfiles/tfpformat.h5",
"hdf5/tools/test/testfiles/tfvalues.h5",
"hdf5/tools/test/testfiles/tgroup.h5",
"hdf5/tools/test/testfiles/tgrp_comments.h5",
"hdf5/tools/test/testfiles/tgrpnullspace.h5",
"hdf5/tools/test/testfiles/thlink.h5",
"hdf5/tools/test/testfiles/thyperslab.h5",
"hdf5/tools/test/testfiles/tintascii.h5",
"hdf5/tools/test/testfiles/tints4dims.h5",
"hdf5/tools/test/testfiles/tintsattrs.h5",
"hdf5/tools/test/testfiles/tintsnodata.h5",
"hdf5/tools/test/testfiles/tlarge_objname.h5",
"hdf5/tools/test/testfiles/tldouble.h5",
"hdf5/tools/test/testfiles/tldouble_scalar.h5",
"hdf5/tools/test/testfiles/tlonglinks.h5",
"hdf5/tools/test/testfiles/tloop.h5",
"hdf5/tools/test/testfiles/tnamed_dtype_attr.h5",
"hdf5/tools/test/testfiles/tnestedcmpddt.h5",
"hdf5/tools/test/testfiles/tnestedcomp.h5",
"hdf5/tools/test/testfiles/tno-subset.h5",
"hdf5/tools/test/testfiles/tnullspace.h5",
"hdf5/tools/test/testfiles/torderattr.h5",
"hdf5/tools/test/testfiles/tordergr.h5",
"hdf5/tools/test/testfiles/trefer_attr.h5",
"hdf5/tools/test/testfiles/trefer_compat.h5",
"hdf5/tools/test/testfiles/trefer_ext1.h5",
"hdf5/tools/test/testfiles/trefer_ext2.h5",
"hdf5/tools/test/testfiles/trefer_grp.h5",
"hdf5/tools/test/testfiles/trefer_obj.h5",
"hdf5/tools/test/testfiles/trefer_obj_del.h5",
"hdf5/tools/test/testfiles/trefer_param.h5",
"hdf5/tools/test/testfiles/trefer_reg.h5",
"hdf5/tools/test/testfiles/trefer_reg_1d.h5",
"hdf5/tools/test/testfiles/tsaf.h5",
"hdf5/tools/test/testfiles/tscalarattrintsize.h5",
"hdf5/tools/test/testfiles/tscalarintattrsize.h5",
"hdf5/tools/test/testfiles/tscalarintsize.h5",
"hdf5/tools/test/testfiles/tscalarstring.h5",
"hdf5/tools/test/testfiles/tslink.h5",
"hdf5/tools/test/testfiles/tsoftlinks.h5",
"hdf5/tools/test/testfiles/tst_onion_dset_1d.h5",
"hdf5/tools/test/testfiles/tst_onion_dset_ext.h5",
"hdf5/tools/test/testfiles/tst_onion_objs.h5",
"hdf5/tools/test/testfiles/tstr.h5",
"hdf5/tools/test/testfiles/tstr2.h5",
"hdf5/tools/test/testfiles/tstr3.h5",
"hdf5/tools/test/testfiles/tudfilter.h5",
"hdf5/tools/test/testfiles/tudfilter2.h5",
"hdf5/tools/test/testfiles/tvldtypes1.h5",
"hdf5/tools/test/testfiles/tvldtypes2.h5",
"hdf5/tools/test/testfiles/tvldtypes3.h5",
"hdf5/tools/test/testfiles/tvldtypes4.h5",
"hdf5/tools/test/testfiles/tvldtypes5.h5",
"hdf5/tools/test/testfiles/tvlenstr_array.h5",
"hdf5/tools/test/testfiles/tvlstr.h5",
"hdf5/tools/test/testfiles/tvms.h5",
"hdf5/tools/test/testfiles/txtfp32.h5",
"hdf5/tools/test/testfiles/txtfp64.h5",
"hdf5/tools/test/testfiles/txtin16.h5",
"hdf5/tools/test/testfiles/txtin32.h5",
"hdf5/tools/test/testfiles/txtin8.h5",
"hdf5/tools/test/testfiles/txtstr.h5",
"hdf5/tools/test/testfiles/txtuin16.h5",
"hdf5/tools/test/testfiles/txtuin32.h5",
"hdf5/tools/test/testfiles/vds/1_a.h5",
"hdf5/tools/test/testfiles/vds/1_b.h5",
"hdf5/tools/test/testfiles/vds/1_c.h5",
"hdf5/tools/test/testfiles/vds/1_d.h5",
"hdf5/tools/test/testfiles/vds/1_e.h5",
"hdf5/tools/test/testfiles/vds/1_f.h5",
"hdf5/tools/test/testfiles/vds/1_vds.h5",
"hdf5/tools/test/testfiles/vds/2_a.h5",
"hdf5/tools/test/testfiles/vds/2_b.h5",
"hdf5/tools/test/testfiles/vds/2_c.h5",
"hdf5/tools/test/testfiles/vds/2_d.h5",
"hdf5/tools/test/testfiles/vds/2_e.h5",
"hdf5/tools/test/testfiles/vds/2_vds.h5",
"hdf5/tools/test/testfiles/vds/3_1_vds.h5",
"hdf5/tools/test/testfiles/vds/3_2_vds.h5",
"hdf5/tools/test/testfiles/vds/4_0.h5",
"hdf5/tools/test/testfiles/vds/4_1.h5",
"hdf5/tools/test/testfiles/vds/4_2.h5",
"hdf5/tools/test/testfiles/vds/4_vds.h5",
"hdf5/tools/test/testfiles/vds/5_a.h5",
"hdf5/tools/test/testfiles/vds/5_b.h5",
"hdf5/tools/test/testfiles/vds/5_c.h5",
"hdf5/tools/test/testfiles/vds/5_vds.h5",
"hdf5/tools/test/testfiles/vds/a.h5",
"hdf5/tools/test/testfiles/vds/b.h5",
"hdf5/tools/test/testfiles/vds/c.h5",
"hdf5/tools/test/testfiles/vds/d.h5",
"hdf5/tools/test/testfiles/vds/f-0.h5",
"hdf5/tools/test/testfiles/vds/f-3.h5",
"hdf5/tools/test/testfiles/vds/vds-eiger.h5",
"hdf5/tools/test/testfiles/vds/vds-percival-unlim-maxmin.h5",
"hdf5/tools/test/testfiles/xml/tbitfields.h5",
"hdf5/tools/test/testfiles/xml/tcompound2.h5",
"hdf5/tools/test/testfiles/xml/tdset2.h5",
"hdf5/tools/test/testfiles/xml/tenum.h5",
"hdf5/tools/test/testfiles/xml/test35.nc",
"hdf5/tools/test/testfiles/xml/tloop2.h5",
"hdf5/tools/test/testfiles/xml/tname-amp.h5",
"hdf5/tools/test/testfiles/xml/tname-apos.h5",
"hdf5/tools/test/testfiles/xml/tname-gt.h5",
"hdf5/tools/test/testfiles/xml/tname-lt.h5",
"hdf5/tools/test/testfiles/xml/tname-quot.h5",
"hdf5/tools/test/testfiles/xml/tname-sp.h5",
"hdf5/tools/test/testfiles/xml/tnodata.h5",
"hdf5/tools/test/testfiles/xml/tobjref.h5",
"hdf5/tools/test/testfiles/xml/topaque.h5",
"hdf5/tools/test/testfiles/xml/tref-escapes-at.h5",
"hdf5/tools/test/testfiles/xml/tref-escapes.h5",
"hdf5/tools/test/testfiles/xml/tref.h5",
"hdf5/tools/test/testfiles/xml/tstring-at.h5",
"hdf5/tools/test/testfiles/xml/tstring.h5",
"hdf5/tools/test/testfiles/zerodim.h5",
"netcdf-c/h5_test/ref_tst_h_compounds.h5",
"netcdf-c/h5_test/ref_tst_h_compounds2.h5",
"netcdf-c/nc_test4/ref_hdf5_compat1.nc",
"netcdf-c/nc_test4/ref_hdf5_compat2.nc",
"netcdf-c/nc_test4/ref_hdf5_compat3.nc",
"netcdf-c/nc_test4/ref_szip.h5",
"netcdf-c/nc_test4/ref_tst_compounds.nc",
"netcdf-c/nc_test4/ref_tst_dims.nc",
"netcdf-c/nc_test4/ref_tst_interops4.nc",
"netcdf-c/nc_test4/ref_tst_xplatform2_1.nc",
"netcdf-c/nc_test4/ref_tst_xplatform2_2.nc",
"netcdf-c/nc_test4/tdset.h5",
"netcdf-c/ncdump/ref_nc_test_netcdf4_4_0.nc",
"netcdf-c/ncdump/ref_no_ncproperty.nc",
"netcdf-c/ncdump/ref_provenance_v1.nc",
"netcdf-c/ncdump/ref_test_corrupt_magic.nc",
"netcdf-c/ncdump/ref_tst_compounds2.nc",
"netcdf-c/ncdump/ref_tst_compounds3.nc",
"netcdf-c/ncdump/ref_tst_compounds4.nc",
"netcdf-c/ncdump/ref_tst_irish_rover.nc",
"netcdf4-python/examples/data/prmsl.2000.nc",
"netcdf4-python/examples/data/prmsl.2001.nc",
"netcdf4-python/examples/data/prmsl.2002.nc",
"netcdf4-python/examples/data/prmsl.2003.nc",
"netcdf4-python/examples/data/prmsl.2004.nc",
"netcdf4-python/examples/data/prmsl.2005.nc",
"netcdf4-python/examples/data/prmsl.2006.nc",
"netcdf4-python/examples/data/prmsl.2007.nc",
"netcdf4-python/examples/data/prmsl.2008.nc",
"netcdf4-python/examples/data/prmsl.2009.nc",
"netcdf4-python/examples/data/prmsl.2010.nc",
"netcdf4-python/examples/data/prmsl.2011.nc",
"netcdf4-python/examples/data/rtofs_glo_3dz_f006_6hrly_reg3.nc",
"netcdf4-python/test/20171025_2056.Cloud_Top_Height.nc",
"netcdf4-python/test/issue1152.nc",
"netcdf4-python/test/issue671.nc",
"netcdf4-python/test/issue672.nc",
"netcdf4-python/test/test_gold.nc",
"usnistgov_h5wasm/test/array.h5",
"usnistgov_h5wasm/test/compressed.h5",
"usnistgov_h5wasm/test/empty.h5",
"usnistgov_h5wasm/test/float16.h5",
"usnistgov_h5wasm/test/vlen.h5",
"xarray-data/ROMS_example.nc",
"xarray-data/basin_mask.nc",
"xarray-data/imerghh_730.hdf5",
"xarray-data/precipitation.nc4"
]
}
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""check.py <results_dir> <baseline.json> [--update]
The conformance gate. Fails (exit 1) when
* clawhdf5 panicked, hung, crashed or ran out of memory on any file, or
* the ok count fell below the baseline's, or
* a file the baseline lists as ok is no longer ok (even if another file
became ok and the total held).
New ok files are reported so the baseline can be raised (--update rewrites it
from the results).
"""
import json
import os
import sys
FATAL = ("panic", "hang", "crash", "oom")
def main():
args = [a for a in sys.argv[1:] if not a.startswith("--")]
update = "--update" in sys.argv
res_dir, base_path = args
res = json.load(open(os.path.join(res_dir, "results.json")))
rows = res["rows"]
counts = {}
per_corpus = {}
for r in rows:
counts[r["class"]] = counts.get(r["class"], 0) + 1
pc = per_corpus.setdefault(r["corpus"], {})
pc[r["class"]] = pc.get(r["class"], 0) + 1
ok_files = sorted(r["file"] for r in rows if r["class"] == "ok")
if update:
meta = {}
mp = os.path.join(res_dir, "report-meta.json")
if os.path.exists(mp):
meta = json.load(open(mp))
base = {
"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": meta.get("commit", ""),
"date": meta.get("date", ""),
"reference": meta.get("reference", ""),
"files": len(rows),
"ok": len(ok_files),
"counts": dict(sorted(counts.items())),
"per_corpus": {k: dict(sorted(v.items())) for k, v in sorted(per_corpus.items())},
"ok_files": ok_files,
}
with open(base_path, "w") as fh:
json.dump(base, fh, indent=1)
fh.write("\n")
print(f"baseline updated: {len(ok_files)} ok of {len(rows)} files -> {base_path}")
return 0
base = json.load(open(base_path))
failures = []
fatal = [r for r in rows if r["class"] in FATAL]
for r in fatal:
failures.append(f"{r['class']}: {r['file']}: {r['ours_detail'][:200]}")
if len(ok_files) < base["ok"]:
failures.append(f"ok count dropped: {len(ok_files)} < baseline {base['ok']}")
now_ok = set(ok_files)
by_file = {r["file"]: r for r in rows}
for f in base["ok_files"]:
if f not in now_ok:
r = by_file.get(f)
why = f"now {r['class']}: {(r['ours_detail'] or r['first_issue'])[:200]}" if r else "no longer in the corpus"
failures.append(f"regressed: {f}: {why}")
gained = sorted(now_ok - set(base["ok_files"]))
print(f"conformance: {len(ok_files)} ok of {len(rows)} files (baseline {base['ok']} of {base['files']}); "
+ ", ".join(f"{k} {v}" for k, v in sorted(counts.items())))
if gained:
print(f"{len(gained)} file(s) newly ok — raise the baseline with `conformance/run.sh --update-baseline`:")
for f in gained:
print(f" + {f}")
if failures:
print(f"CONFORMANCE GATE FAILED ({len(failures)}):")
for f in failures:
print(f" - {f}")
return 1
print("conformance gate passed")
return 0
if __name__ == "__main__":
sys.exit(main())
+289
View File
@@ -0,0 +1,289 @@
#!/usr/bin/env python3
"""compare.py <results_dir>: classify each file and group failures by root cause.
Writes <results_dir>/results.csv, results.json and summary.md.
File classes (first match wins):
hang, oom, crash, panic ours: timeout / allocation failure / signal / any panic (caught or not)
h5py-cannot-read libhdf5/h5py failed to open the file (or crashed/hung)
our-error we fail to open, list, or read something h5py reads
mismatch we read something with different shape/values, or a different object set
ok
"""
import collections
import csv
import json
import os
import re
import sys
R = sys.argv[1]
RUNS = os.path.join(R, "runs")
def load(d, name):
rc_p = os.path.join(d, name + ".rc")
if not os.path.exists(rc_p):
return None
rc = int(open(rc_p).read().strip() or -1)
err = open(os.path.join(d, name + ".err"), errors="replace").read()
js = None
try:
js = json.load(open(os.path.join(d, name + ".json")))
except Exception: # noqa: BLE001
pass
return {"rc": rc, "err": err, "json": js}
def proc_status(p):
"""-> (status, detail)"""
if p is None:
return "missing", ""
rc, err = p["rc"], p["err"]
first_panic = next((ln for ln in err.splitlines() if ln.startswith("PANIC:") or "panicked at" in ln), "")
if rc == 0 and p["json"] is not None:
return "ok", ""
if rc == 137 or rc == 124:
return "hang", f"timeout ({os.environ.get('TMO', '20')} s)"
if "memory allocation of" in err or "MemoryError" in err or "std::bad_alloc" in err:
m = re.search(r"memory allocation of \d+ bytes failed", err)
return "oom", m.group(0) if m else "allocation failure"
if "overflowed its stack" in err:
return "crash", "stack overflow"
if rc == 101:
return "panic", first_panic or (err.strip().splitlines() or [""])[-1]
if rc in (134, 139, 136, 135, 132) or rc > 128:
sig = {134: "SIGABRT", 139: "SIGSEGV", 136: "SIGFPE", 135: "SIGBUS", 132: "SIGILL"}.get(rc, f"signal {rc - 128}")
tail = [ln for ln in err.strip().splitlines() if ln.strip()][-1:]
return "crash", f"{sig}: {tail[0][:200] if tail else ''}"
tail = [ln for ln in err.strip().splitlines() if ln.strip()][-1:]
return "crash", f"rc={rc}: {tail[0][:200] if tail else ''}"
def norm(msg):
m = msg.split("\n")[0]
m = re.sub(r"0x[0-9a-fA-F]+", "X", m)
m = re.sub(r'"[^"]*"', '"…"', m)
m = re.sub(r"'[^']*'", "'…'", m)
m = re.sub(r"\d+", "N", m)
return m[:160]
def panic_head(msg):
"""First line + first clawhdf5 frame of a PANIC record."""
lines = msg.split("\n")
frame = next((ln.strip() for ln in lines[1:] if "clawhdf5_format" in ln), "")
return lines[0][:300], frame[:300]
def eq_shape(a, b):
return a == b
rows = []
issues_by_file = {}
root_causes = collections.defaultdict(lambda: {"files": set(), "count": 0, "examples": []})
mismatch_causes = collections.defaultdict(lambda: {"files": set(), "count": 0, "examples": []})
panics = []
ref_only_errors = collections.Counter()
incomparable = collections.Counter()
def add(bucket, key, file, example):
b = bucket[key]
b["count"] += 1
if file not in b["files"] and len(b["examples"]) < 6:
b["examples"].append(example)
b["files"].add(file)
files = [ln.strip() for ln in open(os.path.join(R, "files.txt")) if ln.strip()]
for rel in files:
d = os.path.join(RUNS, rel.replace("/", "__"))
corpus = rel.split("/")[0]
ours, ref = load(d, "ours"), load(d, "ref")
h5dump = load(d, "h5dump")
os_, od = proc_status(ours)
rs, rd = proc_status(ref)
oj = ours["json"] if ours else None
rj = ref["json"] if ref else None
issues = [] # (kind, detail)
caught_panics = []
def scan_err(path, what, msg):
if msg.startswith("PANIC:"):
caught_panics.append((path, what, msg))
if oj:
for o in oj.get("objects", []):
for k in ("error", "attrs_error", "list_error"):
if k in o:
scan_err(o["path"], k, o[k])
for an, av in (o.get("attrs") or {}).items():
if "error" in av:
scan_err(o["path"], f"attr {an}", av["error"])
if oj.get("open_error", "").startswith("PANIC:"):
caught_panics.append(("<open>", "open", oj["open_error"]))
ref_open_fail = rs != "ok" or (rj is not None and "open_error" in rj)
ours_open_err = oj.get("open_error") if oj else None
n_obj = n_ok = 0
if os_ == "ok" and rj and not ref_open_fail and not ours_open_err:
ro = {x["path"]: x for x in rj.get("objects", [])}
oo = {x["path"]: x for x in oj.get("objects", [])}
our_list_errors = [x for x in oo.values() if "list_error" in x]
for p in sorted(set(ro) | set(oo)):
a, b = ro.get(p), oo.get(p)
n_obj += 1
if a is None:
issues.append(("mismatch", f"extra object {p} (kind={b.get('kind')})", "extra-object", b))
continue
if b is None:
if our_list_errors:
continue # accounted for by the list_error
issues.append(("mismatch", f"missing object {p} (kind={a.get('kind')})", "missing-object", a))
continue
ok = True
if a.get("kind") != b.get("kind") and "error" not in b and "error" not in a:
issues.append(("mismatch", f"{p}: kind {a.get('kind')} vs ours {b.get('kind')}", "kind", b))
ok = False
for k in ("error", "list_error", "attrs_error"):
if k in b and k not in a:
issues.append(("our-error", f"{p}: {k}: {b[k]}", b[k], b))
ok = False
elif k in a and k not in b and k == "error":
ref_only_errors[norm(a[k])] += 1
if a.get("kind") == "dataset" and "error" not in a and "error" not in b:
if "skipped" in a or "skipped" in b:
pass
elif a.get("converted"):
incomparable[f"dataset {a['converted']}"] += 1
elif a.get("shape") != b.get("shape"):
issues.append(("mismatch", f"{p}: shape {a.get('shape')} vs ours {b.get('shape')}", "shape", b))
ok = False
elif a.get("hash") != b.get("hash"):
issues.append(("mismatch", f"{p}: values differ (h5py {a.get('dtype')} vs ours {b.get('dtype')})", "values", b | {"ref_head": a.get("head"), "ref_dtype": a.get("dtype")}))
ok = False
ra, oa = a.get("attrs") or {}, b.get("attrs") or {}
if "attrs_error" not in b and "attrs_error" not in a:
for an in sorted(set(ra) | set(oa)):
x, y = ra.get(an), oa.get(an)
if x is None:
issues.append(("mismatch", f"{p}@{an}: extra attribute", "extra-attr", y or {}))
elif y is None:
issues.append(("mismatch", f"{p}@{an}: missing attribute", "missing-attr", x))
elif "error" in y and "error" not in x:
issues.append(("our-error", f"{p}@{an}: {y['error']}", y["error"], y))
elif "error" in x:
continue
elif x.get("converted"):
incomparable[f"attr {x['converted']}"] += 1
elif x.get("shape") != y.get("shape"):
issues.append(("mismatch", f"{p}@{an}: attr shape {x.get('shape')} vs ours {y.get('shape')}", "attr-shape", y | {"ref_dtype": x.get("dtype")}))
elif x.get("hash") != y.get("hash"):
issues.append(("mismatch", f"{p}@{an}: attr values differ (h5py {x.get('dtype')} vs ours {y.get('dtype')})", "attr-values", y | {"ref_head": x.get("head"), "ref_dtype": x.get("dtype")}))
if ok:
n_ok += 1
# classify
if os_ in ("hang", "oom", "crash", "panic"):
cls = os_
elif caught_panics:
cls = "panic"
elif ref_open_fail:
cls = "h5py-cannot-read"
elif ours_open_err:
cls = "our-error"
issues.append(("our-error", f"open: {ours_open_err}", ours_open_err, {}))
elif any(i[0] == "our-error" for i in issues):
cls = "our-error"
elif issues:
cls = "mismatch"
else:
cls = "ok"
if os_ in ("hang", "oom", "crash", "panic") or caught_panics:
panics.append({
"file": rel, "class": cls, "detail": od,
"stderr": (ours["err"] if ours else "")[:3000],
"caught": [(p, w, m[:2500]) for p, w, m in caught_panics[:3]],
"n_caught": len(caught_panics),
})
for kind, detail, key, rec in issues:
if kind == "our-error":
add(root_causes, norm(key), rel, detail[:300])
else:
if key in ("values", "attr-values", "shape", "attr-shape"):
mk = f"{key}: ours={rec.get('dtype')} h5py={rec.get('ref_dtype')} layout={rec.get('layout','-')} filters={rec.get('filters','-')}"
else:
mk = key
add(mismatch_causes, mk, rel, detail[:300] + (f" | ref_head={rec.get('ref_head')} our_head={rec.get('head')}" if rec.get("ref_head") else ""))
ref_detail = rd if rs != "ok" else ((rj or {}).get("open_error") or "")
h5d = ""
if h5dump:
rc = h5dump["rc"]
h5d = {0: "ok", 1: "error", 137: "hang", 124: "hang", 134: "SIGABRT", 139: "SIGSEGV", 136: "SIGFPE", 135: "SIGBUS"}.get(rc, f"rc={rc}")
if "memory allocation" in h5dump["err"] or "Cannot allocate" in h5dump["err"]:
h5d += "(oom)"
rows.append({
"file": rel, "corpus": corpus, "class": cls,
"ours": os_ if os_ != "ok" else ("open-error" if ours_open_err else ("panic" if caught_panics else "ok")),
"ours_detail": (od or ours_open_err or (caught_panics[0][2].split("\n")[0] if caught_panics else ""))[:300],
"ref": rs if rs != "ok" else ("open-error" if (rj or {}).get("open_error") else "ok"),
"ref_detail": ref_detail[:300],
"h5dump_1_14_6": h5d,
"h5dump_detail": ([ln for ln in h5dump["err"].splitlines() if ln.strip()][-1:] or [""])[0][:200] if h5dump else "",
"objects": n_obj, "objects_ok": n_ok,
"issues": len(issues), "first_issue": issues[0][1][:300] if issues else "",
"superblock": (oj or {}).get("superblock_version", ""),
})
# the first issues of each file, for report.py's known-cause matching
issues_by_file[rel] = [
{"kind": k, "key": key, "detail": det[:300], "ours_dtype": rec.get("dtype"), "ref_dtype": rec.get("ref_dtype")}
for k, det, key, rec in issues[:50]
]
with open(os.path.join(R, "results.csv"), "w", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=list(rows[0].keys()))
w.writeheader()
w.writerows(rows)
def ser(b):
return {k: {"files": len(v["files"]), "count": v["count"], "examples": v["examples"], "file_list": sorted(v["files"])} for k, v in sorted(b.items(), key=lambda kv: -len(kv[1]["files"]))}
json.dump({"rows": rows, "issues": issues_by_file, "root_causes": ser(root_causes), "mismatch_causes": ser(mismatch_causes),
"panics": panics, "incomparable": incomparable.most_common(), "ref_only_errors": ref_only_errors.most_common()},
open(os.path.join(R, "results.json"), "w"), indent=1)
classes = ["ok", "our-error", "mismatch", "h5py-cannot-read", "hang", "panic", "crash", "oom"]
by_corpus = collections.defaultdict(collections.Counter)
for r in rows:
by_corpus[r["corpus"]][r["class"]] += 1
by_corpus["ALL"][r["class"]] += 1
lines = ["# Conformance sweep summary", "", "| corpus | files | " + " | ".join(classes) + " |", "|---" * (len(classes) + 2) + "|"]
for c in sorted(by_corpus, key=lambda k: (k == "ALL", k)):
cnt = by_corpus[c]
lines.append(f"| {c} | {sum(cnt.values())} | " + " | ".join(str(cnt.get(k, 0)) for k in classes) + " |")
lines += ["", "## Panics / hangs / crashes / OOM", ""]
for p in panics:
lines.append(f"- **{p['file']}** [{p['class']}] {p['detail']}")
for path, what, m in p["caught"][:1]:
lines.append(" ```\n " + f"{path} ({what}): " + m.replace("\n", "\n ")[:1500] + "\n ```")
if not p["caught"] and p["stderr"]:
lines.append(" ```\n " + p["stderr"].strip()[:1500].replace("\n", "\n ") + "\n ```")
lines += ["", "## Our-error root causes (files affected)", ""]
for k, v in ser(root_causes).items():
lines.append(f"- [{v['files']} files, {v['count']} objs] `{k}`")
for ex in v["examples"][:3]:
lines.append(f" - {ex}")
lines += ["", "## Mismatch root causes", ""]
for k, v in ser(mismatch_causes).items():
lines.append(f"- [{v['files']} files, {v['count']} objs] `{k}`")
for ex in v["examples"][:3]:
lines.append(f" - {ex}")
lines += ["", "## Objects h5py fails on but we read (top)", ""]
for k, n in ref_only_errors.most_common(15):
lines.append(f"- {n} x `{k}`")
open(os.path.join(R, "summary.md"), "w").write("\n".join(lines) + "\n")
print("\n".join(lines[:4 + len(by_corpus)]))
+19
View File
@@ -0,0 +1,19 @@
# Conformance corpora, pinned by commit. fetch-corpus.sh reads this file.
#
# name git-url commit root [sparse-checkout patterns...]
#
# `root` is the directory inside the checkout that is swept ("." = all of it).
# Patterns are git non-cone sparse-checkout patterns; none = whole repository.
# Every file under <root> with an HDF5/netCDF-4 extension is probed; for
# cve_hdf5 the extension-less files in cvefiles/ and fuzzerfiles/ are too.
# Licences: each corpus keeps its upstream licence; nothing here is committed
# to this repository — the files are downloaded into the gitignored cache.
hdf5 https://github.com/HDFGroup/hdf5.git a3cf1ea82cc7a66e50029a688121e1b105a7ce88 . *.h5 *.he5 *.nc *.hdf5 *.h5f
cve_hdf5 https://github.com/HDFGroup/cve_hdf5.git 3fd1f5ae3869e01b8ae02b41d7108de7ffb1a374 .
netcdf-c https://github.com/Unidata/netcdf-c.git beb7b9585273c1548386231a59b809d906359033 . /nc_test4/*.nc /ncdump/*.nc /nc_test4/*.h5 /ncdump/*.h5 /h5_test/*.h5 /hdf5_test/*.h5
NCAS-CMS_pyfive https://github.com/NCAS-CMS/pyfive.git 8cf07b8749133f41c5e30b8a4c604486f687fe74 . *.h5 *.hdf5 *.hdf *.nc *.he5
usnistgov_h5wasm https://github.com/usnistgov/h5wasm.git 02f6336527d2812783fcedabfbf42127ec8d06d2 . *.h5 *.hdf5 *.hdf *.nc *.he5
netcdf4-python https://github.com/Unidata/netcdf4-python.git 6e67576d39aef8091fb20bd767b4f1a52ddc1bec . *.nc *.h5
xarray-data https://github.com/pydata/xarray-data.git a35297e9da2cc99c811014f0c8a4297345a5c28d . /basin_mask.nc /precipitation.nc4 /imerghh_730.hdf5 /eraint_uvz.nc /ROMS_example.nc /tiny.nc
# h5py 3.16.0 (tag 3.16.0), its test data files.
h5py_data https://github.com/h5py/h5py.git b2f0347c4200333acd89b43733f1caa0c115162f h5py/tests/data_files /h5py/tests/data_files/*
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# fetch-corpus.sh [cache_dir]
#
# Download the corpora pinned in conformance/corpus.txt into the (gitignored)
# cache: <cache>/src/<name> is a shallow, sparse, blob-filtered checkout of the
# pinned commit and <cache>/corpus/<name> links to the swept root inside it.
# A corpus already checked out at its pinned commit is left alone, so a second
# run costs nothing and needs no network.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
CACHE="${1:-${CONFORMANCE_CACHE:-$HERE/.cache}}"
mkdir -p "$CACHE/src" "$CACHE/corpus"
CACHE="$(cd "$CACHE" && pwd)"
retry() { local i; for i in 1 2 3 4; do "$@" && return 0; sleep $((i * 5)); done; return 1; }
grep -v '^[[:space:]]*\(#\|$\)' "$HERE/corpus.txt" | while read -r name url commit root patterns; do
src="$CACHE/src/$name"
if [ -d "$src/.git" ] && [ "$(git -C "$src" rev-parse HEAD 2>/dev/null)" = "$commit" ]; then
echo "cached $name @ ${commit:0:12}"
else
echo "fetching $name @ ${commit:0:12} from $url"
rm -rf "$src"
git init -q "$src"
git -C "$src" remote add origin "$url"
git -C "$src" config advice.detachedHead false
if [ -n "$patterns" ]; then
git -C "$src" config core.sparseCheckout true
# no-cone patterns (globs); `set -f` keeps the shell from expanding them
(set -f; printf '%s\n' $patterns) > "$src/.git/info/sparse-checkout"
fi
retry git -C "$src" fetch -q --depth 1 --filter=blob:none origin "$commit"
retry git -C "$src" checkout -q FETCH_HEAD
got="$(git -C "$src" rev-parse HEAD)"
[ "$got" = "$commit" ] || { echo "error: $name checked out $got, expected $commit" >&2; exit 1; }
fi
ln -sfn "$src/$root" "$CACHE/corpus/$name"
done
echo "corpus ready in $CACHE/corpus"
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""list_files.py <corpus_dir>: print the files the sweep probes, one per line,
as <corpus>/<path> in byte order.
* every file named *.h5 *.hdf5 *.he5 *.nc *.nc4 *.hdf *.h5f in each corpus,
except netCDF classic / 64-bit-offset / CDF5 files (magic "CDF"): they are
not HDF5, so neither side can read them and they say nothing;
* plus, for cve_hdf5, every file in cvefiles/ and fuzzerfiles/ except
.md/.c sources — the reproducers are mostly extension-less, and they are
kept whatever their bytes look like (that is their point).
"""
import os
import sys
EXTS = (".h5", ".hdf5", ".he5", ".nc", ".nc4", ".hdf", ".h5f")
def walk(top):
for dirpath, dirnames, filenames in os.walk(top):
dirnames[:] = [d for d in dirnames if d != ".git"]
for fn in filenames:
p = os.path.join(dirpath, fn)
if os.path.isfile(p) and not os.path.islink(p):
yield os.path.relpath(p, top)
def main(root):
out = set()
for corpus in sorted(os.listdir(root)):
top = os.path.join(root, corpus)
if not os.path.isdir(top):
continue
for rel in walk(top):
path = os.path.join(top, rel)
if rel.lower().endswith(EXTS):
with open(path, "rb") as fh:
if fh.read(3) == b"CDF":
continue
out.add(f"{corpus}/{rel}")
elif corpus == "cve_hdf5" and rel.split(os.sep)[0] in ("cvefiles", "fuzzerfiles") \
and not rel.endswith((".md", ".c")):
out.add(f"{corpus}/{rel}")
for f in sorted(out, key=lambda s: s.encode()):
print(f)
if __name__ == "__main__":
main(sys.argv[1])
+491
View File
@@ -0,0 +1,491 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "better_io"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef0a3155e943e341e557863e69a708999c94ede624e37865c8e2a91b94efa78f"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "byteorder"
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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "clawhdf5-format"
version = "2.7.0"
dependencies = [
"byteorder",
"bzip2",
"flate2",
"libaec-sys",
"lz4_flex",
"pco",
"portable-atomic",
"ruzstd",
"sha2",
"snap",
"zstd",
]
[[package]]
name = "conformance-probe"
version = "0.1.0"
dependencies = [
"clawhdf5-format",
"serde_json",
"sha2",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
dependencies = [
"cfg-if",
]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "dtype_dispatch"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab23e69df104e2fd85ee63a533a22d2132ef5975dc6b36f9f3e5a7305e4a8ed7"
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "flate2"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jobserver"
version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
dependencies = [
"getrandom",
"libc",
]
[[package]]
name = "libaec-sys"
version = "0.1.0"
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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "lz4_flex"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a"
dependencies = [
"twox-hash",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "pco"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "386342cad4c6e97f081568e5d910ea7d871314c843aa8fc564f2a6b64cab9456"
dependencies = [
"better_io",
"dtype_dispatch",
"half",
"rand_xoshiro",
]
[[package]]
name = "pkg-config"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "portable-atomic"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "rand_xoshiro"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa"
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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "simd-adler32"
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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "twox-hash"
version = "2.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a"
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "zerocopy"
version = "0.8.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "7.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.1.0+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0"
dependencies = [
"cc",
"pkg-config",
]
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "conformance-probe"
version = "0.1.0"
edition = "2024"
rust-version = "1.92"
publish = false
description = "Walks an HDF5 file with clawhdf5-format and prints a canonical JSON description (see conformance/README.md)"
# Deliberately outside the main workspace: `cargo test --workspace` never
# builds it, and it links the optional C codecs (zstd, libaec) that the core
# crates' default build must not.
[workspace]
[dependencies]
clawhdf5-format = { path = "../../crates/clawhdf5-format", features = ["lz4", "zstd", "szip", "pcodec", "plugin-filters"] }
serde_json = "1"
sha2 = "0.10"
[profile.release]
# Keep panics catchable (the probe records them per object) and turn integer
# overflow into a reported panic instead of silent wraparound.
debug = 1
overflow-checks = true
debug-assertions = true
panic = "unwind"
+924
View File
@@ -0,0 +1,924 @@
//! Conformance probe: walks an HDF5 file with clawhdf5-format (the same calls
//! the `clawhdf5` facade makes) and prints a canonical JSON description:
//! every hard-linked object (sorted-name DFS, deduplicated by header address),
//! and for each dataset / attribute its shape plus the SHA-256 of its values
//! in a canonical encoding shared with `ref.py`.
//!
//! Canonical value encoding (per element, concatenated, row-major):
//! int / float / bitfield / enum / time : element bytes, little-endian
//! non-IEEE-layout float (e.g. N-Bit) : the IEEE float of the same size it converts to
//! int with bit offset / short precision: the full-width integer it converts to
//! opaque : raw bytes
//! compound : members in declaration order (padding dropped)
//! array : base elements row-major
//! string (fixed or VL) : b'S' + u32le len + bytes (cut at first NUL, trailing spaces stripped)
//! VL sequence : b'V' + u32le count + base elements
//! reference : b'R' (payload not compared)
//!
//! Every object is processed inside catch_unwind; a caught panic is recorded
//! with its message, location and the clawhdf5 frames of its backtrace.
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::panic::{self, AssertUnwindSafe};
use std::rc::Rc;
use clawhdf5_format::attribute::extract_attributes_full;
use clawhdf5_format::data_layout::DataLayout;
use clawhdf5_format::data_read;
use clawhdf5_format::dataspace::{Dataspace, DataspaceType};
use clawhdf5_format::datatype::{Datatype, DatatypeByteOrder};
use clawhdf5_format::filter_pipeline::FilterPipeline;
use clawhdf5_format::global_heap::GlobalHeapCollection;
use clawhdf5_format::group_v1::{self, GroupEntry};
use clawhdf5_format::group_v2;
use clawhdf5_format::message_type::MessageType;
use clawhdf5_format::object_header::ObjectHeader;
use clawhdf5_format::signature;
use clawhdf5_format::superblock::Superblock;
use clawhdf5_format::symbol_table::SymbolTableMessage;
use serde_json::{Map, Value, json};
use sha2::{Digest, Sha256};
const MAX_BYTES: u64 = 200 * 1024 * 1024;
const MAX_OBJECTS: usize = 200_000;
thread_local! {
static LAST_PANIC: RefCell<Option<String>> = const { RefCell::new(None) };
}
fn install_hook() {
panic::set_hook(Box::new(|info| {
let msg = if let Some(s) = info.payload().downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = info.payload().downcast_ref::<String>() {
s.clone()
} else {
"<non-string panic>".into()
};
let loc = info
.location()
.map(|l| format!("{}:{}", l.file(), l.line()))
.unwrap_or_default();
let bt = std::backtrace::Backtrace::force_capture().to_string();
// keep only frames from clawhdf5 code
let mut frames = Vec::new();
let lines: Vec<&str> = bt.lines().collect();
for (i, l) in lines.iter().enumerate() {
let t = l.trim();
if t.contains("clawhdf5_format::") || t.contains("conformance_probe::") {
let at = lines
.get(i + 1)
.map(|n| n.trim())
.filter(|n| n.starts_with("at "))
.map(|n| {
let n = n.trim_start_matches("at ");
match n.find("/crates/") {
Some(p) => n[p + 1..].to_string(),
None => n.to_string(),
}
})
.unwrap_or_default();
let name = t.split_once(": ").map(|x| x.1).unwrap_or(t);
frames.push(format!("{name} ({at})"));
if frames.len() >= 12 {
break;
}
}
}
let full = format!("PANIC: {msg} @ {loc}\n {}", frames.join("\n "));
eprintln!("{full}");
LAST_PANIC.with(|p| *p.borrow_mut() = Some(full));
}));
}
/// Run `f`, turning a panic into Err("PANIC: ...").
fn guarded<T>(f: impl FnOnce() -> Result<T, String>) -> Result<T, String> {
match panic::catch_unwind(AssertUnwindSafe(f)) {
Ok(r) => r,
Err(_) => Err(LAST_PANIC
.with(|p| p.borrow_mut().take())
.unwrap_or_else(|| "PANIC: <unknown>".into())),
}
}
fn e<E: std::fmt::Debug>(x: E) -> String {
format!("{x:?}")
}
struct Ctx<'a> {
data: &'a [u8],
os: u8,
ls: u8,
base_dir: std::path::PathBuf,
heaps: RefCell<HashMap<u64, Result<Rc<GlobalHeapCollection>, String>>>,
}
impl<'a> Ctx<'a> {
fn header(&self, addr: u64) -> Result<ObjectHeader, String> {
ObjectHeader::parse(self.data, addr as usize, self.os, self.ls).map_err(e)
}
fn payload(&self, h: &ObjectHeader, t: MessageType) -> Result<Option<Vec<u8>>, String> {
match h.messages.iter().find(|m| m.msg_type == t) {
None => Ok(None),
Some(m) => {
clawhdf5_format::shared_message::message_data(self.data, m, self.os, self.ls)
.map(|c| Some(c.into_owned()))
.map_err(e)
}
}
}
fn heap_obj(&self, addr: u64, idx: u32) -> Result<Vec<u8>, String> {
let coll = {
let mut cache = self.heaps.borrow_mut();
cache
.entry(addr)
.or_insert_with(|| {
GlobalHeapCollection::parse(self.data, addr as usize, self.ls)
.map(Rc::new)
.map_err(e)
})
.clone()?
};
coll.get_object(idx as u16)
.map(|o| o.data.clone())
.ok_or_else(|| {
format!("GlobalHeapObjectNotFound {{ collection_address: {addr}, index: {idx} }}")
})
}
fn read_offset(&self, b: &[u8]) -> u64 {
let mut v = 0u64;
for (i, x) in b.iter().take(self.os as usize).enumerate() {
v |= (*x as u64) << (8 * i);
}
v
}
fn canon(&self, dt: &Datatype, b: &[u8], out: &mut Vec<u8>) -> Result<(), String> {
let size = dt.type_size() as usize;
if b.len() < size {
return Err(format!(
"canon: element slice {} < type size {size}",
b.len()
));
}
match dt {
Datatype::FloatingPoint { .. } if !ieee_layout(dt) => {
canon_custom_float(dt, &b[..size], out)?
}
Datatype::FixedPoint { .. } if partial_int(dt) => {
canon_partial_int(dt, &b[..size], out)?
}
Datatype::FixedPoint { byte_order, .. }
| Datatype::BitField { byte_order, .. }
| Datatype::FloatingPoint { byte_order, .. } => match byte_order {
DatatypeByteOrder::LittleEndian => out.extend_from_slice(&b[..size]),
DatatypeByteOrder::BigEndian => out.extend(b[..size].iter().rev()),
DatatypeByteOrder::Vax => return Err("canon: VAX byte order".into()),
},
Datatype::Time { .. } | Datatype::Opaque { .. } => out.extend_from_slice(&b[..size]),
Datatype::String { .. } => canon_str(&b[..size], out),
Datatype::Compound { members, .. } => {
for m in members {
let off = m.byte_offset as usize;
let ms = m.datatype.type_size() as usize;
if off.checked_add(ms).is_none_or(|end| end > size) {
return Err(format!("canon: member {} out of bounds", m.name));
}
self.canon(&m.datatype, &b[off..off + ms], out)?;
}
}
Datatype::Reference { .. } => out.push(b'R'),
Datatype::Enumeration { base_type, .. } => self.canon(base_type, b, out)?,
Datatype::Array {
base_type,
dimensions,
} => {
let n: usize = dimensions.iter().map(|d| *d as usize).product();
let bs = base_type.type_size() as usize;
for i in 0..n {
self.canon(base_type, &b[i * bs..], out)?;
}
}
Datatype::VariableLength {
is_string,
base_type,
..
} => {
let len = u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize;
let addr = self.read_offset(&b[4..]);
let idx_off = 4 + self.os as usize;
let idx = u32::from_le_bytes([
b[idx_off],
b[idx_off + 1],
b[idx_off + 2],
b[idx_off + 3],
]);
let obj = if len == 0 || addr == 0 || addr == u64::MAX >> (64 - 8 * self.os as u32)
{
Vec::new()
} else {
self.heap_obj(addr, idx)?
};
if *is_string {
let l = len.min(obj.len());
canon_str(&obj[..l], out);
} else {
let bs = base_type.type_size() as usize;
if bs == 0 {
return Err("canon: VL base size 0".into());
}
let need = len.checked_mul(bs).ok_or("canon: VL overflow")?;
if len > 0 && obj.len() < need {
return Err(format!("canon: VL object {} < {need}", obj.len()));
}
out.push(b'V');
out.extend_from_slice(&(len as u32).to_le_bytes());
for i in 0..len {
self.canon(base_type, &obj[i * bs..], out)?;
}
}
}
}
Ok(())
}
/// Returns (shape json, n_elements)
fn shape(ds: &Dataspace) -> (Value, u64) {
match ds.space_type {
DataspaceType::Null => (Value::String("null".into()), 0),
DataspaceType::Scalar => (json!([]), 1),
DataspaceType::Simple => {
let n = ds.dimensions.iter().fold(1u64, |a, d| a.saturating_mul(*d));
(json!(ds.dimensions), n)
}
}
}
fn hash_values(
&self,
dt: &Datatype,
raw: &[u8],
n: u64,
rec: &mut Map<String, Value>,
) -> Result<(), String> {
let size = dt.type_size() as usize;
let need = (n as usize).checked_mul(size).ok_or("n*size overflow")?;
if raw.len() != need {
return Err(format!(
"raw length {} != n_elements {n} * type_size {size}",
raw.len()
));
}
let mut canon = Vec::with_capacity(need);
for i in 0..n as usize {
self.canon(dt, &raw[i * size..(i + 1) * size], &mut canon)?;
}
let h = Sha256::digest(&canon);
rec.insert("hash".into(), Value::String(hex(&h)));
rec.insert(
"head".into(),
Value::String(hex(&canon[..canon.len().min(48)])),
);
Ok(())
}
/// VDS source files resolve next to the virtual file; like the library,
/// refuse absolute paths and `..`.
fn vds_resolver(
&self,
) -> impl Fn(&str) -> Result<Option<Vec<u8>>, clawhdf5_format::error::FormatError> + use<> {
let base = self.base_dir.clone();
move |name: &str| {
use clawhdf5_format::error::FormatError;
let p = std::path::Path::new(name);
if p.is_absolute()
|| p.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(FormatError::ChunkedReadError(format!("refused {name}")));
}
match std::fs::read(base.join(p)) {
Ok(b) => Ok(Some(b)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(FormatError::ChunkedReadError(err.to_string())),
}
}
}
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<String, Value>) -> Result<(), String> {
let dtb = self
.payload(h, MessageType::Datatype)?
.ok_or("MissingMessage(Datatype)")?;
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)?
.ok_or("MissingMessage(Dataspace)")?;
let mut ds = Dataspace::parse(&dsb, self.ls).map_err(e)?;
// A virtual dataset's extent can come from its sources (unlimited /
// printf mappings), as h5py reports it, rather than the stored one.
if let Some(lm) = h
.messages
.iter()
.find(|m| m.msg_type == MessageType::DataLayout)
&& let Ok(dl @ DataLayout::Virtual { .. }) =
DataLayout::parse(&lm.data, self.os, self.ls)
{
let resolver = self.vds_resolver();
ds.dimensions = clawhdf5_format::vds::virtual_dataset_extent(
self.data,
&dl,
&ds,
self.os,
self.ls,
Some(&resolver),
)
.map_err(e)?;
}
let (shape, n) = Self::shape(&ds);
rec.insert("shape".into(), shape);
if n.saturating_mul(dt.type_size() as u64) > MAX_BYTES {
rec.insert("skipped".into(), Value::String("too large".into()));
return Ok(());
}
let lm = h
.messages
.iter()
.find(|m| m.msg_type == MessageType::DataLayout)
.ok_or("MissingMessage(DataLayout)")?;
let dl = DataLayout::parse(&lm.data, self.os, self.ls).map_err(e)?;
rec.insert(
"layout".into(),
Value::String(
match &dl {
DataLayout::Compact { .. } => "compact",
DataLayout::Contiguous { .. } => "contiguous",
DataLayout::Chunked { .. } => "chunked",
DataLayout::Virtual { .. } => "virtual",
}
.into(),
),
);
let pipeline = match self.payload(h, MessageType::FilterPipeline)? {
Some(p) => Some(FilterPipeline::parse(&p).map_err(e)?),
None => None,
};
if let Some(p) = &pipeline {
rec.insert(
"filters".into(),
json!(p.filters.iter().map(|f| f.filter_id).collect::<Vec<_>>()),
);
}
let raw = if matches!(dl, DataLayout::Virtual { .. }) {
let resolver = self.vds_resolver();
let fill = clawhdf5_format::fill_value::dataset_fill_value_in(
self.data,
&h.messages,
self.os,
self.ls,
)
.map_err(e)?;
clawhdf5_format::vds::read_virtual_dataset(
self.data,
&dl,
&ds,
&dt,
fill.as_deref(),
self.os,
self.ls,
Some(&resolver),
)
.map_err(e)?
.data
} else {
let cache = clawhdf5_format::chunk_cache::ChunkCache::new();
clawhdf5_format::fill_value::read_full_with_fill::<clawhdf5_format::error::FormatError>(
&h.messages,
self.data,
&dl,
&ds,
dt.type_size() as usize,
self.os,
self.ls,
|| {
data_read::read_raw_data_cached(
self.data,
&dl,
&ds,
&dt,
pipeline.as_ref(),
self.os,
self.ls,
&cache,
)
},
)
.map_err(e)?
};
self.hash_values(&dt, &raw, n, rec)
}
fn attrs(&self, h: &ObjectHeader) -> Result<Map<String, Value>, String> {
let msgs = extract_attributes_full(self.data, h, self.os, self.ls).map_err(e)?;
let mut out = Map::new();
for a in &msgs {
let r = guarded(|| {
let mut rec = Map::new();
rec.insert("dtype".into(), Value::String(dtype_str(&a.datatype)));
let (shape, n) = Self::shape(&a.dataspace);
rec.insert("shape".into(), shape);
self.hash_values(&a.datatype, &a.raw_data, n, &mut rec)?;
Ok(rec)
});
let v = match r {
Ok(rec) => Value::Object(rec),
Err(msg) => json!({ "error": msg }),
};
out.insert(a.name.clone(), v);
}
Ok(out)
}
fn entries(&self, h: &ObjectHeader) -> Result<Vec<GroupEntry>, String> {
let v1 = h
.messages
.iter()
.find(|m| m.msg_type == MessageType::SymbolTable);
if let Some(m) = v1 {
let stm = SymbolTableMessage::parse(&m.data, self.os).map_err(e)?;
group_v1::resolve_v1_group_entries(self.data, &stm, self.os, self.ls).map_err(e)
} else if h
.messages
.iter()
.any(|m| m.msg_type == MessageType::LinkInfo || m.msg_type == MessageType::Link)
{
group_v2::resolve_v2_group_entries(self.data, h, self.os, self.ls).map_err(e)
} else {
Ok(Vec::new())
}
}
}
/// Element bytes as an unsigned integer (at most 16 bytes), honouring byte order.
fn element_bits(b: &[u8], byte_order: &DatatypeByteOrder) -> Result<u128, String> {
if b.len() > 16 {
return Err(format!("canon: {}-byte numeric element", b.len()));
}
let mut v = 0u128;
match byte_order {
DatatypeByteOrder::LittleEndian => {
for (i, x) in b.iter().enumerate() {
v |= u128::from(*x) << (8 * i);
}
}
DatatypeByteOrder::BigEndian => {
for x in b {
v = (v << 8) | u128::from(*x);
}
}
DatatypeByteOrder::Vax => return Err("canon: VAX byte order".into()),
}
Ok(v)
}
fn field(v: u128, pos: u32, len: u32) -> u128 {
if len == 0 || pos >= 128 {
return 0;
}
let v = v >> pos;
if len >= 128 {
v
} else {
v & ((1u128 << len) - 1)
}
}
/// True when a float's bit fields are exactly IEEE 754 binary16/32/64 for its
/// size. h5py hands back such a type's bytes untouched; any other layout (an
/// N-Bit `H5Tset_precision` float, say) is *converted* by libhdf5 into the
/// numpy float of the same size, so comparing raw bytes would be meaningless.
fn ieee_layout(dt: &Datatype) -> bool {
let Datatype::FloatingPoint {
size,
bit_offset,
bit_precision,
exponent_location,
exponent_size,
mantissa_location,
mantissa_size,
exponent_bias,
..
} = dt
else {
return true;
};
let std = match size {
2 => (16, 10, 5, 10, 15),
4 => (32, 23, 8, 23, 127),
8 => (64, 52, 11, 52, 1023),
_ => return true, // no same-size numpy float to convert to: compare raw
};
*bit_offset == 0
&& (
*bit_precision,
*exponent_location,
*exponent_size,
*mantissa_size,
*exponent_bias,
) == (std.0, std.1, std.2, std.3, std.4)
&& *mantissa_location == 0
}
/// Canonicalise a non-IEEE-layout float the way libhdf5's float->float
/// conversion presents it to h5py: as the IEEE float of the same size.
/// Assumes the implied-leading-one normalisation and the sign bit at the top
/// of the precision (what `H5Tset_precision` produces; the parser does not
/// keep either field).
fn canon_custom_float(dt: &Datatype, b: &[u8], out: &mut Vec<u8>) -> Result<(), String> {
let Datatype::FloatingPoint {
size,
byte_order,
bit_offset,
bit_precision,
exponent_location,
exponent_size,
mantissa_location,
mantissa_size,
exponent_bias,
} = dt
else {
unreachable!()
};
let (esize, msize) = (u32::from(*exponent_size), u32::from(*mantissa_size));
if esize == 0 || esize > 30 || msize > 64 {
return Err(format!("canon: unsupported float layout e{esize} m{msize}"));
}
let v = element_bits(b, byte_order)?;
let sign_pos = (u32::from(*bit_offset) + u32::from(*bit_precision)).saturating_sub(1);
let neg = field(v, sign_pos, 1) == 1;
let e = field(v, u32::from(*exponent_location), esize) as i64;
let m = field(v, u32::from(*mantissa_location), msize);
let emax = (1i64 << esize) - 1;
let bias = i64::from(*exponent_bias);
let mag = if e == emax {
if m == 0 { f64::INFINITY } else { f64::NAN }
} else if e == 0 {
(m as f64) * 2f64.powi((1 - bias - msize as i64) as i32)
} else {
((1u128 << msize) as f64 + m as f64) * 2f64.powi((e - bias - msize as i64) as i32)
};
let x = if neg { -mag } else { mag };
match size {
2 => out
.extend_from_slice(&clawhdf5_format::float16::f32_to_f16_bits(x as f32).to_le_bytes()),
4 => out.extend_from_slice(&(x as f32).to_le_bytes()),
8 => out.extend_from_slice(&x.to_le_bytes()),
_ => unreachable!("ieee_layout keeps other sizes raw"),
}
Ok(())
}
/// Integers stored with a bit offset or reduced precision (N-Bit): libhdf5
/// converts them to the full-width integer of the same size, shifting the
/// value down and sign-extending from the top precision bit.
fn canon_partial_int(dt: &Datatype, b: &[u8], out: &mut Vec<u8>) -> Result<(), String> {
let Datatype::FixedPoint {
size,
byte_order,
signed,
bit_offset,
bit_precision,
} = dt
else {
unreachable!()
};
let prec = u32::from(*bit_precision);
let v = element_bits(b, byte_order)?;
let mut x = field(v, u32::from(*bit_offset), prec);
if *signed && prec > 0 && prec < 128 && field(x, prec - 1, 1) == 1 {
x |= !0u128 << prec;
}
out.extend_from_slice(&x.to_le_bytes()[..*size as usize]);
Ok(())
}
fn partial_int(dt: &Datatype) -> bool {
matches!(dt, Datatype::FixedPoint { size, bit_offset, bit_precision, .. }
if *bit_offset != 0 || u32::from(*bit_precision) != size * 8)
}
fn canon_str(b: &[u8], out: &mut Vec<u8>) {
let cut = b.iter().position(|&c| c == 0).unwrap_or(b.len());
let mut s = &b[..cut];
while let [rest @ .., b' '] = s {
s = rest;
}
out.push(b'S');
out.extend_from_slice(&(s.len() as u32).to_le_bytes());
out.extend_from_slice(s);
}
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
fn dtype_str(dt: &Datatype) -> String {
match dt {
Datatype::FixedPoint {
size,
signed,
byte_order,
..
} => {
format!(
"{}{}{}",
bo(byte_order),
if *signed { "i" } else { "u" },
size
)
}
Datatype::FloatingPoint {
size, byte_order, ..
} => format!("{}f{}", bo(byte_order), size),
Datatype::BitField {
size, byte_order, ..
} => format!("{}b{}", bo(byte_order), size),
Datatype::Time { size, .. } => format!("time{size}"),
Datatype::String { size, .. } => format!("S{size}"),
Datatype::Opaque { size, .. } => format!("V{size}"),
Datatype::Compound { size, members } => format!(
"{{{}}}{size}",
members
.iter()
.map(|m| format!("{}:{}", m.name, dtype_str(&m.datatype)))
.collect::<Vec<_>>()
.join(",")
),
Datatype::Reference { ref_type, .. } => format!("ref({ref_type:?})"),
Datatype::Enumeration { base_type, .. } => format!("enum({})", dtype_str(base_type)),
Datatype::VariableLength {
is_string: true, ..
} => "vlstr".into(),
Datatype::VariableLength { base_type, .. } => format!("vlen({})", dtype_str(base_type)),
Datatype::Array {
base_type,
dimensions,
} => format!("({}){dimensions:?}", dtype_str(base_type)),
}
}
fn bo(b: &DatatypeByteOrder) -> &'static str {
match b {
DatatypeByteOrder::LittleEndian => "<",
DatatypeByteOrder::BigEndian => ">",
DatatypeByteOrder::Vax => "vax",
}
}
fn is_group(h: &ObjectHeader) -> bool {
h.messages.iter().any(|m| {
matches!(
m.msg_type,
MessageType::LinkInfo | MessageType::Link | MessageType::SymbolTable
)
})
}
fn main() {
install_hook();
let path = std::env::args().nth(1).expect("usage: probe <file>");
let mut top = Map::new();
top.insert("file".into(), Value::String(path.clone()));
let data = match std::fs::read(&path) {
Ok(d) => d,
Err(err) => {
top.insert("open_error".into(), Value::String(format!("Io({err})")));
println!("{}", Value::Object(top));
return;
}
};
// Every address is relative to the superblock: look at the file from
// there on (past any user block), as libhdf5 does.
let hdf5: &[u8] = match signature::find_signature(&data) {
Ok(off) => &data[off..],
Err(_) => &data,
};
let sb = guarded(|| Superblock::parse(hdf5, 0).map_err(e));
let sb = match sb {
Ok(sb) => sb,
Err(msg) => {
top.insert("open_error".into(), Value::String(msg));
println!("{}", Value::Object(top));
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,
os: sb.offset_size,
ls: sb.length_size,
base_dir: std::path::Path::new(&path)
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_default(),
heaps: RefCell::new(HashMap::new()),
};
let mut objects: Vec<Value> = Vec::new();
let mut visited = HashSet::new();
let mut soft_v1 = 0u64;
// explicit DFS stack: (address, path)
let mut stack: Vec<(u64, String)> = vec![(sb.root_group_address, "/".to_string())];
while let Some((addr, p)) = stack.pop() {
if objects.len() >= MAX_OBJECTS {
top.insert("truncated".into(), json!(true));
break;
}
if !visited.insert(addr) {
continue;
}
let mut rec = Map::new();
rec.insert("path".into(), Value::String(p.clone()));
let r = guarded(|| {
let h = ctx.header(addr)?;
Ok(h)
});
let h = match r {
Ok(h) => h,
Err(msg) => {
rec.insert("kind".into(), Value::String("unknown".into()));
rec.insert("error".into(), Value::String(msg));
objects.push(Value::Object(rec));
continue;
}
};
let is_ds = h
.messages
.iter()
.any(|m| m.msg_type == MessageType::DataLayout);
let kind = if is_ds {
"dataset"
} else if is_group(&h) || addr == sb.root_group_address {
"group"
} else if h
.messages
.iter()
.any(|m| m.msg_type == MessageType::Datatype)
{
"datatype"
} else {
"unknown"
};
rec.insert("kind".into(), Value::String(kind.into()));
if kind == "dataset"
&& let Err(msg) = guarded(|| ctx.read_dataset(&h, &mut rec))
{
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) => {
rec.insert("attrs".into(), Value::Object(m));
}
Err(msg) => {
rec.insert("attrs_error".into(), Value::String(msg));
}
}
}
if kind == "group" {
match guarded(|| ctx.entries(&h)) {
Ok(mut ents) => {
ents.retain(|en| {
if en.cache_type == 2 {
soft_v1 += 1;
false
} else {
true
}
});
ents.sort_by(|a, b| a.name.cmp(&b.name));
let base = if p == "/" { String::new() } else { p.clone() };
for en in ents.into_iter().rev() {
stack.push((en.object_header_address, format!("{base}/{}", en.name)));
}
}
Err(msg) => {
rec.insert("list_error".into(), Value::String(msg));
}
}
}
objects.push(Value::Object(rec));
}
if soft_v1 > 0 {
top.insert("v1_soft_link_entries".into(), json!(soft_v1));
}
top.insert("objects".into(), Value::Array(objects));
println!("{}", Value::Object(top));
}
#[cfg(test)]
mod tests {
use super::*;
/// The N-Bit float of libhdf5's `test/testfiles/le_data.h5`
/// (`Nbit_float_data_le`): offset 7, precision 20, sign bit 26, exponent
/// 20+6 (bias 31), mantissa 7+13.
fn nbit_f32(byte_order: DatatypeByteOrder) -> Datatype {
Datatype::FloatingPoint {
size: 4,
byte_order,
bit_offset: 7,
bit_precision: 20,
exponent_location: 20,
exponent_size: 6,
mantissa_location: 7,
mantissa_size: 13,
exponent_bias: 31,
}
}
fn canon_one(dt: &Datatype, bytes: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
canon_custom_float(dt, bytes, &mut out).unwrap();
out
}
#[test]
fn nbit_float_canonicalises_to_the_value_libhdf5_returns() {
let le = nbit_f32(DatatypeByteOrder::LittleEndian);
let be = nbit_f32(DatatypeByteOrder::BigEndian);
assert!(!ieee_layout(&le));
// 1.0: exponent = bias, mantissa 0
let one: u32 = 31 << 20;
assert_eq!(canon_one(&le, &one.to_le_bytes()), 1.0f32.to_le_bytes());
assert_eq!(canon_one(&be, &one.to_be_bytes()), 1.0f32.to_le_bytes());
// -2.1999512 (h5py's reading of the file's -2.2): sign, e = 32, m = 819
let v: u32 = (1 << 26) | (32 << 20) | (819 << 7);
assert_eq!(
canon_one(&le, &v.to_le_bytes()),
(-2.199_951_2f32).to_le_bytes()
);
assert_eq!(canon_one(&le, &[0; 4]), 0.0f32.to_le_bytes());
}
#[test]
fn ieee_floats_keep_their_raw_bytes() {
let f32le = Datatype::FloatingPoint {
size: 4,
byte_order: DatatypeByteOrder::LittleEndian,
bit_offset: 0,
bit_precision: 32,
exponent_location: 23,
exponent_size: 8,
mantissa_location: 0,
mantissa_size: 23,
exponent_bias: 127,
};
assert!(ieee_layout(&f32le));
}
#[test]
fn partial_precision_int_is_shifted_and_sign_extended() {
let dt = Datatype::FixedPoint {
size: 4,
byte_order: DatatypeByteOrder::BigEndian,
signed: true,
bit_offset: 4,
bit_precision: 17,
};
assert!(partial_int(&dt));
let stored = (((-5i32) as u32) & 0x1_FFFF) << 4;
let mut out = Vec::new();
canon_partial_int(&dt, &stored.to_be_bytes(), &mut out).unwrap();
assert_eq!(out, (-5i32).to_le_bytes());
}
}
+259
View File
@@ -0,0 +1,259 @@
#!/usr/bin/env python3
"""Reference probe: same JSON as the Rust `conformance-probe`, produced with h5py.
Walk: iterative DFS from '/', children in sorted (UTF-8 byte) name order, hard
links only, each object once (first path wins, deduplicated by object identity).
Canonical value encoding: see harness/src/main.rs.
"""
import hashlib
import json
import os
import struct
import sys
import numpy as np
import h5py
try:
import hdf5plugin # noqa: F401 registers blosc/lz4/zstd/bzip2/... filters
except Exception: # pragma: no cover
pass
MAX_BYTES = 200 * 1024 * 1024
MAX_OBJECTS = 200_000
def canon_str(b, out):
if isinstance(b, str):
b = b.encode("utf-8", "surrogateescape")
b = bytes(b)
cut = b.find(b"\x00")
if cut >= 0:
b = b[:cut]
b = b.rstrip(b" ")
out += b"S" + struct.pack("<I", len(b)) + b
def simple(dt):
if dt.fields:
return all(simple(dt.fields[n][0]) for n in dt.names)
if dt.subdtype:
return simple(dt.subdtype[0])
return dt.kind in "iufcbV"
def packed(dt):
if dt.fields:
return np.dtype([(n, packed(dt.fields[n][0])) for n in dt.names])
if dt.subdtype:
base, shape = dt.subdtype
return np.dtype((packed(base), shape))
if dt.kind in "iufcb":
return dt.newbyteorder("<")
return dt
def canon_el(dt, val, out):
if dt.fields:
for n in dt.names:
canon_el(dt.fields[n][0], val[n], out)
return
if dt.subdtype:
base, _ = dt.subdtype
for x in np.asarray(val).reshape(-1):
canon_el(base, x, out)
return
k = dt.kind
if k in "iufcb":
out += np.asarray(val, dtype=dt).astype(dt.newbyteorder("<")).tobytes()
elif k == "V":
out += np.asarray(val, dtype=dt).tobytes()
elif k == "S":
canon_str(val, out)
elif k == "O":
if h5py.check_string_dtype(dt) is not None:
canon_str(val if val is not None else b"", out)
elif h5py.check_ref_dtype(dt) is not None:
out += b"R"
else:
base = h5py.check_vlen_dtype(dt)
if base is None:
raise TypeError(f"unhandled object dtype {dt!r}")
arr = np.asarray(val if val is not None else [], dtype=base).reshape(-1)
out += b"V" + struct.pack("<I", arr.shape[0])
if simple(base):
out += arr.astype(packed(base)).tobytes()
else:
for x in arr:
canon_el(base, x, out)
elif k == "U":
canon_str(str(val), out)
else:
raise TypeError(f"unhandled dtype kind {k} ({dt!r})")
def has_obj(dt):
if dt.fields:
return any(has_obj(dt.fields[n][0]) for n in dt.names)
if dt.subdtype:
return has_obj(dt.subdtype[0])
return dt.kind == "O"
def note_conversion(tid, dt, rec):
"""h5py converts some file types (FP8, bfloat16, x87 long double, ...) to a
different-sized numpy type; then value bytes are not comparable."""
try:
if not has_obj(dt) and tid.get_size() != dt.itemsize:
rec["converted"] = f"file type size {tid.get_size()} -> numpy {dt} ({dt.itemsize})"
except Exception: # noqa: BLE001
pass
def hash_values(arr, dt, rec):
if dt.subdtype is not None:
# h5py expands an HDF5 array element type into trailing array dims
dt = dt.subdtype[0]
arr = np.asarray(arr, dtype=dt)
if simple(dt):
c = np.ascontiguousarray(arr).astype(packed(dt)).tobytes()
else:
out = bytearray()
for x in arr.reshape(-1):
canon_el(dt, x, out)
c = bytes(out)
rec["hash"] = hashlib.sha256(c).hexdigest()
rec["head"] = c[:48].hex()
def err(e):
s = f"{type(e).__name__}: {e}"
return s.splitlines()[0][:400] if s else type(e).__name__
def shape_of(s):
return "null" if s is None else list(s)
def n_bytes(shape, tid):
n = 1
for d in shape or ():
n *= d
return n * tid.get_size()
def read_attrs(obj):
out = {}
names = sorted(obj.attrs.keys(), key=lambda s: s.encode("utf-8", "surrogateescape"))
for name in names:
rec = {}
try:
aid = obj.attrs.get_id(name)
rec["dtype"] = str(aid.dtype)
rec["shape"] = shape_of(aid.shape)
note_conversion(aid.get_type(), aid.dtype, rec)
if aid.shape is None:
hash_values(np.empty((0,), dtype=aid.dtype), aid.dtype, rec)
else:
val = obj.attrs[name]
hash_values(val, aid.dtype, rec)
except Exception as e: # noqa: BLE001
rec = {"error": err(e)}
out[name] = rec
return out
def main(path):
top = {"file": path}
try:
f = h5py.File(path, "r")
except Exception as e: # noqa: BLE001
top["open_error"] = err(e)
print(json.dumps(top))
return
objects = []
seen = set()
stack = [("/", None)]
while stack:
p, obj = stack.pop()
if len(objects) >= MAX_OBJECTS:
top["truncated"] = True
break
rec = {"path": p}
try:
if obj is None:
obj = f[p]
key = hash(obj.id) # h5py ObjectID hash = (fileno, object address/token)
except Exception as e: # noqa: BLE001
rec["kind"] = "unknown"
rec["error"] = err(e)
objects.append(rec)
continue
if key in seen:
continue
seen.add(key)
if isinstance(obj, h5py.Dataset):
kind = "dataset"
elif isinstance(obj, h5py.Group):
kind = "group"
elif isinstance(obj, h5py.Datatype):
kind = "datatype"
else:
kind = "unknown"
rec["kind"] = kind
if kind == "dataset":
try:
dt = obj.dtype
rec["dtype"] = str(dt)
rec["shape"] = shape_of(obj.shape)
note_conversion(obj.id.get_type(), dt, rec)
if obj.shape is None:
hash_values(np.empty((0,), dtype=dt), dt, rec)
elif n_bytes(obj.shape, obj.id.get_type()) > MAX_BYTES:
rec["skipped"] = "too large"
else:
arr = np.empty(obj.shape, dtype=dt)
if arr.size:
try:
obj.read_direct(arr)
except Exception: # noqa: BLE001
arr = obj[()]
hash_values(arr, dt, rec)
except Exception as e: # noqa: BLE001
rec["error"] = err(e)
if kind != "datatype":
try:
rec["attrs"] = read_attrs(obj)
except Exception as e: # noqa: BLE001
rec["attrs_error"] = err(e)
if kind == "group":
try:
names = sorted(obj.keys(), key=lambda s: s.encode("utf-8", "surrogateescape"))
base = "" if p == "/" else p
kids = []
for n in names:
try:
link = obj.get(n, getlink=True)
except Exception: # noqa: BLE001
link = None
if link is not None and not isinstance(link, h5py.HardLink):
continue
kids.append(f"{base}/{n}")
for k in reversed(kids):
stack.append((k, None))
except Exception as e: # noqa: BLE001
rec["list_error"] = err(e)
objects.append(rec)
top["objects"] = objects
print(json.dumps(top), flush=True)
# Exit without tearing down the h5py objects: freeing them for some files
# that hold references (hdf5's h5repack_attr_refs.h5, cve-2024-32623.h5)
# makes libhdf5 2.0 abort with "free(): chunks in smallbin corrupted"
# about half the time. That happens after the reading is done, so it says
# nothing about what h5py read, but it flipped those files between ok and
# h5py-cannot-read from one run to the next.
os._exit(0)
if __name__ == "__main__":
main(sys.argv[1])
+335
View File
@@ -0,0 +1,335 @@
#!/usr/bin/env python3
"""report.py <results_dir> <CONFORMANCE.md> <corpus_dir>
Render the sweep's results (compare.py's results.json plus the raw per-side
runs) as CONFORMANCE.md, and write <results_dir>/report-meta.json (commit,
date, versions) for check.py --update.
"""
import collections
import datetime
import json
import os
import platform
import subprocess
import sys
import h5py
import numpy
try:
import hdf5plugin
HDF5PLUGIN = hdf5plugin.version
except Exception: # noqa: BLE001
HDF5PLUGIN = "not installed"
R, OUT_MD, CORPUS = sys.argv[1], sys.argv[2], sys.argv[3]
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
CLASSES = ["ok", "our-error", "mismatch", "h5py-cannot-read", "panic", "hang", "crash", "oom"]
def sh(*cmd, cwd=ROOT):
try:
return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=30).stdout.strip()
except Exception: # noqa: BLE001
return ""
def cpu_model():
try:
for ln in open("/proc/cpuinfo"):
if ln.startswith(("model name", "Model")):
return ln.split(":", 1)[1].strip()
except OSError:
pass
return platform.processor() or "unknown"
def mem_gib():
try:
for ln in open("/proc/meminfo"):
if ln.startswith("MemTotal:"):
return f"{int(ln.split()[1]) / 1048576:.0f} GiB"
except OSError:
pass
return "?"
res = json.load(open(os.path.join(R, "results.json")))
meta_run = json.load(open(os.path.join(R, "meta.json"))) if os.path.exists(os.path.join(R, "meta.json")) else {}
rows = res["rows"]
issues = res.get("issues", {})
# safe.directory: a checkout owned by another user (a container) is still ours to read
commit = sh("git", "-c", "safe.directory=*", "rev-parse", "HEAD") or os.environ.get("GITHUB_SHA", "unknown")
lib_dirty = sh("git", "-c", "safe.directory=*", "status", "--porcelain", "--", "crates", "Cargo.toml")
h5dump_v = sh("h5dump", "--version").replace("h5dump: ", "")
meta = {
"date": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
"commit": commit + (" (library sources modified)" if lib_dirty else ""),
"reference": f"h5py {h5py.__version__} / HDF5 {h5py.version.hdf5_version}",
}
json.dump(meta, open(os.path.join(R, "report-meta.json"), "w"), indent=1)
pins = []
for ln in open(os.path.join(HERE, "corpus.txt")):
if ln.strip() and not ln.lstrip().startswith("#"):
name, url, rev, root, *_ = ln.split()
pins.append((name, url, rev, root))
by_corpus = collections.defaultdict(collections.Counter)
for r in rows:
by_corpus[r["corpus"]][r["class"]] += 1
total = collections.Counter(r["class"] for r in rows)
def ex_list(files, n=3):
s = ", ".join(f"`{f}`" for f in files[:n])
return s + (f" (+{len(files) - n} more)" if len(files) > n else "")
# --- known causes that are not clawhdf5 bugs --------------------------------
def is_h5py_be_vlen(i):
"""h5py returns the elements of a VL sequence of a big-endian base type
with their file (big-endian) bytes but a native-endian dtype."""
return (i["kind"] == "mismatch" and i["key"] in ("values", "attr-values")
and (i.get("ref_dtype") == "object") and (i.get("ours_dtype") or "").startswith("vlen(")
and ">" in (i.get("ours_dtype") or ""))
known = collections.defaultdict(list)
for r in rows:
if r["class"] != "mismatch":
continue
iss = issues.get(r["file"], [])
if iss and all(is_h5py_be_vlen(i) for i in iss):
known["h5py-be-vlen"].append(r["file"])
# --- the CVE corpus: clawhdf5 vs h5dump vs h5py ------------------------------
def side(run, name):
p = os.path.join(R, "runs", run, name)
if not os.path.exists(p + ".rc"):
return None
rc = int(open(p + ".rc").read().strip() or -1)
err = open(p + ".err", errors="replace").read()
try:
j = json.load(open(p + ".json"))
except Exception: # noqa: BLE001
j = None
return rc, err, j
def outcome(s, rust=False):
"""-> (bucket, text). bucket in read / error / panic / crash / hang / oom."""
if s is None:
return "missing", "not run"
rc, err, j = s
if rc in (137, 124):
return "hang", "hang (killed at timeout)"
if "memory allocation of" in err or "MemoryError" in err or "bad_alloc" in err or "Cannot allocate" in err:
return "oom", "out of memory"
if rust and (rc == 101 or "PANIC:" in err):
return "panic", "panic"
if "overflowed its stack" in err:
return "crash", "stack overflow"
if rc == 139:
return "crash", "SIGSEGV"
if rc == 134:
return "crash", "SIGABRT" + (" (heap corruption)" if ("corrupted" in err or "free()" in err) else "")
if rc > 128:
return "crash", f"signal {rc - 128}"
if j is None:
return ("error", "error exit") if rc in (0, 1) else ("crash", f"exit {rc}")
if "open_error" in j:
return "error", "open error"
objs = j.get("objects", [])
ne = sum(1 for o in objs for k in ("error", "attrs_error", "list_error") if k in o)
ne += sum(1 for o in objs for a in (o.get("attrs") or {}).values() if "error" in a)
return "read", f"read {len(objs)} obj" + (f", {ne} errors" if ne else "")
def h5dump_outcome(s):
if s is None:
return "missing", "not run"
rc, err, _ = s
if rc in (137, 124):
return "hang", "hang (killed at timeout)"
if "memory allocation" in err or "Cannot allocate" in err:
return "oom", "out of memory"
if rc == 139:
return "crash", "SIGSEGV"
if rc == 134:
return "crash", "SIGABRT" + (" (heap corruption)" if ("corrupted" in err or "free()" in err) else "")
if rc > 128:
return "crash", f"signal {rc - 128}"
return ("read", "ok") if rc == 0 else ("error", "error exit")
cve_rows = []
buckets = {"clawhdf5": collections.Counter(), "h5dump": collections.Counter(), "h5py": collections.Counter()}
ours_panic = {r["file"] for r in rows if r["class"] == "panic"}
for r in rows:
if r["corpus"] != "cve_hdf5":
continue
run = r["file"].replace("/", "__")
o = outcome(side(run, "ours"), rust=True)
if o[0] == "read" and r["file"] in ours_panic:
o = ("panic", "caught panic")
p = outcome(side(run, "ref"))
d = h5dump_outcome(side(run, "h5dump"))
buckets["clawhdf5"][o[0]] += 1
buckets["h5py"][p[0]] += 1
buckets["h5dump"][d[0]] += 1
cve_rows.append((r["file"].split("/", 1)[1], d[1], p[1], o[1], r["class"]))
# --- render -----------------------------------------------------------------
L = []
w = L.append
w("# clawhdf5 conformance report")
w("")
w("Every HDF5 file of eight public corpora (pinned by commit) is read twice — by")
w("clawhdf5 (`conformance/probe`, the same `clawhdf5-format` calls the facade")
w("makes) and by h5py/libhdf5 (`conformance/ref.py`) — and the two readings are")
w("compared object by object: the set of hard-linked objects, each dataset's and")
w("attribute's shape, and a SHA-256 of its values in a canonical encoding. The")
w("CVE corpus is also run through `h5dump`. Each side runs under a timeout and an")
w("address-space limit, so a hang, crash or runaway allocation is recorded, not")
w("fatal. This file is generated by `conformance/run.sh`; do not edit it by hand.")
w("")
w("## Run")
w("")
w("| | |")
w("|---|---|")
w(f"| date | {meta['date']} |")
w(f"| clawhdf5 commit | `{meta['commit']}` |")
w(f"| machine | `{platform.node()}`: {cpu_model()}, {os.cpu_count()} CPUs, {mem_gib()}, {platform.system()} {platform.release()} {platform.machine()} |")
w(f"| command | `{os.environ.get('CONFORMANCE_CMD', 'conformance/run.sh')}` |")
w(f"| rustc | {sh('rustc', '-V')} |")
w(f"| reference | h5py {h5py.__version__}, HDF5 {h5py.version.hdf5_version}, numpy {numpy.__version__}, hdf5plugin {HDF5PLUGIN}, Python {platform.python_version()} |")
w(f"| h5dump | {h5dump_v} (CVE corpus only) |")
if meta_run:
w(f"| limits | {meta_run.get('timeout_s')} s timeout (SIGKILL), {int(meta_run.get('mem_kb', 0)) // 1024} MiB address space, per process; {meta_run.get('jobs')} files in parallel |")
w(f"| runtime | {meta_run.get('probe_seconds')} s probing + comparing ({meta_run.get('build_seconds')} s fetch/build before it) |")
w("")
w("## Results")
w("")
w("A file's class is the first that applies:")
w("")
w("- **panic / hang / crash / oom** — clawhdf5 panicked (caught per object or not), hit the timeout, died on a signal, or failed an allocation. The CI gate fails on any of these.")
w("- **h5py-cannot-read** — libhdf5 could not open the file (or itself crashed or hung). Nothing to compare against; most are the deliberately malformed CVE reproducers.")
w("- **our-error** — clawhdf5 returned an error for something h5py reads.")
w("- **mismatch** — both read it, but the shapes, values, object set or attribute set differ.")
w("- **ok** — every object h5py reads, clawhdf5 reads identically.")
w("")
w("| corpus | files | " + " | ".join(CLASSES) + " |")
w("|---" * (len(CLASSES) + 2) + "|")
for c in sorted(by_corpus):
cnt = by_corpus[c]
w(f"| {c} | {sum(cnt.values())} | " + " | ".join(str(cnt.get(k, 0)) for k in CLASSES) + " |")
w(f"| **all** | **{len(rows)}** | " + " | ".join(f"**{total.get(k, 0)}**" for k in CLASSES) + " |")
w("")
n_known = sum(len(v) for v in known.values())
if n_known:
w(f"{n_known} of the {total.get('mismatch', 0)} mismatches are a known h5py bug, not ours (see *Known not-our-bug*).")
w("")
w("Corpora (fetched by `conformance/fetch-corpus.sh` into the gitignored `conformance/.cache/`):")
w("")
w("| corpus | source | commit |")
w("|---|---|---|")
for name, url, rev, root in pins:
w(f"| {name} | {url.removesuffix('.git')}" + ("" if root == "." else f" (`{root}`)") + f" | `{rev[:12]}` |")
w("")
w("## Panics, hangs, crashes, out-of-memory")
w("")
if not res["panics"]:
w("None.")
else:
for p in res["panics"]:
w(f"- `{p['file']}` [{p['class']}] {p['detail']}")
w("")
w("## Our-error root causes")
w("")
w("Grouped by normalised error message. *files* counts files whose class this cause affects.")
w("")
w("| files | objects | error | examples |")
w("|---:|---:|---|---|")
for k, v in res["root_causes"].items():
w(f"| {v['files']} | {v['count']} | `{k.replace('|', '/')}` | {ex_list(v['file_list'])} |")
w("")
w("## Mismatch root causes")
w("")
w("| files | objects | cause | examples |")
w("|---:|---:|---|---|")
for k, v in res["mismatch_causes"].items():
w(f"| {v['files']} | {v['count']} | `{k.replace('|', '/')}` | {ex_list(v['file_list'])} |")
w("")
w("## CVE corpus: clawhdf5 vs h5dump vs h5py")
w("")
w(f"The {len(cve_rows)} files of [HDFGroup/cve_hdf5](https://github.com/HDFGroup/cve_hdf5) — reproducers for")
w("published libhdf5 CVEs and fuzzer finds. *read* = produced output (possibly with per-object")
w("errors), *error* = refused cleanly. h5dump exits non-zero on any error anywhere in a file, so")
w("its read/error split is not comparable with the other two rows; the panic, crash, hang and oom")
w("columns are.")
w("")
w("| tool | read | error | panic | crash | hang | oom |")
w("|---|---:|---:|---:|---:|---:|---:|")
for tool, label in (("clawhdf5", "clawhdf5"), ("h5dump", f"h5dump {h5dump_v.split()[-1] if h5dump_v else ''}"),
("h5py", f"h5py {h5py.__version__} / HDF5 {h5py.version.hdf5_version}")):
b = buckets[tool]
w(f"| {label} | " + " | ".join(str(b.get(k, 0)) for k in ("read", "error", "panic", "crash", "hang", "oom")) + " |")
w("")
w("<details><summary>Per-file outcomes</summary>")
w("")
w("| file | h5dump | h5py | clawhdf5 | class |")
w("|---|---|---|---|---|")
for f, d, p, o, cls in cve_rows:
w(f"| {f} | {d} | {p} | {o} | {cls} |")
w("")
w("</details>")
w("")
w("## Known not-our-bug")
w("")
w("- **h5py big-endian variable-length sequences.** h5py returns the elements of a VL sequence")
w(" whose base type is big-endian with the file's big-endian bytes but a native (little-endian)")
w(" numpy dtype, so the values it reports are byte-swapped garbage; `h5dump` prints the values")
w(" clawhdf5 reads. Reproducer: `h5py.vlen_dtype(np.dtype('>f4'))` dataset holding `[1.0, 2.0]`")
w(" reads back in h5py as `[4.6e-41, 9.0e-44]`. Affected here: "
+ (ex_list(sorted(known["h5py-be-vlen"]), 10) if known["h5py-be-vlen"] else "none") + ".")
w("- **Non-IEEE floats and partial-precision integers (N-Bit).** libhdf5 converts a float whose")
w(" bit layout is not IEEE (e.g. `H5Tset_precision` for the N-Bit filter) or an integer with a")
w(" bit offset / reduced precision into the plain numpy type of the same size. The probe")
w(" compares such values as converted numbers, not raw file bytes (before 2026-09-25 it compared")
w(" raw bytes, which reported every N-Bit float dataset as a mismatch).")
if res["incomparable"]:
w("- **Types h5py widens.** Where h5py reads a type into a numpy type of a different size")
w(" (FP8 -> float16, bfloat16 -> float32, x87 long double -> float128) the values are not")
w(" compared (shape and presence still are): "
+ ", ".join(f"{k} ({n}x)" for k, n in res["incomparable"]) + ".")
w("- **References** are compared by presence only (`R`), not by target.")
w("")
if res.get("ref_only_errors"):
w("## Objects h5py fails on but clawhdf5 reads")
w("")
for k, n in res["ref_only_errors"][:15]:
w(f"- {n} x `{k}`")
w("")
w("## Reproduce")
w("")
w("```sh")
w("# needs: Rust, python3 with h5py numpy hdf5plugin (conformance/requirements.txt), h5dump (hdf5-tools), git")
w("CLAWHDF5_PYTHON=/path/to/venv/bin/python conformance/run.sh")
w("```")
w("")
w("The corpus (about 450 MB of sparse checkouts) is cached in `conformance/.cache/`; results for")
w("every file, both sides' raw JSON and stderr, are in `conformance/.cache/results/`.")
w("`conformance/baseline.json` holds the ok files the nightly CI job (`.gitea/workflows/conformance.yml`)")
w("must keep; `conformance/run.sh --update-baseline` rewrites it.")
with open(OUT_MD, "w") as fh:
fh.write("\n".join(L) + "\n")
+6
View File
@@ -0,0 +1,6 @@
# The reference side of the conformance sweep. Pinned so the nightly job and a
# local run compare against the same libhdf5 (h5py wheels bundle it).
h5py==3.16.0
numpy==2.5.3
hdf5plugin==7.1.0
netCDF4==1.7.4
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# conformance/run.sh — the clawhdf5 conformance sweep, end to end.
#
# fetch the pinned corpora (cached) -> build the probe -> probe every file
# with clawhdf5 and with h5py (and h5dump for the CVE corpus), each under a
# timeout and a memory limit -> compare -> write CONFORMANCE.md -> check the
# result against conformance/baseline.json.
#
# Usage: conformance/run.sh [--no-fetch] [--no-report] [--update-baseline]
#
# Environment:
# CLAWHDF5_PYTHON python with h5py, numpy, hdf5plugin (default: repo .venv, then python3)
# CONFORMANCE_CACHE corpus / build / results cache (default: conformance/.cache)
# CONFORMANCE_OUT results directory (default: $CONFORMANCE_CACHE/results)
# CONFORMANCE_REPORT report path (default: CONFORMANCE.md at the repo root)
# JOBS parallel files (default: nproc)
# CONFORMANCE_PROBE use this prebuilt probe binary instead of building one
# TMO / MEM_KB per-process timeout in seconds (20) / address-space limit in KiB (4 GiB)
#
# Exit status: 0 = gate passed; 1 = a panic/hang/crash/oom in clawhdf5, or the
# ok count fell below the baseline, or a baseline-ok file regressed; 2 = setup error.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$HERE/.." && pwd)"
FETCH=1 REPORT=1 UPDATE=0
for a in "$@"; do
case "$a" in
--no-fetch) FETCH=0 ;;
--no-report) REPORT=0 ;;
--update-baseline) UPDATE=1 ;;
-h|--help) sed -n '2,23p' "$0"; exit 0 ;;
*) echo "unknown argument: $a" >&2; exit 2 ;;
esac
done
export PATH="$HOME/.cargo/bin:$PATH"
CACHE="${CONFORMANCE_CACHE:-$HERE/.cache}"
mkdir -p "$CACHE"; CACHE="$(cd "$CACHE" && pwd)"
OUT="${CONFORMANCE_OUT:-$CACHE/results}"
REPORT_PATH="${CONFORMANCE_REPORT:-$ROOT/CONFORMANCE.md}"
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
if [ -n "${CLAWHDF5_PYTHON:-}" ]; then PY="$CLAWHDF5_PYTHON"
elif [ -x "$ROOT/.venv/bin/python" ]; then PY="$ROOT/.venv/bin/python"
else PY="$(command -v python3)"; fi
export PY TMO="${TMO:-20}" MEM_KB="${MEM_KB:-4194304}"
command -v h5dump >/dev/null || { echo "error: h5dump not found (install hdf5-tools)" >&2; exit 2; }
"$PY" -c 'import h5py, numpy, hdf5plugin' || { echo "error: $PY lacks h5py/numpy/hdf5plugin" >&2; exit 2; }
t0=$(date +%s)
[ "$FETCH" = 1 ] && bash "$HERE/fetch-corpus.sh" "$CACHE"
C="$CACHE/corpus"
[ -d "$C" ] || { echo "error: no corpus in $C (run without --no-fetch)" >&2; exit 2; }
if [ -n "${CONFORMANCE_PROBE:-}" ]; then
export PROBE="$CONFORMANCE_PROBE" # a prebuilt probe, e.g. an older one for a before/after
else
echo "== building the probe"
CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$CACHE/target}" \
cargo build -q --release --manifest-path "$HERE/probe/Cargo.toml"
export PROBE="${CARGO_TARGET_DIR:-$CACHE/target}/release/conformance-probe"
fi
t1=$(date +%s)
rm -rf "$OUT"; mkdir -p "$OUT"
"$PY" "$HERE/list_files.py" "$C" > "$OUT/files.txt"
echo "== probing $(wc -l <"$OUT/files.txt") files, $JOBS at a time (timeout ${TMO}s, limit $((MEM_KB / 1024)) MiB)"
export C OUT HERE
# The shell's "Segmentation fault (core dumped)" notices go to probe.log; the
# signals themselves are recorded in each side's .rc.
xargs -a "$OUT/files.txt" -d '\n' -P "$JOBS" -I{} bash -c '
f="$1"; d="$OUT/runs/${f//\//__}"
case "$f" in cve_hdf5/*) export WITH_H5DUMP=1 ;; esac
"$HERE/run_one.sh" "$C/$f" "$d"' _ {} 2>"$OUT/probe.log"
echo "== comparing"
"$PY" "$HERE/compare.py" "$OUT" >/dev/null
t2=$(date +%s)
cat > "$OUT/meta.json" <<EOF
{"build_seconds": $((t1 - t0)), "probe_seconds": $((t2 - t1)), "jobs": $JOBS, "timeout_s": $TMO, "mem_kb": $MEM_KB}
EOF
export CONFORMANCE_CMD="${CONFORMANCE_CMD:-conformance/run.sh${*:+ $*}}"
if [ "$REPORT" = 1 ]; then
"$PY" "$HERE/report.py" "$OUT" "$REPORT_PATH" "$C"
echo "== wrote $REPORT_PATH"
fi
if [ "$UPDATE" = 1 ]; then
"$PY" "$HERE/check.py" "$OUT" "$HERE/baseline.json" --update
fi
"$PY" "$HERE/check.py" "$OUT" "$HERE/baseline.json"
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# run_one.sh <file> <outdir>
#
# Probe one file with clawhdf5 (PROBE) and with h5py (PY ref.py), and with
# h5dump too when WITH_H5DUMP is set. Each side runs under a timeout (TMO
# seconds, SIGKILL) and an address-space limit (MEM_KB), with core dumps off.
# Writes <outdir>/<side>.{json,err,rc}; rc 137 = killed by the timeout.
set -u
f="$1"; out="$2"; mkdir -p "$out"
HERE="$(cd "$(dirname "$0")" && pwd)"
: "${PROBE:?PROBE must name the conformance-probe binary}"
: "${PY:?PY must name a python with h5py}"
TMO="${TMO:-20}"
MEM_KB="${MEM_KB:-4194304}"
run() { # name cmd...
local name=$1; shift
( ulimit -v "$MEM_KB"; ulimit -c 0; RUST_BACKTRACE=1 exec timeout -s KILL "$TMO" "$@" ) \
>"$out/$name.json" 2>"$out/$name.err"
echo $? >"$out/$name.rc"
}
run ours "$PROBE" "$f"
run ref "$PY" "$HERE/ref.py" "$f"
if [ -n "${WITH_H5DUMP:-}" ]; then
run h5dump h5dump "$f"
: >"$out/h5dump.json" # h5dump's text dump is not compared, only its exit status
fi
exit 0
+4
View File
@@ -19,6 +19,10 @@ clawhdf5-ann = { path = "../clawhdf5-ann", version = "2.7.0", optional = true }
clawhdf5-gpu = { path = "../clawhdf5-gpu", version = "2.7.0", optional = true, default-features = false } clawhdf5-gpu = { path = "../clawhdf5-gpu", version = "2.7.0", optional = true, default-features = false }
serde = { workspace = true } serde = { workspace = true }
byteorder = "1" byteorder = "1"
# Signed checkpoints (MemoryConfig-independent; see `signing`). Pure Rust.
ed25519-dalek = { version = "2", features = ["rand_core"] }
sha2 = "0.10"
rand_core = { version = "0.6", features = ["getrandom"] }
half = { workspace = true, optional = true } half = { workspace = true, optional = true }
rayon = { version = "1", optional = true } rayon = { version = "1", optional = true }
matrixmultiply = { version = "0.3", optional = true } matrixmultiply = { version = "0.3", optional = true }
+1 -1
View File
@@ -203,7 +203,7 @@ impl ImportanceScorer {
/// Novelty score: 1.0 − max cosine similarity against all existing records. /// Novelty score: 1.0 − max cosine similarity against all existing records.
/// Returns 1.0 when there are no existing memories. /// Returns 1.0 when there are no existing memories.
/// ///
/// Same result as [`Self::cosine_similarity`] against each record, but the /// Same result as the reference cosine similarity against each record, but the
/// new embedding's norm is computed once rather than per record, each /// new embedding's norm is computed once rather than per record, each
/// record costs one fused pass (dot product and its norm together) rather /// record costs one fused pass (dot product and its norm together) rather
/// than three, and a large working set is scored in parallel. Every insert /// than three, and a large working set is scored in parallel. Every insert
+73 -2
View File
@@ -1,4 +1,4 @@
//! ZeroClaw agent memory HDF5 backend. //! Agent memory stored in a single HDF5 file.
//! //!
//! Provides persistent memory storage for AI agents using HDF5 files. //! Provides persistent memory storage for AI agents using HDF5 files.
//! All data is cached in-memory for fast access and flushed to disk //! All data is cached in-memory for fast access and flushed to disk
@@ -36,6 +36,7 @@ pub mod reranker;
pub mod schema; pub mod schema;
pub mod search; pub mod search;
pub mod session; pub mod session;
pub mod signing;
pub mod storage; pub mod storage;
mod store_lock; mod store_lock;
pub mod temporal; pub mod temporal;
@@ -78,6 +79,7 @@ pub use session::{SessionCache, SessionEntry};
// --- Error type --- // --- Error type ---
#[derive(Debug)] #[derive(Debug)]
#[non_exhaustive]
pub enum MemoryError { pub enum MemoryError {
Io(std::io::Error), Io(std::io::Error),
Hdf5(String), Hdf5(String),
@@ -88,6 +90,11 @@ pub enum MemoryError {
/// A record the store cannot hold as given, e.g. an embedding value /// A record the store cannot hold as given, e.g. an embedding value
/// outside the half-precision range of a `float16` store. /// outside the half-precision range of a `float16` store.
InvalidEntry(String), InvalidEntry(String),
/// The store's checkpoints are signed and no signing key is set, so a
/// checkpoint would leave it unsigned. Set the key with
/// [`HDF5Memory::set_signing_key`], or drop the signature on purpose with
/// [`HDF5Memory::remove_signature`].
SigningKeyRequired(String),
} }
impl std::fmt::Display for MemoryError { impl std::fmt::Display for MemoryError {
@@ -99,6 +106,7 @@ impl std::fmt::Display for MemoryError {
MemoryError::NotFound(e) => write!(f, "not found: {e}"), MemoryError::NotFound(e) => write!(f, "not found: {e}"),
MemoryError::Locked(e) => write!(f, "store is locked: {e}"), MemoryError::Locked(e) => write!(f, "store is locked: {e}"),
MemoryError::InvalidEntry(e) => write!(f, "invalid entry: {e}"), MemoryError::InvalidEntry(e) => write!(f, "invalid entry: {e}"),
MemoryError::SigningKeyRequired(e) => write!(f, "signing key required: {e}"),
} }
} }
} }
@@ -317,6 +325,12 @@ pub struct HDF5Memory {
activations_dirty: bool, activations_dirty: bool,
/// Opened with [`HDF5Memory::open_read_only`]: nothing may reach the disk. /// Opened with [`HDF5Memory::open_read_only`]: nothing may reach the disk.
read_only: bool, read_only: bool,
/// Key that signs every checkpoint; never persisted. See
/// [`HDF5Memory::set_signing_key`].
signing_key: Option<signing::SigningKey>,
/// Checkpoints of this store are signed: the file on disk is, or a key
/// has been set. A checkpoint without a key is then refused.
signed: bool,
/// A WAL that `open()` could not read and moved aside; see /// A WAL that `open()` could not read and moved aside; see
/// [`HDF5Memory::quarantined_wal`]. /// [`HDF5Memory::quarantined_wal`].
quarantined_wal: Option<PathBuf>, quarantined_wal: Option<PathBuf>,
@@ -372,6 +386,8 @@ impl HDF5Memory {
bm25_filter: bm25::TokenFilter::default(), bm25_filter: bm25::TokenFilter::default(),
activations_dirty: false, activations_dirty: false,
read_only: false, read_only: false,
signing_key: None,
signed: false,
quarantined_wal: None, quarantined_wal: None,
_lock: Some(lock), _lock: Some(lock),
}) })
@@ -550,6 +566,8 @@ impl HDF5Memory {
bm25_filter: bm25::TokenFilter::default(), bm25_filter: bm25::TokenFilter::default(),
activations_dirty: false, activations_dirty: false,
read_only, read_only,
signing_key: None,
signed: checkpoint.signed,
quarantined_wal, quarantined_wal,
_lock: lock, _lock: lock,
}) })
@@ -710,6 +728,39 @@ impl HDF5Memory {
} }
} }
/// Sign every checkpoint from now on with `key` (Ed25519). The key is
/// never written anywhere; set it again after every `open`. Once a store
/// is signed, a checkpoint without the key is refused
/// ([`MemoryError::SigningKeyRequired`]) rather than silently leaving it
/// unsigned. Setting a different key re-signs the store under that key
/// from the next checkpoint; a verifier trusting the old key will then
/// reject it, which is the point. Call [`AgentMemory::flush_wal`] to sign
/// right away.
pub fn set_signing_key(&mut self, key: signing::SigningKey) {
self.signing_key = Some(key);
self.signed = true;
}
/// Stop signing: the next checkpoint writes the store unsigned. The
/// deliberate way out of [`MemoryError::SigningKeyRequired`].
pub fn remove_signature(&mut self) {
self.signing_key = None;
self.signed = false;
}
/// Checkpoints of this store are signed (on disk, or from the next
/// checkpoint because a key has been set).
pub fn is_signed(&self) -> bool {
self.signed
}
/// Check the checkpoint at `path` against the public key the caller
/// trusts; see [`signing::verify_store`]. Reads the file only: it works
/// on a store another process has open.
pub fn verify(path: &Path, trusted: &signing::VerifyingKey) -> Result<signing::VerifyReport> {
signing::verify_store(path, trusted)
}
/// Flush current state to disk and truncate the WAL. /// Flush current state to disk and truncate the WAL.
/// ///
/// Every code path that persists the full cache to the .h5 file must /// Every code path that persists the full cache to the .h5 file must
@@ -725,10 +776,28 @@ impl HDF5Memory {
// Record which WAL prefix this checkpoint contains, so a crash before // Record which WAL prefix this checkpoint contains, so a crash before
// the truncate below can't replay those entries a second time. // the truncate below can't replay those entries a second time.
let wal_applied = self.wal.as_ref().map(|w| w.mark()); let wal_applied = self.wal.as_ref().map(|w| w.mark());
let signature = match &self.signing_key {
Some(key) => Some(signing::sign(
key,
&self.config,
&self.cache,
&self.sessions,
&self.knowledge,
wal_applied,
)),
None if self.signed => {
return Err(MemoryError::SigningKeyRequired(format!(
"{} is signed; set its signing key before a checkpoint \
(saves so far are held in the WAL or in memory)",
self.config.path.display()
)));
}
None => None,
};
// Written before the .h5 so a crash in between leaves a sidecar whose // Written before the .h5 so a crash in between leaves a sidecar whose
// generation matches no checkpoint (ignored), never the reverse. // generation matches no checkpoint (ignored), never the reverse.
let ann_generation = self.persist_vector_index(); let ann_generation = self.persist_vector_index();
storage::write_to_disk_with_meta( storage::write_to_disk_signed(
&self.config.path, &self.config.path,
&self.config, &self.config,
&self.cache, &self.cache,
@@ -737,7 +806,9 @@ impl HDF5Memory {
&schema::CheckpointMeta { &schema::CheckpointMeta {
wal_applied, wal_applied,
ann_generation, ann_generation,
signed: signature.is_some(),
}, },
signature.as_ref(),
)?; )?;
if let Some(ref mut w) = self.wal { if let Some(ref mut w) = self.wal {
w.truncate()?; w.truncate()?;
+11 -8
View File
@@ -1,10 +1,12 @@
//! OpenClaw Integration Layer. //! A Markdown-oriented memory backend over [`crate::HDF5Memory`].
//! //!
//! Bridge between OpenClaw agent gateway (Markdown + sqlite-vec) and the //! Named for OpenClaw, whose workspace memory is Markdown, but **not an
//! clawhdf5 HDF5-backed memory backend. Provides: //! OpenClaw plugin**: nothing here registers with OpenClaw, and the
//! integration it was written for never worked (see `docs/openclaw.md`).
//! Provides:
//! //!
//! - [`MemoryBackend`] — the trait OpenClaw implements against. //! - [`MemoryBackend`] — search / read back / write / ingest / export.
//! - [`ClawhdfBackend`] — concrete HDF5-backed implementation. //! - [`ClawhdfBackend`] — the HDF5-backed implementation.
//! - [`MarkdownParser`] — splits Markdown into [`MarkdownSection`] records. //! - [`MarkdownParser`] — splits Markdown into [`MarkdownSection`] records.
//! - [`MarkdownExporter`] — renders sections back to Markdown text. //! - [`MarkdownExporter`] — renders sections back to Markdown text.
@@ -61,7 +63,8 @@ pub struct BackendStats {
// MemoryBackend trait // MemoryBackend trait
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
/// Interface that OpenClaw uses to interact with a memory backend. /// A Markdown-oriented memory backend: search, read back by path, write,
/// ingest and export.
/// ///
/// Implementors provide persistent storage, full-text + vector search, /// Implementors provide persistent storage, full-text + vector search,
/// Markdown ingestion / export, and statistics. /// Markdown ingestion / export, and statistics.
@@ -318,7 +321,7 @@ impl MarkdownExporter {
/// ///
/// # Path mapping /// # Path mapping
/// ///
/// OpenClaw addresses memories by file path (e.g. `"memory/user.md"`). /// Memories are addressed by file path (e.g. `"memory/user.md"`).
/// Internally every [`MemoryEntry`] stores the originating path as its /// Internally every [`MemoryEntry`] stores the originating path as its
/// `source_channel`. Section sub-paths are stored as /// `source_channel`. Section sub-paths are stored as
/// `"<path>::<heading>"`. /// `"<path>::<heading>"`.
@@ -421,7 +424,7 @@ impl ClawhdfBackend {
// ── Compaction & Consolidation hooks (7.6) ──────────────────────────── // ── Compaction & Consolidation hooks (7.6) ────────────────────────────
/// Run a compaction cycle — called by OpenClaw during session compaction. /// Run a compaction cycle (decay, compaction, WAL flush).
/// ///
/// Sequence: /// Sequence:
/// 1. `tick_session()` — apply Hebbian decay to all activation weights. /// 1. `tick_session()` — apply Hebbian decay to all activation weights.
+1 -1
View File
@@ -2,7 +2,7 @@
//! //!
//! Records the origin, authorship, and a content hash of every memory chunk //! Records the origin, authorship, and a content hash of every memory chunk
//! so the system can detect *accidental* corruption and trace data lineage. //! so the system can detect *accidental* corruption and trace data lineage.
//! The hash is unkeyed (see [`fnv1a_64`]) — this is not a tamper-evidence or //! The hash is unkeyed (FNV-1a) — this is not a tamper-evidence or
//! authenticity guarantee. //! authenticity guarantee.
use std::collections::HashMap; use std::collections::HashMap;
+138 -11
View File
@@ -15,6 +15,9 @@ use crate::session::SessionCache;
use crate::wal::WalMark; use crate::wal::WalMark;
pub const SCHEMA_VERSION: &str = "1.0"; pub const SCHEMA_VERSION: &str = "1.0";
/// Writer-version tag stored in `/meta` as `edgehdf5_version`. Kept for file
/// compatibility; despite the name it has nothing to do with ZeroClaw, which
/// does not use clawhdf5.
pub const ZEROCLAW_VERSION: &str = "0.8.0"; pub const ZEROCLAW_VERSION: &str = "0.8.0";
/// `/meta` attributes holding the [`WalMark`] of the WAL prefix already folded /// `/meta` attributes holding the [`WalMark`] of the WAL prefix already folded
@@ -23,6 +26,7 @@ pub const ZEROCLAW_VERSION: &str = "0.8.0";
const WAL_APPLIED_LEN_ATTR: &str = "wal_applied_len"; const WAL_APPLIED_LEN_ATTR: &str = "wal_applied_len";
const WAL_APPLIED_CRC_ATTR: &str = "wal_applied_crc"; const WAL_APPLIED_CRC_ATTR: &str = "wal_applied_crc";
const ANN_GENERATION_ATTR: &str = "ann_generation"; const ANN_GENERATION_ATTR: &str = "ann_generation";
const SIG_VERSION_ATTR: &str = "sig_version";
/// Build a complete HDF5 file from the in-memory state. /// Build a complete HDF5 file from the in-memory state.
pub fn build_hdf5_file( pub fn build_hdf5_file(
@@ -46,7 +50,7 @@ pub fn build_hdf5_file_with_mark(
) -> Result<Vec<u8>, MemoryError> { ) -> Result<Vec<u8>, MemoryError> {
let meta = CheckpointMeta { let meta = CheckpointMeta {
wal_applied, wal_applied,
ann_generation: None, ..CheckpointMeta::default()
}; };
build_hdf5_file_with_meta(config, cache, sessions, knowledge, &meta) build_hdf5_file_with_meta(config, cache, sessions, knowledge, &meta)
} }
@@ -61,6 +65,10 @@ pub struct CheckpointMeta {
/// one left over from another checkpoint can never be attached to records /// one left over from another checkpoint can never be attached to records
/// it wasn't built from. /// it wasn't built from.
pub ann_generation: Option<u64>, pub ann_generation: Option<u64>,
/// The checkpoint carries an Ed25519 signature (see [`crate::signing`]).
/// Read-only: whether a checkpoint is *written* signed is decided by the
/// signature passed to [`build_hdf5_file_signed`].
pub signed: bool,
} }
/// [`build_hdf5_file`] with checkpoint bookkeeping. /// [`build_hdf5_file`] with checkpoint bookkeeping.
@@ -70,6 +78,19 @@ pub fn build_hdf5_file_with_meta(
sessions: &SessionCache, sessions: &SessionCache,
knowledge: &KnowledgeCache, knowledge: &KnowledgeCache,
checkpoint: &CheckpointMeta, checkpoint: &CheckpointMeta,
) -> Result<Vec<u8>, MemoryError> {
build_hdf5_file_signed(config, cache, sessions, knowledge, checkpoint, None)
}
/// [`build_hdf5_file_with_meta`], plus a signed manifest of the contents
/// (see [`crate::signing`]).
pub fn build_hdf5_file_signed(
config: &MemoryConfig,
cache: &MemoryCache,
sessions: &SessionCache,
knowledge: &KnowledgeCache,
checkpoint: &CheckpointMeta,
signature: Option<&crate::signing::StoredSignature>,
) -> Result<Vec<u8>, MemoryError> { ) -> Result<Vec<u8>, MemoryError> {
let wal_applied = checkpoint.wal_applied; let wal_applied = checkpoint.wal_applied;
let mut builder = clawhdf5::FileBuilder::new(); let mut builder = clawhdf5::FileBuilder::new();
@@ -130,11 +151,42 @@ pub fn build_hdf5_file_with_meta(
// round trip through every reader. // round trip through every reader.
meta.set_attr(ANN_GENERATION_ATTR, AttrValue::I64(generation as i64)); meta.set_attr(ANN_GENERATION_ATTR, AttrValue::I64(generation as i64));
} }
if let Some(sig) = signature {
use crate::signing::to_hex;
let m = &sig.manifest;
meta.set_attr(
SIG_VERSION_ATTR,
AttrValue::I64(crate::signing::MANIFEST_VERSION),
);
meta.set_attr("sig_algorithm", AttrValue::String("ed25519".into()));
meta.set_attr("sig_public_key", AttrValue::String(to_hex(&sig.public_key)));
meta.set_attr("sig_signature", AttrValue::String(to_hex(&sig.signature)));
meta.set_attr("sig_record_count", AttrValue::I64(m.record_count as i64));
meta.set_attr(
"sig_records_root",
AttrValue::String(to_hex(&m.records_root)),
);
meta.set_attr("sig_settings", AttrValue::String(to_hex(&m.settings)));
meta.set_attr("sig_sessions", AttrValue::String(to_hex(&m.sessions)));
meta.set_attr("sig_graph", AttrValue::String(to_hex(&m.graph)));
}
// Need at least one dataset in the group for it to be a proper group // Need at least one dataset in the group for it to be a proper group
meta.create_dataset("_marker").with_u8_data(&[1]).compact(); meta.create_dataset("_marker").with_u8_data(&[1]).compact();
let finished_meta = meta.finish(); let finished_meta = meta.finish();
builder.add_group(finished_meta); builder.add_group(finished_meta);
// /integrity: the signed per-record hashes, so verification can say
// which records changed.
if let Some(sig) = signature {
let mut group = builder.create_group("integrity");
let flat: Vec<u8> = sig.record_hashes.iter().flatten().copied().collect();
group
.create_dataset("record_hashes")
.with_u8_data(&flat)
.with_shape(&[sig.record_hashes.len() as u64, 32]);
builder.add_group(group.finish());
}
// /memory group // /memory group
build_memory_group(&mut builder, config, cache)?; build_memory_group(&mut builder, config, cache)?;
@@ -425,10 +477,34 @@ fn write_string_dataset(
} }
} }
/// `/meta`'s attributes, failing if any of them cannot be read.
///
/// `Group::attrs` leaves out an attribute it cannot decode. For the store's
/// settings that would silently fall back to defaults (e.g. `float16`, the
/// WAL mark), so an unreadable attribute is an error here, as it was before
/// `attrs` became tolerant.
fn meta_attrs(
file: &clawhdf5::File,
) -> Result<std::collections::HashMap<String, AttrValue>, MemoryError> {
let meta = file
.group("meta")
.map_err(|e| MemoryError::Schema(format!("missing /meta group: {e}")))?;
let (attrs, errors) = meta
.attrs_with_errors()
.map_err(|e| MemoryError::Schema(format!("cannot read /meta attrs: {e}")))?;
if let Some(e) = errors.first() {
return Err(MemoryError::Schema(format!(
"cannot read /meta attrs: {} unreadable, first: {e}",
errors.len()
)));
}
Ok(attrs)
}
/// Validate an HDF5 file has the correct schema and load all data. /// Validate an HDF5 file has the correct schema and load all data.
/// Read the checkpoint's [`WalMark`] from `/meta`, if it has one. /// Read the checkpoint's [`WalMark`] from `/meta`, if it has one.
pub fn read_wal_mark(file: &clawhdf5::File) -> Option<WalMark> { pub fn read_wal_mark(file: &clawhdf5::File) -> Option<WalMark> {
let attrs = file.group("meta").ok()?.attrs().ok()?; let attrs = meta_attrs(file).ok()?;
let len = match attrs.get(WAL_APPLIED_LEN_ATTR)? { let len = match attrs.get(WAL_APPLIED_LEN_ATTR)? {
AttrValue::I64(v) => u64::try_from(*v).ok()?, AttrValue::I64(v) => u64::try_from(*v).ok()?,
_ => return None, _ => return None,
@@ -440,19 +516,75 @@ pub fn read_wal_mark(file: &clawhdf5::File) -> Option<WalMark> {
Some(WalMark { len, crc }) Some(WalMark { len, crc })
} }
/// Read a checkpoint's signature, if it has one. A signature whose
/// attributes are present but malformed is an error, not "unsigned".
pub fn read_signature(
file: &clawhdf5::File,
) -> Result<Option<crate::signing::StoredSignature>, MemoryError> {
use crate::signing::{Manifest, StoredSignature, from_hex};
let attrs = meta_attrs(file)?;
let version = match attrs.get(SIG_VERSION_ATTR) {
None => return Ok(None),
Some(AttrValue::I64(v)) => *v,
Some(_) => return Err(MemoryError::Schema("malformed sig_version".into())),
};
if version != crate::signing::MANIFEST_VERSION {
return Err(MemoryError::Schema(format!(
"unsupported signature version {version}"
)));
}
fn hex<const N: usize>(
attrs: &std::collections::HashMap<String, AttrValue>,
name: &str,
) -> Result<[u8; N], MemoryError> {
match attrs.get(name) {
Some(AttrValue::String(s)) => from_hex::<N>(s),
_ => None,
}
.ok_or_else(|| MemoryError::Schema(format!("malformed or missing {name}")))
}
let record_count = match attrs.get("sig_record_count") {
Some(AttrValue::I64(v)) if *v >= 0 => *v as u64,
_ => return Err(MemoryError::Schema("malformed sig_record_count".into())),
};
let group = file
.group("integrity")
.map_err(|e| MemoryError::Schema(format!("signed checkpoint without /integrity: {e}")))?;
let flat = read_u8_dataset(&group, "record_hashes")?;
if flat.len() % 32 != 0 {
return Err(MemoryError::Schema(
"/integrity/record_hashes is not a whole number of hashes".into(),
));
}
let record_hashes = flat.as_chunks::<32>().0.to_vec();
Ok(Some(StoredSignature {
manifest: Manifest {
record_count,
records_root: hex::<32>(&attrs, "sig_records_root")?,
settings: hex::<32>(&attrs, "sig_settings")?,
sessions: hex::<32>(&attrs, "sig_sessions")?,
graph: hex::<32>(&attrs, "sig_graph")?,
},
record_hashes,
public_key: hex::<32>(&attrs, "sig_public_key")?,
signature: hex::<64>(&attrs, "sig_signature")?,
}))
}
/// Read the checkpoint bookkeeping from `/meta`. /// Read the checkpoint bookkeeping from `/meta`.
pub fn read_checkpoint_meta(file: &clawhdf5::File) -> CheckpointMeta { pub fn read_checkpoint_meta(file: &clawhdf5::File) -> CheckpointMeta {
let ann_generation = file let ann_generation =
.group("meta") meta_attrs(file)
.ok() .ok()
.and_then(|g| g.attrs().ok())
.and_then(|attrs| match attrs.get(ANN_GENERATION_ATTR) { .and_then(|attrs| match attrs.get(ANN_GENERATION_ATTR) {
Some(AttrValue::I64(v)) => Some(*v as u64), Some(AttrValue::I64(v)) => Some(*v as u64),
_ => None, _ => None,
}); });
let signed = meta_attrs(file).is_ok_and(|attrs| attrs.contains_key(SIG_VERSION_ATTR));
CheckpointMeta { CheckpointMeta {
wal_applied: read_wal_mark(file), wal_applied: read_wal_mark(file),
ann_generation, ann_generation,
signed,
} }
} }
@@ -460,12 +592,7 @@ pub fn validate_and_load(
file: &clawhdf5::File, file: &clawhdf5::File,
) -> Result<(MemoryConfig, MemoryCache, SessionCache, KnowledgeCache), MemoryError> { ) -> Result<(MemoryConfig, MemoryCache, SessionCache, KnowledgeCache), MemoryError> {
// Read /meta group attributes // Read /meta group attributes
let meta = file let attrs = meta_attrs(file)?;
.group("meta")
.map_err(|e| MemoryError::Schema(format!("missing /meta group: {e}")))?;
let attrs = meta
.attrs()
.map_err(|e| MemoryError::Schema(format!("cannot read /meta attrs: {e}")))?;
let schema_version = match attrs.get("schema_version") { let schema_version = match attrs.get("schema_version") {
Some(AttrValue::String(s)) => s.clone(), Some(AttrValue::String(s)) => s.clone(),
+419
View File
@@ -0,0 +1,419 @@
//! Ed25519-signed checkpoints.
//!
//! When a signing key is set ([`crate::HDF5Memory::set_signing_key`]), every
//! checkpoint writes a signed manifest of the store: a SHA-256 per memory
//! record rolled into a Merkle root, plus hashes of the store's settings, its
//! sessions and its knowledge graph. [`verify_store`] recomputes all of it from
//! the file and checks the signature against a public key the caller trusts,
//! so any change to the checkpointed file — a record's text or embedding, a
//! setting, a session, a graph edge, made through this crate or any other HDF5
//! tool — is detected, and the per-record hashes say which records changed.
//!
//! What it does not cover: saves still only in the WAL (made since the last
//! checkpoint). [`VerifyReport::wal_entries_unsigned`] counts them.
//!
//! The hashes cover exactly what the file persists, in the form the loader
//! returns it, so a store verifies after any number of reopen/checkpoint
//! cycles. Derived data (L2 norms, the vector index) is not covered; it is
//! recomputed from covered data.
use ed25519_dalek::{Signature, Signer, Verifier};
pub use ed25519_dalek::{SigningKey, VerifyingKey};
use sha2::{Digest, Sha256};
use crate::MemoryConfig;
use crate::cache::MemoryCache;
use crate::knowledge::KnowledgeCache;
use crate::session::SessionCache;
use crate::wal::WalMark;
/// Version of the manifest encoding; part of what is signed.
pub const MANIFEST_VERSION: i64 = 1;
type Hash = [u8; 32];
/// The hashes a signature covers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Manifest {
pub record_count: u64,
/// Merkle root over the per-record hashes.
pub records_root: Hash,
/// Settings persisted in `/meta`, plus the checkpoint's WAL mark.
pub settings: Hash,
pub sessions: Hash,
pub graph: Hash,
}
impl Manifest {
/// The exact bytes that are signed.
pub fn signed_bytes(&self) -> Vec<u8> {
let mut m = Vec::with_capacity(160);
m.extend_from_slice(b"clawhdf5-agent signed checkpoint\0");
m.extend_from_slice(&MANIFEST_VERSION.to_le_bytes());
m.extend_from_slice(&self.record_count.to_le_bytes());
m.extend_from_slice(&self.records_root);
m.extend_from_slice(&self.settings);
m.extend_from_slice(&self.sessions);
m.extend_from_slice(&self.graph);
m
}
}
/// A signature as stored in a checkpoint.
#[derive(Debug, Clone)]
pub struct StoredSignature {
pub manifest: Manifest,
pub record_hashes: Vec<Hash>,
pub public_key: [u8; 32],
pub signature: [u8; 64],
}
/// Build the manifest (and per-record hashes) for the state about to be
/// checkpointed, and sign it.
pub fn sign(
key: &SigningKey,
config: &MemoryConfig,
cache: &MemoryCache,
sessions: &SessionCache,
knowledge: &KnowledgeCache,
wal_applied: Option<WalMark>,
) -> StoredSignature {
let (manifest, record_hashes) = manifest(config, cache, sessions, knowledge, wal_applied);
let signature = key.sign(&manifest.signed_bytes()).to_bytes();
StoredSignature {
manifest,
record_hashes,
public_key: key.verifying_key().to_bytes(),
signature,
}
}
/// Compute the manifest of a store's state.
pub fn manifest(
config: &MemoryConfig,
cache: &MemoryCache,
sessions: &SessionCache,
knowledge: &KnowledgeCache,
wal_applied: Option<WalMark>,
) -> (Manifest, Vec<Hash>) {
let record_hashes: Vec<Hash> = (0..cache.len()).map(|i| record_hash(cache, i)).collect();
let manifest = Manifest {
record_count: cache.len() as u64,
records_root: merkle_root(&record_hashes),
settings: settings_hash(config, wal_applied),
sessions: sessions_hash(sessions),
graph: graph_hash(knowledge),
};
(manifest, record_hashes)
}
// ---------------------------------------------------------------------------
// Canonical encoding
// ---------------------------------------------------------------------------
/// A SHA-256 over length-prefixed fields, so no two different field lists
/// hash the same bytes.
struct Fields(Sha256);
impl Fields {
fn new(domain: &str) -> Self {
let mut h = Sha256::new();
h.update((domain.len() as u64).to_le_bytes());
h.update(domain.as_bytes());
Self(h)
}
fn bytes(&mut self, b: &[u8]) -> &mut Self {
self.0.update((b.len() as u64).to_le_bytes());
self.0.update(b);
self
}
/// Strings as the loader returns them: stored null-padded, so a trailing
/// NUL cannot survive a round trip and must not be part of the hash.
fn str(&mut self, s: &str) -> &mut Self {
self.bytes(s.trim_end_matches('\0').as_bytes())
}
fn u64(&mut self, v: u64) -> &mut Self {
self.0.update(v.to_le_bytes());
self
}
fn f64(&mut self, v: f64) -> &mut Self {
self.0.update(v.to_bits().to_le_bytes());
self
}
fn f32(&mut self, v: f32) -> &mut Self {
self.0.update(v.to_bits().to_le_bytes());
self
}
fn finish(self) -> Hash {
self.0.finalize().into()
}
}
/// Everything persisted about record `i`, including its position. The
/// embedding is hashed as the cache holds it — for a `float16` store that is
/// the half-rounded value the file holds.
fn record_hash(cache: &MemoryCache, i: usize) -> Hash {
let mut f = Fields::new("clawhdf5-agent/record");
f.u64(i as u64).str(&cache.chunks[i]);
let emb: Vec<u8> = cache.embeddings[i]
.iter()
.flat_map(|v| v.to_bits().to_le_bytes())
.collect();
f.bytes(&emb)
.str(&cache.source_channels[i])
.f64(cache.timestamps[i])
.str(&cache.session_ids[i])
.str(&cache.tags[i])
.u64(u64::from(cache.tombstones[i]))
.f32(cache.activation_weights[i]);
f.finish()
}
/// Binary Merkle tree: leaves are the record hashes; a parent hashes its two
/// children with a node prefix; an odd node is carried up unchanged.
fn merkle_root(leaves: &[Hash]) -> Hash {
if leaves.is_empty() {
return Fields::new("clawhdf5-agent/merkle-empty").finish();
}
let mut level: Vec<Hash> = leaves.to_vec();
while level.len() > 1 {
level = level
.chunks(2)
.map(|pair| match pair {
[l, r] => {
let mut h = Sha256::new();
h.update([1u8]);
h.update(l);
h.update(r);
h.finalize().into()
}
[only] => *only,
_ => unreachable!(),
})
.collect();
}
level[0]
}
fn settings_hash(c: &MemoryConfig, wal_applied: Option<WalMark>) -> Hash {
let mut f = Fields::new("clawhdf5-agent/settings");
f.str(crate::schema::SCHEMA_VERSION)
.str(&c.created_at)
.str(&c.agent_id)
.str(&c.embedder)
.u64(c.embedding_dim as u64)
.u64(c.chunk_size as u64)
.u64(c.overlap as u64)
.u64(u64::from(c.float16))
.u64(u64::from(c.compression))
.u64(u64::from(c.compression_level))
.f32(c.compact_threshold)
.f32(c.hebbian_boost)
.f32(c.decay_factor)
.u64(u64::from(c.wal_enabled))
.u64(c.wal_max_entries as u64)
.u64(u64::from(c.quantized_index))
.u64(c.hnsw_m as u64)
.u64(c.hnsw_ef_construction as u64)
.u64(c.hnsw_ef_search as u64);
// An empty mark is not written to the file, so it must hash as none.
match wal_applied.filter(|m| m.len > 0) {
Some(m) => f.u64(1).u64(m.len).u64(u64::from(m.crc)),
None => f.u64(0),
};
f.finish()
}
fn sessions_hash(s: &SessionCache) -> Hash {
let mut f = Fields::new("clawhdf5-agent/sessions");
f.u64(s.entries.len() as u64);
for (i, e) in s.entries.iter().enumerate() {
f.str(&e.id)
.u64(e.start_idx)
.u64(e.end_idx)
.str(&e.channel)
.f64(e.ts)
.str(s.summaries.get(i).map(String::as_str).unwrap_or(""));
}
f.finish()
}
fn graph_hash(k: &KnowledgeCache) -> Hash {
let mut f = Fields::new("clawhdf5-agent/graph");
f.u64(k.entities.len() as u64);
for e in &k.entities {
f.u64(e.id)
.str(&e.name)
.str(&e.entity_type)
.u64(e.embedding_idx as u64);
}
f.u64(k.relations.len() as u64);
for r in &k.relations {
f.u64(r.src)
.u64(r.tgt)
.str(&r.relation)
.f32(r.weight)
.f64(r.ts);
}
f.u64(k.alias_strings.len() as u64);
for (s, id) in k.alias_strings.iter().zip(&k.alias_entity_ids) {
f.str(s).u64(*id as u64);
}
f.finish()
}
// ---------------------------------------------------------------------------
// Verification
// ---------------------------------------------------------------------------
/// The outcome of [`verify_store`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifyReport {
/// The checkpoint carries a signature.
pub signed: bool,
/// The signature was made by the key the caller trusts.
pub key_matches: bool,
/// The signature over the stored manifest is valid.
pub signature_valid: bool,
/// The file's current contents match the signed manifest.
pub records_match: bool,
pub settings_match: bool,
pub sessions_match: bool,
pub graph_match: bool,
/// Records whose contents differ from what was signed (by position),
/// when the stored per-record hashes are themselves authentic.
pub changed_records: Vec<usize>,
/// Records in the file versus in the signed manifest.
pub record_count: u64,
pub signed_record_count: u64,
/// The public key the checkpoint claims to be signed by.
pub public_key: Option<[u8; 32]>,
/// Saves in the WAL after the checkpoint: not covered by the signature.
pub wal_entries_unsigned: usize,
}
impl VerifyReport {
/// Signed by the trusted key, signature valid, and every part of the
/// file unchanged since it was signed.
pub fn is_valid(&self) -> bool {
self.signed
&& self.key_matches
&& self.signature_valid
&& self.records_match
&& self.settings_match
&& self.sessions_match
&& self.graph_match
}
}
/// Check a store file against the public key the caller trusts.
///
/// Reads the checkpoint (not the WAL), recomputes every hash from its
/// contents and checks the signature. Never writes.
pub fn verify_store(
path: &std::path::Path,
trusted: &VerifyingKey,
) -> Result<VerifyReport, crate::MemoryError> {
let file = clawhdf5::File::open(path)
.map_err(|e| crate::MemoryError::Hdf5(format!("cannot open {}: {e}", path.display())))?;
let (config, cache, sessions, knowledge) = crate::schema::validate_and_load(&file)?;
let checkpoint = crate::schema::read_checkpoint_meta(&file);
let stored = crate::schema::read_signature(&file)?;
let wal_entries_unsigned = count_wal_entries_after(path, checkpoint.wal_applied);
let (current, current_hashes) = manifest(
&config,
&cache,
&sessions,
&knowledge,
checkpoint.wal_applied,
);
let Some(stored) = stored else {
return Ok(VerifyReport {
signed: false,
key_matches: false,
signature_valid: false,
records_match: false,
settings_match: false,
sessions_match: false,
graph_match: false,
changed_records: Vec::new(),
record_count: current.record_count,
signed_record_count: 0,
public_key: None,
wal_entries_unsigned,
});
};
let key_matches = stored.public_key == trusted.to_bytes();
let signature_valid = trusted
.verify(
&stored.manifest.signed_bytes(),
&Signature::from_bytes(&stored.signature),
)
.is_ok();
// The stored per-record hashes can localise a change only if they are
// the ones that were signed.
let hashes_authentic = signature_valid
&& stored.record_hashes.len() as u64 == stored.manifest.record_count
&& merkle_root(&stored.record_hashes) == stored.manifest.records_root;
let changed_records = if hashes_authentic {
let n = current_hashes.len().max(stored.record_hashes.len());
(0..n)
.filter(|&i| current_hashes.get(i) != stored.record_hashes.get(i))
.collect()
} else {
Vec::new()
};
Ok(VerifyReport {
signed: true,
key_matches,
signature_valid,
records_match: signature_valid
&& current.record_count == stored.manifest.record_count
&& current.records_root == stored.manifest.records_root,
settings_match: signature_valid && current.settings == stored.manifest.settings,
sessions_match: signature_valid && current.sessions == stored.manifest.sessions,
graph_match: signature_valid && current.graph == stored.manifest.graph,
changed_records,
record_count: current.record_count,
signed_record_count: stored.manifest.record_count,
public_key: Some(stored.public_key),
wal_entries_unsigned,
})
}
fn count_wal_entries_after(store: &std::path::Path, mark: Option<WalMark>) -> usize {
let wal = store.with_extension("h5.wal");
if !wal.exists() {
return 0;
}
crate::wal::WalFile::read_entries_for_migration(&wal, mark)
.map(|e| e.len())
.unwrap_or(0)
}
/// A new random signing key from the operating system's RNG.
pub fn generate_key() -> SigningKey {
SigningKey::generate(&mut rand_core::OsRng)
}
/// Hex encoding for keys and signatures in attributes and the CLI.
pub fn to_hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// Parse hex into exactly `N` bytes.
pub fn from_hex<const N: usize>(s: &str) -> Option<[u8; N]> {
let s = s.trim();
if s.len() != 2 * N {
return None;
}
let mut out = [0u8; N];
for (i, byte) in out.iter_mut().enumerate() {
*byte = u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()?;
}
Some(out)
}
+16 -2
View File
@@ -36,7 +36,7 @@ pub fn write_to_disk_with_mark(
) -> Result<(), MemoryError> { ) -> Result<(), MemoryError> {
let meta = schema::CheckpointMeta { let meta = schema::CheckpointMeta {
wal_applied, wal_applied,
ann_generation: None, ..schema::CheckpointMeta::default()
}; };
write_to_disk_with_meta(path, config, cache, sessions, knowledge, &meta) write_to_disk_with_meta(path, config, cache, sessions, knowledge, &meta)
} }
@@ -50,7 +50,21 @@ pub fn write_to_disk_with_meta(
knowledge: &KnowledgeCache, knowledge: &KnowledgeCache,
checkpoint: &schema::CheckpointMeta, checkpoint: &schema::CheckpointMeta,
) -> Result<(), MemoryError> { ) -> Result<(), MemoryError> {
let bytes = schema::build_hdf5_file_with_meta(config, cache, sessions, knowledge, checkpoint)?; write_to_disk_signed(path, config, cache, sessions, knowledge, checkpoint, None)
}
/// [`write_to_disk_with_meta`] with a signed manifest of the contents.
pub fn write_to_disk_signed(
path: &Path,
config: &MemoryConfig,
cache: &MemoryCache,
sessions: &SessionCache,
knowledge: &KnowledgeCache,
checkpoint: &schema::CheckpointMeta,
signature: Option<&crate::signing::StoredSignature>,
) -> Result<(), MemoryError> {
let bytes =
schema::build_hdf5_file_signed(config, cache, sessions, knowledge, checkpoint, signature)?;
if bytes.is_empty() { if bytes.is_empty() {
return Err(MemoryError::Hdf5("build_hdf5_file produced 0 bytes".into())); return Err(MemoryError::Hdf5("build_hdf5_file produced 0 bytes".into()));
@@ -258,3 +258,43 @@ fn an_existing_f32_store_stays_f32() {
assert_eq!(&values[..before.1.len()], before.1.as_slice()); assert_eq!(&values[..before.1.len()], before.1.as_slice());
assert_eq!(&values[before.1.len()..], odd.as_slice()); assert_eq!(&values[before.1.len()..], odd.as_slice());
} }
/// `Group::attrs` leaves out an attribute it cannot decode. A store whose
/// `float16` setting is unreadable must not open as `float16 = false` (or with
/// any other default in place of a setting it has): it is an error.
#[test]
fn unreadable_meta_attribute_fails_open_instead_of_defaulting() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("store.h5");
{
let mut m = HDF5Memory::create(config(&dir, "store.h5", true)).unwrap();
m.save(entry(1)).unwrap();
m.flush_wal().unwrap();
}
assert!(HDF5Memory::open_read_only(&path).is_ok());
// Give the `float16` attribute message an unknown version (the name is
// at +8 in a version-1 message and +9 in a version-3 one).
let mut bytes = std::fs::read(&path).unwrap();
let name = b"float16\0";
let mut hit = false;
let positions: Vec<usize> = (9..bytes.len() - name.len())
.filter(|&p| &bytes[p..p + name.len()] == name)
.collect();
for pos in positions {
for (back, version) in [(8, 1u8), (9, 3u8)] {
if bytes[pos - back] == version {
bytes[pos - back] = 0x7f;
hit = true;
}
}
}
assert!(hit, "float16 attribute message not found");
std::fs::write(&path, &bytes).unwrap();
match HDF5Memory::open_read_only(&path) {
Err(MemoryError::Schema(msg)) => assert!(msg.contains("/meta"), "{msg}"),
Err(e) => panic!("unexpected error: {e}"),
Ok(_) => panic!("store opened with an unreadable float16 setting"),
}
}
@@ -92,3 +92,64 @@ print(len(names))
assert!(n >= 10, "only {n} datasets"); assert!(n >= 10, "only {n} datasets");
} }
} }
#[test]
fn an_edit_made_with_h5py_breaks_the_signature_and_names_the_record() {
if !h5py_available() {
assert!(
std::env::var("CLAWHDF5_REQUIRE_INTEROP").as_deref() != Ok("1"),
"CLAWHDF5_REQUIRE_INTEROP=1 but python3 with h5py is not available"
);
eprintln!("SKIP: python3 with h5py not available");
return;
}
use clawhdf5_agent::signing::SigningKey;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("signed.h5");
let key = SigningKey::from_bytes(&[42; 32]);
let mut m = HDF5Memory::create(MemoryConfig::new(path.clone(), "agent", 8)).unwrap();
m.set_signing_key(key.clone());
m.save_batch(
(0..10)
.map(|i| MemoryEntry {
chunk: format!("memory {i}"),
embedding: (0..8).map(|j| ((i * 8 + j) as f32).cos()).collect(),
source_channel: "test".into(),
timestamp: i as f64,
session_id: "s".into(),
tags: String::new(),
})
.collect(),
)
.unwrap();
drop(m);
assert!(
HDF5Memory::verify(&path, &key.verifying_key())
.unwrap()
.is_valid()
);
// Someone edits one timestamp in place with h5py.
let script = format!(
r#"
import h5py
with h5py.File("{}", "r+") as f:
ts = f["memory/timestamps"]
ts[3] = 12345.0
"#,
path.display()
);
let out = Command::new(python())
.args(["-c", &script])
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let r = HDF5Memory::verify(&path, &key.verifying_key()).unwrap();
assert!(r.signature_valid && !r.is_valid(), "{r:?}");
assert_eq!(r.changed_records, vec![3]);
}
+330
View File
@@ -0,0 +1,330 @@
//! Ed25519-signed checkpoints: `HDF5Memory::set_signing_key` and
//! `HDF5Memory::verify`.
use std::path::Path;
use clawhdf5_agent::signing::{SigningKey, VerifyReport, VerifyingKey};
use clawhdf5_agent::storage;
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry, MemoryError, schema};
use tempfile::TempDir;
const DIM: usize = 16;
fn key(seed: u8) -> SigningKey {
SigningKey::from_bytes(&[seed; 32])
}
fn entry(i: usize, chunk: &str) -> MemoryEntry {
MemoryEntry {
chunk: chunk.to_string(),
embedding: (0..DIM)
.map(|j| ((i * DIM + j) as f32 * 0.37).sin())
.collect(),
source_channel: "chat".into(),
timestamp: 1_700_000_000.0 + i as f64,
session_id: format!("s{}", i % 3),
tags: format!("t{i}"),
}
}
/// Awkward strings on purpose: they must hash the same after a round trip.
const TEXTS: [&str; 6] = [
"plain text",
"ünïcödé — 日本語 🙂",
"",
"trailing spaces ",
"tab\tand\nnewline",
"x",
];
fn signed_store(dir: &TempDir, float16: bool, k: &SigningKey) -> std::path::PathBuf {
let mut cfg = MemoryConfig::new(dir.path().join("s.h5"), "agent", DIM);
cfg.float16 = float16;
let path = cfg.path.clone();
let mut m = HDF5Memory::create(cfg).unwrap();
m.set_signing_key(k.clone());
let entries = (0..30).map(|i| entry(i, TEXTS[i % TEXTS.len()])).collect();
m.save_batch(entries).unwrap();
// Some graph and a deleted record, so every part of the manifest is used.
let a = m.knowledge_mut().add_entity("Alice", "person", 0);
let b = m.knowledge_mut().add_entity("Acme", "org", -1);
m.knowledge_mut().add_relation(a, b, "works_at", 0.75);
m.sessions_mut()
.add_at("s0", 0, 9, "chat", "first session", 1_700_000_000.0);
m.delete(4).unwrap();
m.flush_wal().unwrap();
path
}
fn verify(path: &Path, k: &SigningKey) -> VerifyReport {
HDF5Memory::verify(path, &k.verifying_key()).unwrap()
}
#[test]
fn a_signed_store_verifies_through_reopen_and_checkpoint_cycles() {
for float16 in [true, false] {
let dir = TempDir::new().unwrap();
let k = key(7);
let path = signed_store(&dir, float16, &k);
let r = verify(&path, &k);
assert!(r.is_valid(), "float16={float16}: {r:?}");
assert_eq!(r.public_key, Some(k.verifying_key().to_bytes()));
assert_eq!(r.record_count, 30);
assert!(r.changed_records.is_empty());
// Reopen, change nothing, checkpoint again (with the key): still valid.
for _ in 0..3 {
let mut m = HDF5Memory::open(&path).unwrap();
assert!(m.is_signed());
m.set_signing_key(k.clone());
m.flush_wal().unwrap();
drop(m);
assert!(verify(&path, &k).is_valid());
}
// And after real changes, re-signed.
let mut m = HDF5Memory::open(&path).unwrap();
m.set_signing_key(k.clone());
m.save(entry(99, "added later")).unwrap();
m.hybrid_search(&entry(1, "").embedding, "text", 0.4, 0.6, 5);
m.flush_wal().unwrap();
drop(m);
let r = verify(&path, &k);
assert!(r.is_valid(), "{r:?}");
assert_eq!(r.record_count, 31);
}
}
#[test]
fn a_signed_store_refuses_to_checkpoint_without_its_key() {
let dir = TempDir::new().unwrap();
let k = key(1);
let path = signed_store(&dir, true, &k);
let mut m = HDF5Memory::open(&path).unwrap();
m.save(entry(50, "pending")).unwrap();
match m.flush_wal() {
Err(MemoryError::SigningKeyRequired(msg)) => assert!(msg.contains("signed"), "{msg}"),
other => panic!("expected SigningKeyRequired, got {other:?}"),
}
// The file is untouched and still valid; the save is still in the WAL.
let r = verify(&path, &k);
assert!(r.is_valid());
assert_eq!(r.wal_entries_unsigned, 1);
// Supplying the key lets the checkpoint through, signed.
m.set_signing_key(k.clone());
m.flush_wal().unwrap();
drop(m);
let r = verify(&path, &k);
assert!(r.is_valid());
assert_eq!((r.record_count, r.wal_entries_unsigned), (31, 0));
// Removing the signature on purpose writes it unsigned.
let mut m = HDF5Memory::open(&path).unwrap();
m.remove_signature();
m.flush_wal().unwrap();
drop(m);
let r = verify(&path, &k);
assert!(!r.signed && !r.is_valid());
assert!(!HDF5Memory::open(&path).unwrap().is_signed());
}
#[test]
fn the_wrong_key_does_not_verify_and_a_new_key_re_signs() {
let dir = TempDir::new().unwrap();
let (a, b) = (key(1), key(2));
let path = signed_store(&dir, true, &a);
let r = verify(&path, &b);
assert!(r.signed && !r.key_matches && !r.signature_valid && !r.is_valid());
let mut m = HDF5Memory::open(&path).unwrap();
m.set_signing_key(b.clone());
m.flush_wal().unwrap();
drop(m);
assert!(verify(&path, &b).is_valid());
assert!(!verify(&path, &a).is_valid());
}
/// Rewrite the store with changed contents but the *old* signature — what
/// someone with write access to the file, but not the key, can do.
fn tamper(path: &Path, change: impl FnOnce(&mut Tampered)) {
let file = clawhdf5::File::open(path).unwrap();
let (config, cache, sessions, knowledge) = schema::validate_and_load(&file).unwrap();
let checkpoint = schema::read_checkpoint_meta(&file);
let signature = schema::read_signature(&file).unwrap().unwrap();
drop(file);
let mut t = Tampered {
config,
cache,
sessions,
knowledge,
};
change(&mut t);
storage::write_to_disk_signed(
path,
&t.config,
&t.cache,
&t.sessions,
&t.knowledge,
&checkpoint,
Some(&signature),
)
.unwrap();
}
struct Tampered {
config: MemoryConfig,
cache: clawhdf5_agent::cache::MemoryCache,
sessions: clawhdf5_agent::SessionCache,
knowledge: clawhdf5_agent::knowledge::KnowledgeCache,
}
#[test]
fn every_kind_of_edit_is_detected_and_located() {
let k = key(3);
type Edit = Box<dyn FnOnce(&mut Tampered)>;
type Case = (&'static str, Edit, fn(&VerifyReport) -> bool);
let cases: Vec<Case> = vec![
(
"record text",
Box::new(|t: &mut Tampered| t.cache.chunks[7] = "rewritten".into()),
|r| !r.records_match && r.changed_records == vec![7],
),
(
"one embedding value",
Box::new(|t: &mut Tampered| {
let mut e = t.cache.embeddings[12].to_vec();
e[3] = 0.5;
t.cache.embeddings.set(12, &e);
}),
|r| r.changed_records == vec![12],
),
(
"undelete",
Box::new(|t: &mut Tampered| t.cache.tombstones[4] = 0),
|r| r.changed_records == vec![4],
),
(
"timestamp",
Box::new(|t: &mut Tampered| t.cache.timestamps[20] += 1.0),
|r| r.changed_records == vec![20],
),
(
"record appended",
Box::new(|t: &mut Tampered| {
t.cache.push(
"new".into(),
vec![0.1; DIM],
"x".into(),
1.0,
"s".into(),
"".into(),
);
}),
|r| !r.records_match && r.changed_records == vec![30] && r.record_count == 31,
),
(
"setting",
Box::new(|t: &mut Tampered| t.config.agent_id = "someone-else".into()),
|r| !r.settings_match && r.records_match,
),
(
"session summary",
Box::new(|t: &mut Tampered| t.sessions.summaries[0] = "edited".into()),
|r| !r.sessions_match && r.records_match,
),
(
"graph edge",
Box::new(|t: &mut Tampered| t.knowledge.relations[0].weight = 1.0),
|r| !r.graph_match && r.records_match,
),
];
for (name, edit, check) in cases {
let dir = TempDir::new().unwrap();
let path = signed_store(&dir, true, &k);
tamper(&path, edit);
let r = verify(&path, &k);
assert!(
r.signed && r.key_matches && r.signature_valid,
"{name}: {r:?}"
);
assert!(!r.is_valid(), "{name}: edit not detected: {r:?}");
assert!(check(&r), "{name}: {r:?}");
}
}
#[test]
fn a_forged_manifest_fails_the_signature() {
// Recomputing the hashes for tampered contents does not help without the
// key: the signature no longer matches the manifest.
let dir = TempDir::new().unwrap();
let k = key(5);
let path = signed_store(&dir, true, &k);
let file = clawhdf5::File::open(&path).unwrap();
let (config, mut cache, sessions, knowledge) = schema::validate_and_load(&file).unwrap();
let checkpoint = schema::read_checkpoint_meta(&file);
let mut sig = schema::read_signature(&file).unwrap().unwrap();
drop(file);
cache.chunks[0] = "forged".into();
// Re-sign with an attacker key, then splice the victim's public key back.
let forged = clawhdf5_agent::signing::sign(
&key(66),
&config,
&cache,
&sessions,
&knowledge,
checkpoint.wal_applied,
);
sig.manifest = forged.manifest;
sig.record_hashes = forged.record_hashes;
storage::write_to_disk_signed(
&path,
&config,
&cache,
&sessions,
&knowledge,
&checkpoint,
Some(&sig),
)
.unwrap();
let r = verify(&path, &k);
assert!(
r.key_matches && !r.signature_valid && !r.is_valid(),
"{r:?}"
);
}
#[test]
fn an_unsigned_store_reports_unsigned() {
let dir = TempDir::new().unwrap();
let mut m = HDF5Memory::create(MemoryConfig::new(dir.path().join("u.h5"), "a", DIM)).unwrap();
m.save_batch(vec![entry(0, "hello")]).unwrap();
drop(m);
let r = HDF5Memory::verify(&dir.path().join("u.h5"), &VerifyingKey::from(&key(1))).unwrap();
assert!(!r.signed && !r.is_valid());
assert_eq!(r.record_count, 1);
}
#[test]
fn nul_bytes_in_text_still_verify() {
// Strings are stored null-padded; the hash must follow what a reopened
// store actually holds, or an untouched store would fail to verify.
let dir = TempDir::new().unwrap();
let k = key(9);
let mut m = HDF5Memory::create(MemoryConfig::new(dir.path().join("n.h5"), "a", DIM)).unwrap();
m.set_signing_key(k.clone());
m.save_batch(vec![
entry(0, "inner\0nul"),
entry(1, "trailing nul\0"),
entry(2, "\0leading"),
])
.unwrap();
drop(m);
let r = verify(&dir.path().join("n.h5"), &k);
assert!(r.is_valid(), "{r:?}");
let m = HDF5Memory::open(&dir.path().join("n.h5")).unwrap();
eprintln!(
"reloaded: {:?}",
(0..3).map(|i| m.get_chunk(i)).collect::<Vec<_>>()
);
}
+4 -3
View File
@@ -13,7 +13,7 @@ use clawhdf5_format::filter_pipeline::FilterPipeline;
use clawhdf5_format::group_v2::resolve_path_any; use clawhdf5_format::group_v2::resolve_path_any;
use clawhdf5_format::message_type::MessageType; use clawhdf5_format::message_type::MessageType;
use clawhdf5_format::object_header::ObjectHeader; use clawhdf5_format::object_header::ObjectHeader;
use clawhdf5_format::signature::find_signature; use clawhdf5_format::signature::split_user_block;
use clawhdf5_format::superblock::Superblock; use clawhdf5_format::superblock::Superblock;
use clawhdf5_io::FileWriter as IoFileWriter; use clawhdf5_io::FileWriter as IoFileWriter;
@@ -861,8 +861,9 @@ impl HnswIndex {
/// The HDF5 data must contain the `/ann/vectors`, `/ann/graph_layer_*`, /// The HDF5 data must contain the `/ann/vectors`, `/ann/graph_layer_*`,
/// and `/ann/config` datasets as produced by [`to_hdf5_bytes`]. /// and `/ann/config` datasets as produced by [`to_hdf5_bytes`].
pub fn load_from_hdf5(data: &[u8]) -> Result<Self, FormatError> { pub fn load_from_hdf5(data: &[u8]) -> Result<Self, FormatError> {
let sig_offset = find_signature(data)?; // Addresses are relative to the superblock: skip any user block.
let sb = Superblock::parse(data, sig_offset)?; let (_, data) = split_user_block(data)?;
let sb = Superblock::parse(data, 0)?;
// Read config dataset and its attributes // Read config dataset and its attributes
let config_attrs = read_dataset_attrs(data, &sb, "ann/config")?; let config_attrs = read_dataset_attrs(data, &sb, "ann/config")?;
+8
View File
@@ -34,6 +34,10 @@ path = "src/bin/consolidation_efficiency.rs"
name = "ephemeral_perf" name = "ephemeral_perf"
path = "src/bin/ephemeral_perf.rs" path = "src/bin/ephemeral_perf.rs"
[[bin]]
name = "concurrent_read"
path = "src/bin/concurrent_read.rs"
[[bin]] [[bin]]
name = "mpi_io_bench" name = "mpi_io_bench"
path = "src/bin/mpi_io_bench.rs" path = "src/bin/mpi_io_bench.rs"
@@ -64,6 +68,10 @@ clawhdf5-io = { path = "../clawhdf5-io" }
mpi = { version = "0.8", optional = true } mpi = { version = "0.8", optional = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = "1" 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 } tempfile = { workspace = true }
# Optional: libhdf5 C wrapper for side-by-side comparison (requires system libhdf5). # Optional: libhdf5 C wrapper for side-by-side comparison (requires system libhdf5).
# Enable with: cargo bench -p clawhdf5-bench --features libhdf5-compare # Enable with: cargo bench -p clawhdf5-bench --features libhdf5-compare
@@ -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:])
@@ -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()
@@ -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):
//!
//! * `<dir>/deflate.h5`: `--datasets` datasets `d00`, `d01`, ... of `f32`,
//! `--mib` MiB decoded each, shape `[mib * 256, 1024]`, chunks `256 x 256`,
//! deflate level 4.
//! * `<dir>/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<f32> {
(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<bool> {
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::<Manifest>(&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<f64>,
median_s: f64,
mb_s: f64,
/// `mb_s / (threads * mb_s at threads = 1)`; null without a 1-thread row.
efficiency: Option<f64>,
}
struct Args {
dir: PathBuf,
datasets: u64,
mib: u64,
threads: Vec<usize>,
reps: usize,
slab: u64,
slabs: usize,
seed: u64,
cold: bool,
decode_threads: usize,
modes: Vec<String>,
layouts: Vec<String>,
json: Option<PathBuf>,
}
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<T: std::str::FromStr>(s: &str) -> Result<Vec<T>, String> {
s.split(',')
.map(|x| x.trim().parse().map_err(|_| format!("bad list item {x:?}")))
.collect()
}
fn parse_args() -> Result<Args, String> {
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::<u64>()
.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<Row> = 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<f64> = None;
for &threads in &args.threads {
let times: Vec<f64> = (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(&times);
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);
}
}
@@ -21,6 +21,7 @@
//! cargo run --release -p clawhdf5-bench --bin search_harness -- --ann-only --uniform //! cargo run --release -p clawhdf5-bench --bin search_harness -- --ann-only --uniform
//! cargo run --release -p clawhdf5-bench --bin search_harness -- --float16-study --full //! cargo run --release -p clawhdf5-bench --bin search_harness -- --float16-study --full
//! cargo run --release -p clawhdf5-bench --bin search_harness -- --options-study --full //! cargo run --release -p clawhdf5-bench --bin search_harness -- --options-study --full
//! cargo run --release -p clawhdf5-bench --bin search_harness -- --signing-study --full
//! ``` //! ```
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -488,6 +489,81 @@ fn bench_end_to_end(n: usize, json: &mut Vec<serde_json::Value>) {
})); }));
} }
// ---------------------------------------------------------------------------
// Signing study: what does an Ed25519-signed checkpoint cost?
// ---------------------------------------------------------------------------
/// `--signing-study`: checkpoint time unsigned vs signed, `verify` time, and
/// the file-size cost of the stored per-record hashes. Default store
/// settings (float16, int8 index). Medians of five checkpoints / three
/// verifies.
fn signing_study(n: usize) {
use clawhdf5_agent::signing::SigningKey;
let data = make_dataset(n, 0x516 ^ n as u64);
let mut rng = Rng(9);
let entries: Vec<MemoryEntry> = data
.vectors
.iter()
.enumerate()
.map(|(i, v)| MemoryEntry {
chunk: text_for(data.cluster_of[i], i, &mut rng),
embedding: v.clone(),
source_channel: "bench".into(),
timestamp: i as f64,
session_id: format!("s{}", i % 50),
tags: format!("t{i}"),
})
.collect();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sign.h5");
let mut mem = HDF5Memory::create(MemoryConfig::new(path.clone(), "bench", DIM)).unwrap();
mem.save_batch(entries).unwrap();
std::hint::black_box(mem.hybrid_search(&data.queries[0], "", 1.0, 0.0, K));
let median = |mut v: Vec<Duration>| {
v.sort();
v[v.len() / 2]
};
let checkpoint = |mem: &mut HDF5Memory| {
median(
(0..5)
.map(|_| {
let t = Instant::now();
mem.flush_wal().unwrap();
t.elapsed()
})
.collect(),
)
};
let unsigned = checkpoint(&mut mem);
let unsigned_bytes = std::fs::metadata(&path).unwrap().len();
let key = SigningKey::from_bytes(&[7; 32]);
mem.set_signing_key(key.clone());
let signed = checkpoint(&mut mem);
let signed_bytes = std::fs::metadata(&path).unwrap().len();
drop(mem);
let vk = key.verifying_key();
let verify = median(
(0..3)
.map(|_| {
let t = Instant::now();
let r = HDF5Memory::verify(&path, &vk).unwrap();
let d = t.elapsed();
assert!(r.is_valid());
d
})
.collect(),
);
println!(
"| {n} | {:.1} | {:.1} | {:+.1} | {:.1} | {:+.2} |",
millis(unsigned),
millis(signed),
millis(signed) - millis(unsigned),
millis(verify),
(signed_bytes as f64 - unsigned_bytes as f64) / (1024.0 * 1024.0),
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Search options study: source filters, re-ranking, confidence rejection // Search options study: source filters, re-ranking, confidence rejection
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -960,6 +1036,21 @@ fn main() {
} }
return; return;
} }
if args.iter().any(|a| a == "--signing-study") {
println!("## Signed checkpoints ({DIM}-dim, float16, int8 index)\n");
println!(
"| N | checkpoint ms, unsigned | checkpoint ms, signed | signing adds ms | verify ms | file MiB added |"
);
println!("|---:|---:|---:|---:|---:|---:|");
for &n in if full {
&[1_000, 10_000, 100_000][..]
} else {
&[1_000, 10_000][..]
} {
signing_study(n);
}
return;
}
if args.iter().any(|a| a == "--options-study") { if args.iter().any(|a| a == "--options-study") {
println!("## Search options ({DIM}-dim, k = {K}, Hebbian boost off)\n"); println!("## Search options ({DIM}-dim, k = {K}, Hebbian boost off)\n");
println!("| N | options | filtered recall@10 | p50 ms | p99 ms |"); println!("| N | options | filtered recall@10 | p50 ms | p99 ms |");
@@ -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"));
}
+126 -16
View File
@@ -1,15 +1,22 @@
use std::path::PathBuf; use std::path::{Path, PathBuf};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use clawhdf5_agent::signing::{self, SigningKey, VerifyingKey};
use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry}; use clawhdf5_agent::{AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry};
/// ClawhDF5 — HDF5-backed cognitive memory for AI agents /// ClawhDF5 — HDF5-backed cognitive memory for AI agents
#[derive(Parser)] #[derive(Parser)]
#[command(name = "clawhdf5", version, about)] #[command(name = "clawhdf5", version, about)]
struct Cli { struct Cli {
/// Path to the .h5 memory file /// Path to the .h5 memory file (not needed for `keygen`)
#[arg(short, long, env = "CLAWHDF5_PATH")] #[arg(short, long, env = "CLAWHDF5_PATH")]
path: PathBuf, path: Option<PathBuf>,
/// File holding an Ed25519 signing key (64 hex characters, from
/// `keygen`). Every checkpoint this command makes is then signed; a
/// signed store refuses to checkpoint without it.
#[arg(long, env = "CLAWHDF5_SIGNING_KEY", global = true)]
signing_key: Option<PathBuf>,
#[command(subcommand)] #[command(subcommand)]
command: Commands, command: Commands,
@@ -91,6 +98,38 @@ enum Commands {
/// Destination path /// Destination path
dest: PathBuf, dest: PathBuf,
}, },
/// Generate an Ed25519 signing key for signed checkpoints
Keygen {
/// Where to write the secret key (created new, owner-only on Unix)
#[arg(long)]
out: PathBuf,
},
/// Verify a signed store against a public key; exit status 2 if not valid
Verify {
/// The trusted public key: 64 hex characters, or a file holding them
#[arg(long)]
public_key: String,
},
}
fn read_signing_key(path: &Path) -> Result<SigningKey, Box<dyn std::error::Error>> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read signing key {}: {e}", path.display()))?;
let bytes = signing::from_hex::<32>(&text)
.ok_or_else(|| format!("{} is not a 64-hex-character key", path.display()))?;
Ok(SigningKey::from_bytes(&bytes))
}
/// Open for writing, with the signing key applied if one was given.
fn open_writable(
path: &Path,
key: &Option<SigningKey>,
) -> Result<HDF5Memory, Box<dyn std::error::Error>> {
let mut mem = HDF5Memory::open(path)?;
if let Some(k) = key {
mem.set_signing_key(k.clone());
}
Ok(mem)
} }
fn main() { fn main() {
@@ -103,6 +142,37 @@ fn main() {
} }
fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> { fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
if let Commands::Keygen { out } = &cli.command {
let key = signing::generate_key();
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
use std::io::Write;
let mut f = opts
.open(out)
.map_err(|e| format!("cannot create {}: {e}", out.display()))?;
writeln!(f, "{}", signing::to_hex(&key.to_bytes()))?;
let j = serde_json::json!({
"status": "generated",
"secret_key_file": out.display().to_string(),
"public_key": signing::to_hex(&key.verifying_key().to_bytes()),
});
println!("{}", serde_json::to_string_pretty(&j)?);
return Ok(());
}
let path = cli
.path
.clone()
.ok_or("--path (or CLAWHDF5_PATH) is required")?;
let key = cli
.signing_key
.as_deref()
.map(read_signing_key)
.transpose()?;
match cli.command { match cli.command {
Commands::Create { Commands::Create {
agent_id, agent_id,
@@ -113,7 +183,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
f32, f32,
float16: _, float16: _,
} => { } => {
let mut config = MemoryConfig::new(cli.path.clone(), &agent_id, dim); let mut config = MemoryConfig::new(path.clone(), &agent_id, dim);
config.wal_enabled = wal; config.wal_enabled = wal;
// As with --f32-index: only ever switch the library default off. // As with --f32-index: only ever switch the library default off.
if f32 { if f32 {
@@ -127,15 +197,21 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
config.quantized_index = false; config.quantized_index = false;
} }
let config_quantized = config.quantized_index; let config_quantized = config.quantized_index;
let mem = HDF5Memory::create(config)?; let mut mem = HDF5Memory::create(config)?;
// Sign straight away, so the store is never on disk unsigned.
if let Some(k) = &key {
mem.set_signing_key(k.clone());
mem.flush_wal()?;
}
let j = serde_json::json!({ let j = serde_json::json!({
"status": "created", "status": "created",
"path": cli.path.display().to_string(), "path": path.display().to_string(),
"agent_id": agent_id, "agent_id": agent_id,
"embedding_dim": dim, "embedding_dim": dim,
"wal_enabled": wal, "wal_enabled": wal,
"quantized_index": config_quantized, "quantized_index": config_quantized,
"float16": config_float16, "float16": config_float16,
"signed": mem.is_signed(),
"count": mem.count(), "count": mem.count(),
}); });
println!("{}", serde_json::to_string_pretty(&j)?); println!("{}", serde_json::to_string_pretty(&j)?);
@@ -152,7 +228,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
} }
}; };
let entry: MemoryEntry = serde_json::from_str(&input)?; let entry: MemoryEntry = serde_json::from_str(&input)?;
let mut mem = HDF5Memory::open(&cli.path)?; let mut mem = open_writable(&path, &key)?;
let idx = mem.save(entry)?; let idx = mem.save(entry)?;
let j = serde_json::json!({ "status": "saved", "index": idx, "count": mem.count() }); let j = serde_json::json!({ "status": "saved", "index": idx, "count": mem.count() });
println!("{}", serde_json::to_string(&j)?); println!("{}", serde_json::to_string(&j)?);
@@ -166,7 +242,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
keyword_weight, keyword_weight,
} => { } => {
let emb: Vec<f32> = serde_json::from_str(&embedding)?; let emb: Vec<f32> = serde_json::from_str(&embedding)?;
let mut mem = HDF5Memory::open(&cli.path)?; let mut mem = open_writable(&path, &key)?;
let results = mem.hybrid_search(&emb, &query, vector_weight, keyword_weight, top_k); let results = mem.hybrid_search(&emb, &query, vector_weight, keyword_weight, top_k);
let j: Vec<serde_json::Value> = results let j: Vec<serde_json::Value> = results
.iter() .iter()
@@ -184,7 +260,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
} }
Commands::Recall { index } => { Commands::Recall { index } => {
let mem = HDF5Memory::open_read_only(&cli.path)?; let mem = HDF5Memory::open_read_only(&path)?;
match mem.get_chunk(index) { match mem.get_chunk(index) {
Some(content) => { Some(content) => {
let j = serde_json::json!({ "index": index, "chunk": content }); let j = serde_json::json!({ "index": index, "chunk": content });
@@ -198,22 +274,23 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
} }
Commands::Stats => { Commands::Stats => {
let mem = HDF5Memory::open_read_only(&cli.path)?; let mem = HDF5Memory::open_read_only(&path)?;
let cfg = mem.config(); let cfg = mem.config();
let j = serde_json::json!({ let j = serde_json::json!({
"path": cli.path.display().to_string(), "path": path.display().to_string(),
"agent_id": cfg.agent_id, "agent_id": cfg.agent_id,
"embedding_dim": cfg.embedding_dim, "embedding_dim": cfg.embedding_dim,
"count": mem.count(), "count": mem.count(),
"active": mem.count_active(), "active": mem.count_active(),
"wal_enabled": cfg.wal_enabled, "wal_enabled": cfg.wal_enabled,
"wal_pending": mem.wal_pending_count(), "wal_pending": mem.wal_pending_count(),
"signed": mem.is_signed(),
}); });
println!("{}", serde_json::to_string_pretty(&j)?); println!("{}", serde_json::to_string_pretty(&j)?);
} }
Commands::FlushWal => { Commands::FlushWal => {
let mut mem = HDF5Memory::open(&cli.path)?; let mut mem = open_writable(&path, &key)?;
let before = mem.wal_pending_count(); let before = mem.wal_pending_count();
mem.flush_wal()?; mem.flush_wal()?;
let j = serde_json::json!({ let j = serde_json::json!({
@@ -225,7 +302,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
} }
Commands::AgentsMd { output } => { Commands::AgentsMd { output } => {
let mem = HDF5Memory::open_read_only(&cli.path)?; let mem = HDF5Memory::open_read_only(&path)?;
let md = mem.generate_agents_md(); let md = mem.generate_agents_md();
match output { match output {
Some(p) => { Some(p) => {
@@ -237,7 +314,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
} }
Commands::Export => { Commands::Export => {
let mem = HDF5Memory::open_read_only(&cli.path)?; let mem = HDF5Memory::open_read_only(&path)?;
for i in 0..mem.count() { for i in 0..mem.count() {
if let Some(chunk) = mem.get_chunk(i) { if let Some(chunk) = mem.get_chunk(i) {
let j = serde_json::json!({ "index": i, "chunk": chunk }); let j = serde_json::json!({ "index": i, "chunk": chunk });
@@ -246,11 +323,44 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
} }
} }
Commands::Keygen { .. } => unreachable!("handled before opening a store"),
Commands::Verify { public_key } => {
let text = if Path::new(&public_key).is_file() {
std::fs::read_to_string(&public_key)?
} else {
public_key
};
let bytes = signing::from_hex::<32>(&text)
.ok_or("--public-key must be 64 hex characters or a file holding them")?;
let trusted = VerifyingKey::from_bytes(&bytes)?;
let r = HDF5Memory::verify(&path, &trusted)?;
let j = serde_json::json!({
"valid": r.is_valid(),
"signed": r.signed,
"key_matches": r.key_matches,
"signature_valid": r.signature_valid,
"records_match": r.records_match,
"settings_match": r.settings_match,
"sessions_match": r.sessions_match,
"graph_match": r.graph_match,
"changed_records": r.changed_records,
"record_count": r.record_count,
"signed_record_count": r.signed_record_count,
"signed_by": r.public_key.map(|k| signing::to_hex(&k)),
"wal_entries_unsigned": r.wal_entries_unsigned,
});
println!("{}", serde_json::to_string_pretty(&j)?);
if !r.is_valid() {
std::process::exit(2);
}
}
Commands::Snapshot { dest } => { Commands::Snapshot { dest } => {
let _result = clawhdf5_agent::storage::snapshot_file(&cli.path, &dest)?; let _result = clawhdf5_agent::storage::snapshot_file(&path, &dest)?;
let j = serde_json::json!({ let j = serde_json::json!({
"status": "snapshot_created", "status": "snapshot_created",
"source": cli.path.display().to_string(), "source": path.display().to_string(),
"dest": dest.display().to_string(), "dest": dest.display().to_string(),
}); });
println!("{}", serde_json::to_string(&j)?); println!("{}", serde_json::to_string(&j)?);
+19 -1
View File
@@ -22,6 +22,13 @@ zstd = { version = "0.13", optional = true }
blake3 = { version = "1", optional = true } blake3 = { version = "1", optional = true }
libaec-sys = { path = "../libaec-sys", version = "0.1", optional = true } libaec-sys = { path = "../libaec-sys", version = "0.1", optional = true }
pco = { version = "1.0", 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] [dev-dependencies]
half = { workspace = true } half = { workspace = true }
@@ -37,7 +44,7 @@ harness = false
# Deflate backend: `zlib-rs` (pure Rust) by default. `fast-deflate` selects # 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 # 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. # 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 = [] std = []
checksum = [] checksum = []
deflate = ["flate2"] deflate = ["flate2"]
@@ -56,6 +63,17 @@ zstd = ["dep:zstd"]
blake3_hash = ["blake3"] blake3_hash = ["blake3"]
szip = ["libaec-sys"] szip = ["libaec-sys"]
pcodec = ["dep:pco"] 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]] [[bench]]
name = "parallel_decompress_bench" name = "parallel_decompress_bench"
+99 -35
View File
@@ -97,7 +97,7 @@ impl AttributeMessage {
return Ok(Cow::Borrowed(bytes)); return Ok(Cow::Borrowed(bytes));
} }
let (file_data, offset_size) = file.ok_or(FormatError::UnresolvedSharedMessage)?; let (file_data, offset_size) = file.ok_or(FormatError::UnresolvedSharedMessage)?;
let shared_ref = shared_message::parse_shared_ref(bytes, offset_size)?; let shared_ref = shared_message::parse_shared_ref_sized(bytes, offset_size, length_size)?;
shared_message::resolve_shared_message( shared_message::resolve_shared_message(
file_data, file_data,
&shared_ref, &shared_ref,
@@ -362,6 +362,18 @@ fn extract_name(bytes: &[u8]) -> String {
String::from_utf8_lossy(&bytes[..end]).into_owned() 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<AttributeMessage, FormatError> {
if header.version == 1 {
attr.datatype.check_unused_bits()?;
}
Ok(attr)
}
/// Extract all attribute messages from an object header. /// Extract all attribute messages from an object header.
pub fn extract_attributes( pub fn extract_attributes(
header: &ObjectHeader, header: &ObjectHeader,
@@ -371,7 +383,7 @@ pub fn extract_attributes(
for msg in &header.messages { for msg in &header.messages {
if msg.msg_type == MessageType::Attribute { if msg.msg_type == MessageType::Attribute {
let attr = AttributeMessage::parse(&msg.data, length_size)?; let attr = AttributeMessage::parse(&msg.data, length_size)?;
attrs.push(attr); attrs.push(check_in_header(attr, header)?);
} }
} }
Ok(attrs) Ok(attrs)
@@ -394,42 +406,81 @@ pub fn find_attribute<'a>(
/// ///
/// Use this instead of `extract_attributes` when reading files that may use dense storage /// Use this instead of `extract_attributes` when reading files that may use dense storage
/// (e.g., objects with many attributes, typically >8). /// (e.g., objects with many attributes, typically >8).
///
/// Fails if any attribute cannot be read; see [`extract_attributes_tolerant`]
/// to read the others.
pub fn extract_attributes_full( pub fn extract_attributes_full(
file_data: &[u8], file_data: &[u8],
header: &ObjectHeader, header: &ObjectHeader,
offset_size: u8, offset_size: u8,
length_size: u8, length_size: u8,
) -> Result<Vec<AttributeMessage>, FormatError> {
extract_attributes_with(file_data, header, offset_size, length_size, &mut Err)
}
/// Like [`extract_attributes_full`], but an attribute that cannot be read
/// (a corrupt or unsupported attribute message, or a heap object that cannot
/// be located) is left out and its error returned alongside the attributes
/// that could be read, instead of failing them all.
///
/// Errors in the structures that index the attributes (the Attribute Info
/// message, the dense-storage heap header or B-tree) still fail the call:
/// then it is unknown which attributes exist at all.
pub fn extract_attributes_tolerant(
file_data: &[u8],
header: &ObjectHeader,
offset_size: u8,
length_size: u8,
) -> Result<(Vec<AttributeMessage>, Vec<FormatError>), FormatError> {
let mut errors = Vec::new();
let attrs = extract_attributes_with(file_data, header, offset_size, length_size, &mut |e| {
errors.push(e);
Ok(())
})?;
Ok((attrs, errors))
}
/// Read every attribute; each one that fails goes to `on_error`, which
/// either stops the read (returns the error) or skips that attribute.
fn extract_attributes_with(
file_data: &[u8],
header: &ObjectHeader,
offset_size: u8,
length_size: u8,
on_error: &mut dyn FnMut(FormatError) -> Result<(), FormatError>,
) -> Result<Vec<AttributeMessage>, FormatError> { ) -> Result<Vec<AttributeMessage>, FormatError> {
let mut attrs = Vec::new(); let mut attrs = Vec::new();
// Collect compact attributes (inline in OH) // Collect compact attributes (inline in OH)
for msg in &header.messages { for msg in &header.messages {
if msg.msg_type == MessageType::Attribute { if msg.msg_type == MessageType::Attribute {
if shared_message::is_shared(msg.flags) { let attr = if shared_message::is_shared(msg.flags) {
// Shared attribute: resolve the reference to get actual attribute data // Shared attribute: resolve the reference to get actual attribute data
let shared_ref = shared_message::parse_shared_ref(&msg.data, offset_size)?; shared_message::parse_shared_ref_sized(&msg.data, offset_size, length_size)
let resolved_data = shared_message::resolve_shared_message( .and_then(|shared_ref| {
shared_message::resolve_shared_message(
file_data, file_data,
&shared_ref, &shared_ref,
MessageType::Attribute, MessageType::Attribute,
offset_size, offset_size,
length_size, length_size,
)?; )
let attr = AttributeMessage::parse_in_file( })
&resolved_data, .and_then(|resolved| {
AttributeMessage::parse_in_file(
&resolved,
file_data, file_data,
offset_size, offset_size,
length_size, length_size,
)?; )
attrs.push(attr); })
} else { } else {
let attr = AttributeMessage::parse_in_file( AttributeMessage::parse_in_file(&msg.data, file_data, offset_size, length_size)
&msg.data, };
file_data, let attr = attr.and_then(|a| check_in_header(a, header));
offset_size, match attr {
length_size, Ok(attr) => attrs.push(attr),
)?; Err(e) => on_error(e)?,
attrs.push(attr);
} }
} }
} }
@@ -439,9 +490,15 @@ pub fn extract_attributes_full(
if let Some(info) = attr_info if let Some(info) = attr_info
&& let Some(fh_addr) = info.fractal_heap_address && let Some(fh_addr) = info.fractal_heap_address
{ {
let dense_attrs = extract_dense_attributes(
extract_dense_attributes(file_data, &info, fh_addr, offset_size, length_size)?; file_data,
attrs.extend(dense_attrs); &info,
fh_addr,
offset_size,
length_size,
&mut attrs,
on_error,
)?;
} }
Ok(attrs) Ok(attrs)
@@ -468,7 +525,9 @@ fn extract_dense_attributes(
fh_addr: u64, fh_addr: u64,
offset_size: u8, offset_size: u8,
length_size: u8, length_size: u8,
) -> Result<Vec<AttributeMessage>, FormatError> { attrs: &mut Vec<AttributeMessage>,
on_error: &mut dyn FnMut(FormatError) -> Result<(), FormatError>,
) -> Result<(), FormatError> {
// Parse fractal heap // Parse fractal heap
let fh = FractalHeapHeader::parse(file_data, fh_addr as usize, offset_size, length_size)?; let fh = FractalHeapHeader::parse(file_data, fh_addr as usize, offset_size, length_size)?;
@@ -482,28 +541,32 @@ fn extract_dense_attributes(
let btree_hdr = BTreeV2Header::parse(file_data, btree_addr as usize, offset_size, length_size)?; let btree_hdr = BTreeV2Header::parse(file_data, btree_addr as usize, offset_size, length_size)?;
let records = collect_btree_v2_records(file_data, &btree_hdr, offset_size, length_size)?; let records = collect_btree_v2_records(file_data, &btree_hdr, offset_size, length_size)?;
let mut attrs = Vec::new();
for record in &records { for record in &records {
// Per HDF5 spec, both type 8 and type 9 records start with heap_id: // Per HDF5 spec, both type 8 and type 9 records start with heap_id:
// Type 8: heap_id(8) + msg_flags(1) + creation_order(4) + hash(4) // Type 8: heap_id(8) + msg_flags(1) + creation_order(4) + hash(4)
// Type 9: heap_id(8) + msg_flags(1) + creation_order(4) // Type 9: heap_id(8) + msg_flags(1) + creation_order(4)
let id_offset = 0; let id_len = fh.heap_id_length as usize;
let Some(id_bytes) = record.data.get(..id_len) else {
if record.data.len() < id_offset + fh.heap_id_length as usize { on_error(FormatError::UnexpectedEof {
expected: id_len,
available: record.data.len(),
})?;
continue; continue;
} };
let id_bytes = &record.data[id_offset..id_offset + fh.heap_id_length as usize];
// Read attribute message from fractal heap
let attr_data = fh.read_managed_object(file_data, id_bytes, offset_size)?;
// The data in the heap is a complete attribute message // The data in the heap is a complete attribute message
let attr = let attr = fh
AttributeMessage::parse_in_file(&attr_data, file_data, offset_size, length_size)?; .read_managed_object(file_data, id_bytes, offset_size)
attrs.push(attr); .and_then(|attr_data| {
AttributeMessage::parse_in_file(&attr_data, file_data, offset_size, length_size)
});
match attr {
Ok(attr) => attrs.push(attr),
Err(e) => on_error(e)?,
}
} }
Ok(attrs) Ok(())
} }
#[cfg(test)] #[cfg(test)]
@@ -523,7 +586,8 @@ mod tests {
/// Build an f64 LE datatype message. /// Build an f64 LE datatype message.
fn build_f64_dt() -> Vec<u8> { fn build_f64_dt() -> Vec<u8> {
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]; let mut props = [0u8; 12];
props[2..4].copy_from_slice(&64u16.to_le_bytes()); // bit_precision props[2..4].copy_from_slice(&64u16.to_le_bytes()); // bit_precision
props[4] = 52; // exp_location props[4] = 52; // exp_location
+64 -40
View File
@@ -323,39 +323,21 @@ fn collect_internal_records(
let records_start = pos; let records_start = pos;
pos += records_total; pos += records_total;
// Compute sizes for child pointers // Child pointer layout, as libhdf5 computes it (H5B2__hdr_init): the
// max_records at child depth - for variable-width nrec encoding // child's record count is always encoded in the width needed for a
// *leaf's* maximum, and — below the first internal level — the child
// subtree's total record count in the width needed for the most records
// a subtree of that depth can hold.
let child_depth = depth - 1; let child_depth = depth - 1;
let max_nrec_child = if child_depth == 0 { let nrec_width = bytes_for_max_records(max_leaf_nrec);
max_leaf_nrec
} else {
// For internal nodes at child_depth, the true max_nrec depends on the
// node size, record size, and the recursive width of child pointer
// entries (which themselves depend on max_nrec at deeper levels).
// Computing the exact value requires iterating from the leaf level
// upward, as described in the HDF5 spec (III.A.2 "Computing the Size
// of B-tree Nodes").
//
// We use `max_leaf_nrec * 2` as a conservative upper bound. This
// over-estimates the nrec encoding width, which means we may read
// slightly more bytes per child pointer than strictly necessary, but
// never fewer. The over-read bytes are harmless because we only
// decode `num_records` entries (the actual count from the node header).
//
// Known limitation: for very deep trees (depth > 3) with small record
// sizes, the true max could exceed this estimate, causing us to
// under-allocate the nrec encoding width and misparse child pointers.
// In practice, HDF5 B-tree v2 depths rarely exceed 2-3.
max_leaf_nrec * 2
};
let nrec_width = bytes_for_max_records(max_nrec_child);
// Total records in subtree width (only if depth > 1)
let total_nrec_width = if depth > 1 { let total_nrec_width = if depth > 1 {
// Width to hold total records in a subtree bytes_for_max_records(cum_max_records(
// We compute max possible total records at this subtree depth node_size,
let max_total = header_max_total_records(max_leaf_nrec, depth - 1); record_size,
bytes_for_max_records(max_total) offset_size,
max_leaf_nrec,
child_depth,
))
} else { } else {
0 0
}; };
@@ -435,14 +417,36 @@ fn collect_internal_records(
Ok(()) Ok(())
} }
/// Estimate maximum total records at a given depth (for variable-width encoding). /// Most records a subtree whose root is at `depth` can hold (libhdf5's
fn header_max_total_records(max_leaf_nrec: u64, depth: u16) -> u64 { /// `cum_max_nrec`): a leaf holds `max_leaf_nrec`; an internal node at depth
// Conservative: branching factor * max_leaf at each level /// `d` holds `max_nrec(d)` records and `max_nrec(d) + 1` subtrees of depth
let mut total = max_leaf_nrec; /// `d - 1`, where `max_nrec(d)` is what fits in a node once each record is
for _ in 0..depth { /// paired with a child pointer of the width depth `d` needs.
total = total.saturating_mul(max_leaf_nrec.max(2)); fn cum_max_records(
node_size: u32,
record_size: u16,
offset_size: u8,
max_leaf_nrec: u64,
depth: u16,
) -> u64 {
// Internal node overhead: signature(4) + version(1) + type(1) + checksum(4).
const PREFIX: u64 = 10;
let nrec_width = bytes_for_max_records(max_leaf_nrec) as u64;
let mut cum = max_leaf_nrec;
let mut cum_width = 0u64;
for d in 1..=depth {
let ptr = u64::from(offset_size) + nrec_width + if d > 1 { cum_width } else { 0 };
let max_nrec = u64::from(node_size)
.saturating_sub(PREFIX)
.saturating_sub(ptr)
/ (u64::from(record_size) + ptr).max(1);
cum = max_nrec
.saturating_add(1)
.saturating_mul(cum)
.saturating_add(max_nrec);
cum_width = bytes_for_max_records(cum) as u64;
} }
total cum
} }
#[cfg(test)] #[cfg(test)]
@@ -512,9 +516,15 @@ mod tests {
child_nrec: u64, child_nrec: u64,
) -> Vec<u8> { ) -> Vec<u8> {
let max_leaf = max_records_leaf(node_size, record_size); let max_leaf = max_records_leaf(node_size, record_size);
let nrec_width = bytes_for_max_records(if depth == 1 { max_leaf } else { max_leaf * 2 }); let nrec_width = bytes_for_max_records(max_leaf);
let total_width = if depth > 1 { let total_width = if depth > 1 {
bytes_for_max_records(header_max_total_records(max_leaf, depth - 1)) bytes_for_max_records(cum_max_records(
node_size,
record_size,
8,
max_leaf,
depth - 1,
))
} else { } else {
0 0
}; };
@@ -673,4 +683,18 @@ mod tests {
let records = collect_btree_v2_records(&header, &hdr, 8, 8).unwrap(); let records = collect_btree_v2_records(&header, &hdr, 8, 8).unwrap();
assert!(records.is_empty()); assert!(records.is_empty());
} }
#[test]
fn subtree_capacity_matches_libhdf5() {
// A link-name index (11-byte records, 512-byte nodes, 8-byte
// addresses): libhdf5's H5B2__hdr_init gives 45 records per leaf,
// then cum_max_nrec 1 149 at depth 1 and 26 449 at depth 2 — two
// bytes of subtree count in a depth-3 root's child pointers, where
// leaf_max^3 = 91 125 would need three.
let leaf = max_records_leaf(512, 11);
assert_eq!(leaf, 45);
assert_eq!(cum_max_records(512, 11, 8, leaf, 0), 45);
assert_eq!(cum_max_records(512, 11, 8, leaf, 1), 1_149);
assert_eq!(cum_max_records(512, 11, 8, leaf, 2), 26_449);
}
} }
+516 -271
View File
@@ -223,13 +223,32 @@ pub const DEFAULT_CACHE_BYTES: usize = 16 * 1024 * 1024; // 16 MiB
/// coordinate map and reduces collision chains compared to power-of-two sizes. /// coordinate map and reduces collision chains compared to power-of-two sizes.
pub const DEFAULT_MAX_SLOTS: usize = 521; pub const DEFAULT_MAX_SLOTS: usize = 521;
/// Most datasets whose chunk index a [`ChunkCache`] keeps at once.
pub const MAX_INDEXED_DATASETS: usize = 64;
/// Most chunk-index entries, summed over all datasets, a [`ChunkCache`] keeps.
/// Least-recently-used datasets' indexes are dropped past this (the dataset
/// being read is always kept), so a file with many or huge chunked datasets
/// cannot grow the cache without bound.
pub const MAX_INDEXED_CHUNKS: usize = 1 << 20;
/// The dataset key the address-less (legacy) methods use when
/// [`ChunkCache::ensure_dataset`] has not been called.
#[cfg(feature = "std")]
const UNBOUND_DATASET: u64 = u64::MAX;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// LRU entry // LRU entry
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Decompressed chunks are keyed by dataset *and* coordinate: every chunked
/// dataset has a chunk at (0, 0, ...), so the coordinate alone is ambiguous.
#[cfg(feature = "std")]
type SlotKey = (u64, ChunkCoord);
#[cfg(feature = "std")] #[cfg(feature = "std")]
struct CachedChunk { struct CachedChunk {
coord: ChunkCoord, key: SlotKey,
/// Shared so a cache hit is a refcount bump, not a copy of the whole /// Shared so a cache hit is a refcount bump, not a copy of the whole
/// (potentially large) decompressed chunk. /// (potentially large) decompressed chunk.
data: Arc<CacheAlignedBuffer>, data: Arc<CacheAlignedBuffer>,
@@ -237,21 +256,48 @@ struct CachedChunk {
last_access: u64, last_access: u64,
} }
/// Per-dataset index state.
#[cfg(feature = "std")]
#[derive(Default)]
struct DatasetEntry {
/// Chunk coordinate -> ChunkInfo (offset + size in file).
index: Option<Arc<HashMap<ChunkCoord, ChunkInfo>>>,
/// Pre-built chunk index for O(1) coordinate lookups.
chunk_index: Option<Arc<ChunkIndex>>,
/// Pre-computed chunk layout for fast assembly.
chunk_layout: Option<Arc<ChunkLayout>>,
/// Tick of the last use, for dropping the least recently used dataset.
last_used: u64,
}
#[cfg(feature = "std")]
impl DatasetEntry {
fn weight(&self) -> usize {
self.index.as_ref().map_or(0, |m| m.len())
+ self.chunk_index.as_ref().map_or(0, |c| c.num_chunks())
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ChunkCache // ChunkCache
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// A per-dataset chunk cache with hash-based index and LRU eviction. /// A per-file chunk cache: chunk indexes per dataset, plus an LRU of
/// decompressed chunks, all keyed by dataset.
/// ///
/// # Usage /// A dataset is identified by the address of its chunk index (B-tree, fixed
/// or extensible array, ...), which is unique within a file. Every method
/// that takes an `addr` works on that dataset only, so threads reading
/// different datasets through one shared cache never see each other's
/// chunks. The address-less methods (`has_index`, `populate_index`,
/// `get_decompressed`, ...) act on the dataset last bound with
/// [`Self::ensure_dataset`]; that binding is shared state, so concurrent
/// readers must use the `*_in` / `*_for` methods instead (the chunked
/// readers in [`crate::chunked_read`] do).
/// ///
/// ```ignore /// Memory is bounded: decompressed data by `max_bytes`/`max_slots` across
/// let cache = ChunkCache::new(); /// all datasets, indexes by [`MAX_INDEXED_DATASETS`] and
/// // Pass &cache to read_chunked_data — it will populate the index lazily. /// [`MAX_INDEXED_CHUNKS`].
/// ```
///
/// The cache is wrapped in `Mutex` internally so it can be mutated through
/// shared references (thread-safe).
/// ///
/// Only available with the `std` feature because it requires `std::sync::Mutex`. /// Only available with the `std` feature because it requires `std::sync::Mutex`.
#[cfg(feature = "std")] #[cfg(feature = "std")]
@@ -261,26 +307,20 @@ pub struct ChunkCache {
#[cfg(feature = "std")] #[cfg(feature = "std")]
struct CacheInner { struct CacheInner {
/// Hash index: chunk coordinate -> ChunkInfo (offset + size in file). /// Per-dataset chunk indexes, keyed by chunk-index address.
/// Populated once per dataset on first access. datasets: HashMap<u64, DatasetEntry>,
index: Option<HashMap<ChunkCoord, ChunkInfo>>,
/// Address of the dataset (its chunk-index base address) that the cached /// Dataset the address-less methods act on (see `ensure_dataset`).
/// index, chunk index, layout, and decompressed slots currently belong to. current: Option<u64>,
/// The cache is shared per file across datasets, so every cached-read entry
/// checks this and resets the per-dataset state when the dataset changes —
/// otherwise one dataset's chunk index (with its own rank) would be reused
/// for another, corrupting reads.
index_addr: Option<u64>,
/// LRU cache of decompressed chunk data. /// LRU cache of decompressed chunk data.
slots: Vec<CachedChunk>, slots: Vec<CachedChunk>,
/// Coordinate -> index into `slots`, for O(1) lookup instead of a linear /// Key -> index into `slots`, for O(1) lookup instead of a linear
/// scan. Kept in sync with `slots` on every insert/evict/clear — in /// scan. Kept in sync with `slots` on every insert/evict/clear — in
/// particular, `slots.swap_remove(i)` moves the last element into slot /// particular, `slots.swap_remove(i)` moves the last element into slot
/// `i`, so the moved element's index entry must be updated too. /// `i`, so the moved element's index entry must be updated too.
slot_index: HashMap<ChunkCoord, usize>, slot_index: HashMap<SlotKey, usize>,
/// Current total bytes of cached decompressed data. /// Current total bytes of cached decompressed data.
current_bytes: usize, current_bytes: usize,
@@ -294,17 +334,145 @@ struct CacheInner {
/// Monotonic counter for LRU ordering. /// Monotonic counter for LRU ordering.
tick: u64, tick: u64,
/// Last accessed chunk coordinate (for sequential detection). /// Last accessed chunk (for sequential detection).
last_coord: Option<ChunkCoord>, last_coord: Option<SlotKey>,
/// Access pattern statistics. /// Access pattern statistics.
stats: AccessStats, stats: AccessStats,
}
/// Pre-built chunk index for O(1) coordinate lookups. #[cfg(feature = "std")]
chunk_index: Option<ChunkIndex>, impl CacheInner {
fn current(&self) -> u64 {
self.current.unwrap_or(UNBOUND_DATASET)
}
/// Pre-computed chunk layout for fast assembly. fn touch(&mut self, addr: u64) -> &mut DatasetEntry {
chunk_layout: Option<ChunkLayout>, self.tick += 1;
let tick = self.tick;
let entry = self.datasets.entry(addr).or_default();
entry.last_used = tick;
entry
}
fn entry(&self, addr: u64) -> Option<&DatasetEntry> {
self.datasets.get(&addr)
}
/// Drop least-recently-used datasets' indexes (never `keep`'s) until the
/// dataset and chunk-entry budgets hold.
fn trim_datasets(&mut self, keep: u64) {
loop {
let total: usize = self.datasets.values().map(DatasetEntry::weight).sum();
if self.datasets.len() <= MAX_INDEXED_DATASETS && total <= MAX_INDEXED_CHUNKS {
return;
}
let victim = self
.datasets
.iter()
.filter(|(a, _)| **a != keep)
.min_by_key(|(_, e)| e.last_used)
.map(|(a, _)| *a);
match victim {
Some(a) => {
self.datasets.remove(&a);
}
None => return,
}
}
}
fn get_decompressed(&mut self, addr: u64, coord: &[u64]) -> Option<Arc<CacheAlignedBuffer>> {
self.tick += 1;
let tick = self.tick;
// Track sequential vs random access
let is_sequential = self.last_coord.as_ref().is_some_and(|(prev_addr, prev)| {
// Sequential if exactly one dimension changed
let changes: usize = prev
.iter()
.zip(coord.iter())
.filter(|(a, b)| a != b)
.count();
*prev_addr == addr && changes <= 1
});
if is_sequential {
self.stats.sequential_count += 1;
} else if self.last_coord.is_some() {
self.stats.random_count += 1;
}
let key: SlotKey = (addr, coord.to_vec());
let found = if let Some(&idx) = self.slot_index.get(&key) {
self.slots[idx].last_access = tick;
Some(Arc::clone(&self.slots[idx].data))
} else {
None
};
self.last_coord = Some(key);
if let Some(ref data) = found {
self.stats.hits += 1;
self.stats.bytes_read += data.len() as u64;
} else {
self.stats.misses += 1;
}
found
}
fn put_decompressed(
&mut self,
key: SlotKey,
data: Arc<CacheAlignedBuffer>,
) -> Arc<CacheAlignedBuffer> {
let data_len = data.len();
// Don't cache if single chunk exceeds budget — still return the data
// to the caller, just don't retain it.
if data_len > self.max_bytes {
return data;
}
// Check if already present
self.tick += 1;
let tick = self.tick;
if let Some(&idx) = self.slot_index.get(&key) {
self.slots[idx].last_access = tick;
return Arc::clone(&self.slots[idx].data); // already cached
}
// Evict until we have room
while self.slots.len() >= self.max_slots
|| (self.current_bytes + data_len > self.max_bytes && !self.slots.is_empty())
{
// Find LRU slot
let lru_idx = self
.slots
.iter()
.enumerate()
.min_by_key(|(_, s)| s.last_access)
.map(|(i, _)| i)
.unwrap();
let removed = self.slots.swap_remove(lru_idx);
self.slot_index.remove(&removed.key);
// swap_remove moved the former last element into `lru_idx` (unless
// it *was* the last element) — fix up that element's index entry.
if lru_idx < self.slots.len() {
let moved_key = self.slots[lru_idx].key.clone();
self.slot_index.insert(moved_key, lru_idx);
}
self.current_bytes -= removed.data.len();
self.stats.evictions += 1;
}
self.current_bytes += data_len;
let new_idx = self.slots.len();
self.slot_index.insert(key.clone(), new_idx);
self.slots.push(CachedChunk {
key,
data: Arc::clone(&data),
last_access: tick,
});
data
}
} }
/// Access pattern statistics tracked by the chunk cache. /// Access pattern statistics tracked by the chunk cache.
@@ -356,8 +524,8 @@ impl ChunkCache {
pub fn with_capacity(max_bytes: usize, max_slots: usize) -> Self { pub fn with_capacity(max_bytes: usize, max_slots: usize) -> Self {
Self { Self {
inner: std::sync::Mutex::new(CacheInner { inner: std::sync::Mutex::new(CacheInner {
index: None, datasets: HashMap::new(),
index_addr: None, current: None,
slots: Vec::with_capacity(max_slots.min(64)), slots: Vec::with_capacity(max_slots.min(64)),
slot_index: HashMap::with_capacity(max_slots.min(64)), slot_index: HashMap::with_capacity(max_slots.min(64)),
current_bytes: 0, current_bytes: 0,
@@ -366,340 +534,331 @@ impl ChunkCache {
tick: 0, tick: 0,
last_coord: None, last_coord: None,
stats: AccessStats::default(), stats: AccessStats::default(),
chunk_index: None,
chunk_layout: None,
}), }),
} }
} }
// ----- Index operations ----- fn lock(&self) -> std::sync::MutexGuard<'_, CacheInner> {
self.inner.lock().unwrap_or_else(|e| e.into_inner())
}
/// The most decompressed bytes this cache will hold. /// The most decompressed bytes this cache will hold.
pub fn max_bytes(&self) -> usize { pub fn max_bytes(&self) -> usize {
self.inner.lock().map(|g| g.max_bytes).unwrap_or(0) self.lock().max_bytes
} }
/// Bind the cache to the dataset at chunk-index address `addr`. // ----- Dataset-keyed operations (safe to use concurrently) -----
/// The chunk list of the dataset whose chunk index is at `addr`.
/// ///
/// The cache is shared per file across all of its datasets. If the cache /// On the first call for a dataset, `build` scans its chunk index; the
/// currently holds state for a different dataset, all per-dataset state /// result is kept (offsets truncated to `rank` for the lookup key), so
/// (chunk index, chunk-index map, layout, and decompressed slots) is /// later calls skip the scan. `build` runs without the cache lock held;
/// dropped so the next access rebuilds it for this dataset. Reading the /// if two threads race to build the same dataset's index, the first
/// same dataset again is a no-op, preserving the cache's benefit for /// stored one wins and both return equivalent lists.
/// repeated/sequential access. Returns `true` if a reset occurred. pub fn chunks_for<E>(
&self,
addr: u64,
rank: usize,
build: impl FnOnce() -> Result<Vec<ChunkInfo>, E>,
) -> Result<Vec<ChunkInfo>, E> {
Ok(self
.index_for(addr, rank, build)?
.values()
.cloned()
.collect())
}
fn index_for<E>(
&self,
addr: u64,
rank: usize,
build: impl FnOnce() -> Result<Vec<ChunkInfo>, E>,
) -> Result<Arc<HashMap<ChunkCoord, ChunkInfo>>, E> {
if let Some(index) = self.lock().touch(addr).index.clone() {
return Ok(index);
}
let chunks = build()?;
let map: HashMap<ChunkCoord, ChunkInfo> = chunks
.into_iter()
.map(|ci| (ci.offsets.iter().take(rank).copied().collect(), ci))
.collect();
let mut inner = self.lock();
let entry = inner.touch(addr);
let index = Arc::clone(entry.index.get_or_insert_with(|| Arc::new(map)));
inner.trim_datasets(addr);
Ok(index)
}
/// The pre-computed assembly layout of the dataset at `addr`, building
/// its chunk index (via `build`, as in [`Self::chunks_for`]) and layout on
/// first use.
pub fn chunk_layout_for<E>(
&self,
addr: u64,
rank: usize,
build: impl FnOnce() -> Result<Vec<ChunkInfo>, E>,
ds_dims: &[usize],
chunk_dims: &[usize],
elem_size: usize,
) -> Result<Arc<ChunkLayout>, E> {
let (layout, chunk_index) = {
let mut inner = self.lock();
let entry = inner.touch(addr);
(entry.chunk_layout.clone(), entry.chunk_index.clone())
};
if let Some(layout) = layout {
return Ok(layout);
}
let chunk_index = match chunk_index {
Some(ci) => ci,
None => {
let index = self.index_for(addr, rank, build)?;
let chunks: Vec<ChunkInfo> = index.values().cloned().collect();
Arc::new(ChunkIndex::build(&chunks, rank))
}
};
let layout = ChunkLayout::build(&chunk_index, ds_dims, chunk_dims, elem_size);
let mut inner = self.lock();
let entry = inner.touch(addr);
entry.chunk_index.get_or_insert(chunk_index);
let layout = Arc::clone(entry.chunk_layout.get_or_insert_with(|| Arc::new(layout)));
inner.trim_datasets(addr);
Ok(layout)
}
/// Cached decompressed chunk at `coord` of the dataset at `addr`.
///
/// O(1) lookup; the clone is an `Arc` refcount bump, not a copy of the
/// underlying decompressed data.
pub fn get_decompressed_in(&self, addr: u64, coord: &[u64]) -> Option<Arc<CacheAlignedBuffer>> {
self.lock().get_decompressed(addr, coord)
}
/// Cache decompressed chunk data for `coord` of the dataset at `addr`.
/// Returns the `Arc`-shared buffer now cached (or already cached).
pub fn put_decompressed_in(
&self,
addr: u64,
coord: ChunkCoord,
data: Vec<u8>,
) -> Arc<CacheAlignedBuffer> {
self.put_decompressed_aligned_in(addr, coord, CacheAlignedBuffer::from_vec(data))
}
/// [`Self::put_decompressed_in`] for an already-aligned buffer.
pub fn put_decompressed_aligned_in(
&self,
addr: u64,
coord: ChunkCoord,
data: CacheAlignedBuffer,
) -> Arc<CacheAlignedBuffer> {
let data = Arc::new(data);
self.lock().put_decompressed((addr, coord), data)
}
/// Record that the given chunk coordinates of the dataset at `addr` are
/// predicted to be accessed soon (bookkeeping only).
///
/// This does **not** prefetch or pre-decompress anything — it only
/// checks whether each coordinate is already in the chunk index and
/// updates access-pattern stats accordingly.
pub fn prefetch_hint_in(&self, addr: u64, next_coords: &[ChunkCoord]) {
let mut inner = self.lock();
let Some(index) = inner.entry(addr).and_then(|e| e.index.clone()) else {
return;
};
let known = next_coords
.iter()
.filter(|c| index.contains_key(*c))
.count();
inner.stats.sequential_count += known as u64;
}
// ----- Address-less operations on the bound dataset -----
/// Bind the address-less methods to the dataset at chunk-index address
/// `addr`. Returns `true` if this changed the bound dataset.
///
/// Each dataset's state is kept separately, so switching loses nothing
/// and never exposes one dataset's index or chunks to another. The
/// binding itself is shared, though: concurrent readers should use the
/// `addr`-taking methods rather than bind and then call these.
pub fn ensure_dataset(&self, addr: u64) -> bool { pub fn ensure_dataset(&self, addr: u64) -> bool {
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); let mut inner = self.lock();
if inner.index_addr == Some(addr) { let changed = inner.current != Some(addr);
return false; inner.current = Some(addr);
} changed
inner.index = None;
inner.chunk_index = None;
inner.chunk_layout = None;
inner.slots.clear();
inner.slot_index.clear();
inner.current_bytes = 0;
inner.last_coord = None;
inner.index_addr = Some(addr);
true
} }
/// Returns `true` if the chunk index has been built. /// Returns `true` if the bound dataset's chunk index has been built.
pub fn has_index(&self) -> bool { pub fn has_index(&self) -> bool {
self.inner let inner = self.lock();
.lock() inner
.unwrap_or_else(|e| e.into_inner()) .entry(inner.current())
.index .is_some_and(|e| e.index.is_some())
.is_some()
} }
/// Build the chunk index from a pre-collected list of `ChunkInfo`. /// Build the bound dataset's chunk index from a pre-collected list of
/// `ChunkInfo`.
/// ///
/// The `rank` parameter is used to truncate offsets to spatial dims only /// The `rank` parameter is used to truncate offsets to spatial dims only
/// (B-tree v1 stores rank+1 offsets). /// (B-tree v1 stores rank+1 offsets).
pub fn populate_index(&self, chunks: &[ChunkInfo], rank: usize) { pub fn populate_index(&self, chunks: &[ChunkInfo], rank: usize) {
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); let addr = self.lock().current();
if inner.index.is_some() { let _ = self.index_for::<core::convert::Infallible>(addr, rank, || Ok(chunks.to_vec()));
return; // already populated
}
let mut map = HashMap::with_capacity(chunks.len());
for ci in chunks {
let coord: ChunkCoord = ci.offsets.iter().take(rank).copied().collect();
map.insert(coord, ci.clone());
}
inner.index = Some(map);
} }
/// Look up a chunk by its spatial coordinate in the index. /// Look up a chunk by its spatial coordinate in the bound dataset's index.
pub fn lookup_index(&self, coord: &[u64]) -> Option<ChunkInfo> { pub fn lookup_index(&self, coord: &[u64]) -> Option<ChunkInfo> {
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); let inner = self.lock();
inner.index.as_ref()?.get(coord).cloned() inner
.entry(inner.current())?
.index
.as_ref()?
.get(coord)
.cloned()
} }
/// Return all indexed chunks as a `Vec<ChunkInfo>` (order unspecified). /// Return all of the bound dataset's indexed chunks (order unspecified).
pub fn all_indexed_chunks(&self) -> Option<Vec<ChunkInfo>> { pub fn all_indexed_chunks(&self) -> Option<Vec<ChunkInfo>> {
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); let inner = self.lock();
inner.index.as_ref().map(|m| m.values().cloned().collect()) let index = inner.entry(inner.current())?.index.as_ref()?;
Some(index.values().cloned().collect())
} }
// ----- Chunk index (pre-built coordinate → ChunkInfo map) ----- /// Returns `true` if the bound dataset's `ChunkIndex` has been built.
/// Returns `true` if the chunk B-tree index has been built.
pub fn has_chunk_index(&self) -> bool { pub fn has_chunk_index(&self) -> bool {
self.inner let inner = self.lock();
.lock() inner
.unwrap_or_else(|e| e.into_inner()) .entry(inner.current())
.chunk_index .is_some_and(|e| e.chunk_index.is_some())
.is_some()
} }
/// Build and store the chunk B-tree index from a pre-collected list of `ChunkInfo`. /// Build and store the bound dataset's `ChunkIndex`.
pub fn populate_chunk_index(&self, chunks: &[ChunkInfo], rank: usize) { pub fn populate_chunk_index(&self, chunks: &[ChunkInfo], rank: usize) {
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); let built = Arc::new(ChunkIndex::build(chunks, rank));
if inner.chunk_index.is_some() { let mut inner = self.lock();
return; let addr = inner.current();
} inner.touch(addr).chunk_index.get_or_insert(built);
inner.chunk_index = Some(ChunkIndex::build(chunks, rank)); inner.trim_datasets(addr);
} }
// ----- Chunk layout (pre-computed assembly plan) ----- /// Returns `true` if the bound dataset's chunk layout has been computed.
/// Returns `true` if the chunk layout has been computed.
pub fn has_chunk_layout(&self) -> bool { pub fn has_chunk_layout(&self) -> bool {
self.inner let inner = self.lock();
.lock() inner
.unwrap_or_else(|e| e.into_inner()) .entry(inner.current())
.chunk_layout .is_some_and(|e| e.chunk_layout.is_some())
.is_some()
} }
/// Build and store the pre-computed chunk layout for fast assembly. /// Build and store the bound dataset's chunk layout (needs its
/// `ChunkIndex`; does nothing without one).
pub fn populate_chunk_layout(&self, ds_dims: &[usize], chunk_dims: &[usize], elem_size: usize) { pub fn populate_chunk_layout(&self, ds_dims: &[usize], chunk_dims: &[usize], elem_size: usize) {
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); let mut inner = self.lock();
if inner.chunk_layout.is_some() { let addr = inner.current();
let entry = inner.touch(addr);
if entry.chunk_layout.is_some() {
return; return;
} }
if let Some(ref idx) = inner.chunk_index { if let Some(idx) = entry.chunk_index.clone() {
inner.chunk_layout = Some(ChunkLayout::build(idx, ds_dims, chunk_dims, elem_size)); entry.chunk_layout = Some(Arc::new(ChunkLayout::build(
&idx, ds_dims, chunk_dims, elem_size,
)));
} }
} }
/// Execute a function with a reference to the chunk layout. /// Execute a function with a reference to the bound dataset's chunk
/// /// layout. Returns `None` if the layout hasn't been computed yet.
/// Returns `None` if the layout hasn't been computed yet.
pub fn with_chunk_layout<F, R>(&self, f: F) -> Option<R> pub fn with_chunk_layout<F, R>(&self, f: F) -> Option<R>
where where
F: FnOnce(&ChunkLayout) -> R, F: FnOnce(&ChunkLayout) -> R,
{ {
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); let layout = {
inner.chunk_layout.as_ref().map(f) let inner = self.lock();
inner.entry(inner.current())?.chunk_layout.clone()?
};
Some(f(&layout))
} }
// ----- Decompressed data cache (LRU) ----- /// Try to get cached decompressed data for a chunk of the bound dataset.
/// Try to get cached decompressed data for a chunk coordinate.
/// ///
/// O(1) lookup. Returns an owned copy for API compatibility with callers /// Returns an owned copy; prefer [`Self::get_decompressed_aligned`] when
/// that need a `Vec<u8>`; prefer [`Self::get_decompressed_aligned`] when /// an `Arc`-shared buffer works for the caller.
/// an `Arc`-shared buffer works for the caller, since that avoids the
/// copy entirely.
pub fn get_decompressed(&self, coord: &[u64]) -> Option<Vec<u8>> { pub fn get_decompressed(&self, coord: &[u64]) -> Option<Vec<u8>> {
self.get_decompressed_aligned(coord) self.get_decompressed_aligned(coord)
.map(|arc| arc.as_slice().to_vec()) .map(|arc| arc.as_slice().to_vec())
} }
/// Try to get a reference-counted clone of the aligned buffer for a chunk. /// Reference-counted cached buffer for a chunk of the bound dataset.
///
/// O(1) index lookup; the clone is an `Arc` refcount bump, not a copy of
/// the underlying decompressed data.
pub fn get_decompressed_aligned(&self, coord: &[u64]) -> Option<Arc<CacheAlignedBuffer>> { pub fn get_decompressed_aligned(&self, coord: &[u64]) -> Option<Arc<CacheAlignedBuffer>> {
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); let mut inner = self.lock();
inner.tick += 1; let addr = inner.current();
let tick = inner.tick; inner.get_decompressed(addr, coord)
// Track sequential vs random access
let is_sequential = inner.last_coord.as_ref().is_some_and(|prev| {
// Sequential if exactly one dimension changed
let changes: usize = prev
.iter()
.zip(coord.iter())
.filter(|(a, b)| a != b)
.count();
changes <= 1
});
if is_sequential {
inner.stats.sequential_count += 1;
} else if inner.last_coord.is_some() {
inner.stats.random_count += 1;
}
inner.last_coord = Some(coord.to_vec());
let found = if let Some(&idx) = inner.slot_index.get(coord) {
inner.slots[idx].last_access = tick;
Some(Arc::clone(&inner.slots[idx].data))
} else {
None
};
if let Some(ref data) = found {
inner.stats.hits += 1;
inner.stats.bytes_read += data.len() as u64;
} else {
inner.stats.misses += 1;
}
found
} }
/// Insert decompressed chunk data into the LRU cache. /// Insert decompressed chunk data for the bound dataset into the LRU
/// /// cache, returning the `Arc`-shared buffer now cached.
/// The data is stored in a [`CacheAlignedBuffer`] so subsequent reads
/// return cache-line-aligned memory. Returns the `Arc`-shared buffer that
/// is now cached (or already was), so the caller can reuse it directly
/// instead of holding a separate copy of the same data.
pub fn put_decompressed(&self, coord: ChunkCoord, data: Vec<u8>) -> Arc<CacheAlignedBuffer> { pub fn put_decompressed(&self, coord: ChunkCoord, data: Vec<u8>) -> Arc<CacheAlignedBuffer> {
let aligned = CacheAlignedBuffer::from_vec(data); self.put_decompressed_aligned(coord, CacheAlignedBuffer::from_vec(data))
self.put_decompressed_aligned(coord, aligned)
} }
/// Insert an already-aligned buffer into the LRU cache. /// Insert an already-aligned buffer for the bound dataset.
///
/// Returns the `Arc`-shared buffer now held by the cache (the one just
/// inserted, or the existing cached copy if `coord` was already present).
pub fn put_decompressed_aligned( pub fn put_decompressed_aligned(
&self, &self,
coord: ChunkCoord, coord: ChunkCoord,
data: CacheAlignedBuffer, data: CacheAlignedBuffer,
) -> Arc<CacheAlignedBuffer> { ) -> Arc<CacheAlignedBuffer> {
let data = Arc::new(data); let data = Arc::new(data);
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); let mut inner = self.lock();
let data_len = data.len(); let addr = inner.current();
inner.put_decompressed((addr, coord), data)
// Don't cache if single chunk exceeds budget — still return the data
// to the caller, just don't retain it.
if data_len > inner.max_bytes {
return data;
} }
// Check if already present /// [`Self::prefetch_hint_in`] for the bound dataset.
inner.tick += 1; pub fn prefetch_hint(&self, next_coords: &[ChunkCoord]) {
let tick = inner.tick; let addr = self.lock().current();
if let Some(&idx) = inner.slot_index.get(&coord) { self.prefetch_hint_in(addr, next_coords);
inner.slots[idx].last_access = tick;
return Arc::clone(&inner.slots[idx].data); // already cached
} }
// Evict until we have room // ----- Whole-cache operations -----
while inner.slots.len() >= inner.max_slots
|| (inner.current_bytes + data_len > inner.max_bytes && !inner.slots.is_empty())
{
// Find LRU slot
let lru_idx = inner
.slots
.iter()
.enumerate()
.min_by_key(|(_, s)| s.last_access)
.map(|(i, _)| i)
.unwrap();
let removed = inner.slots.swap_remove(lru_idx);
inner.slot_index.remove(&removed.coord);
// swap_remove moved the former last element into `lru_idx` (unless
// it *was* the last element) — fix up that element's index entry.
if lru_idx < inner.slots.len() {
let moved_coord = inner.slots[lru_idx].coord.clone();
inner.slot_index.insert(moved_coord, lru_idx);
}
inner.current_bytes -= removed.data.len();
inner.stats.evictions += 1;
}
inner.current_bytes += data_len; /// Clear the entire cache (indexes + decompressed data + stats).
let new_idx = inner.slots.len();
inner.slot_index.insert(coord.clone(), new_idx);
inner.slots.push(CachedChunk {
coord,
data: Arc::clone(&data),
last_access: tick,
});
data
}
/// Clear the entire cache (index + decompressed data).
pub fn clear(&self) { pub fn clear(&self) {
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); let mut inner = self.lock();
inner.index = None; inner.datasets.clear();
inner.index_addr = None; inner.current = None;
inner.slots.clear(); inner.slots.clear();
inner.slot_index.clear(); inner.slot_index.clear();
inner.current_bytes = 0; inner.current_bytes = 0;
inner.tick = 0; inner.tick = 0;
inner.last_coord = None; inner.last_coord = None;
inner.stats = AccessStats::default(); inner.stats = AccessStats::default();
inner.chunk_index = None;
inner.chunk_layout = None;
}
/// Record that the given chunk coordinates are predicted to be accessed
/// soon (bookkeeping only).
///
/// This does **not** prefetch or pre-decompress anything — it only
/// checks whether each coordinate is already in the chunk index and
/// updates access-pattern stats accordingly. Real prefetching (e.g.
/// background pre-decompression) is not implemented.
pub fn prefetch_hint(&self, next_coords: &[ChunkCoord]) {
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
if inner.index.is_none() {
return;
}
drop(inner);
// For each predicted coordinate, verify it exists in the index.
// The index is already populated, so this is a no-op for known chunks.
// The purpose is to signal intent — callers can pre-decompress if needed.
// We touch the stats to record that prefetch hints were issued.
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
for coord in next_coords {
let exists = inner
.index
.as_ref()
.map(|idx| idx.contains_key(coord))
.unwrap_or(false);
if exists {
inner.stats.sequential_count += 1;
}
}
} }
/// Return the current access pattern statistics. /// Return the current access pattern statistics.
pub fn access_stats(&self) -> AccessStats { pub fn access_stats(&self) -> AccessStats {
self.inner self.lock().stats.clone()
.lock()
.unwrap_or_else(|e| e.into_inner())
.stats
.clone()
} }
/// Update the sweep direction label in the access stats. /// Update the sweep direction label in the access stats.
pub fn set_sweep_direction(&self, direction: &'static str) { pub fn set_sweep_direction(&self, direction: &'static str) {
self.inner self.lock().stats.sweep_direction = Some(direction);
.lock()
.unwrap_or_else(|e| e.into_inner())
.stats
.sweep_direction = Some(direction);
} }
/// Number of decompressed chunks currently cached. /// Number of decompressed chunks currently cached (all datasets).
pub fn cached_chunk_count(&self) -> usize { pub fn cached_chunk_count(&self) -> usize {
self.inner self.lock().slots.len()
.lock()
.unwrap_or_else(|e| e.into_inner())
.slots
.len()
} }
/// Total bytes of decompressed data currently cached. /// Total bytes of decompressed data currently cached (all datasets).
pub fn cached_bytes(&self) -> usize { pub fn cached_bytes(&self) -> usize {
self.inner self.lock().current_bytes
.lock() }
.unwrap_or_else(|e| e.into_inner())
.current_bytes /// Number of datasets whose chunk index is currently kept.
pub fn indexed_dataset_count(&self) -> usize {
self.lock().datasets.len()
} }
} }
@@ -808,6 +967,92 @@ mod tests {
assert_eq!(cache.cached_bytes(), 0); assert_eq!(cache.cached_bytes(), 0);
} }
#[test]
fn datasets_sharing_coordinates_stay_separate() {
let cache = ChunkCache::new();
let a = vec![make_chunk(vec![0, 0], 0x100, 8)];
let b = vec![make_chunk(vec![0, 0], 0x900, 8)];
let got_a = cache.chunks_for::<()>(1, 1, || Ok(a.clone())).unwrap();
let got_b = cache.chunks_for::<()>(2, 1, || Ok(b.clone())).unwrap();
assert_eq!(got_a[0].address, 0x100);
assert_eq!(got_b[0].address, 0x900);
// Built once per dataset: a second lookup doesn't call the builder.
let again = cache
.chunks_for::<()>(1, 1, || panic!("index rebuilt"))
.unwrap();
assert_eq!(again[0].address, 0x100);
cache.put_decompressed_in(1, vec![0], vec![1; 4]);
cache.put_decompressed_in(2, vec![0], vec![2; 4]);
assert_eq!(
cache.get_decompressed_in(1, &[0]).unwrap().as_slice(),
&[1; 4]
);
assert_eq!(
cache.get_decompressed_in(2, &[0]).unwrap().as_slice(),
&[2; 4]
);
assert!(cache.get_decompressed_in(3, &[0]).is_none());
assert_eq!(cache.cached_chunk_count(), 2);
// The bound-dataset methods see only the bound dataset.
cache.ensure_dataset(2);
assert_eq!(cache.lookup_index(&[0]).unwrap().address, 0x900);
assert_eq!(cache.get_decompressed(&[0]).unwrap(), vec![2; 4]);
}
#[test]
fn dataset_indexes_are_bounded() {
let cache = ChunkCache::new();
for addr in 0..(MAX_INDEXED_DATASETS as u64 + 10) {
cache
.chunks_for::<()>(addr, 1, || Ok(vec![make_chunk(vec![0], addr, 8)]))
.unwrap();
}
assert_eq!(cache.indexed_dataset_count(), MAX_INDEXED_DATASETS);
// One huge index evicts the others but is itself kept.
let huge: Vec<ChunkInfo> = (0..MAX_INDEXED_CHUNKS as u64)
.map(|i| make_chunk(vec![i], i, 8))
.collect();
let got = cache.chunks_for::<()>(9999, 1, || Ok(huge)).unwrap();
assert_eq!(got.len(), MAX_INDEXED_CHUNKS);
assert_eq!(cache.indexed_dataset_count(), 1);
}
#[test]
fn concurrent_readers_of_different_datasets_see_their_own_chunks() {
let cache = std::sync::Arc::new(ChunkCache::with_capacity(1 << 20, 64));
let handles: Vec<_> = (0..8u64)
.map(|t| {
let cache = std::sync::Arc::clone(&cache);
std::thread::spawn(move || {
for round in 0..500u64 {
let addr = (t + round) % 16;
let coord = vec![round % 4];
let chunks = cache
.chunks_for::<()>(addr, 1, || {
Ok((0..4).map(|c| make_chunk(vec![c], addr, 8)).collect())
})
.unwrap();
assert!(chunks.iter().all(|c| c.address == addr));
let want = vec![addr as u8; 8];
let got = match cache.get_decompressed_in(addr, &coord) {
Some(hit) => hit.to_vec(),
None => cache
.put_decompressed_in(addr, coord, want.clone())
.to_vec(),
};
assert_eq!(got, want);
}
})
})
.collect();
for h in handles {
h.join().unwrap();
}
}
#[test] #[test]
fn duplicate_insert_is_noop() { fn duplicate_insert_is_noop() {
let cache = ChunkCache::new(); let cache = ChunkCache::new();
+200
View File
@@ -0,0 +1,200 @@
//! Chunk-index linearisation shared by the Fixed Array and Extensible Array
//! chunk indexes (reader and writer).
//!
//! Both indexes store one element per chunk at a *linear* index, and the
//! library derives that index from the chunk's scaled coordinates
//! (`offset / chunk_dim`) using the dataset's **maximum** dimensions, not its
//! current ones (`H5D__farray_idx_get_addr` / `H5D__earray_idx_get_addr`,
//! via `layout->max_down_chunks`). A dataset whose current shape is smaller
//! than its maxshape therefore has gaps in the index, and laying it out by the
//! current shape puts every chunk after the first row in the wrong place.
//!
//! The Extensible Array adds one more step: its one unlimited dimension has no
//! finite chunk count, so the library *swizzles* the coordinates to make that
//! dimension the slowest-varying one (`H5VM_swizzle_coords`, which moves
//! `coords[unlim_dim]` to the front and shifts the dimensions before it right
//! by one) before linearising with `swizzled_max_down_chunks`. When the
//! unlimited dimension is already dimension 0 no swizzle happens.
#[cfg(not(feature = "std"))]
extern crate alloc;
#[cfg(not(feature = "std"))]
use alloc::{vec, vec::Vec};
use crate::error::FormatError;
/// How a chunk index maps linear element indexes to chunk coordinates.
#[derive(Debug, Clone)]
pub(crate) struct ChunkGrid {
/// Spatial chunk dimensions, in dataset order.
chunk_dims: Vec<u64>,
/// Chunks per dimension covering the *current* extent, in dataset order.
cur_chunks: Vec<u64>,
/// Dataset dimension stored at each linearisation position (slowest
/// first). The identity except for a swizzled Extensible Array.
order: Vec<usize>,
/// Linear stride of each linearisation position.
down: Vec<u64>,
}
impl ChunkGrid {
/// Grid for a Fixed Array index: row-major over the chunk counts of the
/// maximum dimensions (`max_dims`, falling back to the current dimensions
/// when the dataspace records none).
pub(crate) fn fixed_array(
cur_dims: &[u64],
max_dims: Option<&[u64]>,
chunk_dims: &[u64],
) -> Result<Self, FormatError> {
Self::build(cur_dims, max_dims, chunk_dims, None)
}
/// Grid for an Extensible Array index: like the Fixed Array, but the
/// unlimited dimension (the one whose maximum is `H5S_UNLIMITED`) is moved
/// to the slowest-varying position first.
pub(crate) fn extensible_array(
cur_dims: &[u64],
max_dims: Option<&[u64]>,
chunk_dims: &[u64],
) -> Result<Self, FormatError> {
let unlim = max_dims.and_then(|m| m.iter().position(|&d| d == u64::MAX));
Self::build(cur_dims, max_dims, chunk_dims, unlim)
}
fn build(
cur_dims: &[u64],
max_dims: Option<&[u64]>,
chunk_dims: &[u64],
unlim: Option<usize>,
) -> Result<Self, FormatError> {
let rank = chunk_dims.len();
if cur_dims.len() != rank || max_dims.is_some_and(|m| m.len() != rank) {
return Err(FormatError::ChunkedReadError(
"chunk index rank does not match the dataspace".into(),
));
}
if chunk_dims.contains(&0) {
return Err(FormatError::ChunkedReadError(
"chunk dimension is zero".into(),
));
}
let cur_chunks: Vec<u64> = cur_dims
.iter()
.zip(chunk_dims)
.map(|(&d, &c)| d.div_ceil(c))
.collect();
// Chunk counts of the maximum extent. An unlimited dimension has no
// finite count; it only ever sits in the slowest position, where its
// count never enters a stride. A (corrupt) maximum smaller than the
// current extent is widened so no allocated chunk becomes unreachable.
let max_chunks: Vec<u64> = (0..rank)
.map(|d| {
let max = max_dims.map_or(cur_dims[d], |m| m[d]);
if max == u64::MAX {
u64::MAX
} else {
max.div_ceil(chunk_dims[d]).max(cur_chunks[d])
}
})
.collect();
let mut order: Vec<usize> = (0..rank).collect();
if let Some(u) = unlim {
order.remove(u);
order.insert(0, u);
}
let mut down = vec![1u64; rank];
for p in (0..rank.saturating_sub(1)).rev() {
let next = max_chunks[order[p + 1]];
if next == u64::MAX {
// Only reachable with more than one unlimited dimension, which
// neither index type can describe.
return Err(FormatError::ChunkedReadError(
"array chunk index with more than one unlimited dimension".into(),
));
}
down[p] = down[p + 1].checked_mul(next).ok_or_else(|| {
FormatError::Overflow("chunk index linear stride overflows u64".into())
})?;
}
Ok(Self {
chunk_dims: chunk_dims.to_vec(),
cur_chunks,
order,
down,
})
}
/// Dataset-space offsets of the chunk stored at linear `index`, or `None`
/// when that chunk lies outside the current extent (the index still has a
/// slot for it; the library ignores such chunks on read).
pub(crate) fn offsets(&self, index: u64) -> Option<Vec<u64>> {
let rank = self.chunk_dims.len();
let mut offsets = vec![0u64; rank];
let mut rem = index;
for p in 0..rank {
let d = self.order[p];
let scaled = rem / self.down[p];
rem %= self.down[p];
if scaled >= self.cur_chunks[d] {
return None;
}
offsets[d] = scaled * self.chunk_dims[d];
}
Some(offsets)
}
/// Linear index of the chunk with scaled coordinates `scaled`
/// (`offset / chunk_dim` per dimension, in dataset order).
pub(crate) fn linear_index(&self, scaled: &[u64]) -> u64 {
self.order
.iter()
.zip(&self.down)
.map(|(&d, &stride)| scaled[d] * stride)
.sum()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fixed_array_uses_max_dims() {
// shape (4, 6), chunks (2, 3), maxshape (20, 10): 10 x 4 chunk grid.
let g = ChunkGrid::fixed_array(&[4, 6], Some(&[20, 10]), &[2, 3]).unwrap();
assert_eq!(g.offsets(0), Some(vec![0, 0]));
assert_eq!(g.offsets(1), Some(vec![0, 3]));
assert_eq!(g.offsets(2), None); // column chunk 2 is beyond the extent
assert_eq!(g.offsets(4), Some(vec![2, 0]));
assert_eq!(g.offsets(5), Some(vec![2, 3]));
assert_eq!(g.offsets(8), None); // row chunk 2 is beyond the extent
assert_eq!(g.linear_index(&[1, 1]), 5);
}
#[test]
fn extensible_array_swizzles_unlimited_dim() {
// maxshape (10, None): dim 1 is unlimited and becomes slowest.
let g = ChunkGrid::extensible_array(&[4, 6], Some(&[10, u64::MAX]), &[2, 3]).unwrap();
// max chunks of dim 0 = 5, so index = c1 * 5 + c0.
assert_eq!(g.linear_index(&[1, 0]), 1);
assert_eq!(g.linear_index(&[0, 1]), 5);
assert_eq!(g.offsets(5), Some(vec![0, 3]));
assert_eq!(g.offsets(6), Some(vec![2, 3]));
assert_eq!(g.offsets(2), None);
}
#[test]
fn extensible_array_unlimited_first_is_row_major() {
let g = ChunkGrid::extensible_array(&[4, 6], Some(&[u64::MAX, 30]), &[2, 3]).unwrap();
// max chunks of dim 1 = 10.
assert_eq!(g.linear_index(&[1, 1]), 11);
assert_eq!(g.offsets(11), Some(vec![2, 3]));
}
#[test]
fn rejects_two_unlimited_dims_after_the_first() {
assert!(ChunkGrid::fixed_array(&[4, 6], Some(&[u64::MAX, u64::MAX]), &[2, 3]).is_err());
}
}
+436 -184
View File
@@ -15,7 +15,7 @@ use crate::datatype::Datatype;
use crate::error::FormatError; use crate::error::FormatError;
use crate::extensible_array::{ExtensibleArrayHeader, read_extensible_array_chunks}; use crate::extensible_array::{ExtensibleArrayHeader, read_extensible_array_chunks};
use crate::filter_pipeline::FilterPipeline; use crate::filter_pipeline::FilterPipeline;
use crate::filters::decompress_chunk; use crate::filters::{all_filters_skipped, decompress_chunk_exact};
use crate::fixed_array::{FixedArrayHeader, read_fixed_array_chunks}; use crate::fixed_array::{FixedArrayHeader, read_fixed_array_chunks};
#[cfg(feature = "std")] #[cfg(feature = "std")]
use std::sync::Arc; use std::sync::Arc;
@@ -65,11 +65,14 @@ fn decompress_all_chunks(
let raw_chunk = &file_data[c_addr..c_addr + size]; let raw_chunk = &file_data[c_addr..c_addr + size];
let decompressed = if let Some(pl) = pipeline { let decompressed = if let Some(pl) = pipeline {
if chunk_info.filter_mask == 0 { decompress_chunk_exact(
decompress_chunk(raw_chunk, pl, chunk_total_bytes, element_size)? raw_chunk,
} else { pl,
raw_chunk.to_vec() chunk_total_bytes,
} element_size,
chunk_info.filter_mask,
&chunk_info.offsets,
)?
} else { } else {
raw_chunk.to_vec() raw_chunk.to_vec()
}; };
@@ -146,6 +149,96 @@ pub(crate) fn checked_byte_len(elements: u64, elem_size: usize) -> Result<usize,
}) })
} }
/// The spatial chunk dimensions of a chunked layout (`chunk_dimensions` is
/// the layout message's list: one per dataspace dimension, then the element
/// size), after the checks libhdf5 makes when it opens a chunked dataset
/// (`H5D__chunk_init` / `H5D__chunk_set_sizes`): the chunk rank must match
/// the dataspace's, no chunk dimension may be 0, and a chunk indexed by a
/// version-1 B-tree (`layout_version` below 4) may not be 4 GiB or more (the
/// B-tree records chunk sizes in 32 bits; libhdf5: "chunk size must be < 4GB
/// with v1 b-tree index"). The other chunk indexes allow larger chunks:
/// HDF5 2.0 writes them with layout version 5. A zero chunk dimension used
/// to read as all fill values, and a huge one to hang the reader.
pub(crate) fn chunk_geometry(
chunk_dimensions: &[u32],
layout_version: u8,
dataspace: &Dataspace,
elem_size: usize,
) -> Result<(usize, Vec<usize>), 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. /// Product of chunk dimensions times the element size, overflow-checked.
pub(crate) fn checked_chunk_byte_len( pub(crate) fn checked_chunk_byte_len(
chunk_dims: &[usize], chunk_dims: &[usize],
@@ -220,9 +313,82 @@ pub fn collect_chunk_info(
offset_size: u8, offset_size: u8,
length_size: u8, length_size: u8,
) -> Result<Vec<ChunkInfo>, FormatError> { ) -> Result<Vec<ChunkInfo>, 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<Vec<ChunkInfo>, 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<Vec<u64>, 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
/// file's size-of-offsets.
const CHUNK_KEY_OFFSET_SIZE: u8 = 8;
/// Maximum recursion depth for chunk B-tree traversal (malformed/cyclic data /// Maximum recursion depth for chunk B-tree traversal (malformed/cyclic data
/// protection), matching `btree_v1.rs`'s `MAX_BTREE_DEPTH`. /// protection), matching `btree_v1.rs`'s `MAX_BTREE_DEPTH`.
const MAX_CHUNK_BTREE_DEPTH: usize = 64; const MAX_CHUNK_BTREE_DEPTH: usize = 64;
@@ -231,6 +397,7 @@ fn collect_chunk_info_inner(
file_data: &[u8], file_data: &[u8],
btree_address: u64, btree_address: u64,
ndims: usize, ndims: usize,
chunk_dimensions: Option<&[u32]>,
offset_size: u8, offset_size: u8,
_length_size: u8, _length_size: u8,
depth: usize, depth: usize,
@@ -260,8 +427,14 @@ fn collect_chunk_info_inner(
let mut pos = offset + 8 + os * 2; // skip left/right sibling let mut pos = offset + 8 + os * 2; // skip left/right sibling
// Key size: chunk_size(4) + filter_mask(4) + ndims * offset_size // Key: chunk_size(4) + filter_mask(4) + one offset per dimension. The
let key_size = 4 + 4 + ndims * os; // offsets are always 8 bytes each — they are dataset coordinates, not file
// addresses, so they do not follow the superblock's size-of-offsets (only
// the sibling and child addresses do).
let key_size = ndims
.checked_mul(CHUNK_KEY_OFFSET_SIZE as usize)
.and_then(|n| n.checked_add(8))
.ok_or_else(|| FormatError::ChunkedReadError("chunk key too large".into()))?;
if node_level == 0 { if node_level == 0 {
// Leaf node: keys and children interleaved // Leaf node: keys and children interleaved
@@ -284,12 +457,7 @@ fn collect_chunk_info_inner(
file_data[pos + 6], file_data[pos + 6],
file_data[pos + 7], file_data[pos + 7],
]); ]);
let mut offsets = Vec::with_capacity(ndims); let offsets = read_key_offsets(file_data, pos, ndims, chunk_dimensions)?;
let mut kp = pos + 8;
for _ in 0..ndims {
offsets.push(read_offset(file_data, kp, offset_size)?);
kp += os;
}
pos += key_size; pos += key_size;
// Parse child address // Parse child address
@@ -303,7 +471,8 @@ fn collect_chunk_info_inner(
address, 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) Ok(chunks)
} else { } else {
// Internal node: recurse into children // Internal node: recurse into children
@@ -312,11 +481,13 @@ fn collect_chunk_info_inner(
let mut child_addrs = Vec::with_capacity(entries_used); let mut child_addrs = Vec::with_capacity(entries_used);
for _ in 0..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)?; let child_addr = read_offset(file_data, pos, offset_size)?;
child_addrs.push(child_addr); child_addrs.push(child_addr);
pos += os; pos += os;
} }
read_key_offsets(file_data, pos, ndims, chunk_dimensions)?;
let mut all_chunks = Vec::new(); let mut all_chunks = Vec::new();
for child_addr in child_addrs { for child_addr in child_addrs {
@@ -324,6 +495,7 @@ fn collect_chunk_info_inner(
file_data, file_data,
child_addr, child_addr,
ndims, ndims,
chunk_dimensions,
offset_size, offset_size,
_length_size, _length_size,
depth + 1, depth + 1,
@@ -507,6 +679,7 @@ pub fn list_chunks(
addr_opt, addr_opt,
single_filtered_size, single_filtered_size,
single_filter_mask, single_filter_mask,
unfiltered_edges,
) = match layout { ) = match layout {
DataLayout::Chunked { DataLayout::Chunked {
chunk_dimensions, chunk_dimensions,
@@ -515,6 +688,7 @@ pub fn list_chunks(
chunk_index_type, chunk_index_type,
single_chunk_filtered_size, single_chunk_filtered_size,
single_chunk_filter_mask, single_chunk_filter_mask,
dont_filter_partial_edge_chunks,
} => ( } => (
chunk_dimensions, chunk_dimensions,
*version, *version,
@@ -522,6 +696,7 @@ pub fn list_chunks(
*btree_address, *btree_address,
*single_chunk_filtered_size, *single_chunk_filtered_size,
*single_chunk_filter_mask, *single_chunk_filter_mask,
*dont_filter_partial_edge_chunks,
), ),
_ => { _ => {
return Err(FormatError::ChunkedReadError( return Err(FormatError::ChunkedReadError(
@@ -534,30 +709,13 @@ pub fn list_chunks(
.ok_or_else(|| FormatError::ChunkedReadError("no address for chunked layout".into()))?; .ok_or_else(|| FormatError::ChunkedReadError("no address for chunked layout".into()))?;
// Both v3 and v4 include element size as last dim (rank+1) // Both v3 and v4 include element size as last dim (rank+1)
let ndims = chunk_dimensions.len(); let (rank, chunk_dims) = chunk_geometry(chunk_dimensions, version, dataspace, elem_size)?;
let rank = ndims
.checked_sub(1)
.ok_or_else(|| FormatError::ChunkedReadError("chunked layout has no dimensions".into()))?;
let chunk_dims: Vec<usize> = chunk_dimensions[..rank]
.iter()
.map(|&d| d as usize)
.collect();
let ds_dims: Vec<usize> = dataspace.dimensions.iter().map(|&d| d as usize).collect(); let ds_dims: Vec<usize> = 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 // Collect chunks based on version and index type
let chunks = match (version, chunk_index_type) { let mut chunks = match (version, chunk_index_type) {
(3, _) => { (3, _) => {
let ndims = chunk_dimensions.len(); // rank+1 collect_chunk_info_checked(file_data, addr, chunk_dimensions, offset_size, length_size)?
collect_chunk_info(file_data, addr, ndims, offset_size, length_size)?
} }
(4, Some(1)) => { (4, Some(1)) => {
// Single chunk — one chunk covering the entire dataset // Single chunk — one chunk covering the entire dataset
@@ -593,6 +751,7 @@ pub fn list_chunks(
file_data, file_data,
&header, &header,
&dataspace.dimensions, &dataspace.dimensions,
dataspace.max_dimensions.as_deref(),
spatial_chunk_dims, spatial_chunk_dims,
elem_size as u32, elem_size as u32,
offset_size, offset_size,
@@ -608,6 +767,7 @@ pub fn list_chunks(
file_data, file_data,
&header, &header,
&dataspace.dimensions, &dataspace.dimensions,
dataspace.max_dimensions.as_deref(),
spatial_chunk_dims, spatial_chunk_dims,
elem_size as u32, elem_size as u32,
offset_size, offset_size,
@@ -633,6 +793,23 @@ pub fn list_chunks(
} }
}; };
// With "don't filter partial edge chunks", a chunk that extends past the
// dataset's extent is stored raw while its filter mask still reads 0.
// Mark every filter skipped so all read paths copy it as-is.
if unfiltered_edges {
for chunk in &mut chunks {
let partial = chunk
.offsets
.iter()
.zip(&chunk_dims)
.zip(&ds_dims)
.any(|((&off, &cd), &dd)| off.saturating_add(cd as u64) > dd as u64);
if partial {
chunk.filter_mask = u32::MAX;
}
}
}
Ok((chunks, chunk_dims)) Ok((chunks, chunk_dims))
} }
@@ -645,6 +822,7 @@ pub fn read_chunked_data(
offset_size: u8, offset_size: u8,
length_size: u8, length_size: u8,
) -> Result<Vec<u8>, FormatError> { ) -> Result<Vec<u8>, FormatError> {
check_chunk_element_size(layout, datatype, offset_size)?;
let elem_size = datatype.type_size() as usize; let elem_size = datatype.type_size() as usize;
let (chunks, chunk_dims) = list_chunks( let (chunks, chunk_dims) = list_chunks(
file_data, file_data,
@@ -768,12 +946,13 @@ pub fn read_chunked_data_cached(
length_size: u8, length_size: u8,
cache: &ChunkCache, cache: &ChunkCache,
) -> Result<Vec<u8>, FormatError> { ) -> Result<Vec<u8>, FormatError> {
let (chunk_dimensions, addr_opt) = match layout { let (chunk_dimensions, version, addr_opt) = match layout {
DataLayout::Chunked { DataLayout::Chunked {
chunk_dimensions, chunk_dimensions,
version,
btree_address, btree_address,
.. ..
} => (chunk_dimensions, *btree_address), } => (chunk_dimensions, *version, *btree_address),
_ => { _ => {
return Err(FormatError::ChunkedReadError( return Err(FormatError::ChunkedReadError(
"expected chunked layout".into(), "expected chunked layout".into(),
@@ -784,44 +963,25 @@ pub fn read_chunked_data_cached(
let addr = addr_opt let addr = addr_opt
.ok_or_else(|| FormatError::ChunkedReadError("no address for chunked layout".into()))?; .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 elem_size = datatype.type_size() as usize;
let ndims = chunk_dimensions.len(); let (rank, chunk_dims) = chunk_geometry(chunk_dimensions, version, dataspace, elem_size)?;
let rank = ndims
.checked_sub(1)
.ok_or_else(|| FormatError::ChunkedReadError("chunked layout has no dimensions".into()))?;
let chunk_dims: Vec<usize> = chunk_dimensions[..rank]
.iter()
.map(|&d| d as usize)
.collect();
let ds_dims: Vec<usize> = dataspace.dimensions.iter().map(|&d| d as usize).collect(); let ds_dims: Vec<usize> = 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; bind it to this one so a // The per-file cache is shared across datasets (and threads); every
// different dataset's chunk index is never reused for this read. // lookup is keyed by this dataset's chunk-index address, so another
cache.ensure_dataset(addr); // dataset's index or chunks are never used for this read.
let chunks = cache.chunks_for(addr, rank, || {
// Populate chunk index on first access list_chunks(
if !cache.has_index() {
let (chunks, _) = list_chunks(
file_data, file_data,
layout, layout,
dataspace, dataspace,
elem_size, elem_size,
offset_size, offset_size,
length_size, length_size,
)?; )
cache.populate_index(&chunks, rank); .map(|(chunks, _)| chunks)
} })?;
let chunks = cache.all_indexed_chunks().unwrap_or_default();
// Assemble output // Assemble output
let total_bytes = checked_byte_len(dataspace.checked_num_elements()?, elem_size)?; let total_bytes = checked_byte_len(dataspace.checked_num_elements()?, elem_size)?;
@@ -876,10 +1036,11 @@ pub fn read_chunked_data_cached(
}; };
// Chunks stored as-is (no pipeline, or the filter mask says this chunk // Chunks stored as-is (no pipeline, or the filter mask says this chunk
// skipped it) are copied straight from the file bytes: they are already in // skipped every filter) are copied straight from the file bytes: they are
// memory, so routing them through a Vec and then an aligned cache buffer // already in memory, so routing them through a Vec and then an aligned
// was two extra copies of the whole dataset for nothing. // cache buffer was two extra copies of the whole dataset for nothing.
let stored_raw = |c: &ChunkInfo| pipeline.is_none() || c.filter_mask != 0; let stored_raw =
|c: &ChunkInfo| pipeline.is_none_or(|pl| all_filters_skipped(pl, c.filter_mask));
let mut misses: Vec<&ChunkInfo> = Vec::new(); let mut misses: Vec<&ChunkInfo> = Vec::new();
for chunk_info in &chunks { for chunk_info in &chunks {
if stored_raw(chunk_info) { if stored_raw(chunk_info) {
@@ -887,7 +1048,7 @@ pub fn read_chunked_data_cached(
continue; continue;
} }
let coord: Vec<u64> = chunk_info.offsets.iter().take(rank).copied().collect(); let coord: Vec<u64> = chunk_info.offsets.iter().take(rank).copied().collect();
match cache.get_decompressed_aligned(&coord) { match cache.get_decompressed_in(addr, &coord) {
Some(cached) => place(&cached, chunk_info), Some(cached) => place(&cached, chunk_info),
None => misses.push(chunk_info), None => misses.push(chunk_info),
} }
@@ -901,7 +1062,14 @@ pub fn read_chunked_data_cached(
let cache_them = total_bytes <= cache.max_bytes(); let cache_them = total_bytes <= cache.max_bytes();
if let Some(pl) = pipeline { if let Some(pl) = pipeline {
let decode = |c: &&ChunkInfo| -> Result<Vec<u8>, FormatError> { let decode = |c: &&ChunkInfo| -> Result<Vec<u8>, FormatError> {
decompress_chunk(raw_bytes(c)?, pl, chunk_total_bytes, elem_size as u32) 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) { for batch in misses.chunks(DECODE_BATCH) {
#[cfg(feature = "parallel")] #[cfg(feature = "parallel")]
@@ -918,7 +1086,7 @@ pub fn read_chunked_data_cached(
let data = data?; let data = data?;
if cache_them { if cache_them {
let coord: Vec<u64> = chunk_info.offsets.iter().take(rank).copied().collect(); let coord: Vec<u64> = chunk_info.offsets.iter().take(rank).copied().collect();
let cached = cache.put_decompressed(coord, data); let cached = cache.put_decompressed_in(addr, coord, data);
place(&cached, chunk_info); place(&cached, chunk_info);
} else { } else {
place(&data, chunk_info); place(&data, chunk_info);
@@ -1086,12 +1254,13 @@ pub fn read_chunked_data_sweep(
cache: &ChunkCache, cache: &ChunkCache,
sweep: &mut SweepContext, sweep: &mut SweepContext,
) -> Result<Vec<u8>, FormatError> { ) -> Result<Vec<u8>, FormatError> {
let (chunk_dimensions, addr_opt) = match layout { let (chunk_dimensions, version, addr_opt) = match layout {
DataLayout::Chunked { DataLayout::Chunked {
chunk_dimensions, chunk_dimensions,
version,
btree_address, btree_address,
.. ..
} => (chunk_dimensions, *btree_address), } => (chunk_dimensions, *version, *btree_address),
_ => { _ => {
return Err(FormatError::ChunkedReadError( return Err(FormatError::ChunkedReadError(
"expected chunked layout".into(), "expected chunked layout".into(),
@@ -1102,44 +1271,25 @@ pub fn read_chunked_data_sweep(
let addr = addr_opt let addr = addr_opt
.ok_or_else(|| FormatError::ChunkedReadError("no address for chunked layout".into()))?; .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 elem_size = datatype.type_size() as usize;
let ndims = chunk_dimensions.len(); let (rank, chunk_dims) = chunk_geometry(chunk_dimensions, version, dataspace, elem_size)?;
let rank = ndims
.checked_sub(1)
.ok_or_else(|| FormatError::ChunkedReadError("chunked layout has no dimensions".into()))?;
let chunk_dims: Vec<usize> = chunk_dimensions[..rank]
.iter()
.map(|&d| d as usize)
.collect();
let ds_dims: Vec<usize> = dataspace.dimensions.iter().map(|&d| d as usize).collect(); let ds_dims: Vec<usize> = 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; bind it to this one so a // The per-file cache is shared across datasets (and threads); every
// different dataset's chunk index is never reused for this read. // lookup is keyed by this dataset's chunk-index address, so another
cache.ensure_dataset(addr); // dataset's index or chunks are never used for this read.
let chunks = cache.chunks_for(addr, rank, || {
// Populate chunk index on first access list_chunks(
if !cache.has_index() {
let (chunks, _) = list_chunks(
file_data, file_data,
layout, layout,
dataspace, dataspace,
elem_size, elem_size,
offset_size, offset_size,
length_size, length_size,
)?; )
cache.populate_index(&chunks, rank); .map(|(chunks, _)| chunks)
} })?;
let chunks = cache.all_indexed_chunks().unwrap_or_default();
// Assemble output // Assemble output
let total_bytes = checked_byte_len(dataspace.checked_num_elements()?, elem_size)?; let total_bytes = checked_byte_len(dataspace.checked_num_elements()?, elem_size)?;
@@ -1170,12 +1320,12 @@ pub fn read_chunked_data_sweep(
// Issue prefetch hint for predicted next chunks // Issue prefetch hint for predicted next chunks
if !sweep.predicted_next.is_empty() { if !sweep.predicted_next.is_empty() {
cache.prefetch_hint(&sweep.predicted_next); cache.prefetch_hint_in(addr, &sweep.predicted_next);
cache.set_sweep_direction(sweep.direction); cache.set_sweep_direction(sweep.direction);
} }
// Try decompressed cache first // Try decompressed cache first
let decompressed = if let Some(cached) = cache.get_decompressed_aligned(&coord) { let decompressed = if let Some(cached) = cache.get_decompressed_in(addr, &coord) {
cached cached
} else { } else {
// Decompress from file // Decompress from file
@@ -1184,15 +1334,18 @@ pub fn read_chunked_data_sweep(
ensure_len(file_data, c_addr, size)?; ensure_len(file_data, c_addr, size)?;
let raw_chunk = &file_data[c_addr..c_addr + size]; let raw_chunk = &file_data[c_addr..c_addr + size];
let dec = if let Some(pl) = pipeline { let dec = if let Some(pl) = pipeline {
if chunk_info.filter_mask == 0 { decompress_chunk_exact(
decompress_chunk(raw_chunk, pl, chunk_total_bytes, elem_size as u32)? raw_chunk,
} else { pl,
raw_chunk.to_vec() chunk_total_bytes,
} elem_size as u32,
chunk_info.filter_mask,
&coord,
)?
} else { } else {
raw_chunk.to_vec() raw_chunk.to_vec()
}; };
cache.put_decompressed(coord, dec) cache.put_decompressed_in(addr, coord, dec)
}; };
let chunk_offsets: Vec<usize> = chunk_info let chunk_offsets: Vec<usize> = chunk_info
@@ -1240,12 +1393,13 @@ pub fn read_chunked_data_indexed(
length_size: u8, length_size: u8,
cache: &ChunkCache, cache: &ChunkCache,
) -> Result<Vec<u8>, FormatError> { ) -> Result<Vec<u8>, FormatError> {
let (chunk_dimensions, addr_opt) = match layout { let (chunk_dimensions, version, addr_opt) = match layout {
DataLayout::Chunked { DataLayout::Chunked {
chunk_dimensions, chunk_dimensions,
version,
btree_address, btree_address,
.. ..
} => (chunk_dimensions, *btree_address), } => (chunk_dimensions, *version, *btree_address),
_ => { _ => {
return Err(FormatError::ChunkedReadError( return Err(FormatError::ChunkedReadError(
"expected chunked layout".into(), "expected chunked layout".into(),
@@ -1256,68 +1410,39 @@ pub fn read_chunked_data_indexed(
let addr = addr_opt let addr = addr_opt
.ok_or_else(|| FormatError::ChunkedReadError("no address for chunked layout".into()))?; .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 elem_size = datatype.type_size() as usize;
let ndims = chunk_dimensions.len(); let (rank, chunk_dims) = chunk_geometry(chunk_dimensions, version, dataspace, elem_size)?;
let rank = ndims
.checked_sub(1)
.ok_or_else(|| FormatError::ChunkedReadError("chunked layout has no dimensions".into()))?;
let chunk_dims: Vec<usize> = chunk_dimensions[..rank]
.iter()
.map(|&d| d as usize)
.collect();
let ds_dims: Vec<usize> = dataspace.dimensions.iter().map(|&d| d as usize).collect(); let ds_dims: Vec<usize> = 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; bind it to this one so a // Chunk index and assembly plan for this dataset, built on first access
// different dataset's chunk index is never reused for this read. // and kept per dataset (keyed by chunk-index address) in the shared cache.
cache.ensure_dataset(addr); let plan = cache.chunk_layout_for(
addr,
// Build chunk index on first access rank,
if !cache.has_chunk_index() { || {
let (chunks, _) = list_chunks( list_chunks(
file_data, file_data,
layout, layout,
dataspace, dataspace,
elem_size, elem_size,
offset_size, offset_size,
length_size, length_size,
)
.map(|(chunks, _)| chunks)
},
&ds_dims,
&chunk_dims,
elem_size,
)?; )?;
cache.populate_chunk_index(&chunks, rank); let chunk_total_bytes = plan.chunk_total_bytes;
// Also populate the legacy index for compatibility
if !cache.has_index() {
cache.populate_index(&chunks, rank);
}
}
// Build chunk layout on first access
if !cache.has_chunk_layout() {
cache.populate_chunk_layout(&ds_dims, &chunk_dims, elem_size);
}
// Get the layout info (mappings, output size, chunk total bytes)
let (mappings_info, output_bytes, chunk_total_bytes) = cache
.with_chunk_layout(|layout| {
let info: Vec<_> = layout
.mappings
.iter()
.map(|m| (m.coord.clone(), m.file_offset, m.file_size, m.filter_mask))
.collect();
(info, layout.output_bytes, layout.chunk_total_bytes)
})
.ok_or_else(|| FormatError::ChunkedReadError("chunk layout not available".into()))?;
// Decompress chunks (using LRU cache where possible) // Decompress chunks (using LRU cache where possible)
let mut chunk_buffers: Vec<Arc<CacheAlignedBuffer>> = Vec::with_capacity(mappings_info.len()); let mut chunk_buffers: Vec<Arc<CacheAlignedBuffer>> = Vec::with_capacity(plan.mappings.len());
for (coord, file_offset, file_size, filter_mask) in &mappings_info { for m in &plan.mappings {
if let Some(cached) = cache.get_decompressed_aligned(coord) { let (coord, file_offset, file_size, filter_mask) =
(&m.coord, &m.file_offset, &m.file_size, &m.filter_mask);
if let Some(cached) = cache.get_decompressed_in(addr, coord) {
chunk_buffers.push(cached); chunk_buffers.push(cached);
} else { } else {
let c_addr = *file_offset as usize; let c_addr = *file_offset as usize;
@@ -1325,26 +1450,27 @@ pub fn read_chunked_data_indexed(
ensure_len(file_data, c_addr, size)?; ensure_len(file_data, c_addr, size)?;
let raw_chunk = &file_data[c_addr..c_addr + size]; let raw_chunk = &file_data[c_addr..c_addr + size];
let decompressed = if let Some(pl) = pipeline { let decompressed = if let Some(pl) = pipeline {
if *filter_mask == 0 { decompress_chunk_exact(
decompress_chunk(raw_chunk, pl, chunk_total_bytes, elem_size as u32)? raw_chunk,
} else { pl,
raw_chunk.to_vec() chunk_total_bytes,
} elem_size as u32,
*filter_mask,
coord,
)?
} else { } else {
raw_chunk.to_vec() raw_chunk.to_vec()
}; };
let aligned = CacheAlignedBuffer::from_vec(decompressed); let aligned = CacheAlignedBuffer::from_vec(decompressed);
let arc = cache.put_decompressed_aligned(coord.clone(), aligned); let arc = cache.put_decompressed_aligned_in(addr, coord.clone(), aligned);
chunk_buffers.push(arc); chunk_buffers.push(arc);
} }
} }
// Assemble using pre-computed layout // Assemble using pre-computed layout
let mut output = vec![0u8; output_bytes]; let mut output = vec![0u8; plan.output_bytes];
let data_refs: Vec<&[u8]> = chunk_buffers.iter().map(|b| b.as_slice()).collect(); let data_refs: Vec<&[u8]> = chunk_buffers.iter().map(|b| b.as_slice()).collect();
cache.with_chunk_layout(|layout| { plan.assemble(&data_refs, &mut output);
layout.assemble(&data_refs, &mut output);
});
Ok(output) Ok(output)
} }
@@ -1592,17 +1718,19 @@ mod tests {
} else { } else {
0 0
}; };
write_offset(&mut buf, off, offset_size); // Key offsets are always 8 bytes (they are coordinates).
write_offset(&mut buf, off, 8);
} }
// Child: address // Child: address
write_offset(&mut buf, chunk.address, offset_size); 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()); // chunk_size
buf.extend_from_slice(&0u32.to_le_bytes()); // filter_mask buf.extend_from_slice(&0u32.to_le_bytes()); // filter_mask
for _ in 0..ndims { for _ in 0..ndims {
write_offset(&mut buf, u64::MAX, offset_size); write_offset(&mut buf, 0, 8);
} }
buf buf
@@ -1610,6 +1738,48 @@ mod tests {
// --- ChunkInfo collection tests --- // --- ChunkInfo collection tests ---
#[test]
fn checked_collection_refuses_keys_off_the_chunk_grid() {
let chunk = |offsets: Vec<u64>, 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] #[test]
fn collect_two_chunks_from_leaf() { fn collect_two_chunks_from_leaf() {
let ndims = 2; // rank+1 for 1D dataset let ndims = 2; // rank+1 for 1D dataset
@@ -1680,6 +1850,37 @@ mod tests {
assert_eq!(result[2].address, 0x300); assert_eq!(result[2].address, 0x300);
} }
#[test]
fn collect_chunks_with_four_byte_addresses() {
// Sibling and child addresses are 4 bytes; the key offsets stay 8.
let ndims = 3;
let os: u8 = 4;
let chunks = vec![
ChunkInfo {
chunk_size: 80,
filter_mask: 2,
offsets: vec![0, 5, 0],
address: 0x1000,
},
ChunkInfo {
chunk_size: 96,
filter_mask: 0,
offsets: vec![8, 10, 0],
address: 0x2000,
},
];
let btree = build_chunk_btree_leaf(&chunks, ndims, os);
assert_eq!(btree.len(), 8 + 2 * 4 + 2 * (8 + 3 * 8 + 4) + (8 + 3 * 8));
let result = collect_chunk_info(&btree, 0, ndims, os, os).unwrap();
assert_eq!(result.len(), 2);
for (got, want) in result.iter().zip(&chunks) {
assert_eq!(got.offsets, want.offsets);
assert_eq!(got.address, want.address);
assert_eq!(got.chunk_size, want.chunk_size);
assert_eq!(got.filter_mask, want.filter_mask);
}
}
#[test] #[test]
fn collect_empty_btree() { fn collect_empty_btree() {
let ndims = 2; let ndims = 2;
@@ -1697,6 +1898,51 @@ mod tests {
use crate::dataspace::{Dataspace, DataspaceType}; use crate::dataspace::{Dataspace, DataspaceType};
use crate::datatype::{Datatype, DatatypeByteOrder}; 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 { fn make_f64_type() -> Datatype {
Datatype::FloatingPoint { Datatype::FloatingPoint {
size: 8, size: 8,
@@ -1774,6 +2020,7 @@ mod tests {
chunk_index_type: None, chunk_index_type: None,
single_chunk_filtered_size: None, single_chunk_filtered_size: None,
single_chunk_filter_mask: None, single_chunk_filter_mask: None,
dont_filter_partial_edge_chunks: false,
}; };
let dataspace = Dataspace { let dataspace = Dataspace {
@@ -1797,6 +2044,7 @@ mod tests {
chunk_index_type: None, chunk_index_type: None,
single_chunk_filtered_size: None, single_chunk_filtered_size: None,
single_chunk_filter_mask: None, single_chunk_filter_mask: None,
dont_filter_partial_edge_chunks: false,
}; };
let dataspace = Dataspace { let dataspace = Dataspace {
space_type: DataspaceType::Simple, space_type: DataspaceType::Simple,
@@ -1808,8 +2056,8 @@ mod tests {
let file_data = vec![0u8; 64]; let file_data = vec![0u8; 64];
let result = read_chunked_data(&file_data, &layout, &dataspace, &datatype, None, 8, 8); let result = read_chunked_data(&file_data, &layout, &dataspace, &datatype, None, 8, 8);
assert!( assert!(
matches!(result, Err(FormatError::ChunkedReadError(_))), matches!(result, Err(FormatError::InvalidChunkDimensions(_))),
"expected a clean ChunkedReadError, got {result:?}" "expected a clean InvalidChunkDimensions, got {result:?}"
); );
} }
@@ -1954,6 +2202,7 @@ mod tests {
chunk_index_type: None, chunk_index_type: None,
single_chunk_filtered_size: None, single_chunk_filtered_size: None,
single_chunk_filter_mask: None, single_chunk_filter_mask: None,
dont_filter_partial_edge_chunks: false,
}; };
let dataspace = Dataspace { let dataspace = Dataspace {
space_type: DataspaceType::Simple, space_type: DataspaceType::Simple,
@@ -2036,6 +2285,7 @@ mod tests {
chunk_index_type: None, chunk_index_type: None,
single_chunk_filtered_size: None, single_chunk_filtered_size: None,
single_chunk_filter_mask: None, single_chunk_filter_mask: None,
dont_filter_partial_edge_chunks: false,
}; };
let dataspace = Dataspace { let dataspace = Dataspace {
space_type: DataspaceType::Simple, space_type: DataspaceType::Simple,
@@ -2198,6 +2448,7 @@ mod tests {
chunk_index_type: Some(1), chunk_index_type: Some(1),
single_chunk_filtered_size: None, single_chunk_filtered_size: None,
single_chunk_filter_mask: None, single_chunk_filter_mask: None,
dont_filter_partial_edge_chunks: false,
}; };
let dataspace = Dataspace { let dataspace = Dataspace {
space_type: DataspaceType::Simple, space_type: DataspaceType::Simple,
@@ -2227,12 +2478,12 @@ mod tests {
let datatype = make_f64_type(); let datatype = make_f64_type();
let cache = ChunkCache::new(); let cache = ChunkCache::new();
assert!(!cache.has_index()); assert_eq!(cache.indexed_dataset_count(), 0);
let raw = read_chunked_data_cached( let raw = read_chunked_data_cached(
&file_data, &layout, &dataspace, &datatype, None, 8, 8, &cache, &file_data, &layout, &dataspace, &datatype, None, 8, 8, &cache,
) )
.unwrap(); .unwrap();
assert!(cache.has_index()); assert_eq!(cache.indexed_dataset_count(), 1);
assert_eq!(raw.len(), 20 * 8); assert_eq!(raw.len(), 20 * 8);
for i in 0..20 { for i in 0..20 {
let val = f64::from_le_bytes(raw[i * 8..(i + 1) * 8].try_into().unwrap()); let val = f64::from_le_bytes(raw[i * 8..(i + 1) * 8].try_into().unwrap());
@@ -2254,7 +2505,7 @@ mod tests {
&file_data, &layout, &dataspace, &datatype, None, 8, 8, &cache, &file_data, &layout, &dataspace, &datatype, None, 8, 8, &cache,
) )
.unwrap(); .unwrap();
assert!(cache.has_index()); assert_eq!(cache.indexed_dataset_count(), 1);
assert_eq!(cache.cached_chunk_count(), 0); assert_eq!(cache.cached_chunk_count(), 0);
// Second read — reuses the cached index // Second read — reuses the cached index
@@ -2263,6 +2514,7 @@ mod tests {
) )
.unwrap(); .unwrap();
assert_eq!(raw1, raw2); assert_eq!(raw1, raw2);
assert_eq!(cache.indexed_dataset_count(), 1);
} }
#[test] #[test]
+640 -164
View File
@@ -4,15 +4,17 @@
extern crate alloc; extern crate alloc;
#[cfg(not(feature = "std"))] #[cfg(not(feature = "std"))]
use alloc::{vec, vec::Vec}; use alloc::{format, vec, vec::Vec};
use crate::checksum::jenkins_lookup3; use crate::checksum::jenkins_lookup3;
use crate::chunk_cache::{CACHE_LINE_SIZE, align_to_cache_line}; use crate::chunk_cache::{CACHE_LINE_SIZE, align_to_cache_line};
use crate::chunk_grid::ChunkGrid;
use crate::ea_writer; use crate::ea_writer;
use crate::error::FormatError; use crate::error::FormatError;
use crate::filter_pipeline::{ use crate::filter_pipeline::{
FILTER_DEFLATE, FILTER_FLETCHER32, FILTER_LZ4, FILTER_PCODEC, FILTER_SHUFFLE, FILTER_ZSTD, FILTER_BITSHUFFLE, FILTER_BLOSC, FILTER_BZIP2, FILTER_DEFLATE, FILTER_FLETCHER32, FILTER_LZ4,
FilterDescription, FilterPipeline, FILTER_LZF, FILTER_PCODEC, FILTER_PCODEC_NAME, FILTER_SHUFFLE, FILTER_ZSTD, FilterDescription,
FilterPipeline,
}; };
use crate::filters::compress_chunk; use crate::filters::compress_chunk;
/// Round a file offset up to the next cache-line boundary. /// Round a file offset up to the next cache-line boundary.
@@ -44,8 +46,170 @@ pub struct ChunkOptions {
pub lz4: bool, pub lz4: bool,
/// Zstandard compression level (1-22), None = no zstd. Filter ID 32015. /// Zstandard compression level (1-22), None = no zstd. Filter ID 32015.
pub zstd_level: Option<u32>, pub zstd_level: Option<u32>,
/// Pcodec lossless numerical compression. Filter ID 32023. /// Pcodec lossless numerical compression. Private, unregistered filter
/// ID [`FILTER_PCODEC`] (480): only clawhdf5 can read it.
pub pcodec: bool, 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<PluginFilter>,
}
/// 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. /// Largest chunk the automatic choice produces, in bytes.
@@ -90,14 +254,33 @@ impl ChunkOptions {
|| self.lz4 || self.lz4
|| self.zstd_level.is_some() || self.zstd_level.is_some()
|| self.pcodec || self.pcodec
|| self.plugin.is_some()
} }
/// Build a FilterPipeline from the options. /// Build a FilterPipeline from the options.
pub fn build_pipeline(&self, element_size: u32) -> Option<FilterPipeline> { pub fn build_pipeline(&self, element_size: u32) -> Option<FilterPipeline> {
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<FilterPipeline> {
let mut filters = Vec::new(); let mut filters = Vec::new();
let has_compression = let plugin_shuffles = self
self.deflate_level.is_some() || self.zstd_level.is_some() || self.lz4 || self.pcodec; .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 // Shuffle before compression. Applied if explicitly requested OR if compression
// is active and the caller hasn't disabled it — matches h5py default behavior // is active and the caller hasn't disabled it — matches h5py default behavior
@@ -111,11 +294,14 @@ impl ChunkOptions {
}); });
} }
// Compression filters (mutually exclusive, priority: pcodec > zstd > lz4 > deflate) // Compression filters (mutually exclusive, priority: plugin > pcodec >
if self.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 { filters.push(FilterDescription {
filter_id: FILTER_PCODEC, filter_id: FILTER_PCODEC,
name: Some("pcodec".into()), name: Some(FILTER_PCODEC_NAME.into()),
flags: 0, flags: 0,
client_data: vec![element_size], client_data: vec![element_size],
}); });
@@ -381,39 +567,7 @@ fn serialize_v4_single_chunk(
let ndims = chunk_dims.len() as u8 + 1; let ndims = chunk_dims.len() as u8 + 1;
buf.push(ndims); buf.push(ndims);
// dim_size_encoded_length: how many bytes per dimension push_v4_chunk_dims(&mut buf, chunk_dims, element_size);
// 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()),
_ => {}
}
// chunk index type = 1 (single chunk) // chunk index type = 1 (single chunk)
buf.push(1); buf.push(1);
@@ -443,45 +597,7 @@ fn serialize_v4_fixed_array(
element_size: u32, element_size: u32,
max_bits: u8, max_bits: u8,
) -> Vec<u8> { ) -> Vec<u8> {
let mut buf = Vec::new(); let mut buf = layout_v4_chunked_prefix(chunk_dims, element_size);
buf.push(4); // version
buf.push(2); // class = chunked
let flags: u8 = 0x00;
buf.push(flags);
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()),
_ => {}
}
// chunk index type = 3 (Fixed Array) // chunk index type = 3 (Fixed Array)
buf.push(3); buf.push(3);
@@ -499,107 +615,175 @@ fn serialize_v4_fixed_array(
buf buf
} }
/// 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<u8>, 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<u8> {
let mut buf = Vec::new();
buf.push(4); // version
buf.push(2); // class = chunked
let flags: u8 = 0x00;
buf.push(flags);
let ndims = chunk_dims.len() as u8 + 1;
buf.push(ndims);
push_v4_chunk_dims(&mut buf, chunk_dims, element_size);
buf
}
/// log2 of the elements per Fixed Array data block page (the library's
/// default, `H5D_FARRAY_MAX_DBLK_PAGE_NELMTS_BITS`).
const FA_PAGE_BITS: u8 = 10;
pub(crate) fn push_addr(buf: &mut Vec<u8>, addr: u64, offset_size: u8) {
match offset_size {
4 => buf.extend_from_slice(&(addr as u32).to_le_bytes()),
_ => buf.extend_from_slice(&addr.to_le_bytes()),
}
}
/// Width of the chunk-size field of a filtered chunk index element. Must
/// match the library's `H5D_FARRAY_FILT_COMPUTE_CHUNK_SIZE_LEN` (the EA and
/// B-tree v2 indexes use the same formula):
/// `1 + ((log2(unfiltered chunk bytes) + 8) / 8)`, capped at 8.
pub(crate) fn filtered_chunk_size_len(slots: &[Option<WrittenChunk>]) -> usize {
let max_raw = slots
.iter()
.flatten()
.map(|c| c.raw_size)
.max()
.unwrap_or(1);
let log2_val = if max_raw <= 1 {
0
} else {
63 - max_raw.leading_zeros()
};
(1 + ((log2_val + 8) / 8) as usize).min(8)
}
/// Append one chunk index element: the chunk's address, plus its stored size
/// and filter mask when the dataset is filtered. `None` is an unallocated
/// chunk (undefined address, zero size and mask).
pub(crate) fn push_index_element(
buf: &mut Vec<u8>,
slot: Option<&WrittenChunk>,
offset_size: u8,
chunk_size_bytes: Option<usize>,
) {
match slot {
Some(c) => {
push_addr(buf, c.address, offset_size);
if let Some(n) = chunk_size_bytes {
buf.extend_from_slice(&c.compressed_size.to_le_bytes()[..n]);
buf.extend_from_slice(&c.filter_mask.to_le_bytes());
}
}
None => {
buf.extend(core::iter::repeat_n(0xFF, offset_size as usize));
if let Some(n) = chunk_size_bytes {
buf.extend(core::iter::repeat_n(0x00, n + 4));
}
}
}
}
/// Build a complete Fixed Array at a known absolute address. /// Build a complete Fixed Array at a known absolute address.
///
/// `slots` holds one entry per element of the array, i.e. per chunk of the
/// dataset's *maximum* extent in the order [`crate::chunk_grid`] defines;
/// `None` marks a chunk that is not allocated. An array with more elements
/// than fit in one page (`2^FA_PAGE_BITS`) gets a paged data block: a
/// page-init bitmap after the prefix, then one checksummed page per
/// `2^FA_PAGE_BITS` elements, the last one short (`H5FA__dblock_create`).
pub fn build_fixed_array_at( pub fn build_fixed_array_at(
chunks: &[WrittenChunk], slots: &[Option<WrittenChunk>],
offset_size: u8, offset_size: u8,
length_size: u8, length_size: u8,
has_filters: bool, has_filters: bool,
fa_base_address: u64, fa_base_address: u64,
) -> Vec<u8> { ) -> Vec<u8> {
let os = offset_size as usize; let os = offset_size as usize;
let num_elements = chunks.len(); let num_elements = slots.len();
// For filtered chunks, compute chunk_size encoding width.
// Must match the HDF5 C library's H5D_FARRAY_FILT_COMPUTE_CHUNK_SIZE_LEN macro:
// chunk_size_len = 1 + ((H5VM_log2_gen(chunk.size) + 8) / 8)
// where chunk.size is the unfiltered chunk size in bytes (product of all chunk dims).
let chunk_size_bytes: usize = if has_filters {
let max_raw = chunks.iter().map(|c| c.raw_size).max().unwrap_or(1);
let log2_val = if max_raw <= 1 {
0
} else {
63 - max_raw.leading_zeros()
};
let len = 1 + ((log2_val + 8) / 8) as usize;
len.min(8)
} else {
0
};
let elem_size = if has_filters {
os + chunk_size_bytes + 4
} else {
os
};
let chunk_size_bytes = has_filters.then(|| filtered_chunk_size_len(slots));
let elem_size = os + chunk_size_bytes.map_or(0, |n| n + 4);
let client_id: u8 = if has_filters { 1 } else { 0 }; let client_id: u8 = if has_filters { 1 } else { 0 };
// FAHD total size // FAHD total size
let nelmts_field_size = length_size as usize; let fahd_total_size = 4 + 1 + 1 + 1 + 1 + length_size as usize + os + 4;
let fahd_total_size = 4 + 1 + 1 + 1 + 1 + nelmts_field_size + os + 4;
let fadb_address = fa_base_address + fahd_total_size as u64; let fadb_address = fa_base_address + fahd_total_size as u64;
// Build FAHD
let mut fahd = Vec::with_capacity(fahd_total_size); let mut fahd = Vec::with_capacity(fahd_total_size);
fahd.extend_from_slice(b"FAHD"); fahd.extend_from_slice(b"FAHD");
fahd.push(0); // version fahd.push(0); // version
fahd.push(client_id); fahd.push(client_id);
fahd.push(elem_size as u8); fahd.push(elem_size as u8);
fahd.push(FA_PAGE_BITS);
// max_nelmts_bits: use 10 as default (page_size = 1024), matching h5py convention
let max_bits: u8 = 10;
fahd.push(max_bits);
match length_size { match length_size {
4 => fahd.extend_from_slice(&(num_elements as u32).to_le_bytes()), 4 => fahd.extend_from_slice(&(num_elements as u32).to_le_bytes()),
8 => fahd.extend_from_slice(&(num_elements as u64).to_le_bytes()),
_ => fahd.extend_from_slice(&(num_elements as u64).to_le_bytes()), _ => fahd.extend_from_slice(&(num_elements as u64).to_le_bytes()),
} }
push_addr(&mut fahd, fadb_address, offset_size);
match offset_size {
4 => fahd.extend_from_slice(&(fadb_address as u32).to_le_bytes()),
8 => fahd.extend_from_slice(&fadb_address.to_le_bytes()),
_ => fahd.extend_from_slice(&fadb_address.to_le_bytes()),
}
// Checksum
let checksum = jenkins_lookup3(&fahd); let checksum = jenkins_lookup3(&fahd);
fahd.extend_from_slice(&checksum.to_le_bytes()); fahd.extend_from_slice(&checksum.to_le_bytes());
assert_eq!(fahd.len(), fahd_total_size); assert_eq!(fahd.len(), fahd_total_size);
// Build FADB // FADB prefix
let mut fadb = Vec::new(); let mut fadb = Vec::new();
fadb.extend_from_slice(b"FADB"); fadb.extend_from_slice(b"FADB");
fadb.push(0); // version fadb.push(0); // version
fadb.push(client_id); fadb.push(client_id);
push_addr(&mut fadb, fa_base_address, offset_size);
// header address let page_nelmts = 1usize << FA_PAGE_BITS;
match offset_size { if num_elements <= page_nelmts {
4 => fadb.extend_from_slice(&(fa_base_address as u32).to_le_bytes()), // Unpaged: the elements follow the prefix, one checksum over both.
8 => fadb.extend_from_slice(&fa_base_address.to_le_bytes()), for slot in slots {
_ => fadb.extend_from_slice(&fa_base_address.to_le_bytes()), push_index_element(&mut fadb, slot.as_ref(), offset_size, chunk_size_bytes);
} }
// Element data
for chunk in chunks {
match offset_size {
4 => fadb.extend_from_slice(&(chunk.address as u32).to_le_bytes()),
8 => fadb.extend_from_slice(&chunk.address.to_le_bytes()),
_ => fadb.extend_from_slice(&chunk.address.to_le_bytes()),
}
if has_filters {
// Write compressed size using chunk_size_bytes (variable width)
let cs_bytes = chunk.compressed_size.to_le_bytes();
fadb.extend_from_slice(&cs_bytes[..chunk_size_bytes]);
fadb.extend_from_slice(&chunk.filter_mask.to_le_bytes());
}
}
// FADB checksum
let fadb_checksum = jenkins_lookup3(&fadb); let fadb_checksum = jenkins_lookup3(&fadb);
fadb.extend_from_slice(&fadb_checksum.to_le_bytes()); fadb.extend_from_slice(&fadb_checksum.to_le_bytes());
} else {
// Paged: every page is written, so every page-init bit is set
// (MSB-first, as `H5VM_bit_set` packs them). The prefix and bitmap
// share a checksum; each page carries its own.
let npages = num_elements.div_ceil(page_nelmts);
let mut bitmap = vec![0u8; npages.div_ceil(8)];
for p in 0..npages {
bitmap[p / 8] |= 0x80 >> (p % 8);
}
fadb.extend_from_slice(&bitmap);
let prefix_checksum = jenkins_lookup3(&fadb);
fadb.extend_from_slice(&prefix_checksum.to_le_bytes());
for page in slots.chunks(page_nelmts) {
let start = fadb.len();
for slot in page {
push_index_element(&mut fadb, slot.as_ref(), offset_size, chunk_size_bytes);
}
let page_checksum = jenkins_lookup3(&fadb[start..]);
fadb.extend_from_slice(&page_checksum.to_le_bytes());
}
}
let mut combined = fahd; let mut combined = fahd;
combined.extend_from_slice(&fadb); combined.extend_from_slice(&fadb);
@@ -634,7 +818,12 @@ pub fn precompress_chunks(
element_size: usize, element_size: usize,
options: &ChunkOptions, options: &ChunkOptions,
) -> Result<PrecompressedChunks, FormatError> { ) -> Result<PrecompressedChunks, FormatError> {
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 has_filters = pipeline.is_some();
let pipeline_message = pipeline.as_ref().map(|pl| pl.serialize()); let pipeline_message = pipeline.as_ref().map(|pl| pl.serialize());
@@ -667,7 +856,8 @@ pub fn build_chunked_data_from_precompressed(
pre: &PrecompressedChunks, pre: &PrecompressedChunks,
base_address: u64, base_address: u64,
maxshape: Option<&[u64]>, maxshape: Option<&[u64]>,
) -> ChunkedDataResult { ) -> Result<ChunkedDataResult, FormatError> {
let index = ChunkIndexPlan::new(&pre.shape, maxshape, &pre.chunk_dims)?;
let offset_size: u8 = 8; let offset_size: u8 = 8;
let length_size: u8 = 8; let length_size: u8 = 8;
let num_chunks = pre.chunks.len(); let num_chunks = pre.chunks.len();
@@ -693,17 +883,18 @@ pub fn build_chunked_data_from_precompressed(
} }
let chunk_dims_u32: Vec<u32> = pre.chunk_dims.iter().map(|&d| d as u32).collect(); let chunk_dims_u32: Vec<u32> = pre.chunk_dims.iter().map(|&d| d as u32).collect();
let use_extensible = maxshape.is_some_and(|ms| ms.contains(&u64::MAX));
let aligned_idx = align_to_cache_line(data_buf.len()); let aligned_idx = align_to_cache_line(data_buf.len());
if aligned_idx > data_buf.len() { if aligned_idx > data_buf.len() {
data_buf.resize(aligned_idx, 0u8); data_buf.resize(aligned_idx, 0u8);
} }
let layout_message = if use_extensible { let layout_message = match &index {
ChunkIndexPlan::ExtensibleArray(grid) => {
let ea_address = base_address + data_buf.len() as u64; let ea_address = base_address + data_buf.len() as u64;
let slots = index_slots(grid, &pre.shape, &pre.chunk_dims, &written_chunks, None)?;
let ea_bytes = ea_writer::build_extensible_array_at( let ea_bytes = ea_writer::build_extensible_array_at(
&written_chunks, &slots,
offset_size, offset_size,
length_size, length_size,
pre.has_filters, pre.has_filters,
@@ -716,7 +907,8 @@ pub fn build_chunked_data_from_precompressed(
offset_size, offset_size,
element_size as u32, element_size as u32,
) )
} else if num_chunks == 1 { }
ChunkIndexPlan::SingleChunk => {
let chunk_addr = written_chunks[0].address; let chunk_addr = written_chunks[0].address;
let filtered_size = if pre.has_filters { let filtered_size = if pre.has_filters {
Some(written_chunks[0].compressed_size) Some(written_chunks[0].compressed_size)
@@ -732,10 +924,18 @@ pub fn build_chunked_data_from_precompressed(
offset_size, offset_size,
element_size as u32, element_size as u32,
) )
} else { }
ChunkIndexPlan::FixedArray(grid, nslots) => {
let fa_address = base_address + data_buf.len() as u64; let fa_address = base_address + data_buf.len() as u64;
let fa_bytes = build_fixed_array_at( let slots = index_slots(
grid,
&pre.shape,
&pre.chunk_dims,
&written_chunks, &written_chunks,
Some(*nslots),
)?;
let fa_bytes = build_fixed_array_at(
&slots,
offset_size, offset_size,
length_size, length_size,
pre.has_filters, pre.has_filters,
@@ -747,15 +947,263 @@ pub fn build_chunked_data_from_precompressed(
fa_address, fa_address,
offset_size, offset_size,
element_size as u32, element_size as u32,
10, // max_nelmts_bits — matches h5py convention FA_PAGE_BITS,
) )
}
ChunkIndexPlan::BTreeV2 => {
let bt_address = base_address + data_buf.len() as u64;
let records: Vec<(Vec<u64>, &WrittenChunk)> = written_chunks
.iter()
.enumerate()
.map(|(i, c)| (scaled_coords(&pre.shape, &pre.chunk_dims, i), c))
.collect();
let (bt_bytes, node_size) = build_btree_v2_chunk_index_at(
pre.shape.len(),
&records,
offset_size,
length_size,
pre.has_filters,
bt_address,
)?;
data_buf.extend_from_slice(&bt_bytes);
serialize_v4_btree_v2(
&chunk_dims_u32,
bt_address,
offset_size,
element_size as u32,
node_size,
)
}
}; };
ChunkedDataResult { Ok(ChunkedDataResult {
data_bytes: data_buf, data_bytes: data_buf,
layout_message, layout_message,
pipeline_message: pre.pipeline_message.clone(), pipeline_message: pre.pipeline_message.clone(),
})
}
/// Most slots a Fixed Array index may have before we refuse to build it: its
/// data block holds one element per chunk of the *maximum* extent, so a huge
/// finite maxshape with small chunks would otherwise exhaust memory.
const MAX_FIXED_ARRAY_SLOTS: u64 = 1 << 26;
/// Which chunk index a dataset gets, following the library's choice in
/// `H5D__layout_set_latest_indexing`: version-2 B-tree for more than one
/// unlimited dimension, Extensible Array for exactly one, Fixed Array for a
/// finite maxshape, Single Chunk when the whole maximum extent is one chunk.
enum ChunkIndexPlan {
SingleChunk,
/// The grid and the number of array elements (chunks of the max extent).
FixedArray(ChunkGrid, usize),
ExtensibleArray(ChunkGrid),
BTreeV2,
}
impl ChunkIndexPlan {
fn new(
shape: &[u64],
maxshape: Option<&[u64]>,
chunk_dims: &[u64],
) -> Result<Self, FormatError> {
let bad = |what: &str| FormatError::ChunkedReadError(format!("maxshape: {what}"));
if let Some(ms) = maxshape {
if ms.len() != shape.len() {
return Err(bad("rank differs from the shape"));
} }
if ms.iter().zip(shape).any(|(&m, &s)| m < s) {
return Err(bad("smaller than the shape"));
}
}
let max = maxshape.unwrap_or(shape);
let nunlim = max.iter().filter(|&&d| d == u64::MAX).count();
match nunlim {
0 => {
let nslots = max
.iter()
.zip(chunk_dims)
.try_fold(1u64, |acc, (&m, &c)| acc.checked_mul(m.div_ceil(c.max(1))))
.filter(|&n| n <= MAX_FIXED_ARRAY_SLOTS)
.ok_or_else(|| {
bad("too many chunks for a Fixed Array index; \
use larger chunks or an unlimited dimension")
})?;
// A Single Chunk index needs that one chunk to exist; an
// empty dataset gets an all-unallocated Fixed Array instead.
let empty = shape.contains(&0);
if nslots == 1 && !empty {
Ok(Self::SingleChunk)
} else {
let grid = ChunkGrid::fixed_array(shape, Some(max), chunk_dims)?;
Ok(Self::FixedArray(grid, nslots as usize))
}
}
1 => Ok(Self::ExtensibleArray(ChunkGrid::extensible_array(
shape,
Some(max),
chunk_dims,
)?)),
_ => Ok(Self::BTreeV2),
}
}
}
/// Place each written chunk at its linear index in `grid`. `chunks` are in
/// row-major order over the chunks of the current extent (`split_into_chunks`).
/// `len` fixes the slot count (Fixed Array); otherwise it is one past the
/// highest index used.
fn index_slots(
grid: &ChunkGrid,
shape: &[u64],
chunk_dims: &[u64],
chunks: &[WrittenChunk],
len: Option<usize>,
) -> Result<Vec<Option<WrittenChunk>>, FormatError> {
let mut placed: Vec<(usize, &WrittenChunk)> = Vec::with_capacity(chunks.len());
for (i, chunk) in chunks.iter().enumerate() {
let scaled = scaled_coords(shape, chunk_dims, i);
let idx = usize::try_from(grid.linear_index(&scaled))
.map_err(|_| FormatError::Overflow("chunk index slot".into()))?;
placed.push((idx, chunk));
}
let n = len.unwrap_or_else(|| placed.iter().map(|&(i, _)| i + 1).max().unwrap_or(0));
let mut slots = vec![None; n];
for (idx, chunk) in placed {
*slots
.get_mut(idx)
.ok_or_else(|| FormatError::Overflow("chunk index slot".into()))? = Some(chunk.clone());
}
Ok(slots)
}
/// Scaled coordinates (`offset / chunk_dim`) of the `i`-th chunk in the
/// row-major order `split_into_chunks` produces over the current extent.
fn scaled_coords(shape: &[u64], chunk_dims: &[u64], i: usize) -> Vec<u64> {
let rank = shape.len();
let mut scaled = vec![0u64; rank];
let mut rem = i as u64;
for d in (0..rank).rev() {
let n = shape[d].div_ceil(chunk_dims[d]);
scaled[d] = rem % n;
rem /= n;
}
scaled
}
/// Node size the library gives a chunk index B-tree (`H5D_BT2_NODE_SIZE`),
/// with its split and merge percentages.
const BT2_NODE_SIZE: u32 = 2048;
const BT2_SPLIT_PERCENT: u8 = 100;
const BT2_MERGE_PERCENT: u8 = 40;
/// B-tree v2 record types for chunk indexes (`H5B2_CDSET_ID`,
/// `H5B2_CDSET_FILT_ID`).
const BT2_CHUNK_UNFILTERED: u8 = 10;
const BT2_CHUNK_FILTERED: u8 = 11;
/// Build a version-2 B-tree chunk index (the library's index for datasets
/// with more than one unlimited dimension) at a known absolute address.
///
/// `records` are `(scaled coordinates, chunk)` in lexicographic order of the
/// coordinates, which is the order the library's comparator
/// (`H5VM_vector_cmp_u`) keeps them in. The tree is a single leaf: the
/// library's 2048-byte node when the records fit, otherwise a leaf node
/// sized to hold them all (the root's record count is 16-bit, so at most
/// 65535 chunks). Returns the bytes and the node size the layout message
/// must record.
fn build_btree_v2_chunk_index_at(
rank: usize,
records: &[(Vec<u64>, &WrittenChunk)],
offset_size: u8,
length_size: u8,
has_filters: bool,
base_address: u64,
) -> Result<(Vec<u8>, u32), FormatError> {
let os = offset_size as usize;
let nrec = u16::try_from(records.len()).map_err(|_| {
FormatError::ChunkedReadError(
"more than 65535 chunks with more than one unlimited dimension: \
use larger chunks"
.into(),
)
})?;
let chunk_size_bytes = has_filters.then(|| {
let slots: Vec<Option<WrittenChunk>> =
records.iter().map(|(_, c)| Some((*c).clone())).collect();
filtered_chunk_size_len(&slots)
});
let record_size = os + chunk_size_bytes.map_or(0, |n| n + 4) + 8 * rank;
// Leaf: signature, version, type, records, checksum.
let leaf_len = 4 + 1 + 1 + records.len() * record_size + 4;
let node_size = u32::try_from(leaf_len)
.map_err(|_| FormatError::Overflow("B-tree v2 leaf size".into()))?
.max(BT2_NODE_SIZE);
let tree_type = if has_filters {
BT2_CHUNK_FILTERED
} else {
BT2_CHUNK_UNFILTERED
};
let hdr_len = 4 + 1 + 1 + 4 + 2 + 2 + 1 + 1 + os + 2 + length_size as usize + 4;
let leaf_address = base_address + hdr_len as u64;
let mut out = Vec::with_capacity(hdr_len + node_size as usize);
out.extend_from_slice(b"BTHD");
out.push(0); // version
out.push(tree_type);
out.extend_from_slice(&node_size.to_le_bytes());
out.extend_from_slice(&(record_size as u16).to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes()); // depth
out.push(BT2_SPLIT_PERCENT);
out.push(BT2_MERGE_PERCENT);
if records.is_empty() {
out.extend(core::iter::repeat_n(0xFF, os));
} else {
push_addr(&mut out, leaf_address, offset_size);
}
out.extend_from_slice(&nrec.to_le_bytes());
match length_size {
4 => out.extend_from_slice(&(records.len() as u32).to_le_bytes()),
_ => out.extend_from_slice(&(records.len() as u64).to_le_bytes()),
}
let sum = jenkins_lookup3(&out);
out.extend_from_slice(&sum.to_le_bytes());
debug_assert_eq!(out.len(), hdr_len);
if records.is_empty() {
return Ok((out, node_size));
}
let leaf_start = out.len();
out.extend_from_slice(b"BTLF");
out.push(0); // version
out.push(tree_type);
for (scaled, chunk) in records {
push_index_element(&mut out, Some(chunk), offset_size, chunk_size_bytes);
for &c in scaled {
out.extend_from_slice(&c.to_le_bytes());
}
}
let sum = jenkins_lookup3(&out[leaf_start..]);
out.extend_from_slice(&sum.to_le_bytes());
// The library reads whole nodes; pad the leaf out to the node size.
out.resize(leaf_start + node_size as usize, 0);
Ok((out, node_size))
}
/// Serialize a v4 layout message for a version-2 B-tree chunk index.
fn serialize_v4_btree_v2(
chunk_dims: &[u32],
btree_address: u64,
offset_size: u8,
element_size: u32,
node_size: u32,
) -> Vec<u8> {
let mut buf = layout_v4_chunked_prefix(chunk_dims, element_size);
buf.push(5); // chunk index type = 5 (version-2 B-tree)
buf.extend_from_slice(&node_size.to_le_bytes());
buf.push(BT2_SPLIT_PERCENT);
buf.push(BT2_MERGE_PERCENT);
push_addr(&mut buf, btree_address, offset_size);
buf
} }
/// Build chunked data with absolute addresses. /// Build chunked data with absolute addresses.
@@ -790,11 +1238,7 @@ pub fn build_chunked_data_at_ext(
maxshape: Option<&[u64]>, maxshape: Option<&[u64]>,
) -> Result<ChunkedDataResult, FormatError> { ) -> Result<ChunkedDataResult, FormatError> {
let pre = precompress_chunks(raw_data, shape, chunk_dims, element_size, options)?; let pre = precompress_chunks(raw_data, shape, chunk_dims, element_size, options)?;
Ok(build_chunked_data_from_precompressed( build_chunked_data_from_precompressed(&pre, base_address, maxshape)
&pre,
base_address,
maxshape,
))
} }
/// Write selected elements into an existing in-memory dataset buffer. /// Write selected elements into an existing in-memory dataset buffer.
@@ -1273,6 +1717,35 @@ mod tests {
assert_eq!(pl.filters[1].client_data, vec![3]); 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] #[test]
fn chunk_options_zstd_priority_over_deflate() { fn chunk_options_zstd_priority_over_deflate() {
let options = ChunkOptions { let options = ChunkOptions {
@@ -1314,6 +1787,7 @@ mod tests {
chunk_index_type, chunk_index_type,
single_chunk_filtered_size, single_chunk_filtered_size,
single_chunk_filter_mask, single_chunk_filter_mask,
..
} => { } => {
assert_eq!(version, 4); assert_eq!(version, 4);
assert_eq!(chunk_index_type, Some(1)); assert_eq!(chunk_index_type, Some(1));
@@ -1382,7 +1856,8 @@ mod tests {
filter_mask: 0, filter_mask: 0,
}, },
]; ];
let fa = build_fixed_array_at(&chunks, 8, 8, false, 0x2000); let slots: Vec<_> = chunks.into_iter().map(Some).collect();
let fa = build_fixed_array_at(&slots, 8, 8, false, 0x2000);
// Should start with FAHD // Should start with FAHD
assert_eq!(&fa[0..4], b"FAHD"); assert_eq!(&fa[0..4], b"FAHD");
// FAHD size = 4+1+1+1+1+8+8+4 = 28 // FAHD size = 4+1+1+1+1+8+8+4 = 28
@@ -1429,7 +1904,8 @@ mod tests {
filter_mask: 0, filter_mask: 0,
}, },
]; ];
let ea = ea_writer::build_extensible_array_at(&chunks, 8, 8, false, 0x2000); let slots: Vec<_> = chunks.into_iter().map(Some).collect();
let ea = ea_writer::build_extensible_array_at(&slots, 8, 8, false, 0x2000);
assert_eq!(&ea[0..4], b"EAHD"); assert_eq!(&ea[0..4], b"EAHD");
// Find EAIB after EAHD: 12 fixed + 6*8 stats + 8 addr + 4 checksum = 72 // Find EAIB after EAHD: 12 fixed + 6*8 stats + 8 addr + 4 checksum = 72
let aehd_size = 4 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 6 * 8 + 8 + 4; let aehd_size = 4 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 6 * 8 + 8 + 4;
+492 -49
View File
@@ -1,7 +1,7 @@
//! HDF5 Data Layout message parsing (message type 0x0008). //! HDF5 Data Layout message parsing (message type 0x0008).
#[cfg(not(feature = "std"))] #[cfg(not(feature = "std"))]
use alloc::{string::String, vec::Vec}; use alloc::{format, string::String, vec::Vec};
#[cfg(feature = "std")] #[cfg(feature = "std")]
use std::string::String; use std::string::String;
@@ -24,6 +24,34 @@ pub struct VdsMapping {
pub virtual_selection: Vec<u8>, pub virtual_selection: Vec<u8>,
} }
/// 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<u32>, layout_version: u8) -> Result<Vec<u32>, 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. /// Parsed HDF5 data layout message.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum DataLayout { pub enum DataLayout {
@@ -45,7 +73,9 @@ pub enum DataLayout {
chunk_dimensions: Vec<u32>, chunk_dimensions: Vec<u32>,
/// B-tree address, or `None` if undefined. /// B-tree address, or `None` if undefined.
btree_address: Option<u64>, btree_address: Option<u64>,
/// Layout version (3 or 4). /// Layout version (3 or 4). Version 1/2 messages (HDF5 1.4/1.6-era)
/// use the same version-1 B-tree chunk index as version 3 and are
/// reported as 3.
version: u8, version: u8,
/// Chunk index type (v4 only). /// Chunk index type (v4 only).
chunk_index_type: Option<u8>, chunk_index_type: Option<u8>,
@@ -53,6 +83,11 @@ pub enum DataLayout {
single_chunk_filtered_size: Option<u64>, single_chunk_filtered_size: Option<u64>,
/// Filter mask for v4 single chunk with filters. /// Filter mask for v4 single chunk with filters.
single_chunk_filter_mask: Option<u32>, single_chunk_filter_mask: Option<u32>,
/// Layout v4 flag bit 0 (`H5D_CHUNK_DONT_FILTER_PARTIAL_CHUNKS`):
/// partial edge chunks — those extending past the dataset's current
/// extent in some dimension — are stored without the filter pipeline,
/// even though their filter mask is 0. Always `false` for v3.
dont_filter_partial_edge_chunks: bool,
}, },
/// Virtual dataset layout (v4 only). /// Virtual dataset layout (v4 only).
Virtual { Virtual {
@@ -67,21 +102,33 @@ pub enum DataLayout {
}, },
} }
/// Version-1 VDS mapping flag: the source file name is stored by an earlier
/// entry, whose index follows in place of the name.
const VDS_SOURCE_FILE_SHARED: u8 = 0x01;
/// Version-1 VDS mapping flag: likewise for the source dataset name.
const VDS_SOURCE_DSET_SHARED: u8 = 0x02;
/// Version-1 VDS mapping flag: the source is in the virtual file itself
/// (`"."`); no file name is stored.
const VDS_SOURCE_SAME_FILE: u8 = 0x04;
const VDS_ALL_FLAGS: u8 = VDS_SOURCE_FILE_SHARED | VDS_SOURCE_DSET_SHARED | VDS_SOURCE_SAME_FILE;
/// Parse VDS mappings from global-heap object data. /// Parse VDS mappings from global-heap object data.
/// ///
/// The global-heap block holding a VDS mapping list is laid out as /// The global-heap block holding a VDS mapping list is laid out as
/// (reverse-engineered and validated against HDF5 2.0): /// (`H5D__virtual_store_layout` / `H5D__virtual_load_layout` in libhdf5):
/// ///
/// ```text /// ```text
/// version(1) · nused(length_size, LE) · entry[nused] · checksum(4) /// version(1) · nused(length_size, LE) · entry[nused] · checksum(4)
/// ``` /// ```
/// ///
/// Each entry is: /// Each entry is:
/// - source file name — a null-terminated string in **block version 0**; in /// - **block version 1 only:** a flags byte. `0x04`: the source is in the
/// **block version 1** a same-file reference is encoded as a single `0x04` /// virtual file itself and no file name is stored; `0x01`/`0x02`: the
/// marker byte (the source file is the virtual file itself) in place of the /// source file/dataset name is that of an earlier entry, whose index
/// name; /// (`length_size` bytes) is stored instead of the name. libhdf5 2.0 writes
/// - source dataset name (null-terminated string); /// version 1 when the file's low version bound is 2.0 and it saves space;
/// - source file name (null-terminated string, unless flagged above);
/// - source dataset name (null-terminated string, unless flagged above);
/// - source selection (serialized `H5S` dataspace selection — self-describing /// - source selection (serialized `H5S` dataspace selection — self-describing
/// in length); /// in length);
/// - virtual selection (serialized `H5S` dataspace selection). /// - virtual selection (serialized `H5S` dataspace selection).
@@ -107,7 +154,7 @@ pub fn parse_vds_mappings(
// `nused` is untrusted; don't pre-allocate from it. Each entry consumes at // `nused` is untrusted; don't pre-allocate from it. Each entry consumes at
// least a few bytes, so the loop is naturally bounded by the heap data and // least a few bytes, so the loop is naturally bounded by the heap data and
// a bogus `nused` simply errors out on the first short read. // a bogus `nused` simply errors out on the first short read.
let mut mappings = Vec::new(); let mut mappings: Vec<VdsMapping> = Vec::new();
// Reads one self-describing selection at `pos`, returning its raw bytes and // Reads one self-describing selection at `pos`, returning its raw bytes and
// advancing past it — bounds-checked so a corrupt selection can't overrun. // advancing past it — bounds-checked so a corrupt selection can't overrun.
let read_selection = |heap_data: &[u8], pos: &mut usize| -> Result<Vec<u8>, FormatError> { let read_selection = |heap_data: &[u8], pos: &mut usize| -> Result<Vec<u8>, FormatError> {
@@ -127,17 +174,57 @@ pub fn parse_vds_mappings(
Ok(bytes) Ok(bytes)
}; };
for _ in 0..nused { if version > 1 {
// Source file name (with the version-1 same-file marker handled). return Err(FormatError::ChunkedReadError(
let source_file = if version >= 1 && heap_data.get(pos) == Some(&0x04) { "unsupported VDS mapping block version".into(),
));
}
for i in 0..nused {
// Version 1 prefixes each entry with a flags byte; a name may then be
// omitted (same file) or replaced by the index of an earlier entry
// holding the same name (`H5D__virtual_load_layout`).
let flags = if version >= 1 {
let f = *heap_data.get(pos).ok_or(FormatError::UnexpectedEof {
expected: pos + 1,
available: heap_data.len(),
})?;
pos += 1; pos += 1;
if f & !VDS_ALL_FLAGS != 0 {
return Err(FormatError::ChunkedReadError(
"unknown VDS mapping flags".into(),
));
}
f
} else {
0
};
// Index of an earlier entry, for a shared name.
let earlier = |pos: &mut usize| -> Result<usize, FormatError> {
let idx = read_length(heap_data, *pos, length_size)?;
*pos += ls;
if idx >= i {
return Err(FormatError::ChunkedReadError(
"VDS mapping shares a name with a later entry".into(),
));
}
Ok(idx as usize)
};
let source_file = if flags & VDS_SOURCE_SAME_FILE != 0 {
String::from(".") String::from(".")
} else if flags & VDS_SOURCE_FILE_SHARED != 0 {
let idx = earlier(&mut pos)?;
mappings[idx].source_file.clone()
} else { } else {
read_null_terminated_string(heap_data, &mut pos)? read_null_terminated_string(heap_data, &mut pos)?
}; };
// Source dataset name. let source_dataset = if flags & VDS_SOURCE_DSET_SHARED != 0 {
let source_dataset = read_null_terminated_string(heap_data, &mut pos)?; let idx = earlier(&mut pos)?;
mappings[idx].source_dataset.clone()
} else {
read_null_terminated_string(heap_data, &mut pos)?
};
// Source selection, then virtual selection (both self-describing length). // Source selection, then virtual selection (both self-describing length).
let source_selection = read_selection(heap_data, &mut pos)?; let source_selection = read_selection(heap_data, &mut pos)?;
@@ -256,6 +343,7 @@ impl DataLayout {
let layout_class = data[1]; let layout_class = data[1];
match version { match version {
1 | 2 => Self::parse_v1_v2(data, offset_size),
3 => Self::parse_v3(data, layout_class, offset_size, length_size), 3 => Self::parse_v3(data, layout_class, offset_size, length_size),
// v5 (emitted by HDF5 1.14+/2.0 with `libver=latest`) uses the same // v5 (emitted by HDF5 1.14+/2.0 with `libver=latest`) uses the same
// message structure as v4 — only the version number was bumped. // message structure as v4 — only the version number was bumped.
@@ -264,6 +352,87 @@ impl DataLayout {
} }
} }
/// Layout message versions 1 and 2 (HDF5 before 1.6.3):
///
/// ```text
/// version(1) · dimensionality(1) · layout class(1) · reserved(5)
/// · address(offset_size) — contiguous and chunked only
/// · dimension sizes(4 × dimensionality)
/// · compact data size(4) · compact raw data — compact only
/// ```
///
/// The dimension sizes are the dataset's (contiguous/compact) or the
/// chunk's (chunked) extent plus a trailing element-size dimension, as in
/// version 3's chunked form. libhdf5 ignores them for contiguous storage
/// and sizes the data from the dataspace; the product of the stored
/// dimensions is that same size, and a disagreement (a dimension that was
/// truncated to 32 bits) is caught by the reader's size check rather than
/// returning wrong data.
fn parse_v1_v2(data: &[u8], offset_size: u8) -> Result<DataLayout, FormatError> {
ensure_len(data, 0, 8)?;
let dimensionality = data[1] as usize;
let layout_class = data[2];
// H5O_LAYOUT_NDIMS: 32 dataspace dimensions + the element-size one.
if dimensionality > 33 {
return Err(FormatError::Overflow(format!(
"data layout dimensionality {dimensionality} exceeds 33"
)));
}
let mut p = 8;
let os = offset_size as usize;
let address = match layout_class {
1 | 2 => {
ensure_len(data, p, os)?;
let a = if is_undefined(data, p, offset_size) {
None
} else {
Some(read_offset(data, p, offset_size)?)
};
p += os;
a
}
0 => None,
_ => return Err(FormatError::InvalidLayoutClass(layout_class)),
};
ensure_len(data, p, dimensionality * 4)?;
let dims: Vec<u32> = data[p..p + dimensionality * 4]
.as_chunks::<4>()
.0
.iter()
.map(|c| u32::from_le_bytes(*c))
.collect();
p += dimensionality * 4;
match layout_class {
0 => {
ensure_len(data, p, 4)?;
let size =
u32::from_le_bytes([data[p], data[p + 1], data[p + 2], data[p + 3]]) as usize;
ensure_len(data, p + 4, size)?;
Ok(DataLayout::Compact {
data: data[p + 4..p + 4 + size].to_vec(),
})
}
1 => {
let size = dims
.iter()
.try_fold(1u64, |acc, &d| acc.checked_mul(d as u64))
.ok_or_else(|| {
FormatError::Overflow(format!("contiguous layout size {dims:?}"))
})?;
Ok(DataLayout::Contiguous { address, size })
}
_ => Ok(DataLayout::Chunked {
chunk_dimensions: check_chunk_dims(dims, 2)?,
btree_address: address,
version: 3,
chunk_index_type: None,
single_chunk_filtered_size: None,
single_chunk_filter_mask: None,
dont_filter_partial_edge_chunks: false,
}),
}
}
fn parse_v3( fn parse_v3(
data: &[u8], data: &[u8],
layout_class: u8, layout_class: u8,
@@ -316,12 +485,13 @@ impl DataLayout {
p += 4; p += 4;
} }
Ok(DataLayout::Chunked { Ok(DataLayout::Chunked {
chunk_dimensions, chunk_dimensions: check_chunk_dims(chunk_dimensions, 3)?,
btree_address, btree_address,
version: 3, version: 3,
chunk_index_type: None, chunk_index_type: None,
single_chunk_filtered_size: None, single_chunk_filtered_size: None,
single_chunk_filter_mask: None, single_chunk_filter_mask: None,
dont_filter_partial_edge_chunks: false,
}) })
} }
_ => Err(FormatError::InvalidLayoutClass(layout_class)), _ => Err(FormatError::InvalidLayoutClass(layout_class)),
@@ -364,47 +534,40 @@ impl DataLayout {
let dimensionality = data[pos + 1] as usize; let dimensionality = data[pos + 1] as usize;
let dim_size_encoded_length = data[pos + 2] as usize; let dim_size_encoded_length = data[pos + 2] as usize;
let mut p = pos + 3; 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)?; ensure_len(data, p, dimensionality * dim_size_encoded_length)?;
let mut chunk_dimensions = Vec::with_capacity(dimensionality); let mut chunk_dimensions = Vec::with_capacity(dimensionality);
for _ in 0..dimensionality { for _ in 0..dimensionality {
let val = match dim_size_encoded_length { let val = data[p..p + dim_size_encoded_length]
1 => data[p] as u32, .iter()
2 => u16::from_le_bytes([data[p], data[p + 1]]) as u32, .rev()
4 => u32::from_le_bytes([data[p], data[p + 1], data[p + 2], data[p + 3]]), .fold(0u64, |acc, &b| (acc << 8) | u64::from(b));
8 => { // Chunk dimensions are held as u32; HDF5 2.0 can write
// V4 chunked encodes dimension sizes as 8 bytes, but // larger ones (layout version 5), which are refused
// our ChunkedStorageV4 stores them as u32. We read only // rather than truncated.
// the low 4 bytes (little-endian). This silently let val = u32::try_from(val).map_err(|_| {
// truncates dimensions > 4 GiB, which are not expected FormatError::InvalidChunkDimensions(format!(
// in practice (HDF5 chunk dimensions are always small). "chunk dimension {val} is larger than 2^32 - 1, which is not supported"
// 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(),
});
}
};
chunk_dimensions.push(val); chunk_dimensions.push(val);
p += dim_size_encoded_length; p += dim_size_encoded_length;
} }
let chunk_dimensions = check_chunk_dims(chunk_dimensions, 4)?;
// chunk index type // chunk index type
ensure_len(data, p, 1)?; ensure_len(data, p, 1)?;
@@ -505,6 +668,7 @@ impl DataLayout {
chunk_index_type: Some(chunk_index_type), chunk_index_type: Some(chunk_index_type),
single_chunk_filtered_size, single_chunk_filtered_size,
single_chunk_filter_mask, single_chunk_filter_mask,
dont_filter_partial_edge_chunks: flags & 0x01 != 0,
}) })
} }
3 => { 3 => {
@@ -539,6 +703,202 @@ impl DataLayout {
mod tests { mod tests {
use super::*; use super::*;
/// Version 1/2 header: version, dimensionality, class, reserved(5).
fn v1v2_header(version: u8, ndims: u8, class: u8) -> Vec<u8> {
vec![version, ndims, class, 0, 0, 0, 0, 0]
}
#[test]
fn v2_compact() {
let mut buf = v1v2_header(2, 2, 0);
// dims (3 elements of 2 bytes) — no address for compact
buf.extend_from_slice(&3u32.to_le_bytes());
buf.extend_from_slice(&2u32.to_le_bytes());
buf.extend_from_slice(&6u32.to_le_bytes()); // compact size (u32 in v1/v2)
buf.extend_from_slice(&[1, 0, 2, 0, 3, 0]);
assert_eq!(
DataLayout::parse(&buf, 8, 8).unwrap(),
DataLayout::Compact {
data: vec![1, 0, 2, 0, 3, 0]
}
);
}
#[test]
fn v1_contiguous_size_from_dimensions() {
let mut buf = v1v2_header(1, 3, 1);
buf.extend_from_slice(&0x800u32.to_le_bytes()); // 4-byte address
for d in [10u32, 20, 4] {
buf.extend_from_slice(&d.to_le_bytes());
}
assert_eq!(
DataLayout::parse(&buf, 4, 4).unwrap(),
DataLayout::Contiguous {
address: Some(0x800),
size: 800,
}
);
}
#[test]
fn v1_contiguous_undefined_address() {
let mut buf = v1v2_header(1, 2, 1);
buf.extend_from_slice(&[0xFF; 8]);
buf.extend_from_slice(&5u32.to_le_bytes());
buf.extend_from_slice(&8u32.to_le_bytes());
assert_eq!(
DataLayout::parse(&buf, 8, 8).unwrap(),
DataLayout::Contiguous {
address: None,
size: 40,
}
);
}
#[test]
fn v1_chunked_maps_to_btree_v1_index() {
let mut buf = v1v2_header(1, 3, 2);
buf.extend_from_slice(&0x1234u64.to_le_bytes());
for d in [50u32, 50, 4] {
buf.extend_from_slice(&d.to_le_bytes());
}
assert_eq!(
DataLayout::parse(&buf, 8, 8).unwrap(),
DataLayout::Chunked {
chunk_dimensions: vec![50, 50, 4],
btree_address: Some(0x1234),
version: 3,
chunk_index_type: None,
single_chunk_filtered_size: None,
single_chunk_filter_mask: None,
dont_filter_partial_edge_chunks: false,
}
);
}
/// A v3 chunked layout message with these dims (element size last).
fn v3_chunked_msg(dims: &[u32]) -> Vec<u8> {
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<u8> {
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!(
DataLayout::parse(&v1v2_header(1, 1, 3), 8, 8).unwrap_err(),
FormatError::InvalidLayoutClass(3)
);
assert!(matches!(
DataLayout::parse(&v1v2_header(2, 34, 1), 8, 8).unwrap_err(),
FormatError::Overflow(_)
));
// Chunked, dims cut short.
let mut buf = v1v2_header(1, 2, 2);
buf.extend_from_slice(&0x10u64.to_le_bytes());
buf.extend_from_slice(&7u32.to_le_bytes());
assert!(matches!(
DataLayout::parse(&buf, 8, 8).unwrap_err(),
FormatError::UnexpectedEof { .. }
));
// Compact, raw data shorter than its declared size.
let mut buf = v1v2_header(2, 1, 0);
buf.extend_from_slice(&4u32.to_le_bytes());
buf.extend_from_slice(&100u32.to_le_bytes());
buf.extend_from_slice(&[0; 4]);
assert!(matches!(
DataLayout::parse(&buf, 8, 8).unwrap_err(),
FormatError::UnexpectedEof { .. }
));
}
#[test] #[test]
fn v3_compact() { fn v3_compact() {
let mut buf = vec![3u8, 0]; // version=3, class=0 (compact) let mut buf = vec![3u8, 0]; // version=3, class=0 (compact)
@@ -602,6 +962,7 @@ mod tests {
chunk_index_type: None, chunk_index_type: None,
single_chunk_filtered_size: None, single_chunk_filtered_size: None,
single_chunk_filter_mask: None, single_chunk_filter_mask: None,
dont_filter_partial_edge_chunks: false,
} }
); );
} }
@@ -679,10 +1040,35 @@ mod tests {
chunk_index_type: Some(1), chunk_index_type: Some(1),
single_chunk_filtered_size: None, single_chunk_filtered_size: None,
single_chunk_filter_mask: None, single_chunk_filter_mask: None,
dont_filter_partial_edge_chunks: false,
} }
); );
} }
#[test]
fn v4_chunked_dont_filter_partial_edge_chunks_flag() {
let mut buf = vec![4u8, 2]; // version=4, class=2
buf.push(0x01); // flags bit 0 = don't filter partial edge chunks
buf.push(2); // dimensionality=2
buf.push(4); // dim_size_encoded_length=4
buf.extend_from_slice(&5u32.to_le_bytes());
buf.extend_from_slice(&4u32.to_le_bytes());
buf.push(3); // Fixed Array
buf.push(10); // max_dblk_page_nelmts_bits
buf.extend_from_slice(&0x3000u64.to_le_bytes());
match DataLayout::parse(&buf, 8, 8).unwrap() {
DataLayout::Chunked {
dont_filter_partial_edge_chunks,
btree_address,
..
} => {
assert!(dont_filter_partial_edge_chunks);
assert_eq!(btree_address, Some(0x3000));
}
other => panic!("expected Chunked, got {other:?}"),
}
}
#[test] #[test]
fn v4_chunked_single_chunk_with_filters() { fn v4_chunked_single_chunk_with_filters() {
let mut buf = vec![4u8, 2]; // version=4, class=2 let mut buf = vec![4u8, 2]; // version=4, class=2
@@ -705,6 +1091,7 @@ mod tests {
chunk_index_type: Some(1), chunk_index_type: Some(1),
single_chunk_filtered_size: Some(1024), single_chunk_filtered_size: Some(1024),
single_chunk_filter_mask: Some(0), single_chunk_filter_mask: Some(0),
dont_filter_partial_edge_chunks: false,
} }
); );
} }
@@ -815,6 +1202,62 @@ mod tests {
assert_eq!(v1.iter_linear_1d(8).unwrap(), vec![4, 5, 6, 7]); assert_eq!(v1.iter_linear_1d(8).unwrap(), vec![4, 5, 6, 7]);
} }
#[test]
fn parse_vds_mappings_v1_shared_names() {
// Written by HDF5 2.0 (h5py, libver=("v200", "v200")) for three
// mappings from `a_rather_long_source_file.h5:a_rather_long_dataset_name`
// and one from the same file: the entries carry flags 0x00, 0x03, 0x03
// and 0x06, so names after the first are stored as entry indices.
let blob: &[u8] = &[
0x01, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x61, 0x5f, 0x72, 0x61,
0x74, 0x68, 0x65, 0x72, 0x5f, 0x6c, 0x6f, 0x6e, 0x67, 0x5f, 0x73, 0x6f, 0x75, 0x72,
0x63, 0x65, 0x5f, 0x66, 0x69, 0x6c, 0x65, 0x2e, 0x68, 0x35, 0x00, 0x61, 0x5f, 0x72,
0x61, 0x74, 0x68, 0x65, 0x72, 0x5f, 0x6c, 0x6f, 0x6e, 0x67, 0x5f, 0x64, 0x61, 0x74,
0x61, 0x73, 0x65, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x00, 0x02, 0x00, 0x00, 0x00,
0x03, 0x00, 0x00, 0x00, 0x01, 0x02, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00,
0x01, 0x00, 0x04, 0x00, 0x02, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x02,
0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00,
0x01, 0x00, 0x01, 0x00, 0x04, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x03,
0x00, 0x00, 0x00, 0x01, 0x02, 0x01, 0x00, 0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x01,
0x00, 0x04, 0x00, 0x02, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x02, 0x02,
0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x00, 0x01, 0x00, 0x04, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x03, 0x00,
0x00, 0x00, 0x01, 0x02, 0x01, 0x00, 0x00, 0x00, 0x08, 0x00, 0x01, 0x00, 0x01, 0x00,
0x04, 0x00, 0x02, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x02, 0x02, 0x00,
0x00, 0x00, 0x02, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00,
0x01, 0x00, 0x04, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02,
0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x02, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x01, 0x00, 0x01, 0x00, 0x04, 0x00, 0x02, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00,
0x00, 0x01, 0x02, 0x02, 0x00, 0x00, 0x00, 0x03, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01,
0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x04, 0x00, 0x8e, 0xa7, 0xea, 0x7a,
];
let mappings = parse_vds_mappings(blob, 8).unwrap();
let names: Vec<(&str, &str)> = mappings
.iter()
.map(|m| (m.source_file.as_str(), m.source_dataset.as_str()))
.collect();
let (file, dset) = ("a_rather_long_source_file.h5", "a_rather_long_dataset_name");
assert_eq!(
names,
vec![(file, dset), (file, dset), (file, dset), (".", dset)]
);
}
#[test]
fn parse_vds_mappings_v1_forward_reference_is_error() {
// Entry 0 claiming to share entry 0's file name must not index past
// the entries decoded so far.
let mut blob = vec![0x01u8, 1, 0, 0, 0, 0, 0, 0, 0, 0x01];
blob.extend_from_slice(&[0u8; 8]);
blob.extend_from_slice(b"d\0");
assert!(parse_vds_mappings(&blob, 8).is_err());
// Unknown flag bits are refused.
let blob = [0x01u8, 1, 0, 0, 0, 0, 0, 0, 0, 0x08, b'd', 0];
assert!(parse_vds_mappings(&blob, 8).is_err());
}
#[test] #[test]
fn parse_vds_mappings_external_v0() { fn parse_vds_mappings_external_v0() {
// Block version 0 with an explicit (external) source file name. // Block version 0 with an explicit (external) source file name.
+398 -241
View File
@@ -191,14 +191,9 @@ fn read_raw_data_full_impl(
offset_size, offset_size,
length_size, length_size,
), ),
DataLayout::Virtual { DataLayout::Virtual { .. } => read_virtual_data(
global_heap_address,
global_heap_index,
..
} => read_virtual_data(
file_data, file_data,
*global_heap_address, layout,
*global_heap_index,
dataspace, dataspace,
datatype, datatype,
offset_size, offset_size,
@@ -308,6 +303,7 @@ pub fn read_raw_data_selection(
use crate::selection::Selection; use crate::selection::Selection;
crate::partial_read::validate(selection, &dataspace.dimensions)?; 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 // Read only what the selection's bounding box touches when that is
// possible; everything below is the decode-everything-then-pick path, // possible; everything below is the decode-everything-then-pick path,
@@ -365,6 +361,7 @@ pub fn read_raw_data_selection(
chunk_index_type, chunk_index_type,
.. ..
} => { } => {
crate::chunked_read::chunk_geometry(chunk_dimensions, *version, dataspace, elem_size)?;
// For chunked data, only read chunks that intersect the selection // For chunked data, only read chunks that intersect the selection
let chunk_dims: Vec<u64> = chunk_dimensions.iter().map(|&d| d as u64).collect(); let chunk_dims: Vec<u64> = chunk_dimensions.iter().map(|&d| d as u64).collect();
let rank = dims.len(); let rank = dims.len();
@@ -405,10 +402,10 @@ pub fn read_raw_data_selection(
} else { } else {
// v3: B-tree v1 // v3: B-tree v1
if let Some(addr) = btree_address { if let Some(addr) = btree_address {
crate::chunked_read::collect_chunk_info( crate::chunked_read::collect_chunk_info_checked(
file_data, file_data,
*addr, *addr,
rank + 1, chunk_dimensions,
offset_size, offset_size,
length_size, length_size,
)? )?
@@ -465,158 +462,54 @@ pub fn read_raw_data_selection(
} }
} }
/// Assemble a **Virtual Dataset (VDS)** from its source mappings. /// Assemble a **Virtual Dataset (VDS)** through the raw-read API, which has no
/// access to the dataset's fill value message.
/// ///
/// Supports virtual datasets of any rank. Same-file sources are read directly; /// Delegates to [`crate::vds::read_virtual_dataset`]. Because the fill value
/// **external-file** sources are read through the caller-supplied `resolver`, /// is unknown here, a virtual dataset with any element no mapping supplies
/// which maps a stored source file name to that file's bytes. Each mapping's /// (an unmapped region, or a missing source file or dataset) is an error
/// selected source elements are scattered into the virtual buffer at the /// rather than a guess at the fill value; so is one whose extent libhdf5
/// positions given by the virtual selection (both enumerated in row-major /// would report differently from the stored dataspace (unlimited mappings).
/// order, as HDF5 pairs them). Unmapped regions are left at the zero fill value. /// Use [`crate::vds::read_virtual_dataset`] to read those.
///
/// A mapping whose external source file the resolver cannot supply (`None`) is
/// skipped, leaving its region at fill — matching HDF5's tolerance of missing
/// sources. An external source with no resolver at all is a hard error.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn read_virtual_data( fn read_virtual_data(
file_data: &[u8], file_data: &[u8],
global_heap_address: Option<u64>, layout: &DataLayout,
global_heap_index: u32,
dataspace: &Dataspace, dataspace: &Dataspace,
datatype: &Datatype, datatype: &Datatype,
offset_size: u8, offset_size: u8,
length_size: u8, length_size: u8,
resolver: Option<&VdsSourceResolver>, resolver: Option<&VdsSourceResolver>,
) -> Result<Vec<u8>, FormatError> { ) -> Result<Vec<u8>, FormatError> {
use crate::data_layout::parse_vds_mappings; let wrapped =
use crate::global_heap::GlobalHeapCollection; resolver.map(|r| move |name: &str| -> Result<Option<Vec<u8>>, FormatError> { Ok(r(name)) });
use crate::selection::Selection; let wrapped_ref = wrapped.as_ref().map(|w| w as &crate::vds::VdsFileResolver);
let v = crate::vds::read_virtual_dataset(
let elem_size = datatype.type_size() as usize;
let mut out = crate::chunked_read::alloc_output(crate::chunked_read::checked_byte_len(
dataspace.checked_num_elements()?,
elem_size,
)?)?;
let virtual_dims = &dataspace.dimensions;
let addr = global_heap_address.ok_or_else(|| {
FormatError::ChunkedReadError("virtual dataset has no mapping global heap".into())
})?;
let coll = GlobalHeapCollection::parse(file_data, addr as usize, length_size)?;
let obj =
coll.get_object(global_heap_index as u16)
.ok_or(FormatError::GlobalHeapObjectNotFound {
collection_address: addr,
index: global_heap_index as u16,
})?;
let mappings = parse_vds_mappings(&obj.data, length_size)?;
for m in &mappings {
let same_file = m.source_file.is_empty() || m.source_file == ".";
// Resolve the bytes of the file holding this source dataset.
let external;
let src_file_data: &[u8] = if same_file {
file_data
} else {
let r = resolver.ok_or_else(|| {
FormatError::ChunkedReadError(
"external-file virtual dataset sources require a file resolver".into(),
)
})?;
match r(&m.source_file) {
Some(bytes) => {
external = bytes;
&external
}
// Source file unavailable: leave this region at fill value.
None => continue,
}
};
let (vsel, _) = Selection::decode_serialized(&m.virtual_selection)?;
let (ssel, _) = Selection::decode_serialized(&m.source_selection)?;
let (src_raw, src_dims) =
read_named_dataset_raw(src_file_data, &m.source_dataset, offset_size, length_size)?;
let vidx = vsel.iter_linear(virtual_dims)?;
let sidx = ssel.iter_linear(&src_dims)?;
if vidx.len() != sidx.len() {
return Err(FormatError::ChunkedReadError(
"virtual/source selection element counts differ".into(),
));
}
for (&v, &s) in vidx.iter().zip(sidx.iter()) {
let (vo, so) = (v as usize * elem_size, s as usize * elem_size);
if vo + elem_size > out.len() || so + elem_size > src_raw.len() {
return Err(FormatError::ChunkedReadError(
"virtual dataset selection out of bounds".into(),
));
}
out[vo..vo + elem_size].copy_from_slice(&src_raw[so..so + elem_size]);
}
}
Ok(out)
}
/// Read a named dataset's raw (decoded) bytes and its dimensions, navigating
/// from the superblock. Used to pull VDS source datasets out of the same file.
fn read_named_dataset_raw(
file_data: &[u8],
path: &str,
_offset_size: u8,
_length_size: u8,
) -> Result<(Vec<u8>, Vec<u64>), FormatError> {
use crate::filter_pipeline::FilterPipeline;
use crate::group_v2::resolve_path_any;
use crate::message_type::MessageType;
use crate::object_header::ObjectHeader;
use crate::signature::find_signature;
use crate::superblock::Superblock;
let sig = find_signature(file_data)?;
let sb = Superblock::parse(file_data, sig)?;
let addr = resolve_path_any(file_data, &sb, path)?;
let hdr = ObjectHeader::parse(file_data, addr as usize, sb.offset_size, sb.length_size)?;
let find = |t: MessageType| hdr.messages.iter().find(|m| m.msg_type == t);
let ds_msg = find(MessageType::Dataspace)
.ok_or_else(|| FormatError::ChunkedReadError("VDS source has no dataspace".into()))?;
let dataspace = Dataspace::parse(&ds_msg.data, sb.length_size)?;
let dt_msg = find(MessageType::Datatype)
.ok_or_else(|| FormatError::ChunkedReadError("VDS source has no datatype".into()))?;
let (datatype, _) = Datatype::parse(&dt_msg.data)?;
let dl_msg = find(MessageType::DataLayout)
.ok_or_else(|| FormatError::ChunkedReadError("VDS source has no data layout".into()))?;
let layout = DataLayout::parse(&dl_msg.data, sb.offset_size, sb.length_size)?;
// A virtual dataset whose source is itself another virtual dataset could
// form a cycle (A -> B -> A) and recurse into a stack overflow. Nested
// virtual sources are exotic and unsupported, so stop here cleanly.
if matches!(layout, DataLayout::Virtual { .. }) {
return Err(FormatError::ChunkedReadError(
"virtual dataset source is itself virtual (unsupported)".into(),
));
}
let pipeline = find(MessageType::FilterPipeline)
.map(|m| FilterPipeline::parse(&m.data))
.transpose()?;
let raw = read_raw_data_full(
file_data, file_data,
&layout, layout,
&dataspace, dataspace,
&datatype, datatype,
pipeline.as_ref(), None,
sb.offset_size, offset_size,
sb.length_size, length_size,
wrapped_ref,
)?; )?;
Ok((raw, dataspace.dimensions.clone())) if v.dims != dataspace.dimensions {
return Err(FormatError::ChunkedReadError(
"virtual dataset extent differs from its stored dataspace; \
read it with vds::read_virtual_dataset"
.into(),
));
}
if v.unmapped > 0 {
return Err(FormatError::ChunkedReadError(
"virtual dataset has elements no source supplies, which read as its \
fill value; read it with vds::read_virtual_dataset and the fill value"
.into(),
));
}
Ok(v.data)
} }
/// Extract selected elements from a full dataset buffer. /// Extract selected elements from a full dataset buffer.
pub fn extract_selection_from_buffer( pub fn extract_selection_from_buffer(
full_data: &[u8], full_data: &[u8],
@@ -773,14 +666,7 @@ pub fn read_as_f64_zerocopy<'a>(raw: &'a [u8], datatype: &Datatype) -> Option<&'
// Only native LE f64 is eligible // Only native LE f64 is eligible
#[cfg(target_endian = "little")] #[cfg(target_endian = "little")]
{ {
if !matches!( if !is_native_le_float(datatype, FloatFormat::Double) {
datatype,
Datatype::FloatingPoint {
size: 8,
byte_order: DatatypeByteOrder::LittleEndian,
..
}
) {
return None; return None;
} }
if !raw.len().is_multiple_of(8) { if !raw.len().is_multiple_of(8) {
@@ -809,14 +695,7 @@ pub fn read_as_f64_zerocopy<'a>(raw: &'a [u8], datatype: &Datatype) -> Option<&'
pub fn read_as_f32_zerocopy<'a>(raw: &'a [u8], datatype: &Datatype) -> Option<&'a [f32]> { pub fn read_as_f32_zerocopy<'a>(raw: &'a [u8], datatype: &Datatype) -> Option<&'a [f32]> {
#[cfg(target_endian = "little")] #[cfg(target_endian = "little")]
{ {
if !matches!( if !is_native_le_float(datatype, FloatFormat::Single) {
datatype,
Datatype::FloatingPoint {
size: 4,
byte_order: DatatypeByteOrder::LittleEndian,
..
}
) {
return None; return None;
} }
if !raw.len().is_multiple_of(4) { if !raw.len().is_multiple_of(4) {
@@ -902,9 +781,9 @@ fn native_le_to_vec<T: Copy>(raw: &[u8], count: usize) -> Vec<T> {
/// Convert raw bytes to `f64` values. /// Convert raw bytes to `f64` values.
pub fn read_as_f64(raw: &[u8], datatype: &Datatype) -> Result<Vec<f64>, FormatError> { pub fn read_as_f64(raw: &[u8], datatype: &Datatype) -> Result<Vec<f64>, FormatError> {
// Array datatypes (e.g. an array-typed compound member) are read as a flat // Array datatypes read as a flat sequence of their base elements, and
// sequence of their base elements. // enumerations (h5py's bool among them) as their integer values.
if let Datatype::Array { base_type, .. } = datatype { if let Datatype::Array { base_type, .. } | Datatype::Enumeration { base_type, .. } = datatype {
return read_as_f64(raw, base_type); return read_as_f64(raw, base_type);
} }
ensure_numeric(datatype, "FloatingPoint or FixedPoint")?; ensure_numeric(datatype, "FloatingPoint or FixedPoint")?;
@@ -919,20 +798,19 @@ pub fn read_as_f64(raw: &[u8], datatype: &Datatype) -> Result<Vec<f64>, FormatEr
// Fast path: native-endian f64 — single bulk memcpy // Fast path: native-endian f64 — single bulk memcpy
#[cfg(target_endian = "little")] #[cfg(target_endian = "little")]
if matches!( if is_native_le_float(datatype, FloatFormat::Double) {
datatype,
Datatype::FloatingPoint {
size: 8,
byte_order: DatatypeByteOrder::LittleEndian,
..
}
) {
return Ok(native_le_to_vec::<f64>(raw, count)); return Ok(native_le_to_vec::<f64>(raw, count));
} }
let order = get_byte_order(datatype); let order = get_byte_order(datatype);
let mut result = Vec::with_capacity(count); let mut result = Vec::with_capacity(count);
if let Datatype::FloatingPoint { .. } = datatype {
let format = FloatFormat::of(datatype)?;
for chunk in raw.chunks_exact(elem_size) {
result.push(format.decode(chunk, &order));
}
return Ok(result);
}
for i in 0..count { for i in 0..count {
let chunk = &raw[i * elem_size..(i + 1) * elem_size]; let chunk = &raw[i * elem_size..(i + 1) * elem_size];
let val = convert_to_f64(chunk, datatype, &order)?; let val = convert_to_f64(chunk, datatype, &order)?;
@@ -947,18 +825,7 @@ fn convert_to_f64(
order: &DatatypeByteOrder, order: &DatatypeByteOrder,
) -> Result<f64, FormatError> { ) -> Result<f64, FormatError> {
match dt { match dt {
Datatype::FloatingPoint { size, .. } => match size { Datatype::FloatingPoint { .. } => Ok(FloatFormat::of(dt)?.decode(bytes, order)),
4 => {
let v = read_f32_bytes(bytes, order);
Ok(v as f64)
}
8 => Ok(read_f64_bytes(bytes, order)),
2 => Ok(read_f16_bytes(bytes, order) as f64),
_ => Err(FormatError::DataSizeMismatch {
expected: 8,
actual: *size as usize,
}),
},
Datatype::FixedPoint { Datatype::FixedPoint {
size, size,
signed, signed,
@@ -982,9 +849,83 @@ fn convert_to_f64(
} }
} }
/// One numeric element as stored, before conversion to the caller's type.
#[derive(Debug, Clone, Copy, PartialEq)]
enum Scalar {
Signed(i64),
Unsigned(u64),
Float(f64),
}
impl Scalar {
// Every conversion follows libhdf5's default (hard) conversions: a value
// outside the target type's range saturates to its minimum or maximum —
// including a negative value read as unsigned, which reads as 0 — rather
// than being truncated to its low bits. Floats truncate toward zero; NaN
// converts to 0 (libhdf5 leaves that case to the C cast, whose result is
// platform-dependent).
fn to_i64(self) -> i64 {
match self {
Scalar::Signed(v) => v,
Scalar::Unsigned(v) => i64::try_from(v).unwrap_or(i64::MAX),
Scalar::Float(v) => v as i64,
}
}
fn to_u64(self) -> u64 {
match self {
Scalar::Signed(v) => u64::try_from(v).unwrap_or(0),
Scalar::Unsigned(v) => v,
Scalar::Float(v) => v as u64,
}
}
fn to_i32(self) -> i32 {
match self {
Scalar::Signed(v) => v.clamp(i32::MIN.into(), i32::MAX.into()) as i32,
Scalar::Unsigned(v) => i32::try_from(v).unwrap_or(i32::MAX),
Scalar::Float(v) => v as i32,
}
}
}
/// Decode one element of a numeric datatype.
fn decode_scalar(
bytes: &[u8],
dt: &Datatype,
order: &DatatypeByteOrder,
) -> Result<Scalar, FormatError> {
match dt {
Datatype::FixedPoint {
size,
signed,
bit_offset,
bit_precision,
..
} => {
let full = read_unsigned_int(bytes, *size as usize, order);
let (off, prec) = effective_bits(*size as usize, *bit_offset, *bit_precision);
Ok(if *signed {
Scalar::Signed(extract_signed(full, off, prec))
} else {
Scalar::Unsigned(extract_unsigned(full, off, prec))
})
}
_ => convert_to_f64(bytes, dt, order).map(Scalar::Float),
}
}
/// Convert raw bytes to `i64` values. /// Convert raw bytes to `i64` values.
///
/// Values are converted the way libhdf5 converts them: integers outside the
/// target range saturate at its minimum or maximum (a negative value read as
/// unsigned is 0), and floating-point data is truncated toward zero and
/// saturated, with NaN read as 0.
pub fn read_as_i64(raw: &[u8], datatype: &Datatype) -> Result<Vec<i64>, FormatError> { pub fn read_as_i64(raw: &[u8], datatype: &Datatype) -> Result<Vec<i64>, FormatError> {
if let Datatype::Array { base_type, .. } = datatype { // Array datatypes read as a flat sequence of their base elements, and
// enumerations (h5py's bool among them) as their integer values.
if let Datatype::Array { base_type, .. } | Datatype::Enumeration { base_type, .. } = datatype {
return read_as_i64(raw, base_type); return read_as_i64(raw, base_type);
} }
ensure_numeric(datatype, "FixedPoint (signed)")?; ensure_numeric(datatype, "FixedPoint (signed)")?;
@@ -1014,19 +955,24 @@ pub fn read_as_i64(raw: &[u8], datatype: &Datatype) -> Result<Vec<i64>, FormatEr
} }
let order = get_byte_order(datatype); let order = get_byte_order(datatype);
let (off, prec) = fixed_bits(datatype);
let mut result = Vec::with_capacity(count); let mut result = Vec::with_capacity(count);
for i in 0..count { for i in 0..count {
let chunk = &raw[i * elem_size..(i + 1) * elem_size]; let chunk = &raw[i * elem_size..(i + 1) * elem_size];
let full = read_unsigned_int(chunk, elem_size, &order); result.push(decode_scalar(chunk, datatype, &order)?.to_i64());
result.push(extract_signed(full, off, prec));
} }
Ok(result) Ok(result)
} }
/// Convert raw bytes to `u64` values. /// Convert raw bytes to `u64` values.
///
/// Values are converted the way libhdf5 converts them: integers outside the
/// target range saturate at its minimum or maximum (a negative value read as
/// unsigned is 0), and floating-point data is truncated toward zero and
/// saturated, with NaN read as 0.
pub fn read_as_u64(raw: &[u8], datatype: &Datatype) -> Result<Vec<u64>, FormatError> { pub fn read_as_u64(raw: &[u8], datatype: &Datatype) -> Result<Vec<u64>, FormatError> {
if let Datatype::Array { base_type, .. } = datatype { // Array datatypes read as a flat sequence of their base elements, and
// enumerations (h5py's bool among them) as their integer values.
if let Datatype::Array { base_type, .. } | Datatype::Enumeration { base_type, .. } = datatype {
return read_as_u64(raw, base_type); return read_as_u64(raw, base_type);
} }
ensure_numeric(datatype, "FixedPoint (unsigned)")?; ensure_numeric(datatype, "FixedPoint (unsigned)")?;
@@ -1039,19 +985,19 @@ pub fn read_as_u64(raw: &[u8], datatype: &Datatype) -> Result<Vec<u64>, FormatEr
} }
let count = raw.len() / elem_size; let count = raw.len() / elem_size;
let order = get_byte_order(datatype); let order = get_byte_order(datatype);
let (off, prec) = fixed_bits(datatype);
let mut result = Vec::with_capacity(count); let mut result = Vec::with_capacity(count);
for i in 0..count { for i in 0..count {
let chunk = &raw[i * elem_size..(i + 1) * elem_size]; let chunk = &raw[i * elem_size..(i + 1) * elem_size];
let full = read_unsigned_int(chunk, elem_size, &order); result.push(decode_scalar(chunk, datatype, &order)?.to_u64());
result.push(extract_unsigned(full, off, prec));
} }
Ok(result) Ok(result)
} }
/// Convert raw bytes to `f32` values. /// Convert raw bytes to `f32` values.
pub fn read_as_f32(raw: &[u8], datatype: &Datatype) -> Result<Vec<f32>, FormatError> { pub fn read_as_f32(raw: &[u8], datatype: &Datatype) -> Result<Vec<f32>, FormatError> {
if let Datatype::Array { base_type, .. } = datatype { // Array datatypes read as a flat sequence of their base elements, and
// enumerations (h5py's bool among them) as their integer values.
if let Datatype::Array { base_type, .. } | Datatype::Enumeration { base_type, .. } = datatype {
return read_as_f32(raw, base_type); return read_as_f32(raw, base_type);
} }
ensure_numeric(datatype, "FloatingPoint")?; ensure_numeric(datatype, "FloatingPoint")?;
@@ -1066,25 +1012,11 @@ pub fn read_as_f32(raw: &[u8], datatype: &Datatype) -> Result<Vec<f32>, FormatEr
// Fast path: native-endian f32 — single bulk memcpy // Fast path: native-endian f32 — single bulk memcpy
#[cfg(target_endian = "little")] #[cfg(target_endian = "little")]
if matches!( if is_native_le_float(datatype, FloatFormat::Single) {
datatype,
Datatype::FloatingPoint {
size: 4,
byte_order: DatatypeByteOrder::LittleEndian,
..
}
) {
return Ok(native_le_to_vec::<f32>(raw, count)); return Ok(native_le_to_vec::<f32>(raw, count));
} }
// Little-endian half precision (numpy float16): widen directly. // Little-endian IEEE half precision (numpy float16): widen directly.
if matches!( if is_native_le_float(datatype, FloatFormat::Half) {
datatype,
Datatype::FloatingPoint {
size: 2,
byte_order: DatatypeByteOrder::LittleEndian,
..
}
) {
let (halves, _) = raw[..count * 2].as_chunks::<2>(); let (halves, _) = raw[..count * 2].as_chunks::<2>();
return Ok(halves return Ok(halves
.iter() .iter()
@@ -1094,18 +1026,22 @@ pub fn read_as_f32(raw: &[u8], datatype: &Datatype) -> Result<Vec<f32>, FormatEr
let order = get_byte_order(datatype); let order = get_byte_order(datatype);
let mut result = Vec::with_capacity(count); let mut result = Vec::with_capacity(count);
if let Datatype::FloatingPoint { .. } = datatype {
let format = FloatFormat::of(datatype)?;
for chunk in raw.chunks_exact(elem_size) {
result.push(match format {
FloatFormat::Single => read_f32_bytes(chunk, &order),
FloatFormat::Half => read_f16_bytes(chunk, &order),
// Double rounds; every other supported layout (bfloat16, FP8)
// is exact in f32.
_ => format.decode(chunk, &order) as f32,
});
}
return Ok(result);
}
for i in 0..count { for i in 0..count {
let chunk = &raw[i * elem_size..(i + 1) * elem_size]; let chunk = &raw[i * elem_size..(i + 1) * elem_size];
match datatype { match datatype {
Datatype::FloatingPoint { size: 4, .. } => {
result.push(read_f32_bytes(chunk, &order));
}
Datatype::FloatingPoint { size: 8, .. } => {
result.push(read_f64_bytes(chunk, &order) as f32);
}
Datatype::FloatingPoint { size: 2, .. } => {
result.push(read_f16_bytes(chunk, &order));
}
Datatype::FixedPoint { Datatype::FixedPoint {
signed: true, signed: true,
size, size,
@@ -1140,8 +1076,15 @@ pub fn read_as_f32(raw: &[u8], datatype: &Datatype) -> Result<Vec<f32>, FormatEr
} }
/// Convert raw bytes to `i32` values. /// Convert raw bytes to `i32` values.
///
/// Values are converted the way libhdf5 converts them: integers outside the
/// target range saturate at its minimum or maximum (a negative value read as
/// unsigned is 0), and floating-point data is truncated toward zero and
/// saturated, with NaN read as 0.
pub fn read_as_i32(raw: &[u8], datatype: &Datatype) -> Result<Vec<i32>, FormatError> { pub fn read_as_i32(raw: &[u8], datatype: &Datatype) -> Result<Vec<i32>, FormatError> {
if let Datatype::Array { base_type, .. } = datatype { // Array datatypes read as a flat sequence of their base elements, and
// enumerations (h5py's bool among them) as their integer values.
if let Datatype::Array { base_type, .. } | Datatype::Enumeration { base_type, .. } = datatype {
return read_as_i32(raw, base_type); return read_as_i32(raw, base_type);
} }
ensure_numeric(datatype, "FixedPoint")?; ensure_numeric(datatype, "FixedPoint")?;
@@ -1162,6 +1105,7 @@ pub fn read_as_i32(raw: &[u8], datatype: &Datatype) -> Result<Vec<i32>, FormatEr
datatype, datatype,
Datatype::FixedPoint { Datatype::FixedPoint {
byte_order: DatatypeByteOrder::LittleEndian, byte_order: DatatypeByteOrder::LittleEndian,
signed: true,
.. ..
} }
) )
@@ -1170,12 +1114,10 @@ pub fn read_as_i32(raw: &[u8], datatype: &Datatype) -> Result<Vec<i32>, FormatEr
} }
let order = get_byte_order(datatype); let order = get_byte_order(datatype);
let (off, prec) = fixed_bits(datatype);
let mut result = Vec::with_capacity(count); let mut result = Vec::with_capacity(count);
for i in 0..count { for i in 0..count {
let chunk = &raw[i * elem_size..(i + 1) * elem_size]; let chunk = &raw[i * elem_size..(i + 1) * elem_size];
let full = read_unsigned_int(chunk, elem_size, &order); result.push(decode_scalar(chunk, datatype, &order)?.to_i32());
result.push(extract_signed(full, off, prec) as i32);
} }
Ok(result) Ok(result)
} }
@@ -1616,6 +1558,174 @@ fn reorder_bytes(bytes: &[u8], order: &DatatypeByteOrder) -> [u8; 8] {
buf buf
} }
/// How the bits of a floating-point datatype are laid out, read from the
/// datatype message's fields rather than assumed from its size (a 2-byte
/// float may be IEEE half or bfloat16).
#[derive(Debug, Clone, Copy, PartialEq)]
enum FloatFormat {
/// IEEE-754 binary16.
Half,
/// IEEE-754 binary32.
Single,
/// IEEE-754 binary64.
Double,
/// Any other IEEE-style layout (implied leading mantissa bit, all-ones
/// exponent for infinity/NaN) whose values are all exact in `f64`:
/// bfloat16, the FP8 formats, and similar.
Other(FloatLayout),
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct FloatLayout {
exponent_location: u32,
exponent_size: u32,
mantissa_location: u32,
mantissa_size: u32,
exponent_bias: u32,
}
impl FloatFormat {
fn of(dt: &Datatype) -> Result<FloatFormat, FormatError> {
let Datatype::FloatingPoint {
size,
exponent_location,
exponent_size,
mantissa_location,
mantissa_size,
exponent_bias,
..
} = dt
else {
return Err(FormatError::TypeMismatch {
expected: "FloatingPoint",
actual: datatype_name(dt),
});
};
let layout = FloatLayout {
exponent_location: u32::from(*exponent_location),
exponent_size: u32::from(*exponent_size),
mantissa_location: u32::from(*mantissa_location),
mantissa_size: u32::from(*mantissa_size),
exponent_bias: *exponent_bias,
};
let fields = (
layout.exponent_location,
layout.exponent_size,
layout.mantissa_location,
layout.mantissa_size,
layout.exponent_bias,
);
let bits = size.saturating_mul(8);
// The sign bit is not kept in `Datatype`; every standard layout has it
// directly above the exponent, with the mantissa below.
let well_formed = layout.exponent_size > 0
&& layout.mantissa_size > 0
&& layout.mantissa_location + layout.mantissa_size <= layout.exponent_location
&& layout.exponent_location + layout.exponent_size < bits;
match (size, fields) {
(2, (10, 5, 0, 10, 15)) => Ok(FloatFormat::Half),
(4, (23, 8, 0, 23, 127)) => Ok(FloatFormat::Single),
(8, (52, 11, 0, 52, 1023)) => Ok(FloatFormat::Double),
_ if well_formed
&& *size <= 8
&& layout.exponent_size <= 11
&& layout.mantissa_size <= 52 =>
{
Ok(FloatFormat::Other(layout))
}
// Fields that cannot describe any float (e.g. left zeroed by a
// hand-built datatype): fall back to the IEEE type of that size.
(2, _) if !well_formed => Ok(FloatFormat::Half),
(4, _) if !well_formed => Ok(FloatFormat::Single),
(8, _) if !well_formed => Ok(FloatFormat::Double),
// x87 80-bit extended, binary128, ...: not representable in f64.
_ => Err(FormatError::TypeMismatch {
expected: "floating point of at most 64 bits (IEEE-style layout)",
actual: "FloatingPoint",
}),
}
}
fn decode(self, bytes: &[u8], order: &DatatypeByteOrder) -> f64 {
match self {
FloatFormat::Half => f64::from(read_f16_bytes(bytes, order)),
FloatFormat::Single => f64::from(read_f32_bytes(bytes, order)),
FloatFormat::Double => read_f64_bytes(bytes, order),
FloatFormat::Other(layout) => {
layout.decode(read_unsigned_int(bytes, bytes.len(), order))
}
}
}
}
impl FloatLayout {
/// Decode the value held in the low `size * 8` bits of `bits`.
fn decode(self, bits: u64) -> f64 {
let field = |location: u32, size: u32| (bits >> location) & ((1u64 << size) - 1);
let exponent = field(self.exponent_location, self.exponent_size);
let mantissa = field(self.mantissa_location, self.mantissa_size);
let negative = field(self.exponent_location + self.exponent_size, 1) == 1;
let max_exponent = (1u64 << self.exponent_size) - 1;
let magnitude = if exponent == max_exponent {
if mantissa == 0 {
f64::INFINITY
} else {
f64::NAN
}
} else {
let bias = i64::from(self.exponent_bias);
let msize = i64::from(self.mantissa_size);
// value = significand * 2^power, with an implied leading 1 unless
// the number is subnormal (exponent field 0).
let (significand, power) = if exponent == 0 {
(mantissa, 1 - bias - msize)
} else {
(
mantissa | (1u64 << self.mantissa_size),
exponent as i64 - bias - msize,
)
};
scale_by_pow2(significand as f64, power)
};
if negative { -magnitude } else { magnitude }
}
}
/// `x * 2^power` without `std` (no `powi`/`libm`). `x` is a non-negative
/// integer below 2^53, so it is exact.
fn scale_by_pow2(x: f64, power: i64) -> f64 {
if x == 0.0 || power < -1200 {
return 0.0;
}
if power > 1100 {
return f64::INFINITY;
}
let pow2 = |p: i64| f64::from_bits(((p + 1023) as u64) << 52);
let mut x = x;
let mut power = power;
while power > 1023 {
x *= pow2(1023);
power -= 1023;
}
while power < -1022 {
x *= pow2(-1022);
power += 1022;
}
x * pow2(power)
}
/// Whether `datatype` is the little-endian IEEE float `format`, whose bytes
/// can be copied straight into native values on a little-endian target.
fn is_native_le_float(datatype: &Datatype, format: FloatFormat) -> bool {
matches!(
datatype,
Datatype::FloatingPoint {
byte_order: DatatypeByteOrder::LittleEndian,
..
}
) && FloatFormat::of(datatype).is_ok_and(|f| f == format)
}
fn read_f64_bytes(bytes: &[u8], order: &DatatypeByteOrder) -> f64 { fn read_f64_bytes(bytes: &[u8], order: &DatatypeByteOrder) -> f64 {
let buf = reorder_bytes(bytes, order); let buf = reorder_bytes(bytes, order);
f64::from_le_bytes(buf) f64::from_le_bytes(buf)
@@ -1666,20 +1776,6 @@ fn effective_bits(size: usize, bit_offset: u16, bit_precision: u16) -> (u32, u32
(bit_offset as u32, prec) (bit_offset as u32, prec)
} }
/// `(bit_offset, bit_precision)` for a fixed-point datatype, full width for
/// other types.
fn fixed_bits(datatype: &Datatype) -> (u32, u32) {
match datatype {
Datatype::FixedPoint {
size,
bit_offset,
bit_precision,
..
} => effective_bits(*size as usize, *bit_offset, *bit_precision),
_ => (0, 0),
}
}
/// Whether a datatype occupies its full storage width (bit offset 0, precision /// Whether a datatype occupies its full storage width (bit offset 0, precision
/// == size·8), in which case the bulk-copy fast read paths apply. Non /// == size·8), in which case the bulk-copy fast read paths apply. Non
/// fixed-point types are treated as full width. /// fixed-point types are treated as full width.
@@ -1892,6 +1988,67 @@ mod tests {
assert_eq!(read_as_u64(&raw, &dt).unwrap(), vec![4095, 1, 2048]); assert_eq!(read_as_u64(&raw, &dt).unwrap(), vec![4095, 1, 2048]);
} }
#[test]
fn float_to_int_truncates_and_saturates() {
// Values libhdf5 hands to an undefined C cast: NaN reads as 0 and
// exactly 2^63 saturates instead of wrapping to i64::MIN.
let dt = make_f64_le_type();
let vals = [f64::NAN, 2f64.powi(63), -2.5, 2.0f64.powi(64)];
let raw: Vec<u8> = vals.iter().flat_map(|v| v.to_le_bytes()).collect();
assert_eq!(
read_as_i64(&raw, &dt).unwrap(),
vec![0, i64::MAX, -2, i64::MAX]
);
assert_eq!(
read_as_u64(&raw, &dt).unwrap(),
vec![0, 1 << 63, 0, u64::MAX]
);
assert_eq!(
read_as_i32(&raw, &dt).unwrap(),
vec![0, i32::MAX, -2, i32::MAX]
);
}
#[test]
fn bfloat16_and_fp8_decode_by_fields() {
// bfloat16 is a 2-byte float that is not IEEE half.
let bf16 = Datatype::FloatingPoint {
size: 2,
byte_order: DatatypeByteOrder::LittleEndian,
bit_offset: 0,
bit_precision: 16,
exponent_location: 7,
exponent_size: 8,
mantissa_location: 0,
mantissa_size: 7,
exponent_bias: 127,
};
let raw: Vec<u8> = [0x3FC0u16, 0xC010, 0x7F80, 0x0001]
.iter()
.flat_map(|v| v.to_le_bytes())
.collect();
let got = read_as_f64(&raw, &bf16).unwrap();
assert_eq!(&got[..3], &[1.5, -2.25, f64::INFINITY]);
assert_eq!(got[3], 2f64.powi(-133)); // smallest subnormal
assert_eq!(read_as_f32(&raw, &bf16).unwrap()[..2], [1.5, -2.25]);
// FP8 E4M3: 1, -1, 2, 0, NaN (IEEE-style, as libhdf5 treats it).
let e4m3 = Datatype::FloatingPoint {
size: 1,
byte_order: DatatypeByteOrder::LittleEndian,
bit_offset: 0,
bit_precision: 8,
exponent_location: 3,
exponent_size: 4,
mantissa_location: 0,
mantissa_size: 3,
exponent_bias: 7,
};
let got = read_as_f64(&[0x38, 0xB8, 0x40, 0x00, 0x7E], &e4m3).unwrap();
assert_eq!(&got[..4], &[1.0, -1.0, 2.0, 0.0]);
assert!(got[4].is_nan());
}
#[test] #[test]
fn full_width_signed_unchanged() { fn full_width_signed_unchanged() {
// Regression: full-width 32-bit signed must be unaffected. // Regression: full-width 32-bit signed must be unaffected.
File diff suppressed because it is too large Load Diff
+248 -297
View File
@@ -7,7 +7,9 @@ extern crate alloc;
use alloc::{vec, vec::Vec}; use alloc::{vec, vec::Vec};
use crate::checksum::jenkins_lookup3; use crate::checksum::jenkins_lookup3;
use crate::chunked_write::WrittenChunk; 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. /// Serialize a v4 Extensible Array layout message.
pub(crate) fn serialize_v4_extensible_array( pub(crate) fn serialize_v4_extensible_array(
@@ -24,45 +26,17 @@ pub(crate) fn serialize_v4_extensible_array(
let ndims = chunk_dims.len() as u8 + 1; let ndims = chunk_dims.len() as u8 + 1;
buf.push(ndims); buf.push(ndims);
let max_dim = chunk_dims push_v4_chunk_dims(&mut buf, chunk_dims, element_size);
.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}"),
}
// chunk index type = 4 (Extensible Array) // chunk index type = 4 (Extensible Array)
buf.push(4); buf.push(4);
// EA creation parameters (must match AEHD and HDF5 C library defaults) // EA creation parameters (must match AEHD and HDF5 C library defaults)
buf.push(32); // max_nelmts_bits buf.push(MAX_NELMTS_BITS);
buf.push(4); // idx_blk_elmts buf.push(IDX_BLK_ELMTS);
buf.push(4); // super_blk_min_data_ptrs buf.push(SUP_BLK_MIN_DATA_PTRS);
buf.push(16); // data_blk_min_elmts buf.push(DATA_BLK_MIN_ELMTS);
buf.push(10); // max_dblk_page_nelmts_bits buf.push(MAX_DBLK_PAGE_NELMTS_BITS);
// EA header address // EA header address
match offset_size { match offset_size {
@@ -74,304 +48,281 @@ pub(crate) fn serialize_v4_extensible_array(
buf buf
} }
// EA creation parameters — the HDF5 library's defaults for chunk indexes
// (`H5D_EARRAY_*`); the layout message above and the header must agree.
const MAX_NELMTS_BITS: u8 = 32;
const IDX_BLK_ELMTS: u8 = 4;
const SUP_BLK_MIN_DATA_PTRS: u8 = 4;
const DATA_BLK_MIN_ELMTS: u8 = 16;
const MAX_DBLK_PAGE_NELMTS_BITS: u8 = 10;
/// One data block of the array: its first element (relative to the end of
/// the index block's own elements), element count, and address when it is
/// allocated.
struct DataBlock {
start: usize,
nelmts: usize,
addr: Option<u64>,
}
/// Build a complete Extensible Array at a known absolute address. /// Build a complete Extensible Array at a known absolute address.
/// ///
/// For simplicity, we put all elements inline in the index block when the /// `slots[i]` is the element at linear index `i` (see `chunk_grid`); `None`
/// number of chunks is small (up to idx_blk_elmts), otherwise use inline + /// marks an unallocated chunk. The first `IDX_BLK_ELMTS` elements live in
/// direct data blocks. /// the index block, the rest in data blocks grouped by super block level
/// exactly as `H5EA__hdr_init` sizes them: level `u` has `2^(u/2)` data
/// blocks of `DATA_BLK_MIN_ELMTS * 2^ceil(u/2)` elements. The data blocks of
/// the first levels are addressed straight from the index block; later
/// levels go through a super block (EASB). Data blocks larger than a page
/// (`2^MAX_DBLK_PAGE_NELMTS_BITS` elements) are paged, with their page-init
/// bits kept in the owning super block. Only blocks holding a defined element
/// are allocated; the rest keep the undefined address, as in a file the
/// library wrote.
pub fn build_extensible_array_at( pub fn build_extensible_array_at(
chunks: &[WrittenChunk], slots: &[Option<WrittenChunk>],
offset_size: u8, offset_size: u8,
length_size: u8, length_size: u8,
has_filters: bool, has_filters: bool,
ea_base_address: u64, ea_base_address: u64,
) -> Vec<u8> { ) -> Vec<u8> {
let os = offset_size as usize; let os = offset_size as usize;
let num_elements = chunks.len(); let chunk_size_bytes = has_filters.then(|| filtered_chunk_size_len(slots));
let elem_size = os + chunk_size_bytes.map_or(0, |n| n + 4);
// Compute element encoding size (same logic as Fixed Array)
let chunk_size_bytes: usize = if has_filters {
let max_raw = chunks.iter().map(|c| c.raw_size).max().unwrap_or(1);
let log2_val = if max_raw <= 1 {
0
} else {
63 - max_raw.leading_zeros()
};
let len = 1 + ((log2_val + 8) / 8) as usize;
len.min(8)
} else {
0
};
let elem_size = if has_filters {
os + chunk_size_bytes + 4
} else {
os
};
let client_id: u8 = if has_filters { 1 } else { 0 }; let client_id: u8 = if has_filters { 1 } else { 0 };
let arr_off_size = (MAX_NELMTS_BITS as usize).div_ceil(8);
let page_nelmts = 1usize << MAX_DBLK_PAGE_NELMTS_BITS;
let idx_blk = IDX_BLK_ELMTS as usize;
// EA creation parameters — must match HDF5 C library defaults exactly // Elements past the last defined one are never realised
let max_nelmts_bits: u8 = 32; // (`max_idx_set` is one past the highest index ever set).
let idx_blk_elmts: u8 = 4; let max_idx_set = slots.iter().rposition(Option::is_some).map_or(0, |i| i + 1);
let min_dblk_nelmts: u8 = 16; let slots = &slots[..max_idx_set];
let super_blk_min_nelmts: u8 = 4; let defined_in = |start: usize, n: usize| -> bool {
let max_dblk_nelmts_bits: u8 = 10; let lo = idx_blk.saturating_add(start).min(slots.len());
let hi = idx_blk
.saturating_add(start)
.saturating_add(n)
.min(slots.len());
slots[lo..hi].iter().any(Option::is_some)
};
// EAHD size: fixed(12) + 6 stats(6*length_size) + addr(offset_size) + checksum(4) // Super block levels: (ndblks, dblk_nelmts, first element).
let log2_dmin = (DATA_BLK_MIN_ELMTS as u32).trailing_zeros() as usize;
let nsblks = 1 + MAX_NELMTS_BITS as usize - log2_dmin;
let ndblk_addrs = 2 * (SUP_BLK_MIN_DATA_PTRS as usize - 1);
let mut levels: Vec<(usize, usize, usize)> = Vec::with_capacity(nsblks);
let mut start = 0usize;
for u in 0..nsblks {
let ndblks = 1usize << (u / 2);
let nelmts = (DATA_BLK_MIN_ELMTS as usize) << u.div_ceil(2);
levels.push((ndblks, nelmts, start));
// Saturate: on 32-bit targets the last levels only need to compare
// as "beyond the end".
start = start.saturating_add(ndblks.saturating_mul(nelmts));
}
// Levels whose data blocks the index block addresses directly.
let mut direct_levels = 0;
let mut n = 0;
while n < ndblk_addrs {
n += levels[direct_levels].0;
direct_levels += 1;
}
let nsblk_addrs = nsblks - direct_levels;
let dblk_size = |nelmts: usize| -> usize {
let prefix = 4 + 1 + 1 + os + arr_off_size + 4;
if nelmts > page_nelmts {
prefix + (nelmts / page_nelmts) * (page_nelmts * elem_size + 4)
} else {
prefix + nelmts * elem_size
}
};
let sblk_bitmap_len = |ndblks: usize, nelmts: usize| -> usize {
if nelmts > page_nelmts {
ndblks * (nelmts / page_nelmts).div_ceil(8)
} else {
0
}
};
// Plan addresses: header, index block, the direct data blocks, then each
// allocated super block followed by its allocated data blocks.
let aehd_size = 4 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 6 * length_size as usize + os + 4; let aehd_size = 4 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 6 * length_size as usize + os + 4;
let aeib_address = ea_base_address + aehd_size as u64; let aeib_address = ea_base_address + aehd_size as u64;
let aeib_size = 4 + 1 + 1 + os + idx_blk * elem_size + ndblk_addrs * os + nsblk_addrs * os + 4;
let mut cursor = aeib_address + aeib_size as u64;
// Determine how many elements go inline vs data blocks let mut ndata_blks = 0u64;
let n_inline = (idx_blk_elmts as usize).min(num_elements); let mut data_blk_size = 0u64;
let remaining_after_inline = num_elements.saturating_sub(n_inline); let mut nsuper_blks = 0u64;
let mut super_blk_size = 0u64;
let mut realized = idx_blk as u64;
// Compute super block layout per HDF5 spec let mut plan_dblk = |cursor: &mut u64, start: usize, nelmts: usize| -> DataBlock {
let sblk_min = super_blk_min_nelmts as usize; let addr = defined_in(start, nelmts).then(|| {
let log2_dblk_min = if min_dblk_nelmts <= 1 { let a = *cursor;
0 let size = dblk_size(nelmts) as u64;
} else { *cursor += size;
(min_dblk_nelmts as u32).trailing_zeros() as usize ndata_blks += 1;
}; data_blk_size += size;
let nsblks = (max_nelmts_bits as usize).saturating_sub(log2_dblk_min) + 1; realized += nelmts as u64;
a
// Direct data block addresses (from super blocks 0..sblk_min-1) });
let mut dblk_sizes: Vec<usize> = Vec::new(); DataBlock {
for sblk_idx in 0..sblk_min.min(nsblks) { start,
let ndblks = 1usize << (sblk_idx / 2); nelmts,
let dblk_nelmts = (min_dblk_nelmts as usize) * (1 << sblk_idx.div_ceil(2)); addr,
for _ in 0..ndblks {
dblk_sizes.push(dblk_nelmts);
} }
}
let n_direct_dblks = dblk_sizes.len();
// Super block addresses (for super blocks sblk_min..nsblks-1)
let n_sblk_addrs = nsblks.saturating_sub(sblk_min);
// EAIB size
let aeib_size = 4
+ 1
+ 1
+ os
+ idx_blk_elmts as usize * elem_size
+ n_direct_dblks * os
+ n_sblk_addrs * os
+ 4;
// Build AEHD
let mut aehd = Vec::with_capacity(aehd_size);
aehd.extend_from_slice(b"EAHD");
aehd.push(0); // version
aehd.push(client_id);
aehd.push(elem_size as u8);
aehd.push(max_nelmts_bits);
aehd.push(idx_blk_elmts);
aehd.push(min_dblk_nelmts);
aehd.push(super_blk_min_nelmts);
aehd.push(max_dblk_nelmts_bits);
// Count data blocks that will have chunks
let n_active_dblks: u64 = if remaining_after_inline > 0 {
let mut count = 0u64;
let mut ci = n_inline;
for &sz in &dblk_sizes {
if ci < num_elements {
count += 1;
ci += sz;
}
}
count
} else {
0
};
let blk_off_size = (max_nelmts_bits as usize).div_ceil(8);
let aedb_header_overhead = 4 + 1 + 1 + os + blk_off_size + 4;
let data_blk_total_size: u64 = if remaining_after_inline > 0 {
let mut total = 0u64;
let mut ci = n_inline;
for &sz in &dblk_sizes {
if ci < num_elements {
total += (aedb_header_overhead + sz * elem_size) as u64;
ci += sz;
}
}
total
} else {
0
};
let max_idx_set: u64 = if remaining_after_inline > 0 {
let mut max_set = idx_blk_elmts as u64;
let mut ci = n_inline;
for &sz in &dblk_sizes {
if ci < num_elements {
max_set += sz as u64;
ci += sz;
}
}
max_set
} else {
idx_blk_elmts as u64
}; };
let mut direct: Vec<DataBlock> = Vec::with_capacity(ndblk_addrs);
for &(ndblks, nelmts, first) in &levels[..direct_levels] {
for k in 0..ndblks {
direct.push(plan_dblk(&mut cursor, first + k * nelmts, nelmts));
}
}
// (super block address, level, its data blocks)
let mut supers: Vec<(Option<u64>, usize, Vec<DataBlock>)> = Vec::with_capacity(nsblk_addrs);
for (u, &(ndblks, nelmts, first)) in levels.iter().enumerate().skip(direct_levels) {
if !defined_in(first, ndblks.saturating_mul(nelmts)) {
supers.push((None, u, Vec::new()));
continue;
}
let sb_size =
4 + 1 + 1 + os + arr_off_size + sblk_bitmap_len(ndblks, nelmts) + ndblks * os + 4;
let sb_addr = cursor;
cursor += sb_size as u64;
nsuper_blks += 1;
super_blk_size += sb_size as u64;
let dblks = (0..ndblks)
.map(|k| plan_dblk(&mut cursor, first + k * nelmts, nelmts))
.collect();
supers.push((Some(sb_addr), u, dblks));
}
let slot = |i: usize| slots.get(i).and_then(Option::as_ref);
let write_length = |buf: &mut Vec<u8>, val: u64| match length_size { let write_length = |buf: &mut Vec<u8>, val: u64| match length_size {
4 => buf.extend_from_slice(&(val as u32).to_le_bytes()), 4 => buf.extend_from_slice(&(val as u32).to_le_bytes()),
_ => buf.extend_from_slice(&val.to_le_bytes()), _ => buf.extend_from_slice(&val.to_le_bytes()),
}; };
let write_addr = |buf: &mut Vec<u8>, val: u64| match offset_size { let write_addr_opt = |buf: &mut Vec<u8>, addr: Option<u64>| match addr {
4 => buf.extend_from_slice(&(val as u32).to_le_bytes()), Some(a) => push_addr(buf, a, offset_size),
_ => buf.extend_from_slice(&val.to_le_bytes()), None => buf.extend(core::iter::repeat_n(0xFF, os)),
};
let block_prefix = |buf: &mut Vec<u8>, sig: &[u8; 4], block_off: usize| {
buf.extend_from_slice(sig);
buf.push(0); // version
buf.push(client_id);
push_addr(buf, ea_base_address, offset_size);
buf.extend_from_slice(&(block_off as u64).to_le_bytes()[..arr_off_size]);
};
// Serialise one data block (paged or not) onto `out`.
let write_dblk = |out: &mut Vec<u8>, db: &DataBlock| {
let at = out.len();
block_prefix(out, b"EADB", db.start);
let first = idx_blk + db.start;
if db.nelmts > page_nelmts {
// Paged: the prefix carries only its own checksum; each page
// follows with one of its own.
let sum = jenkins_lookup3(&out[at..]);
out.extend_from_slice(&sum.to_le_bytes());
for p in 0..db.nelmts / page_nelmts {
let page_at = out.len();
for e in 0..page_nelmts {
let i = first + p * page_nelmts + e;
push_index_element(out, slot(i), offset_size, chunk_size_bytes);
}
let sum = jenkins_lookup3(&out[page_at..]);
out.extend_from_slice(&sum.to_le_bytes());
}
} else {
for i in first..first + db.nelmts {
push_index_element(out, slot(i), offset_size, chunk_size_bytes);
}
let sum = jenkins_lookup3(&out[at..]);
out.extend_from_slice(&sum.to_le_bytes());
}
debug_assert_eq!(out.len() - at, dblk_size(db.nelmts));
}; };
write_length(&mut aehd, 0); // Header (EAHD). The six statistics are, in order: super blocks, their
write_length(&mut aehd, 0); // bytes, data blocks, their bytes, max index set, elements realised.
write_length(&mut aehd, n_active_dblks); let mut out = Vec::with_capacity((cursor - ea_base_address) as usize);
write_length(&mut aehd, data_blk_total_size); out.extend_from_slice(b"EAHD");
write_length(&mut aehd, num_elements as u64); out.push(0); // version
write_length(&mut aehd, max_idx_set); out.push(client_id);
out.push(elem_size as u8);
out.push(MAX_NELMTS_BITS);
out.push(IDX_BLK_ELMTS);
out.push(DATA_BLK_MIN_ELMTS);
out.push(SUP_BLK_MIN_DATA_PTRS);
out.push(MAX_DBLK_PAGE_NELMTS_BITS);
write_length(&mut out, nsuper_blks);
write_length(&mut out, super_blk_size);
write_length(&mut out, ndata_blks);
write_length(&mut out, data_blk_size);
write_length(&mut out, max_idx_set as u64);
write_length(&mut out, realized);
push_addr(&mut out, aeib_address, offset_size);
let sum = jenkins_lookup3(&out);
out.extend_from_slice(&sum.to_le_bytes());
debug_assert_eq!(out.len(), aehd_size);
write_addr(&mut aehd, aeib_address); // Index block (EAIB): inline elements, data block and super block
// addresses.
let aehd_checksum = jenkins_lookup3(&aehd); let ib_start = out.len();
aehd.extend_from_slice(&aehd_checksum.to_le_bytes()); out.extend_from_slice(b"EAIB");
debug_assert_eq!(aehd.len(), aehd_size); out.push(0);
out.push(client_id);
// Build AEIB push_addr(&mut out, ea_base_address, offset_size);
let mut aeib = Vec::with_capacity(aeib_size); for i in 0..idx_blk {
aeib.extend_from_slice(b"EAIB"); push_index_element(&mut out, slot(i), offset_size, chunk_size_bytes);
aeib.push(0);
aeib.push(client_id);
match offset_size {
4 => aeib.extend_from_slice(&(ea_base_address as u32).to_le_bytes()),
8 => aeib.extend_from_slice(&ea_base_address.to_le_bytes()),
_ => aeib.extend_from_slice(&ea_base_address.to_le_bytes()),
} }
for db in &direct {
// Inline elements write_addr_opt(&mut out, db.addr);
#[allow(clippy::needless_range_loop)]
for i in 0..idx_blk_elmts as usize {
if i < n_inline {
write_chunk_element(
&mut aeib,
&chunks[i],
offset_size,
has_filters,
chunk_size_bytes,
);
} else {
write_undefined_element(&mut aeib, offset_size, has_filters, chunk_size_bytes);
} }
for (sb_addr, _, _) in &supers {
write_addr_opt(&mut out, *sb_addr);
} }
let sum = jenkins_lookup3(&out[ib_start..]);
out.extend_from_slice(&sum.to_le_bytes());
debug_assert_eq!(out.len() - ib_start, aeib_size);
// Data block addresses + build data blocks for db in direct.iter().filter(|d| d.addr.is_some()) {
let mut data_blocks_buf = Vec::new(); write_dblk(&mut out, db);
let dblks_base = aeib_address + aeib_size as u64;
let mut dblk_cursor = dblks_base;
let mut chunk_idx = n_inline;
for &nelmts in &dblk_sizes {
if chunk_idx >= num_elements {
match offset_size {
4 => aeib.extend_from_slice(&u32::MAX.to_le_bytes()),
8 => aeib.extend_from_slice(&u64::MAX.to_le_bytes()),
_ => aeib.extend_from_slice(&u64::MAX.to_le_bytes()),
} }
for (sb_addr, u, dblks) in &supers {
if sb_addr.is_none() {
continue; continue;
} }
let (ndblks, nelmts, first) = levels[*u];
match offset_size { let sb_start = out.len();
4 => aeib.extend_from_slice(&(dblk_cursor as u32).to_le_bytes()), block_prefix(&mut out, b"EASB", first);
8 => aeib.extend_from_slice(&dblk_cursor.to_le_bytes()), if nelmts > page_nelmts {
_ => aeib.extend_from_slice(&dblk_cursor.to_le_bytes()), // Page-init bits, `npages` per data block, packed MSB-first
} // (`H5VM_bit_set`): every page of an allocated data block is
// written.
// Build EADB let npages = nelmts / page_nelmts;
let mut aedb = Vec::new(); let mut bitmap = vec![0u8; sblk_bitmap_len(ndblks, nelmts)];
aedb.extend_from_slice(b"EADB"); for (k, db) in dblks.iter().enumerate() {
aedb.push(0); if db.addr.is_some() {
aedb.push(client_id); for p in 0..npages {
match offset_size { let bit = k * npages + p;
4 => aedb.extend_from_slice(&(ea_base_address as u32).to_le_bytes()), bitmap[bit / 8] |= 0x80 >> (bit % 8);
8 => aedb.extend_from_slice(&ea_base_address.to_le_bytes()),
_ => aedb.extend_from_slice(&ea_base_address.to_le_bytes()),
}
let blk_off_size = (max_nelmts_bits as usize).div_ceil(8);
let blk_off_val = (chunk_idx - n_inline) as u64;
aedb.extend_from_slice(&blk_off_val.to_le_bytes()[..blk_off_size]);
for slot in 0..nelmts {
if chunk_idx + slot < num_elements {
write_chunk_element(
&mut aedb,
&chunks[chunk_idx + slot],
offset_size,
has_filters,
chunk_size_bytes,
);
} else {
write_undefined_element(&mut aedb, offset_size, has_filters, chunk_size_bytes);
} }
} }
let aedb_checksum = jenkins_lookup3(&aedb);
aedb.extend_from_slice(&aedb_checksum.to_le_bytes());
dblk_cursor += aedb.len() as u64;
data_blocks_buf.extend_from_slice(&aedb);
chunk_idx += nelmts;
} }
out.extend_from_slice(&bitmap);
// Super block addresses (all undefined) }
for _ in 0..n_sblk_addrs { for db in dblks {
match offset_size { write_addr_opt(&mut out, db.addr);
4 => aeib.extend_from_slice(&u32::MAX.to_le_bytes()), }
8 => aeib.extend_from_slice(&u64::MAX.to_le_bytes()), let sum = jenkins_lookup3(&out[sb_start..]);
_ => aeib.extend_from_slice(&u64::MAX.to_le_bytes()), out.extend_from_slice(&sum.to_le_bytes());
for db in dblks.iter().filter(|d| d.addr.is_some()) {
write_dblk(&mut out, db);
} }
} }
debug_assert_eq!(out.len() as u64, cursor - ea_base_address);
let aeib_checksum = jenkins_lookup3(&aeib); out
aeib.extend_from_slice(&aeib_checksum.to_le_bytes());
debug_assert_eq!(aeib.len(), aeib_size);
let mut combined = aehd;
combined.extend_from_slice(&aeib);
combined.extend_from_slice(&data_blocks_buf);
combined
}
fn write_chunk_element(
buf: &mut Vec<u8>,
chunk: &WrittenChunk,
offset_size: u8,
has_filters: bool,
chunk_size_bytes: usize,
) {
match offset_size {
4 => buf.extend_from_slice(&(chunk.address as u32).to_le_bytes()),
8 => buf.extend_from_slice(&chunk.address.to_le_bytes()),
_ => buf.extend_from_slice(&chunk.address.to_le_bytes()),
}
if has_filters {
let cs_bytes = chunk.compressed_size.to_le_bytes();
buf.extend_from_slice(&cs_bytes[..chunk_size_bytes]);
buf.extend_from_slice(&chunk.filter_mask.to_le_bytes());
}
}
fn write_undefined_element(
buf: &mut Vec<u8>,
offset_size: u8,
has_filters: bool,
chunk_size_bytes: usize,
) {
let os = offset_size as usize;
// Use extend with repeat to avoid heap-allocating a temporary Vec on each call.
buf.extend(core::iter::repeat_n(0xFF, os));
if has_filters {
buf.extend(core::iter::repeat_n(0x00, chunk_size_bytes));
buf.extend_from_slice(&0u32.to_le_bytes());
}
} }
+76 -3
View File
@@ -80,6 +80,9 @@ pub enum FormatError {
InvalidLocalHeapSignature, InvalidLocalHeapSignature,
/// Invalid local heap version. /// Invalid local heap version.
InvalidLocalHeapVersion(u8), InvalidLocalHeapVersion(u8),
/// A local heap's free list points outside its data segment (libhdf5:
/// "bad heap free list").
InvalidLocalHeapFreeList,
/// Invalid B-tree v1 signature. /// Invalid B-tree v1 signature.
InvalidBTreeSignature, InvalidBTreeSignature,
/// Invalid B-tree node type. /// Invalid B-tree node type.
@@ -117,6 +120,14 @@ pub enum FormatError {
/// A message is marked shared but was parsed without access to the file, /// A message is marked shared but was parsed without access to the file,
/// so the reference to the real message could not be followed. /// so the reference to the real message could not be followed.
UnresolvedSharedMessage, UnresolvedSharedMessage,
/// A shared-message reference points at an object header that holds no
/// (unshared) message of the referenced type (raw message type id).
SharedMessageTargetMissing(u16),
/// A superblock was parsed at a non-zero offset of the buffer (the file
/// has a user block of this many bytes). HDF5 addresses are relative to
/// the superblock, so the buffer must start there: see
/// `signature::split_user_block`.
UserBlockNotStripped(u64),
/// A selection does not fit the dataset it was applied to (wrong rank, or /// A selection does not fit the dataset it was applied to (wrong rank, or
/// it reaches past a dimension's extent). /// it reaches past a dimension's extent).
SelectionOutOfBounds(String), SelectionOutOfBounds(String),
@@ -190,6 +201,28 @@ pub enum FormatError {
DuplicateDatasetName(String), DuplicateDatasetName(String),
/// Integer overflow in size computation (malformed data protection). /// Integer overflow in size computation (malformed data protection).
Overflow(String), 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 { impl fmt::Display for FormatError {
@@ -270,6 +303,9 @@ impl fmt::Display for FormatError {
FormatError::InvalidLocalHeapSignature => { FormatError::InvalidLocalHeapSignature => {
write!(f, "invalid local heap signature") write!(f, "invalid local heap signature")
} }
FormatError::InvalidLocalHeapFreeList => {
write!(f, "bad local heap free list")
}
FormatError::InvalidLocalHeapVersion(v) => { FormatError::InvalidLocalHeapVersion(v) => {
write!(f, "invalid local heap version: {v}") write!(f, "invalid local heap version: {v}")
} }
@@ -339,6 +375,16 @@ impl fmt::Display for FormatError {
FormatError::SelectionOutOfBounds(msg) => { FormatError::SelectionOutOfBounds(msg) => {
write!(f, "selection out of bounds: {msg}") write!(f, "selection out of bounds: {msg}")
} }
FormatError::UserBlockNotStripped(n) => write!(
f,
"file has a {n}-byte user block: parse the bytes from the superblock on \
(signature::split_user_block)"
),
FormatError::SharedMessageTargetMissing(t) => write!(
f,
"shared message reference points at an object header with no message of type \
{t:#06x}"
),
FormatError::UnresolvedSharedMessage => write!( FormatError::UnresolvedSharedMessage => write!(
f, f,
"message is shared but no file data was available to resolve it" "message is shared but no file data was available to resolve it"
@@ -382,9 +428,17 @@ impl fmt::Display for FormatError {
FormatError::InvalidFilterPipelineVersion(v) => { FormatError::InvalidFilterPipelineVersion(v) => {
write!(f, "invalid filter pipeline version: {v}") write!(f, "invalid filter pipeline version: {v}")
} }
FormatError::UnsupportedFilter(id) => { FormatError::UnsupportedFilter(id) => match crate::filter_registry::known_filter(*id) {
write!(f, "unsupported 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) => { FormatError::FilterError(msg) => {
write!(f, "filter error: {msg}") write!(f, "filter error: {msg}")
} }
@@ -421,6 +475,25 @@ impl fmt::Display for FormatError {
FormatError::Overflow(msg) => { FormatError::Overflow(msg) => {
write!(f, "integer 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"
)
}
} }
} }
} }
+56 -99
View File
@@ -9,6 +9,7 @@ extern crate alloc;
#[cfg(not(feature = "std"))] #[cfg(not(feature = "std"))]
use alloc::{format, vec, vec::Vec}; use alloc::{format, vec, vec::Vec};
use crate::chunk_grid::ChunkGrid;
use crate::chunked_read::ChunkInfo; use crate::chunked_read::ChunkInfo;
use crate::error::FormatError; use crate::error::FormatError;
@@ -203,8 +204,7 @@ fn read_element(
offset_size: u8, offset_size: u8,
chunk_byte_size: u64, chunk_byte_size: u64,
linear_index: usize, linear_index: usize,
num_chunks_per_dim: &[u64], grid: &ChunkGrid,
chunk_dimensions: &[u32],
) -> Result<(Option<ChunkInfo>, usize), FormatError> { ) -> Result<(Option<ChunkInfo>, usize), FormatError> {
let os = offset_size as usize; let os = offset_size as usize;
@@ -220,7 +220,10 @@ fn read_element(
return Ok((None, os)); return Ok((None, os));
} }
let address = read_offset(data, pos, offset_size)?; let address = read_offset(data, pos, offset_size)?;
let offsets = index_to_chunk_offsets(linear_index, num_chunks_per_dim, chunk_dimensions); // A slot beyond the current extent is ignored, as the library does.
let Some(offsets) = grid.offsets(linear_index as u64) else {
return Ok((None, os));
};
Ok(( Ok((
Some(ChunkInfo { Some(ChunkInfo {
chunk_size: chunk_byte_size as u32, chunk_size: chunk_byte_size as u32,
@@ -261,7 +264,9 @@ fn read_element(
data[fm_off + 2], data[fm_off + 2],
data[fm_off + 3], data[fm_off + 3],
]); ]);
let offsets = index_to_chunk_offsets(linear_index, num_chunks_per_dim, chunk_dimensions); let Some(offsets) = grid.offsets(linear_index as u64) else {
return Ok((None, elem_total));
};
Ok(( Ok((
Some(ChunkInfo { Some(ChunkInfo {
chunk_size: chunk_size as u32, chunk_size: chunk_size as u32,
@@ -274,27 +279,6 @@ fn read_element(
} }
} }
/// Convert a linear chunk index to N-dimensional chunk offsets in dataset space.
fn index_to_chunk_offsets(
index: usize,
num_chunks_per_dim: &[u64],
chunk_dimensions: &[u32],
) -> Vec<u64> {
let rank = num_chunks_per_dim.len();
let mut offsets = vec![0u64; rank];
let mut remaining = index as u64;
for d in (0..rank).rev() {
let nchunks = num_chunks_per_dim[d];
if nchunks == 0 {
continue;
}
let chunk_idx = remaining % nchunks;
remaining /= nchunks;
offsets[d] = chunk_idx * chunk_dimensions[d] as u64;
}
offsets
}
/// Collect elements from a data block at the given offset. /// Collect elements from a data block at the given offset.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
/// Layout of super block `u`, per the HDF5 spec: the number of data blocks it /// Layout of super block `u`, per the HDF5 spec: the number of data blocks it
@@ -339,8 +323,7 @@ fn read_data_block_elements(
offset_size: u8, offset_size: u8,
chunk_byte_size: u64, chunk_byte_size: u64,
start_index: usize, start_index: usize,
num_chunks_per_dim: &[u64], grid: &ChunkGrid,
chunk_dimensions: &[u32],
page_init: &[u8], page_init: &[u8],
first_page: usize, first_page: usize,
) -> Result<Vec<ChunkInfo>, FormatError> { ) -> Result<Vec<ChunkInfo>, FormatError> {
@@ -376,8 +359,7 @@ fn read_data_block_elements(
offset_size, offset_size,
chunk_byte_size, chunk_byte_size,
first_index + i, first_index + i,
num_chunks_per_dim, grid,
chunk_dimensions,
)?; )?;
if let Some(ci) = info { if let Some(ci) = info {
chunks.push(ci); chunks.push(ci);
@@ -449,25 +431,19 @@ pub fn read_extensible_array_chunks(
file_data: &[u8], file_data: &[u8],
header: &ExtensibleArrayHeader, header: &ExtensibleArrayHeader,
dataset_dims: &[u64], dataset_dims: &[u64],
max_dims: Option<&[u64]>,
chunk_dimensions: &[u32], chunk_dimensions: &[u32],
element_size: u32, element_size: u32,
offset_size: u8, offset_size: u8,
_length_size: u8, _length_size: u8,
) -> Result<Vec<ChunkInfo>, FormatError> { ) -> Result<Vec<ChunkInfo>, FormatError> {
let rank = chunk_dimensions.len();
let os = offset_size as usize; let os = offset_size as usize;
let mut num_chunks_per_dim = Vec::with_capacity(rank); // Linear indexes follow the maximum dimensions, with the unlimited
for d in 0..rank { // dimension swizzled to the slowest position (see `chunk_grid`).
let ch_dim = chunk_dimensions[d] as u64; let dims_u64: Vec<u64> = chunk_dimensions.iter().map(|&d| d as u64).collect();
if ch_dim == 0 { let grid = ChunkGrid::extensible_array(dataset_dims, max_dims, &dims_u64)?;
return Err(FormatError::ChunkedReadError( let grid = &grid;
"chunk dimension is zero".into(),
));
}
let ds_dim = dataset_dims[d];
num_chunks_per_dim.push(ds_dim.div_ceil(ch_dim));
}
let chunk_byte_size: u64 = let chunk_byte_size: u64 =
chunk_dimensions.iter().map(|&d| d as u64).product::<u64>() * element_size as u64; chunk_dimensions.iter().map(|&d| d as u64).product::<u64>() * element_size as u64;
@@ -557,8 +533,7 @@ pub fn read_extensible_array_chunks(
offset_size, offset_size,
chunk_byte_size, chunk_byte_size,
i, i,
&num_chunks_per_dim, grid,
chunk_dimensions,
)?; )?;
if let Some(ci) = info { if let Some(ci) = info {
chunks.push(ci); chunks.push(ci);
@@ -594,8 +569,7 @@ pub fn read_extensible_array_chunks(
offset_size, offset_size,
chunk_byte_size, chunk_byte_size,
global_index, global_index,
&num_chunks_per_dim, grid,
chunk_dimensions,
&[], &[],
0, 0,
)?); )?);
@@ -625,8 +599,7 @@ pub fn read_extensible_array_chunks(
offset_size, offset_size,
chunk_byte_size, chunk_byte_size,
global_index, global_index,
&num_chunks_per_dim, grid,
chunk_dimensions,
)?); )?);
} }
global_index = global_index =
@@ -653,8 +626,7 @@ fn read_super_block(
offset_size: u8, offset_size: u8,
chunk_byte_size: u64, chunk_byte_size: u64,
start_index: usize, start_index: usize,
num_chunks_per_dim: &[u64], grid: &ChunkGrid,
chunk_dimensions: &[u32],
) -> Result<Vec<ChunkInfo>, FormatError> { ) -> Result<Vec<ChunkInfo>, FormatError> {
let os = offset_size as usize; let os = offset_size as usize;
let sb_header_size = 4 + 1 + 1 + os + arr_off_size(header); let sb_header_size = 4 + 1 + 1 + os + arr_off_size(header);
@@ -710,8 +682,7 @@ fn read_super_block(
offset_size, offset_size,
chunk_byte_size, chunk_byte_size,
global_idx, global_idx,
num_chunks_per_dim, grid,
chunk_dimensions,
bitmap, bitmap,
i * npages, i * npages,
)?); )?);
@@ -735,35 +706,18 @@ mod tests {
} }
#[test] #[test]
fn index_to_offsets_1d() { fn index_to_offsets_1d() {
let num_chunks = vec![5u64]; let g = ChunkGrid::fixed_array(&[100], None, &[20]).unwrap();
let chunk_dims = vec![20u32]; assert_eq!(g.offsets(0).unwrap(), vec![0]);
assert_eq!(index_to_chunk_offsets(0, &num_chunks, &chunk_dims), vec![0]); assert_eq!(g.offsets(1).unwrap(), vec![20]);
assert_eq!( assert_eq!(g.offsets(4).unwrap(), vec![80]);
index_to_chunk_offsets(1, &num_chunks, &chunk_dims),
vec![20]
);
assert_eq!(
index_to_chunk_offsets(4, &num_chunks, &chunk_dims),
vec![80]
);
} }
#[test] #[test]
fn index_to_offsets_2d() { fn index_to_offsets_2d() {
let num_chunks = vec![3u64, 2]; let g = ChunkGrid::fixed_array(&[10, 6], None, &[4, 3]).unwrap();
let chunk_dims = vec![4u32, 3]; assert_eq!(g.offsets(0).unwrap(), vec![0, 0]);
assert_eq!( assert_eq!(g.offsets(1).unwrap(), vec![0, 3]);
index_to_chunk_offsets(0, &num_chunks, &chunk_dims), assert_eq!(g.offsets(2).unwrap(), vec![4, 0]);
vec![0, 0]
);
assert_eq!(
index_to_chunk_offsets(1, &num_chunks, &chunk_dims),
vec![0, 3]
);
assert_eq!(
index_to_chunk_offsets(2, &num_chunks, &chunk_dims),
vec![4, 0]
);
} }
#[test] #[test]
@@ -830,7 +784,7 @@ mod tests {
index_block_address: (usize::MAX - 4) as u64, index_block_address: (usize::MAX - 4) as u64,
}; };
let buf = vec![0u8; 64]; let buf = vec![0u8; 64];
let r = read_extensible_array_chunks(&buf, &header, &[100], &[20], 8, 8, 8); let r = read_extensible_array_chunks(&buf, &header, &[100], None, &[20], 8, 8, 8);
assert!(r.is_err()); assert!(r.is_err());
} }
@@ -913,8 +867,16 @@ mod tests {
let header = ExtensibleArrayHeader::parse(&file_data, aehd_offset, os, ls).unwrap(); let header = ExtensibleArrayHeader::parse(&file_data, aehd_offset, os, ls).unwrap();
let ds_dims = vec![40u64]; // 2 chunks × 20 elements let ds_dims = vec![40u64]; // 2 chunks × 20 elements
let chunk_dims = vec![20u32]; let chunk_dims = vec![20u32];
let chunks = let chunks = read_extensible_array_chunks(
read_extensible_array_chunks(&file_data, &header, &ds_dims, &chunk_dims, 8, os, ls) &file_data,
&header,
&ds_dims,
None,
&chunk_dims,
8,
os,
ls,
)
.unwrap(); .unwrap();
assert_eq!(chunks.len(), 2); assert_eq!(chunks.len(), 2);
@@ -1023,8 +985,16 @@ mod tests {
let header = ExtensibleArrayHeader::parse(&file_data, aehd_offset, os, ls).unwrap(); let header = ExtensibleArrayHeader::parse(&file_data, aehd_offset, os, ls).unwrap();
let ds_dims = vec![40u64]; let ds_dims = vec![40u64];
let chunk_dims = vec![10u32]; let chunk_dims = vec![10u32];
let chunks = let chunks = read_extensible_array_chunks(
read_extensible_array_chunks(&file_data, &header, &ds_dims, &chunk_dims, 8, os, ls) &file_data,
&header,
&ds_dims,
None,
&chunk_dims,
8,
os,
ls,
)
.unwrap(); .unwrap();
assert_eq!(chunks.len(), 4); assert_eq!(chunks.len(), 4);
@@ -1047,10 +1017,8 @@ mod tests {
#[test] #[test]
fn read_element_unallocated() { fn read_element_unallocated() {
let data = vec![0xFFu8; 16]; let data = vec![0xFFu8; 16];
let num_chunks = vec![5u64]; let grid = ChunkGrid::fixed_array(&[50], None, &[10]).unwrap();
let chunk_dims = vec![10u32]; let (info, consumed) = read_element(&data, 0, 0, 8, 8, 80, 0, &grid).unwrap();
let (info, consumed) =
read_element(&data, 0, 0, 8, 8, 80, 0, &num_chunks, &chunk_dims).unwrap();
assert!(info.is_none()); assert!(info.is_none());
assert_eq!(consumed, 8); assert_eq!(consumed, 8);
} }
@@ -1069,20 +1037,9 @@ mod tests {
// Filter mask // Filter mask
data[12..16].copy_from_slice(&0u32.to_le_bytes()); data[12..16].copy_from_slice(&0u32.to_le_bytes());
let num_chunks = vec![5u64]; let grid = ChunkGrid::fixed_array(&[50], None, &[10]).unwrap();
let chunk_dims = vec![10u32]; let (info, consumed) =
let (info, consumed) = read_element( read_element(&data, 0, 1, elem_size as u8, os, 80, 2, &grid).unwrap();
&data,
0,
1,
elem_size as u8,
os,
80,
2,
&num_chunks,
&chunk_dims,
)
.unwrap();
let ci = info.unwrap(); let ci = info.unwrap();
assert_eq!(ci.address, 0x2000); assert_eq!(ci.address, 0x2000);
assert_eq!(ci.chunk_size, 120); assert_eq!(ci.chunk_size, 120);
+195 -59
View File
@@ -4,7 +4,7 @@
//! link messages, contiguous datasets, inline and dense attributes. //! link messages, contiguous datasets, inline and dense attributes.
#[cfg(not(feature = "std"))] #[cfg(not(feature = "std"))]
use alloc::{string::String, string::ToString, vec, vec::Vec}; use alloc::{format, string::String, string::ToString, vec, vec::Vec};
use crate::attribute::AttributeMessage; use crate::attribute::AttributeMessage;
use crate::chunked_write::{ use crate::chunked_write::{
@@ -19,7 +19,7 @@ use crate::metadata_index::{DatasetMetadata, MetadataBlock, MetadataIndex};
use crate::object_header_writer::ObjectHeaderWriter; use crate::object_header_writer::ObjectHeaderWriter;
use crate::superblock::Superblock; use crate::superblock::Superblock;
use crate::type_builders::{ use crate::type_builders::{
DatasetBuilder, FillTime, FinishedGroup, GroupBuilder, build_attr_message, DatasetBuilder, FinishedGroup, GroupBuilder, build_attr_message, fill_value_message,
}; };
// Re-export public types that moved to type_builders for API compatibility. // Re-export public types that moved to type_builders for API compatibility.
@@ -33,6 +33,49 @@ pub(crate) const OFFSET_SIZE: u8 = 8;
pub(crate) const LENGTH_SIZE: u8 = 8; pub(crate) const LENGTH_SIZE: u8 = 8;
const SUPERBLOCK_SIZE: usize = 48; const SUPERBLOCK_SIZE: usize = 48;
/// Largest raw data a compact dataset can hold: the layout message (version,
/// class, 2-byte size, data) must fit an object header message, whose size
/// field is 2 bytes. Bigger "compact" requests fall back to contiguous storage.
const MAX_COMPACT_DATA_SIZE: usize = crate::object_header_writer::MAX_MESSAGE_SIZE - 4;
/// libhdf5's bounds on a file space page size (`H5F_FILE_SPACE_PAGE_SIZE_MIN`
/// and `_MAX`).
const MIN_FILE_SPACE_PAGE_SIZE: u32 = 512;
const MAX_FILE_SPACE_PAGE_SIZE: u32 = 1024 * 1024 * 1024;
/// Superblock extension object header for a file using the paged file-space
/// strategy: a single File Space Info message (0x0017), as libhdf5 writes it
/// for `fs_strategy="page"` without persisted free space.
fn build_paged_superblock_extension(page_size: u32) -> Result<Vec<u8>, FormatError> {
let mut fsinfo = Vec::new();
fsinfo.push(1); // version
fsinfo.push(1); // strategy: H5F_FSPACE_STRATEGY_PAGE
fsinfo.push(0); // persisting free space: no
write_length(&mut fsinfo, 1, LENGTH_SIZE); // free-space section threshold
write_length(&mut fsinfo, u64::from(page_size), LENGTH_SIZE);
fsinfo.extend_from_slice(&0u16.to_le_bytes()); // page end metadata threshold
write_undef_offset(&mut fsinfo, OFFSET_SIZE); // EOA before free-space info
let mut w = ObjectHeaderWriter::new();
// Flags as libhdf5 sets them: bit 2 (never share) and bit 4 (mark if
// unknown). Not constant: libhdf5 rewrites the message when it closes a
// file it opened for writing.
w.add_message_with_flags(MessageType::Unknown(0x0017), fsinfo, 0x14);
w.serialize()
}
/// A group or dataset name must be one path component: not empty, not ".",
/// and without '/'. `FileWriter` writes a root group plus one level of
/// groups, and cannot create intermediate groups for a path.
fn check_link_name(name: &str) -> Result<(), FormatError> {
if name.is_empty() || name == "." || name.contains('/') {
return Err(FormatError::SerializationError(format!(
"invalid object name {name:?}: names must be a single path component \
(FileWriter does not create nested groups)"
)));
}
Ok(())
}
/// Threshold for switching from compact (inline) to dense attribute storage. /// Threshold for switching from compact (inline) to dense attribute storage.
const DENSE_ATTR_THRESHOLD: usize = 8; const DENSE_ATTR_THRESHOLD: usize = 8;
@@ -50,12 +93,12 @@ pub(crate) fn build_chunked_dataset_oh(
pipeline_message: Option<&[u8]>, pipeline_message: Option<&[u8]>,
attrs: &[AttributeMessage], attrs: &[AttributeMessage],
dense_blob: Option<&DenseAttrBlob>, dense_blob: Option<&DenseAttrBlob>,
fill_time: FillTime, fill_message: &[u8],
) -> Vec<u8> { ) -> Result<Vec<u8>, FormatError> {
let mut w = ObjectHeaderWriter::new(); let mut w = ObjectHeaderWriter::new();
w.add_message_with_flags(MessageType::Datatype, dt.serialize(), 0x01); w.add_message_with_flags(MessageType::Datatype, dt.serialize(), 0x01);
w.add_message(MessageType::Dataspace, ds.serialize(LENGTH_SIZE)); w.add_message(MessageType::Dataspace, ds.serialize(LENGTH_SIZE));
w.add_message_with_flags(MessageType::FillValue, vec![3, fill_time.to_byte()], 0x01); w.add_message_with_flags(MessageType::FillValue, fill_message.to_vec(), 0x01);
w.add_message(MessageType::DataLayout, layout_message.to_vec()); w.add_message(MessageType::DataLayout, layout_message.to_vec());
if let Some(pm) = pipeline_message { if let Some(pm) = pipeline_message {
w.add_message(MessageType::FilterPipeline, pm.to_vec()); w.add_message(MessageType::FilterPipeline, pm.to_vec());
@@ -77,12 +120,12 @@ pub(crate) fn build_dataset_oh(
data_size: u64, data_size: u64,
attrs: &[AttributeMessage], attrs: &[AttributeMessage],
dense_blob: Option<&DenseAttrBlob>, dense_blob: Option<&DenseAttrBlob>,
fill_time: FillTime, fill_message: &[u8],
) -> Vec<u8> { ) -> Result<Vec<u8>, FormatError> {
let mut w = ObjectHeaderWriter::new(); let mut w = ObjectHeaderWriter::new();
w.add_message_with_flags(MessageType::Datatype, dt.serialize(), 0x01); w.add_message_with_flags(MessageType::Datatype, dt.serialize(), 0x01);
w.add_message(MessageType::Dataspace, ds.serialize(LENGTH_SIZE)); w.add_message(MessageType::Dataspace, ds.serialize(LENGTH_SIZE));
w.add_message_with_flags(MessageType::FillValue, vec![3, fill_time.to_byte()], 0x01); w.add_message_with_flags(MessageType::FillValue, fill_message.to_vec(), 0x01);
let mut dl = Vec::new(); let mut dl = Vec::new();
dl.push(4); // version dl.push(4); // version
dl.push(1); // class = contiguous dl.push(1); // class = contiguous
@@ -112,12 +155,12 @@ pub(crate) fn build_compact_dataset_oh(
data: &[u8], data: &[u8],
attrs: &[AttributeMessage], attrs: &[AttributeMessage],
dense_blob: Option<&DenseAttrBlob>, dense_blob: Option<&DenseAttrBlob>,
fill_time: FillTime, fill_message: &[u8],
) -> Vec<u8> { ) -> Result<Vec<u8>, FormatError> {
let mut w = ObjectHeaderWriter::new(); let mut w = ObjectHeaderWriter::new();
w.add_message_with_flags(MessageType::Datatype, dt.serialize(), 0x01); w.add_message_with_flags(MessageType::Datatype, dt.serialize(), 0x01);
w.add_message(MessageType::Dataspace, ds.serialize(LENGTH_SIZE)); w.add_message(MessageType::Dataspace, ds.serialize(LENGTH_SIZE));
w.add_message_with_flags(MessageType::FillValue, vec![3, fill_time.to_byte()], 0x01); w.add_message_with_flags(MessageType::FillValue, fill_message.to_vec(), 0x01);
// Compact layout message: version=4, class=0, u16 size, inline data // Compact layout message: version=4, class=0, u16 size, inline data
let mut dl = Vec::new(); let mut dl = Vec::new();
dl.push(4); // version dl.push(4); // version
@@ -140,7 +183,7 @@ pub(crate) fn build_group_oh(
dense_link_info: Option<&[u8]>, dense_link_info: Option<&[u8]>,
attrs: &[AttributeMessage], attrs: &[AttributeMessage],
dense_blob: Option<&DenseAttrBlob>, dense_blob: Option<&DenseAttrBlob>,
) -> Vec<u8> { ) -> Result<Vec<u8>, FormatError> {
let mut w = ObjectHeaderWriter::new(); let mut w = ObjectHeaderWriter::new();
if let Some(li) = dense_link_info { if let Some(li) = dense_link_info {
// Dense link storage: a LinkInfo pointing at the fractal heap + name // Dense link storage: a LinkInfo pointing at the fractal heap + name
@@ -902,12 +945,12 @@ pub(crate) fn build_vds_dataset_oh(
global_heap_addr: u64, global_heap_addr: u64,
attrs: &[AttributeMessage], attrs: &[AttributeMessage],
dense_blob: Option<&DenseAttrBlob>, dense_blob: Option<&DenseAttrBlob>,
fill_time: FillTime, fill_message: &[u8],
) -> Vec<u8> { ) -> Result<Vec<u8>, FormatError> {
let mut w = ObjectHeaderWriter::new(); let mut w = ObjectHeaderWriter::new();
w.add_message_with_flags(MessageType::Datatype, dt.serialize(), 0x01); w.add_message_with_flags(MessageType::Datatype, dt.serialize(), 0x01);
w.add_message(MessageType::Dataspace, ds.serialize(LENGTH_SIZE)); w.add_message(MessageType::Dataspace, ds.serialize(LENGTH_SIZE));
w.add_message_with_flags(MessageType::FillValue, vec![3, fill_time.to_byte()], 0x01); w.add_message_with_flags(MessageType::FillValue, fill_message.to_vec(), 0x01);
// VDS layout message: version=4, class=3, global_heap_address(8), global_heap_index=1(4) // VDS layout message: version=4, class=3, global_heap_address(8), global_heap_index=1(4)
let mut dl = Vec::new(); let mut dl = Vec::new();
dl.push(4u8); // version dl.push(4u8); // version
@@ -956,7 +999,9 @@ pub struct FileWriter {
alignment_threshold: usize, alignment_threshold: usize,
/// Global alignment boundary in bytes (0 = disabled). /// Global alignment boundary in bytes (0 = disabled).
alignment_bytes: usize, alignment_bytes: usize,
/// Page size for page-buffer mode. When set, a v4 superblock is written. /// File space page size. When set, the file uses libhdf5's paged
/// file-space strategy (a File Space Info message in the superblock
/// extension).
page_size: Option<u32>, page_size: Option<u32>,
} }
@@ -988,9 +1033,16 @@ impl FileWriter {
self self
} }
/// Enable page-buffer mode with the given page size. Writing this causes /// Write the file with libhdf5's *paged* file-space strategy and the given
/// the file to be written with a v4 superblock (page_size field) instead /// page size, as `H5Pset_file_space_strategy(H5F_FSPACE_STRATEGY_PAGE)` +
/// of the default v3. /// `H5Pset_file_space_page_size` (h5py: `fs_strategy="page"`,
/// `fs_page_size=...`) do: a v3 superblock with an extension holding a
/// File Space Info message, and the file padded to a whole number of
/// pages. Readers with a page buffer can then fetch metadata page by page.
///
/// `page_size` must be between 512 bytes and 1 GiB (libhdf5's limits);
/// [`Self::finish`] fails otherwise. This used to write a "version 4"
/// superblock, which does not exist and no HDF5 library can open.
pub fn with_page_size(&mut self, page_size: u32) -> &mut Self { pub fn with_page_size(&mut self, page_size: u32) -> &mut Self {
self.page_size = Some(page_size); self.page_size = Some(page_size);
self self
@@ -1015,6 +1067,14 @@ impl FileWriter {
pub fn finish(self) -> Result<Vec<u8>, FormatError> { pub fn finish(self) -> Result<Vec<u8>, FormatError> {
let page_size = self.page_size; let page_size = self.page_size;
if let Some(ps) = page_size
&& !(MIN_FILE_SPACE_PAGE_SIZE..=MAX_FILE_SPACE_PAGE_SIZE).contains(&ps)
{
return Err(FormatError::SerializationError(format!(
"file space page size {ps} is outside libhdf5's \
{MIN_FILE_SPACE_PAGE_SIZE}..={MAX_FILE_SPACE_PAGE_SIZE} bytes"
)));
}
struct DsFlat { struct DsFlat {
name: String, name: String,
dt: Datatype, dt: Datatype,
@@ -1023,7 +1083,8 @@ impl FileWriter {
attrs: Vec<AttributeMessage>, attrs: Vec<AttributeMessage>,
chunk_options: ChunkOptions, chunk_options: ChunkOptions,
maxshape: Option<Vec<u64>>, maxshape: Option<Vec<u64>>,
fill_time: FillTime, /// Serialized Fill Value message.
fill_message: Vec<u8>,
compact: bool, compact: bool,
alignment: usize, alignment: usize,
/// VDS source mappings (set for Virtual datasets). /// VDS source mappings (set for Virtual datasets).
@@ -1073,6 +1134,7 @@ impl FileWriter {
}; };
attrs.extend(p.build_attrs(&raw)); attrs.extend(p.build_attrs(&raw));
} }
let fill_message = fill_value_message(db.fill_time, db.fill_value.as_deref(), &dt)?;
Ok(DsFlat { Ok(DsFlat {
name: db.name, name: db.name,
dt, dt,
@@ -1081,13 +1143,26 @@ impl FileWriter {
attrs, attrs,
chunk_options: db.chunk_options, chunk_options: db.chunk_options,
maxshape: db.maxshape, maxshape: db.maxshape,
fill_time: db.fill_time, fill_message,
compact: db.compact, compact: db.compact,
alignment: db.alignment, alignment: db.alignment,
virtual_sources: db.virtual_sources, virtual_sources: db.virtual_sources,
}) })
}; };
// Every name becomes a single link in its parent group. The writer
// has no nested groups, so a path like "a/b" would be stored as one
// link literally named "a/b" — which no HDF5 reader can resolve.
let root_names = self.root_datasets.iter().map(|d| d.name.as_str());
let group_names = self.groups.iter().flat_map(|g| {
core::iter::once(g.name.as_str())
.chain(g.datasets.iter().map(|d| d.name.as_str()))
.chain(g.external_links.iter().map(|l| l.0.as_str()))
});
for name in root_names.chain(group_names) {
check_link_name(name)?;
}
let mut all_ds: Vec<DsFlat> = Vec::new(); let mut all_ds: Vec<DsFlat> = Vec::new();
let mut groups: Vec<GrpFlat> = Vec::new(); let mut groups: Vec<GrpFlat> = Vec::new();
let mut root_ds_indices: Vec<usize> = Vec::new(); let mut root_ds_indices: Vec<usize> = Vec::new();
@@ -1120,17 +1195,35 @@ impl FileWriter {
root_attrs.push(build_attr_message(n, v)); root_attrs.push(build_attr_message(n, v));
} }
// Every datatype must have an on-disk encoding before anything is laid
// out: `Datatype::serialize` itself cannot report a failure.
let group_attrs = groups.iter().flat_map(|g| &g.attrs);
let ds_attrs = all_ds.iter().flat_map(|d| &d.attrs);
for a in root_attrs.iter().chain(group_attrs).chain(ds_attrs) {
a.datatype.check_encodable()?;
}
for d in &all_ds {
d.dt.check_encodable()?;
}
let is_vds: Vec<bool> = all_ds.iter().map(|d| d.virtual_sources.is_some()).collect(); let is_vds: Vec<bool> = all_ds.iter().map(|d| d.virtual_sources.is_some()).collect();
let is_chunked: Vec<bool> = all_ds let is_chunked: Vec<bool> = all_ds
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, d)| !is_vds[i] && (d.chunk_options.is_chunked() || d.maxshape.is_some())) .map(|(i, d)| {
// Only a dataset that can grow needs chunks; a maxshape equal
// to the shape is as fixed as no maxshape at all.
let resizable = d.maxshape.as_ref().is_some_and(|m| *m != d.ds.dimensions);
!is_vds[i] && (d.chunk_options.is_chunked() || resizable)
})
.collect(); .collect();
// Determine which datasets use compact storage // Determine which datasets use compact storage
let is_compact: Vec<bool> = all_ds let is_compact: Vec<bool> = all_ds
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, d)| !is_vds[i] && !is_chunked[i] && d.compact && d.raw.len() <= 65535) .map(|(i, d)| {
!is_vds[i] && !is_chunked[i] && d.compact && d.raw.len() <= MAX_COMPACT_DATA_SIZE
})
.collect(); .collect();
let root_dense = root_attrs.len() > DENSE_ATTR_THRESHOLD; let root_dense = root_attrs.len() > DENSE_ATTR_THRESHOLD;
let group_dense: Vec<bool> = groups let group_dense: Vec<bool> = groups
@@ -1169,9 +1262,9 @@ impl FileWriter {
} }
let attr_blob = group_dense[gi].then(|| build_dense_attrs(&g.attrs, 0)); let attr_blob = group_dense[gi].then(|| build_dense_attrs(&g.attrs, 0));
let dl = group_links_dense[gi].then_some(dummy_link_info.as_slice()); let dl = group_links_dense[gi].then_some(dummy_link_info.as_slice());
build_group_oh(&dummy_links, dl, &g.attrs, attr_blob.as_ref()).len() build_group_oh(&dummy_links, dl, &g.attrs, attr_blob.as_ref()).map(|oh| oh.len())
}) })
.collect(); .collect::<Result<_, _>>()?;
let root_dummy_links: Vec<LinkMessage> = { let root_dummy_links: Vec<LinkMessage> = {
let mut links = Vec::new(); let mut links = Vec::new();
@@ -1186,7 +1279,7 @@ impl FileWriter {
let root_oh_size = { let root_oh_size = {
let attr_blob = root_dense.then(|| build_dense_attrs(&root_attrs, 0)); let attr_blob = root_dense.then(|| build_dense_attrs(&root_attrs, 0));
let dl = root_links_dense.then_some(dummy_link_info.as_slice()); let dl = root_links_dense.then_some(dummy_link_info.as_slice());
build_group_oh(&root_dummy_links, dl, &root_attrs, attr_blob.as_ref()).len() build_group_oh(&root_dummy_links, dl, &root_attrs, attr_blob.as_ref())?.len()
}; };
struct DataBlob { struct DataBlob {
@@ -1214,8 +1307,8 @@ impl FileWriter {
0, // dummy address 0, // dummy address
&d.attrs, &d.attrs,
dense_blob.as_ref(), dense_blob.as_ref(),
d.fill_time, &d.fill_message,
); )?;
// Global heap blob size is address-independent; compute it now // Global heap blob size is address-independent; compute it now
// so pass 2 can place it correctly. // so pass 2 can place it correctly.
let vds_mappings = d.virtual_sources.as_deref().unwrap_or(&[]); let vds_mappings = d.virtual_sources.as_deref().unwrap_or(&[]);
@@ -1244,7 +1337,7 @@ impl FileWriter {
&pre, &pre,
dummy_cursor, dummy_cursor,
d.maxshape.as_deref(), d.maxshape.as_deref(),
); )?;
dummy_cursor += result.data_bytes.len() as u64; dummy_cursor += result.data_bytes.len() as u64;
let dense_blob = if ds_dense[i] { let dense_blob = if ds_dense[i] {
Some(build_dense_attrs(&d.attrs, 0)) Some(build_dense_attrs(&d.attrs, 0))
@@ -1258,8 +1351,8 @@ impl FileWriter {
result.pipeline_message.as_deref(), result.pipeline_message.as_deref(),
&d.attrs, &d.attrs,
dense_blob.as_ref(), dense_blob.as_ref(),
d.fill_time, &d.fill_message,
); )?;
dummy_blobs.push(DataBlob { dummy_blobs.push(DataBlob {
data: result.data_bytes, data: result.data_bytes,
oh_bytes: oh, oh_bytes: oh,
@@ -1277,8 +1370,8 @@ impl FileWriter {
&d.raw, &d.raw,
&d.attrs, &d.attrs,
dense_blob.as_ref(), dense_blob.as_ref(),
d.fill_time, &d.fill_message,
); )?;
dummy_blobs.push(DataBlob { dummy_blobs.push(DataBlob {
data: vec![], data: vec![],
oh_bytes: oh, oh_bytes: oh,
@@ -1297,8 +1390,8 @@ impl FileWriter {
d.raw.len() as u64, d.raw.len() as u64,
&d.attrs, &d.attrs,
dense_blob.as_ref(), dense_blob.as_ref(),
d.fill_time, &d.fill_message,
); )?;
dummy_blobs.push(DataBlob { dummy_blobs.push(DataBlob {
data: d.raw.clone(), data: d.raw.clone(),
oh_bytes: oh, oh_bytes: oh,
@@ -1310,12 +1403,12 @@ impl FileWriter {
let actual_ds_oh_sizes: Vec<usize> = dummy_blobs.iter().map(|b| b.oh_bytes.len()).collect(); let actual_ds_oh_sizes: Vec<usize> = dummy_blobs.iter().map(|b| b.oh_bytes.len()).collect();
// Pass 2: compute real addresses // Pass 2: compute real addresses
// v4 superblocks add a 4-byte page_size field before the checksum. // A paged file carries its File Space Info in a superblock extension
let superblock_size = if page_size.is_some() { // object header, placed right after the superblock.
SUPERBLOCK_SIZE + 4 let sb_ext = page_size
} else { .map(build_paged_superblock_extension)
SUPERBLOCK_SIZE .transpose()?;
}; let superblock_size = SUPERBLOCK_SIZE + sb_ext.as_ref().map_or(0, Vec::len);
let root_group_addr = superblock_size as u64; let root_group_addr = superblock_size as u64;
let mut cursor2 = superblock_size + root_oh_size; let mut cursor2 = superblock_size + root_oh_size;
@@ -1406,8 +1499,8 @@ impl FileWriter {
heap_addr, heap_addr,
&d.attrs, &d.attrs,
ds_dense_blobs[i].as_ref(), ds_dense_blobs[i].as_ref(),
d.fill_time, &d.fill_message,
); )?;
ds_blobs2.push(DataBlob { ds_blobs2.push(DataBlob {
data: gcol_bytes.clone(), data: gcol_bytes.clone(),
oh_bytes: oh, oh_bytes: oh,
@@ -1424,7 +1517,7 @@ impl FileWriter {
.expect("chunked dataset missing precompressed cache"), .expect("chunked dataset missing precompressed cache"),
base_address, base_address,
d.maxshape.as_deref(), d.maxshape.as_deref(),
); )?;
cursor2 += result.data_bytes.len(); cursor2 += result.data_bytes.len();
let oh = build_chunked_dataset_oh( let oh = build_chunked_dataset_oh(
&d.dt, &d.dt,
@@ -1433,8 +1526,8 @@ impl FileWriter {
result.pipeline_message.as_deref(), result.pipeline_message.as_deref(),
&d.attrs, &d.attrs,
ds_dense_blobs[i].as_ref(), ds_dense_blobs[i].as_ref(),
d.fill_time, &d.fill_message,
); )?;
ds_blobs2.push(DataBlob { ds_blobs2.push(DataBlob {
data: result.data_bytes, data: result.data_bytes,
oh_bytes: oh, oh_bytes: oh,
@@ -1448,8 +1541,8 @@ impl FileWriter {
&d.raw, &d.raw,
&d.attrs, &d.attrs,
ds_dense_blobs[i].as_ref(), ds_dense_blobs[i].as_ref(),
d.fill_time, &d.fill_message,
); )?;
ds_blobs2.push(DataBlob { ds_blobs2.push(DataBlob {
data: vec![], data: vec![],
oh_bytes: oh, oh_bytes: oh,
@@ -1473,8 +1566,8 @@ impl FileWriter {
d.raw.len() as u64, d.raw.len() as u64,
&d.attrs, &d.attrs,
ds_dense_blobs[i].as_ref(), ds_dense_blobs[i].as_ref(),
d.fill_time, &d.fill_message,
); )?;
let mut data = vec![0u8; padding]; let mut data = vec![0u8; padding];
data.extend_from_slice(&d.raw); data.extend_from_slice(&d.raw);
cursor2 += d.raw.len(); cursor2 += d.raw.len();
@@ -1489,11 +1582,16 @@ impl FileWriter {
let actual_ds_oh_sizes2: Vec<usize> = ds_blobs2.iter().map(|b| b.oh_bytes.len()).collect(); let actual_ds_oh_sizes2: Vec<usize> = ds_blobs2.iter().map(|b| b.oh_bytes.len()).collect();
debug_assert_eq!(actual_ds_oh_sizes, actual_ds_oh_sizes2); debug_assert_eq!(actual_ds_oh_sizes, actual_ds_oh_sizes2);
// libhdf5 ends a paged file on a page boundary.
let data_end = cursor2;
if let Some(ps) = page_size {
cursor2 = cursor2.next_multiple_of(ps as usize);
}
let eof_addr2 = cursor2 as u64; let eof_addr2 = cursor2 as u64;
let mut buf = Vec::with_capacity(cursor2); let mut buf = Vec::with_capacity(cursor2);
let sb = Superblock { let sb = Superblock {
version: if page_size.is_some() { 4 } else { 3 }, version: 3,
offset_size: OFFSET_SIZE, offset_size: OFFSET_SIZE,
length_size: LENGTH_SIZE, length_size: LENGTH_SIZE,
base_address: 0, base_address: 0,
@@ -1505,11 +1603,18 @@ impl FileWriter {
free_space_address: None, free_space_address: None,
driver_info_address: None, driver_info_address: None,
consistency_flags: 0, consistency_flags: 0,
superblock_extension_address: Some(u64::MAX), superblock_extension_address: Some(if sb_ext.is_some() {
SUPERBLOCK_SIZE as u64
} else {
u64::MAX
}),
checksum: None, checksum: None,
page_size, page_size: None,
}; };
buf.extend_from_slice(&sb.serialize()); buf.extend_from_slice(&sb.serialize());
if let Some(ref ext) = sb_ext {
buf.extend_from_slice(ext);
}
// Root group OH // Root group OH
let mut root_links: Vec<LinkMessage> = Vec::new(); let mut root_links: Vec<LinkMessage> = Vec::new();
@@ -1530,7 +1635,7 @@ impl FileWriter {
root_dl, root_dl,
&root_attrs, &root_attrs,
root_dense_blob.as_ref(), root_dense_blob.as_ref(),
)); )?);
if let Some(ref b) = root_link_blob { if let Some(ref b) = root_link_blob {
buf.extend_from_slice(&b.blob); buf.extend_from_slice(&b.blob);
} }
@@ -1555,7 +1660,7 @@ impl FileWriter {
dl, dl,
&g.attrs, &g.attrs,
group_dense_blobs[gi].as_ref(), group_dense_blobs[gi].as_ref(),
)); )?);
if let Some(ref b) = link_blob { if let Some(ref b) = link_blob {
buf.extend_from_slice(&b.blob); buf.extend_from_slice(&b.blob);
} }
@@ -1577,7 +1682,8 @@ impl FileWriter {
buf.extend_from_slice(&blob.data); buf.extend_from_slice(&blob.data);
} }
debug_assert_eq!(buf.len(), cursor2); debug_assert_eq!(buf.len(), data_end);
buf.resize(cursor2, 0);
Ok(buf) Ok(buf)
} }
} }
@@ -1841,6 +1947,12 @@ mod tests {
root_block_address: 0, root_block_address: 0,
current_rows_in_root_indirect_block: 0, current_rows_in_root_indirect_block: 0,
managed_objects_count: 0, managed_objects_count: 0,
huge_btree_address: u64::MAX,
filter_pipeline: None,
root_direct_block_filtered_size: 0,
root_direct_block_filter_mask: 0,
offset_size: 8,
length_size: 8,
}; };
let (off, len) = fh.decode_managed_id(&id).unwrap(); let (off, len) = fh.decode_managed_id(&id).unwrap();
assert_eq!(off, 100); assert_eq!(off, 100);
@@ -2151,7 +2263,8 @@ mod tests {
} }
#[test] #[test]
fn file_writer_v4_superblock() { fn file_writer_paged_file_uses_v3_superblock_and_fsinfo_extension() {
// This used to write superblock "version 4", which does not exist.
let mut fw = FileWriter::new(); let mut fw = FileWriter::new();
fw.with_page_size(4096); fw.with_page_size(4096);
fw.create_dataset("data").with_f64_data(&[1.0, 2.0]); fw.create_dataset("data").with_f64_data(&[1.0, 2.0]);
@@ -2159,8 +2272,31 @@ mod tests {
let sig = signature::find_signature(&bytes).unwrap(); let sig = signature::find_signature(&bytes).unwrap();
let sb = Superblock::parse(&bytes, sig).unwrap(); let sb = Superblock::parse(&bytes, sig).unwrap();
assert_eq!(sb.version, 4, "expected superblock v4"); assert_eq!(sb.version, 3);
assert_eq!(sb.page_size, Some(4096)); assert_eq!(sb.superblock_extension_address, Some(48));
assert_eq!(bytes.len() % 4096, 0);
assert_eq!(sb.eof_address, bytes.len() as u64);
let ext = ObjectHeader::parse(&bytes, 48, 8, 8).unwrap();
let fsinfo = &ext.messages[0];
assert_eq!(fsinfo.msg_type, MessageType::Unknown(0x0017));
// Byte-for-byte what HDF5 2.0 writes for fs_strategy="page",
// fs_page_size=4096.
let mut expected = vec![1u8, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0];
expected.extend_from_slice(&4096u64.to_le_bytes());
expected.extend_from_slice(&[0, 0]);
expected.extend_from_slice(&[0xff; 8]);
assert_eq!(fsinfo.data, expected);
assert_eq!(fsinfo.flags, 0x14);
assert_eq!(read_dataset_f64(&bytes, "data"), vec![1.0, 2.0]);
}
#[test]
fn file_writer_rejects_page_sizes_libhdf5_would() {
for ps in [0u32, 511, MAX_FILE_SPACE_PAGE_SIZE + 1] {
let mut fw = FileWriter::new();
fw.with_page_size(ps);
assert!(fw.finish().is_err(), "page size {ps}");
}
} }
#[test] #[test]
+42 -7
View File
@@ -98,15 +98,50 @@ pub fn parse_fill_value(msg: &HeaderMessage) -> Result<Option<Vec<u8>>, FormatEr
/// The fill value that applies to a dataset given its header messages. The new /// The fill value that applies to a dataset given its header messages. The new
/// message wins over the old one when both are present. /// message wins over the old one when both are present.
///
/// A *shared* fill value message holds only a reference to the real message,
/// which cannot be followed without the file: this returns
/// [`FormatError::UnresolvedSharedMessage`] for one (it used to answer "zeros").
/// Use [`dataset_fill_value_in`] when the file bytes are at hand.
pub fn dataset_fill_value(messages: &[HeaderMessage]) -> Result<Option<Vec<u8>>, FormatError> { pub fn dataset_fill_value(messages: &[HeaderMessage]) -> Result<Option<Vec<u8>>, FormatError> {
fill_value_from(messages, |_| Err(FormatError::UnresolvedSharedMessage))
}
/// [`dataset_fill_value`] for a dataset in `file_data`, following a shared
/// fill value message to where it lives: another object header, or the
/// file's shared-message (SOHM) heap, as libhdf5 writes it when the file has
/// a SOHM index for fill values.
pub fn dataset_fill_value_in(
file_data: &[u8],
messages: &[HeaderMessage],
offset_size: u8,
length_size: u8,
) -> Result<Option<Vec<u8>>, FormatError> {
fill_value_from(messages, |msg| {
crate::shared_message::message_data_with_sohm(file_data, msg, offset_size, length_size)
.map(|data| data.into_owned())
})
}
fn fill_value_from(
messages: &[HeaderMessage],
resolve_shared: impl Fn(&HeaderMessage) -> Result<Vec<u8>, FormatError>,
) -> Result<Option<Vec<u8>>, FormatError> {
for wanted in [MessageType::FillValue, MessageType::FillValueOld] { for wanted in [MessageType::FillValue, MessageType::FillValueOld] {
if let Some(msg) = messages.iter().find(|m| m.msg_type == wanted) { if let Some(msg) = messages.iter().find(|m| m.msg_type == wanted) {
if crate::shared_message::is_shared(msg.flags) { let value = if crate::shared_message::is_shared(msg.flags) {
// A shared fill value is legal but vanishingly rare; treat it let data = resolve_shared(msg)?;
// as the default rather than misparsing the reference. parse_fill_value(&HeaderMessage {
return Ok(None); msg_type: msg.msg_type,
} size: data.len(),
if let Some(value) = parse_fill_value(msg)? { flags: msg.flags & !0x02,
creation_order: msg.creation_order,
data,
})?
} else {
parse_fill_value(msg)?
};
if let Some(value) = value {
return Ok(Some(value)); return Ok(Some(value));
} }
} }
@@ -174,7 +209,7 @@ pub fn read_full_with_fill<E: From<FormatError>>(
{ {
return Err(FormatError::ExternalDataFilesUnsupported.into()); return Err(FormatError::ExternalDataFilesUnsupported.into());
} }
let fill = dataset_fill_value(messages)?; let fill = dataset_fill_value_in(file_data, messages, offset_size, length_size)?;
if !has_storage(layout) { if !has_storage(layout) {
return Ok(filled_dataset(dataspace, elem_size, fill.as_deref())?); return Ok(filled_dataset(dataspace, elem_size, fill.as_deref())?);
} }
+29 -2
View File
@@ -19,8 +19,35 @@ pub const FILTER_SCALEOFFSET: u16 = 6;
pub const FILTER_LZ4: u16 = 32004; pub const FILTER_LZ4: u16 = 32004;
/// Zstandard compression. /// Zstandard compression.
pub const FILTER_ZSTD: u16 = 32015; pub const FILTER_ZSTD: u16 = 32015;
/// Pcodec lossless numerical codec (clawhdf5 internal; not yet HDF5-registered). /// bzip2 (registered by PyTables; hdf5plugin's `BZip2`).
pub const FILTER_PCODEC: u16 = 32023; 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
/// ID from the registry's testing/private range (256–511). No libhdf5 plugin
/// decodes it: h5py/libhdf5 report the filter as unavailable. Only clawhdf5
/// (with the `pcodec` feature) reads these datasets.
pub const FILTER_PCODEC: u16 = 480;
/// Filter name written with [`FILTER_PCODEC`].
pub const FILTER_PCODEC_NAME: &str = "pcodec (clawhdf5 private)";
/// The ID clawhdf5 up to 2.7.0 wrote pcodec under. It is registered to
/// Granular BitRound (GBR), whose decode is a pass-through, so libhdf5 with
/// that plugin would have returned the compressed bytes as data. Read as
/// pcodec only when the filter is named exactly [`FILTER_PCODEC_LEGACY_NAME`],
/// the name those versions wrote; never written.
pub const FILTER_PCODEC_LEGACY: u16 = 32023;
/// The filter name clawhdf5 up to 2.7.0 wrote with [`FILTER_PCODEC_LEGACY`].
pub const FILTER_PCODEC_LEGACY_NAME: &str = "pcodec";
/// Description of a single filter in a pipeline. /// Description of a single filter in a pipeline.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
@@ -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<Vec<u8>, FormatError>;
/// Apply the filter to one chunk.
fn encode(&self, input: &[u8], ctx: &FilterContext<'_>) -> Result<Vec<u8>, FormatError> {
let _ = input;
Err(FormatError::UnsupportedFilter(ctx.filter.filter_id))
}
}
/// Any `Fn(&[u8], &FilterContext) -> Result<Vec<u8>, FormatError>` is a
/// decode-only codec.
impl<F> FilterCodec for F
where
F: Fn(&[u8], &FilterContext<'_>) -> Result<Vec<u8>, FormatError> + Send + Sync,
{
fn decode(&self, input: &[u8], ctx: &FilterContext<'_>) -> Result<Vec<u8>, FormatError> {
self(input, ctx)
}
}
/// Signature of a built-in filter's decoder or encoder.
pub type BuiltinFn = fn(&[u8], &FilterContext<'_>) -> Result<Vec<u8>, 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<BuiltinFn>,
}
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<u16, Arc<dyn FilterCodec>>;
static REGISTRY: RwLock<Registry> = RwLock::new(BTreeMap::new());
pub(super) fn with_read<R>(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(&REGISTRY.read().unwrap_or_else(PoisonError::into_inner))
}
pub(super) fn with_write<R>(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<Vec<u8>, 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<C>(
id: u16,
codec: C,
) -> Result<Option<std::sync::Arc<dyn FilterCodec>>, FormatError>
where
C: FilterCodec + 'static,
{
if let Some(builtin) = builtin_filter(id).filter(|b| !b.is_shared()) {
return Err(FormatError::FilterError(format!(
"filter {id} ({}) is built in and cannot be re-registered",
builtin.name
)));
}
let codec: std::sync::Arc<dyn FilterCodec> = std::sync::Arc::new(codec);
Ok(custom::with_write(|r| r.insert(id, codec)))
}
/// Remove the codec registered for `id`. Returns whether one was registered.
#[cfg(feature = "std")]
pub fn unregister_filter(id: u16) -> bool {
custom::with_write(|r| r.remove(&id).is_some())
}
/// The codec registered for `id`, if any.
#[cfg(feature = "std")]
pub fn registered(id: u16) -> Option<std::sync::Arc<dyn FilterCodec>> {
custom::with_read(|r| r.get(&id).cloned())
}
/// Undo filter `ctx.filter` on `input`: the built-in decoder if there is one
/// that claims the chunk, else a registered one, else the built-in decoder's
/// own refusal or [`FormatError::UnsupportedFilter`].
pub(crate) fn decode(input: &[u8], ctx: &FilterContext<'_>) -> Result<Vec<u8>, FormatError> {
let id = ctx.filter.filter_id;
let builtin = builtin_filter(id);
if let Some(builtin) = builtin.filter(|b| b.claims(ctx.filter)) {
return (builtin.decode)(input, ctx);
}
#[cfg(feature = "std")]
if let Some(codec) = registered(id) {
let out = codec.decode(input, ctx)?;
// A registered codec is outside our control: hold it to the same
// bound the built-in decoders enforce.
if out.len() > ctx.output_limit() {
return Err(FormatError::DecompressionError(format!(
"filter {id}: decoded {} bytes, more than the {} the chunk can hold",
out.len(),
ctx.output_limit()
)));
}
return Ok(out);
}
match builtin {
Some(builtin) => (builtin.decode)(input, ctx),
None => Err(FormatError::UnsupportedFilter(id)),
}
}
/// Apply filter `ctx.filter` to `input`.
pub(crate) fn encode(input: &[u8], ctx: &FilterContext<'_>) -> Result<Vec<u8>, FormatError> {
let id = ctx.filter.filter_id;
#[cfg(feature = "std")]
if builtin_filter(id).is_some_and(|b| b.is_shared())
&& let Some(codec) = registered(id)
{
return codec.encode(input, ctx);
}
if let Some(builtin) = builtin_filter(id) {
return match builtin.encode {
Some(encode) => encode(input, ctx),
None => Err(FormatError::UnsupportedFilter(id)),
};
}
#[cfg(feature = "std")]
if let Some(codec) = registered(id) {
return codec.encode(input, ctx);
}
Err(FormatError::UnsupportedFilter(id))
}
#[cfg(all(test, feature = "std"))]
pub(crate) mod tests {
use super::*;
use crate::filter_pipeline::{FILTER_FLETCHER32, FILTER_SHUFFLE, FilterPipeline};
use crate::filters::{compress_chunk, decompress_chunk};
fn pipeline(id: u16) -> FilterPipeline {
FilterPipeline {
version: 2,
filters: vec![FilterDescription {
filter_id: id,
name: Some("test".into()),
flags: 0,
client_data: vec![7],
}],
}
}
struct Xor;
impl FilterCodec for Xor {
fn decode(&self, input: &[u8], ctx: &FilterContext<'_>) -> Result<Vec<u8>, FormatError> {
let k = ctx.client_data()[0] as u8;
Ok(input.iter().map(|b| b ^ k).collect())
}
fn encode(&self, input: &[u8], ctx: &FilterContext<'_>) -> Result<Vec<u8>, FormatError> {
self.decode(input, ctx)
}
}
// Each test uses its own ID: the registry is process-wide and tests run
// in parallel.
#[test]
fn unknown_filter_keeps_its_error() {
let err = decompress_chunk(b"abc", &pipeline(311), 3, 1).unwrap_err();
assert_eq!(err, FormatError::UnsupportedFilter(311));
let err = compress_chunk(b"abc", &pipeline(311), 1).unwrap_err();
assert_eq!(err, FormatError::UnsupportedFilter(311));
}
#[test]
fn registered_codec_round_trips_through_the_pipeline() {
assert!(!is_filter_available(312));
assert!(register_filter(312, Xor).unwrap().is_none());
assert!(is_filter_available(312));
let data = b"hello, registry".to_vec();
let enc = compress_chunk(&data, &pipeline(312), 1).unwrap();
assert_ne!(enc, data);
assert_eq!(
decompress_chunk(&enc, &pipeline(312), data.len(), 1).unwrap(),
data
);
assert!(unregister_filter(312));
assert!(!unregister_filter(312));
assert_eq!(
decompress_chunk(&enc, &pipeline(312), data.len(), 1).unwrap_err(),
FormatError::UnsupportedFilter(312)
);
}
#[test]
fn closure_registers_a_decoder_only() {
register_filter(313, |input: &[u8], _ctx: &FilterContext<'_>| {
Ok(input.iter().rev().copied().collect())
})
.unwrap();
assert_eq!(
decompress_chunk(b"abc", &pipeline(313), 3, 1).unwrap(),
b"cba"
);
assert_eq!(
compress_chunk(b"abc", &pipeline(313), 1).unwrap_err(),
FormatError::UnsupportedFilter(313)
);
unregister_filter(313);
}
#[test]
fn registered_decoder_output_is_bounded() {
register_filter(314, |_input: &[u8], _ctx: &FilterContext<'_>| {
Ok(vec![0u8; 1000])
})
.unwrap();
let err = decompress_chunk(b"abc", &pipeline(314), 10, 1).unwrap_err();
assert!(matches!(err, FormatError::DecompressionError(_)), "{err:?}");
unregister_filter(314);
}
#[test]
fn builtins_cannot_be_overridden() {
for id in [FILTER_SHUFFLE, FILTER_FLETCHER32] {
let Err(err) = register_filter(id, Xor) else {
panic!("built-in filter {id} was re-registered");
};
assert!(matches!(err, FormatError::FilterError(_)), "{err:?}");
}
assert!(builtin_filter(FILTER_SHUFFLE).is_some());
}
/// Serialises the tests that register or read filter 32023 (the
/// registry is process-wide).
pub(crate) static ID_32023: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// 32023 is Granular BitRound's ID; the `pcodec` build's built-in entry
/// there reads only clawhdf5 <= 2.7.0's pcodec chunks (named "pcodec"),
/// so a codec can be registered for the rest, and writes with it.
#[test]
fn a_codec_can_be_registered_for_granular_bitround() {
let _guard = ID_32023
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let named = |name: Option<&str>| FilterPipeline {
version: 2,
filters: vec![FilterDescription {
filter_id: 32023,
name: name.map(Into::into),
flags: 0,
client_data: vec![7],
}],
};
let prev = register_filter(32023, Xor).expect("32023 must be registrable");
assert!(prev.is_none());
let data = b"granular bitround".to_vec();
for name in [None, Some("granular_bitround"), Some("test")] {
let pl = named(name);
let enc = compress_chunk(&data, &pl, 1).unwrap();
assert_ne!(enc, data);
assert_eq!(decompress_chunk(&enc, &pl, data.len(), 1).unwrap(), data);
}
// clawhdf5 <= 2.7.0's pcodec chunks still go to the built-in reader.
#[cfg(feature = "pcodec")]
{
let raw: Vec<u8> = (0..64)
.flat_map(|i| (f64::from(i) * 0.5).to_le_bytes())
.collect();
let comp = crate::filters::pcodec_compress(&raw, 8).unwrap();
let mut pl = named(Some("pcodec"));
pl.filters[0].client_data = vec![8];
assert_eq!(decompress_chunk(&comp, &pl, raw.len(), 8).unwrap(), raw);
}
assert!(unregister_filter(32023));
let pl = named(None);
assert!(matches!(
decompress_chunk(&data, &pl, data.len(), 1),
Err(FormatError::UnsupportedFilter(32023))
));
}
#[test]
fn unsupported_filter_error_names_the_filter() {
let msg = FormatError::UnsupportedFilter(32026).to_string();
assert!(
msg.contains("Blosc2") && msg.contains("not implemented"),
"{msg}"
);
let msg = FormatError::UnsupportedFilter(32013).to_string();
assert!(msg.contains("ZFP"), "{msg}");
let msg = FormatError::UnsupportedFilter(32000).to_string();
assert!(msg.contains("LZF") && msg.contains("`lzf`"), "{msg}");
assert_eq!(
FormatError::UnsupportedFilter(399).to_string(),
"unsupported filter: 399"
);
}
#[test]
fn builtin_table_is_sorted_and_unique() {
let ids: Vec<u16> = builtin_filters().iter().map(|f| f.id).collect();
let mut sorted = ids.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(ids, sorted);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,438 @@
//! Bitshuffle (HDF5 filter 32008) and the bit transpose it shares with blosc.
//!
//! **The transform.** A block of `n` elements (`n` a multiple of 8) of
//! `es` bytes each is viewed as an `n × 8·es` bit matrix — row *i* is
//! element *i*, column `8·j + k` is bit *k* (LSB first) of its byte *j* — and
//! transposed: the output is `8·es` rows of `n` bits, row `8·j + k` holding
//! bit *k* of byte *j* of every element in order, packed LSB first. That is
//! what `bshuf_trans_bit_elem` produces (checked against hdf5plugin's
//! library bit for bit).
//!
//! **The filter** (`bshuf_h5filter.c`). `cd_values`: `[0..2]` bitshuffle
//! version, `[2]` element size, `[3]` block size in elements (0 = default:
//! 8192 bytes' worth, rounded down to a multiple of 8, at least 128),
//! `[4]` compression (0 none, 2 LZ4, 3 Zstandard), `[5]` Zstandard level.
//! The chunk is cut into blocks of `block size` elements; the tail shorter
//! than a block is transposed as one block rounded down to a multiple of 8
//! elements, and the last `n mod 8` elements are stored as they are.
//! Uncompressed, that is the whole chunk. Compressed, the chunk starts with a
//! 12-byte header — the decoded size (u64 big-endian) and the block size in
//! bytes (u32 big-endian) — and each transposed block is stored as a u32
//! big-endian length and an LZ4 block / Zstandard frame; the untransposed
//! tail follows the last block.
#[cfg(not(feature = "std"))]
extern crate alloc;
#[cfg(not(feature = "std"))]
use alloc::{format, vec, vec::Vec};
use crate::error::FormatError;
#[cfg(feature = "bitshuffle")]
use crate::filter_registry::FilterContext;
/// Transpose an 8×8 bit matrix packed in a u64 (byte *r* = row *r*, bit *c*
/// of that byte = column *c*). An involution.
#[inline]
fn transpose8(mut x: u64) -> u64 {
let t = (x ^ (x >> 7)) & 0x00AA_00AA_00AA_00AA;
x = x ^ t ^ (t << 7);
let t = (x ^ (x >> 14)) & 0x0000_CCCC_0000_CCCC;
x = x ^ t ^ (t << 14);
let t = (x ^ (x >> 28)) & 0x0000_0000_F0F0_F0F0;
x ^ t ^ (t << 28)
}
/// Bit-transpose one block: `input` and `out` are `n * es` bytes, `n` a
/// multiple of 8.
pub(crate) fn bitshuffle_block(input: &[u8], out: &mut [u8], n: usize, es: usize) {
debug_assert!(n.is_multiple_of(8) && input.len() == n * es && out.len() == n * es);
let row = n / 8;
for j in 0..es {
for g in 0..row {
let mut x = 0u64;
for t in 0..8 {
x |= u64::from(input[(8 * g + t) * es + j]) << (8 * t);
}
let y = transpose8(x);
for k in 0..8 {
out[(8 * j + k) * row + g] = (y >> (8 * k)) as u8;
}
}
}
}
/// Undo [`bitshuffle_block`].
pub(crate) fn bitunshuffle_block(input: &[u8], out: &mut [u8], n: usize, es: usize) {
debug_assert!(n.is_multiple_of(8) && input.len() == n * es && out.len() == n * es);
let row = n / 8;
for j in 0..es {
for g in 0..row {
let mut y = 0u64;
for k in 0..8 {
y |= u64::from(input[(8 * j + k) * row + g]) << (8 * k);
}
let x = transpose8(y);
for t in 0..8 {
out[(8 * g + t) * es + j] = (x >> (8 * t)) as u8;
}
}
}
}
/// `bshuf_default_block_size`: 8 KiB of elements, a multiple of 8, >= 128.
#[cfg(feature = "bitshuffle")]
fn default_block_size(es: usize) -> usize {
((8192 / es) / 8 * 8).max(128)
}
#[cfg(feature = "bitshuffle")]
fn err(msg: &str) -> FormatError {
FormatError::DecompressionError(format!("bitshuffle: {msg}"))
}
/// `cd_values[4]`: the compression bitshuffle applies after the transpose.
#[cfg(feature = "bitshuffle")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Codec {
None,
Lz4,
Zstd,
}
#[cfg(feature = "bitshuffle")]
fn codec(cd: &[u32]) -> Result<Codec, FormatError> {
match cd.get(4).copied().unwrap_or(0) {
0 => Ok(Codec::None),
2 => Ok(Codec::Lz4),
3 => Ok(Codec::Zstd),
other => Err(FormatError::FilterError(format!(
"bitshuffle: unknown compression {other}"
))),
}
}
/// The element counts of the transposed blocks for `size` elements.
#[cfg(feature = "bitshuffle")]
fn blocks(size: usize, block: usize) -> impl Iterator<Item = usize> {
let full = size / block;
let last = (size % block) / 8 * 8;
core::iter::repeat_n(block, full).chain((last > 0).then_some(last))
}
/// Decode a bitshuffle-filtered chunk.
#[cfg(feature = "bitshuffle")]
pub(crate) fn bitshuffle_decode(
input: &[u8],
ctx: &FilterContext<'_>,
) -> Result<Vec<u8>, FormatError> {
let cd = ctx.client_data();
let es = match cd.get(2) {
Some(&e) if e != 0 => e as usize,
_ => return Err(err("missing element size")),
};
let codec = codec(cd)?;
let limit = ctx.output_limit();
if codec == Codec::None {
if input.len() > limit {
return Err(err("output exceeds the chunk size"));
}
let block = match cd.get(3) {
Some(&b) if b != 0 => b as usize,
_ => default_block_size(es),
};
if !block.is_multiple_of(8) {
return Err(err("block size is not a multiple of 8"));
}
if !input.len().is_multiple_of(es) {
return Err(err("chunk is not a whole number of elements"));
}
let size = input.len() / es;
let mut out = vec![0u8; input.len()];
let mut pos = 0;
for n in blocks(size, block) {
let bytes = n * es;
bitunshuffle_block(&input[pos..pos + bytes], &mut out[pos..pos + bytes], n, es);
pos += bytes;
}
out[pos..].copy_from_slice(&input[pos..]);
return Ok(out);
}
let header = input.get(..12).ok_or_else(|| err("truncated header"))?;
let total = u64::from_be_bytes(header[..8].try_into().unwrap());
let block_bytes = u32::from_be_bytes(header[8..12].try_into().unwrap()) as usize;
let total = usize::try_from(total)
.ok()
.filter(|&t| t <= limit)
.ok_or_else(|| err("decoded size exceeds the chunk size"))?;
if !total.is_multiple_of(es) {
return Err(err("chunk is not a whole number of elements"));
}
if block_bytes == 0 || !block_bytes.is_multiple_of(es) {
return Err(err("bad block size"));
}
let block = block_bytes / es;
if !block.is_multiple_of(8) {
return Err(err("block size is not a multiple of 8"));
}
let size = total / es;
let mut out = vec![0u8; total];
let mut tmp = vec![0u8; block_bytes.min(total)];
let mut ip = 12usize;
let mut op = 0usize;
let mut zstd = None;
for n in blocks(size, block) {
let bytes = n * es;
let len = input
.get(ip..ip + 4)
.map(|b| u32::from_be_bytes(b.try_into().unwrap()) as usize)
.ok_or_else(|| err("truncated block header"))?;
ip += 4;
let comp = input
.get(ip..ip.saturating_add(len))
.ok_or_else(|| err("truncated block"))?;
ip += len;
let dst = &mut tmp[..bytes];
let got = match codec {
Codec::Lz4 => lz4_flex::block::decompress_into(comp, dst)
.map_err(|e| err(&format!("lz4: {e}")))?,
Codec::Zstd => zstd_decode_into(
zstd.get_or_insert_with(ruzstd::decoding::FrameDecoder::new),
comp,
dst,
)?,
Codec::None => unreachable!(),
};
if got != bytes {
return Err(err("block decoded to the wrong size"));
}
bitunshuffle_block(dst, &mut out[op..op + bytes], n, es);
op += bytes;
}
let tail = total - op;
let rest = input
.get(ip..ip + tail)
.ok_or_else(|| err("truncated trailing elements"))?;
out[op..].copy_from_slice(rest);
Ok(out)
}
/// Decode Zstandard frames into exactly `dst`, failing if they hold more.
#[cfg(any(feature = "bitshuffle", feature = "blosc"))]
pub(crate) fn zstd_decode_into(
decoder: &mut ruzstd::decoding::FrameDecoder,
frames: &[u8],
dst: &mut [u8],
) -> Result<usize, FormatError> {
decoder
.decode_all(frames, dst)
.map_err(|e| FormatError::DecompressionError(format!("zstd: {e}")))
}
/// Compress with ruzstd. It implements one level (roughly zstd's level 1),
/// so the requested level only matters to other encoders.
#[cfg(any(feature = "bitshuffle", feature = "blosc"))]
pub(crate) fn zstd_encode(data: &[u8]) -> Vec<u8> {
ruzstd::encoding::compress_to_vec(data, ruzstd::encoding::CompressionLevel::Fastest)
}
/// Encode a chunk with the bitshuffle filter.
#[cfg(feature = "bitshuffle")]
pub(crate) fn bitshuffle_encode(
input: &[u8],
ctx: &FilterContext<'_>,
) -> Result<Vec<u8>, FormatError> {
let cd = ctx.client_data();
let es = match cd.get(2) {
Some(&e) if e != 0 => e as usize,
_ => ctx.element_size.max(1),
};
let codec = codec(cd)?;
let block = match cd.get(3) {
Some(&b) if b != 0 => b as usize,
_ => default_block_size(es),
};
let cerr = |m: &str| FormatError::CompressionError(format!("bitshuffle: {m}"));
if !block.is_multiple_of(8) {
return Err(cerr("block size is not a multiple of 8"));
}
if !input.len().is_multiple_of(es) {
return Err(cerr("chunk is not a whole number of elements"));
}
let size = input.len() / es;
let mut out = Vec::with_capacity(input.len() + 12 + input.len() / 64);
if codec != Codec::None {
out.extend_from_slice(&(input.len() as u64).to_be_bytes());
let block_bytes =
u32::try_from(block * es).map_err(|_| cerr("block size does not fit in 32 bits"))?;
out.extend_from_slice(&block_bytes.to_be_bytes());
}
let mut tmp = vec![0u8; (block * es).min(input.len())];
let mut pos = 0;
for n in blocks(size, block) {
let bytes = n * es;
let dst = &mut tmp[..bytes];
bitshuffle_block(&input[pos..pos + bytes], dst, n, es);
match codec {
Codec::None => out.extend_from_slice(dst),
Codec::Lz4 | Codec::Zstd => {
let comp = if codec == Codec::Lz4 {
lz4_flex::block::compress(dst)
} else {
zstd_encode(dst)
};
out.extend_from_slice(&(comp.len() as u32).to_be_bytes());
out.extend_from_slice(&comp);
}
}
pos += bytes;
}
out.extend_from_slice(&input[pos..]);
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
/// The definition, one bit at a time.
fn naive(input: &[u8], n: usize, es: usize) -> Vec<u8> {
let mut out = vec![0u8; n * es];
for i in 0..n {
for j in 0..es {
for k in 0..8 {
if input[i * es + j] >> k & 1 == 1 {
let p = (8 * j + k) * n + i;
out[p / 8] |= 1 << (p % 8);
}
}
}
}
out
}
#[test]
fn transpose_matches_the_definition_and_inverts() {
for (n, es) in [(8, 1), (16, 2), (24, 4), (128, 8), (64, 3), (8, 16)] {
let input: Vec<u8> = (0..n * es)
.map(|i| (i as u32).wrapping_mul(2_654_435_761).rotate_left(7) as u8)
.collect();
let mut out = vec![0u8; n * es];
bitshuffle_block(&input, &mut out, n, es);
assert_eq!(out, naive(&input, n, es), "n={n} es={es}");
let mut back = vec![0u8; n * es];
bitunshuffle_block(&out, &mut back, n, es);
assert_eq!(back, input);
}
}
#[cfg(feature = "bitshuffle")]
fn ctx_for(cd: Vec<u32>) -> crate::filter_pipeline::FilterDescription {
crate::filter_pipeline::FilterDescription {
filter_id: crate::filter_pipeline::FILTER_BITSHUFFLE,
name: None,
flags: 0,
client_data: cd,
}
}
#[cfg(feature = "bitshuffle")]
#[test]
fn filter_round_trips_every_mode() {
for es in [1usize, 2, 4, 8] {
for n in [0usize, 1, 7, 8, 100, 1000, 5003] {
let data: Vec<u8> = (0..n * es)
.map(|i| (i % 97) as u8 ^ (i / 300) as u8)
.collect();
for (comp, block) in [(0, 0), (0, 16), (2, 0), (2, 64), (3, 0), (3, 1024)] {
let f = ctx_for(vec![0, 4, es as u32, block, comp]);
let ctx = FilterContext {
filter: &f,
element_size: es,
max_output: data.len(),
};
let enc = bitshuffle_encode(&data, &ctx).unwrap();
let dec = bitshuffle_decode(&enc, &ctx).unwrap();
assert_eq!(dec, data, "es={es} n={n} comp={comp} block={block}");
}
}
}
}
#[cfg(feature = "bitshuffle")]
#[test]
fn rejects_oversized_and_truncated_chunks() {
let data = vec![5u8; 4096];
let f = ctx_for(vec![0, 4, 4, 0, 2]);
let mut ctx = FilterContext {
filter: &f,
element_size: 4,
max_output: data.len(),
};
let enc = bitshuffle_encode(&data, &ctx).unwrap();
assert!(bitshuffle_decode(&enc[..enc.len() - 1], &ctx).is_err());
ctx.max_output = 100;
assert!(bitshuffle_decode(&enc, &ctx).is_err());
}
/// Random and mutated chunks, in every mode, and hostile `cd_values`:
/// errors are fine, panics are not.
#[cfg(feature = "bitshuffle")]
#[test]
fn fuzzed_chunks_never_panic() {
use crate::test_fuzz::{Rng, fuzz_decoder};
let data: Vec<u8> = (0..3001u32)
.flat_map(|i| ((i / 7) as u16).to_le_bytes())
.collect();
for (comp, block) in [(0, 0), (0, 16), (2, 0), (2, 64), (3, 0), (3, 1024)] {
let f = ctx_for(vec![0, 4, 2, block, comp]);
let ctx = FilterContext {
filter: &f,
element_size: 2,
max_output: data.len(),
};
let seeds = vec![
bitshuffle_encode(&data, &ctx).unwrap(),
bitshuffle_encode(&data[..34], &ctx).unwrap(),
bitshuffle_encode(&data[..512], &ctx).unwrap(),
];
fuzz_decoder(
0xb5 + comp as u64 * 7 + block as u64,
&seeds,
4_000,
data.len(),
|s| bitshuffle_decode(s, &ctx),
);
}
// Hostile filter parameters on a valid chunk.
let mut rng = Rng::new(0xcd);
let good = ctx_for(vec![0, 4, 2, 0, 2]);
let enc = bitshuffle_encode(
&data,
&FilterContext {
filter: &good,
element_size: 2,
max_output: data.len(),
},
)
.unwrap();
for _ in 0..3_000 {
let cd: Vec<u32> = (0..rng.below(7))
.map(|_| match rng.below(4) {
0 => rng.below(5) as u32,
1 => u32::MAX - rng.below(4) as u32,
2 => 1 << rng.below(32),
_ => rng.next_u64() as u32,
})
.collect();
let f = ctx_for(cd);
let ctx = FilterContext {
filter: &f,
element_size: 2,
max_output: data.len(),
};
let _ = bitshuffle_decode(&enc, &ctx);
let _ = bitshuffle_decode(&data, &ctx);
}
}
}
+711
View File
@@ -0,0 +1,711 @@
//! Blosc 1 (HDF5 filter 32001, `hdf5-blosc`, hdf5plugin's `Blosc`), in pure
//! Rust: the Blosc 1 frame, its byte shuffle and bit shuffle, and the
//! BloscLZ, LZ4/LZ4HC, Snappy, Zlib and Zstandard codecs inside it.
//!
//! **Frame** (c-blosc 1.x, format version 2). A 16-byte header — version
//! (2), codec format version (1), flags, type size, then little-endian `u32`
//! decoded size, block size and frame size. Flags: bit 0 byte shuffle, bit
//! 1 stored raw ("memcpyed": the data follows the header), bit 2 bit
//! shuffle, bit 4 "do not split", bits 5-7 the codec (0 BloscLZ, 1 LZ4 and
//! LZ4HC, 2 Snappy, 3 Zlib, 4 Zstandard). Unless stored raw, a table of
//! `u32` block offsets follows, one per block of `block size` bytes (the
//! last one may be shorter). A block is one stream, or — when the "do not
//! split" flag is clear, the type size is at most 16, the block holds at
//! least 128 elements, and it is not the short last block — `type size`
//! streams, one per byte plane. Each stream is a `u32` length and the
//! codec's output; a length equal to the stream's decoded size means the
//! bytes are stored raw. The decoded block is then unshuffled (byte shuffle
//! for type size > 1; bit shuffle when the block holds a multiple of 8
//! elements, the trailing partial element copied as is).
//!
//! **Filter** (`blosc_filter.c`) `cd_values`: `[0]` filter revision, `[1]`
//! Blosc format version, `[2]` type size, `[3]` chunk size in bytes, `[4]`
//! compression level, `[5]` shuffle (0 none, 1 byte, 2 bit), `[6]`
//! compressor (0 blosclz, 1 lz4, 2 lz4hc, 3 snappy, 4 zlib, 5 zstd). The
//! decoder needs only the frame.
use crate::error::FormatError;
use crate::filter_registry::FilterContext;
use crate::filters_bitshuffle::{bitshuffle_block, bitunshuffle_block};
const HEADER: usize = 16;
const FLAG_SHUFFLE: u8 = 0x01;
const FLAG_MEMCPYED: u8 = 0x02;
const FLAG_BITSHUFFLE: u8 = 0x04;
const FLAG_FUTURE: u8 = 0x08;
const FLAG_DONT_SPLIT: u8 = 0x10;
const MAX_SPLITS: usize = 16;
const MIN_BUFFERSIZE: usize = 128;
fn err(msg: &str) -> FormatError {
FormatError::DecompressionError(format!("blosc: {msg}"))
}
fn le32(b: &[u8], at: usize) -> Result<usize, FormatError> {
b.get(at..at + 4)
.map(|s| u32::from_le_bytes(s.try_into().unwrap()) as usize)
.ok_or_else(|| err("truncated frame"))
}
/// The codec inside a Blosc frame (flags bits 5-7).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Codec {
BloscLz,
Lz4,
Snappy,
Zlib,
Zstd,
}
impl Codec {
fn from_flags(flags: u8) -> Result<Codec, FormatError> {
match flags >> 5 {
0 => Ok(Codec::BloscLz),
1 => Ok(Codec::Lz4),
2 => Ok(Codec::Snappy),
3 => Ok(Codec::Zlib),
4 => Ok(Codec::Zstd),
other => Err(err(&format!("unknown codec {other}"))),
}
}
}
/// Decode one codec stream into exactly `dst`.
fn decode_stream(
codec: Codec,
src: &[u8],
dst: &mut [u8],
zstd: &mut Option<ruzstd::decoding::FrameDecoder>,
) -> Result<(), FormatError> {
let n = match codec {
Codec::BloscLz => blosclz_decompress(src, dst),
Codec::Lz4 => {
lz4_flex::block::decompress_into(src, dst).map_err(|e| err(&format!("lz4: {e}")))?
}
Codec::Snappy => {
let len = snap::raw::decompress_len(src).map_err(|e| err(&format!("snappy: {e}")))?;
if len != dst.len() {
return Err(err("snappy stream has the wrong size"));
}
snap::raw::Decoder::new()
.decompress(src, dst)
.map_err(|e| err(&format!("snappy: {e}")))?
}
Codec::Zlib => {
let out = crate::filters::inflate_bounded(src, dst.len(), dst.len())
.map_err(|e| err(&format!("zlib: {e}")))?;
let n = out.len();
if n == dst.len() {
dst.copy_from_slice(&out);
}
n
}
Codec::Zstd => crate::filters_bitshuffle::zstd_decode_into(
zstd.get_or_insert_with(ruzstd::decoding::FrameDecoder::new),
src,
dst,
)?,
};
if n != dst.len() {
return Err(err("stream decoded to the wrong size"));
}
Ok(())
}
/// Decode a Blosc-filtered chunk: one Blosc 1 frame.
///
/// An HDF5 chunk is never empty, so a frame that decodes to nothing where
/// the chunk size is known is corrupt (libhdf5's filter fails it too).
pub(crate) fn blosc_decode(input: &[u8], ctx: &FilterContext<'_>) -> Result<Vec<u8>, FormatError> {
let out = blosc_decompress(input, ctx.output_limit())?;
if out.is_empty() && ctx.max_output != 0 {
return Err(err("empty frame for a non-empty chunk"));
}
Ok(out)
}
/// Decompress a Blosc 1 frame, refusing more than `limit` bytes of output.
pub fn blosc_decompress(input: &[u8], limit: usize) -> Result<Vec<u8>, FormatError> {
if input.len() < HEADER {
return Err(err("truncated header"));
}
let version = input[0];
let codec_version = input[1];
let flags = input[2];
let typesize = input[3] as usize;
let nbytes = le32(input, 4)?;
let blocksize = le32(input, 8)?;
let cbytes = le32(input, 12)?;
if version != 1 && version != 2 {
return Err(err(&format!(
"frame format version {version} is not Blosc 1 (a Blosc 2 chunk?)"
)));
}
if flags & FLAG_FUTURE != 0 {
return Err(err("unknown header flags"));
}
if nbytes > limit {
return Err(err("decoded size exceeds the chunk size"));
}
if cbytes > input.len() {
return Err(err("frame is longer than the chunk"));
}
if cbytes < HEADER {
return Err(err("truncated frame"));
}
let src = &input[..cbytes];
if nbytes == 0 {
return Ok(Vec::new());
}
if blocksize == 0 || typesize == 0 {
return Err(err("bad block or type size"));
}
let mut out = vec![0u8; nbytes];
if flags & FLAG_MEMCPYED != 0 {
if cbytes != nbytes + HEADER {
return Err(err("stored frame has the wrong size"));
}
out.copy_from_slice(&src[HEADER..]);
return Ok(out);
}
let codec = Codec::from_flags(flags)?;
if codec_version != 1 {
return Err(err(&format!(
"unsupported {codec:?} format version {codec_version}"
)));
}
let nblocks = nbytes.div_ceil(blocksize);
let leftover = nbytes % blocksize;
if nblocks > (cbytes - HEADER) / 4 {
return Err(err("block table is truncated"));
}
let block_len = blocksize.min(nbytes);
let mut tmp = vec![0u8; block_len];
let mut zstd = None;
let dont_split = flags & FLAG_DONT_SPLIT != 0;
for j in 0..nblocks {
let is_leftover = j == nblocks - 1 && leftover > 0;
let bsize = if is_leftover { leftover } else { blocksize };
let nsplits = if !dont_split
&& typesize <= MAX_SPLITS
&& bsize / typesize >= MIN_BUFFERSIZE
&& !is_leftover
{
typesize
} else {
1
};
let neblock = bsize / nsplits;
let mut pos = le32(src, HEADER + 4 * j)?;
let tmp = &mut tmp[..bsize];
for s in 0..nsplits {
let clen = src
.get(pos..)
.and_then(|rest| rest.get(..4))
.map(|b| u32::from_le_bytes(b.try_into().unwrap()) as usize)
.ok_or_else(|| err("block offset out of range"))?;
pos += 4;
let stream = src
.get(pos..pos.saturating_add(clen))
.ok_or_else(|| err("stream runs past the frame"))?;
let dst = &mut tmp[s * neblock..(s + 1) * neblock];
if clen == neblock {
dst.copy_from_slice(stream);
} else {
decode_stream(codec, stream, dst, &mut zstd)?;
}
pos += clen;
}
// `bsize` is a whole number of splits by construction (`nsplits` > 1
// only for full blocks, and c-blosc sizes those in whole elements).
if nsplits * neblock != bsize {
return Err(err("block is not a whole number of streams"));
}
let dest = &mut out[j * blocksize..j * blocksize + bsize];
unshuffle_block(flags, typesize, tmp, dest);
}
Ok(out)
}
/// Undo the frame's shuffle on one decoded block.
fn unshuffle_block(flags: u8, typesize: usize, src: &[u8], dest: &mut [u8]) {
let bsize = src.len();
if flags & FLAG_SHUFFLE != 0 && typesize > 1 {
let n = bsize / typesize;
for i in 0..n {
for b in 0..typesize {
dest[i * typesize + b] = src[b * n + i];
}
}
dest[n * typesize..].copy_from_slice(&src[n * typesize..]);
} else if flags & FLAG_BITSHUFFLE != 0 && bsize >= typesize {
let n = bsize / typesize;
if n.is_multiple_of(8) {
let body = n * typesize;
bitunshuffle_block(&src[..body], &mut dest[..body], n, typesize);
dest[body..].copy_from_slice(&src[body..]);
} else {
dest.copy_from_slice(src);
}
} else {
dest.copy_from_slice(src);
}
}
/// BloscLZ decompression (c-blosc 1.21 `blosclz_decompress`): returns the
/// number of bytes written, or 0 on malformed input — exactly as the C
/// decoder, including stopping before a match that ends the stream, so a
/// stream libblosc rejects is rejected here too.
///
/// Instructions: a control byte `ctrl`. Below 32, a literal run of
/// `ctrl + 1` bytes. Otherwise a match: length `(ctrl >> 5) + 2`, extended
/// by following bytes while they are 255 when the top three bits are all
/// set; distance `((ctrl & 31) << 8) + next byte + 1`, or — when that byte
/// is 255 and the high bits are 31 — a 16-bit big-endian distance plus 8192.
/// The first instruction is always a literal.
pub(crate) fn blosclz_decompress(input: &[u8], out: &mut [u8]) -> usize {
const MAX_DISTANCE: usize = 8191;
let limit = input.len();
if limit == 0 {
return 0;
}
let mut ip = 1usize;
let mut op = 0usize;
let mut ctrl = (input[0] & 31) as usize;
loop {
if ctrl >= 32 {
let mut len = (ctrl >> 5) - 1;
let ofs = (ctrl & 31) << 8;
if len == 6 {
loop {
if ip + 1 >= limit {
return 0;
}
let code = input[ip] as usize;
ip += 1;
len += code;
if code != 255 {
break;
}
}
} else if ip + 1 >= limit {
return 0;
}
let code = input[ip] as usize;
ip += 1;
len += 3;
// The copy source is `distance` bytes back.
let mut distance = ofs + code + 1;
if code == 255 && ofs == 31 << 8 {
if ip + 1 >= limit {
return 0;
}
let far = ((input[ip] as usize) << 8) + input[ip + 1] as usize;
ip += 2;
distance = far + MAX_DISTANCE + 1;
}
if op + len > out.len() {
return 0;
}
if distance > op {
return 0;
}
if ip >= limit {
break;
}
ctrl = input[ip] as usize;
ip += 1;
let start = op - distance;
if distance >= len {
out.copy_within(start..start + len, op);
} else {
for k in 0..len {
out[op + k] = out[start + k];
}
}
op += len;
} else {
let run = ctrl + 1;
if op + run > out.len() || ip + run > limit {
return 0;
}
out[op..op + run].copy_from_slice(&input[ip..ip + run]);
op += run;
ip += run;
if ip >= limit {
break;
}
ctrl = input[ip] as usize;
ip += 1;
}
}
op
}
/// The codec our encoder puts inside the frame.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum EncodeCodec {
Lz4,
Snappy,
Zlib,
Zstd,
}
impl EncodeCodec {
/// From the filter's `cd_values[6]` compressor code.
fn from_cd(code: u32) -> Result<EncodeCodec, FormatError> {
match code {
1 | 2 => Ok(EncodeCodec::Lz4),
3 => Ok(EncodeCodec::Snappy),
4 => Ok(EncodeCodec::Zlib),
5 => Ok(EncodeCodec::Zstd),
0 => Err(FormatError::CompressionError(
"blosc: clawhdf5 cannot write BloscLZ; choose lz4, snappy, zlib or zstd".into(),
)),
other => Err(FormatError::CompressionError(format!(
"blosc: unknown compressor {other}"
))),
}
}
fn flags(self) -> u8 {
(match self {
EncodeCodec::Lz4 => 1,
EncodeCodec::Snappy => 2,
EncodeCodec::Zlib => 3,
EncodeCodec::Zstd => 4,
}) << 5
}
fn encode(self, data: &[u8], level: u32) -> Result<Vec<u8>, FormatError> {
match self {
EncodeCodec::Lz4 => Ok(lz4_flex::block::compress(data)),
EncodeCodec::Snappy => snap::raw::Encoder::new()
.compress_vec(data)
.map_err(|e| FormatError::CompressionError(format!("blosc: snappy: {e}"))),
EncodeCodec::Zlib => crate::filters::deflate_bounded(data, level.min(9))
.map_err(|e| FormatError::CompressionError(format!("blosc: zlib: {e}"))),
EncodeCodec::Zstd => Ok(crate::filters_bitshuffle::zstd_encode(data)),
}
}
}
/// Block size our encoder uses: at most 256 KiB, a whole number of
/// elements (and, for bit shuffle, of 8-element groups).
fn encode_block_size(nbytes: usize, typesize: usize, bitshuffle: bool) -> usize {
let unit = if bitshuffle { 8 * typesize } else { typesize };
let target = (256 * 1024).min(nbytes);
if target < unit {
return nbytes.max(1);
}
target / unit * unit
}
/// Encode a chunk as one Blosc 1 frame. `cd_values` as hdf5-blosc:
/// `[2]` type size, `[4]` level (0 = store), `[5]` shuffle, `[6]` codec.
pub(crate) fn blosc_encode(input: &[u8], ctx: &FilterContext<'_>) -> Result<Vec<u8>, FormatError> {
let cd = ctx.client_data();
let cerr = |m: &str| FormatError::CompressionError(format!("blosc: {m}"));
let typesize = match cd.get(2) {
Some(&t) if t != 0 => t as usize,
_ => ctx.element_size.max(1),
};
// Blosc records the type size in one byte; c-blosc treats larger types
// as bytes.
let typesize = if typesize > 255 { 1 } else { typesize };
let level = cd.get(4).copied().unwrap_or(5);
let shuffle = cd.get(5).copied().unwrap_or(1);
let codec = EncodeCodec::from_cd(cd.get(6).copied().unwrap_or(1))?;
let nbytes = input.len();
if nbytes > i32::MAX as usize - HEADER {
return Err(cerr("chunk too large for a Blosc frame"));
}
let mut flags = codec.flags();
match shuffle {
0 => {}
1 => flags |= FLAG_SHUFFLE,
2 => flags |= FLAG_BITSHUFFLE,
other => return Err(cerr(&format!("unknown shuffle mode {other}"))),
}
let blocksize = encode_block_size(nbytes, typesize, shuffle == 2);
let header = |flags: u8, blocksize: usize, cbytes: usize| {
let mut h = Vec::with_capacity(HEADER);
h.extend_from_slice(&[2, 1, flags, typesize as u8]);
h.extend_from_slice(&(nbytes as u32).to_le_bytes());
h.extend_from_slice(&(blocksize as u32).to_le_bytes());
h.extend_from_slice(&(cbytes as u32).to_le_bytes());
h
};
let stored = || {
let mut out = header(
FLAG_MEMCPYED | (flags & !(FLAG_SHUFFLE | FLAG_BITSHUFFLE)),
blocksize,
nbytes + HEADER,
);
out.extend_from_slice(input);
out
};
if level == 0 || nbytes == 0 {
return Ok(stored());
}
let nblocks = nbytes.div_ceil(blocksize);
let leftover = nbytes % blocksize;
let mut body = Vec::with_capacity(nbytes / 2);
let mut starts = Vec::with_capacity(nblocks);
let table_end = HEADER + 4 * nblocks;
let mut shuffled = vec![0u8; blocksize];
for j in 0..nblocks {
let is_leftover = j == nblocks - 1 && leftover > 0;
let bsize = if is_leftover { leftover } else { blocksize };
let block = &input[j * blocksize..j * blocksize + bsize];
let sh = &mut shuffled[..bsize];
shuffle_block(flags, typesize, block, sh);
starts.push(table_end + body.len());
let nsplits = if typesize <= MAX_SPLITS
&& bsize / typesize >= MIN_BUFFERSIZE
&& !is_leftover
&& bsize.is_multiple_of(typesize)
{
typesize
} else {
1
};
let neblock = bsize / nsplits;
for s in 0..nsplits {
let part = &sh[s * neblock..(s + 1) * neblock];
let comp = codec.encode(part, level)?;
if comp.len() < neblock {
body.extend_from_slice(&(comp.len() as u32).to_le_bytes());
body.extend_from_slice(&comp);
} else {
body.extend_from_slice(&(neblock as u32).to_le_bytes());
body.extend_from_slice(part);
}
}
if table_end + body.len() >= nbytes + HEADER {
// Incompressible: store instead, as c-blosc does.
return Ok(stored());
}
}
// A split block must decode as split: the decoder infers splitting from
// the same rule, which requires a whole number of elements per block.
let cbytes = table_end + body.len();
let mut out = header(flags, blocksize, cbytes);
for s in starts {
out.extend_from_slice(&(s as u32).to_le_bytes());
}
out.extend_from_slice(&body);
Ok(out)
}
/// Apply the frame's shuffle to one block (the inverse of
/// [`unshuffle_block`]).
fn shuffle_block(flags: u8, typesize: usize, src: &[u8], dest: &mut [u8]) {
let bsize = src.len();
if flags & FLAG_SHUFFLE != 0 && typesize > 1 {
let n = bsize / typesize;
for i in 0..n {
for b in 0..typesize {
dest[b * n + i] = src[i * typesize + b];
}
}
dest[n * typesize..].copy_from_slice(&src[n * typesize..]);
} else if flags & FLAG_BITSHUFFLE != 0 && bsize >= typesize {
let n = bsize / typesize;
if n.is_multiple_of(8) {
let body = n * typesize;
bitshuffle_block(&src[..body], &mut dest[..body], n, typesize);
dest[body..].copy_from_slice(&src[body..]);
} else {
dest.copy_from_slice(src);
}
} else {
dest.copy_from_slice(src);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::filter_pipeline::{FILTER_BLOSC, FilterDescription};
/// A blosclz stream: literal "abc", then a 9-byte match 3 back (a run
/// of "abc"), then literal "Z".
#[test]
fn blosclz_decodes_literals_and_overlapping_matches() {
// Match: length (ctrl >> 5) + 2 = 8, distance ofs + code + 1 = 3.
let stream = [2, b'a', b'b', b'c', (6 << 5), 2, 0, b'Z'];
let mut out = [0u8; 12];
assert_eq!(blosclz_decompress(&stream, &mut out), 12);
assert_eq!(&out, b"abcabcabcabZ");
// A stream cut inside a match is malformed.
let mut out = [0u8; 11];
assert_eq!(blosclz_decompress(&stream[..6], &mut out), 0);
// A match before the start of the output is malformed.
assert_eq!(blosclz_decompress(&[0, b'a', 32, 5, 0, b'x'], &mut out), 0);
}
fn desc(cd: Vec<u32>) -> FilterDescription {
FilterDescription {
filter_id: FILTER_BLOSC,
name: None,
flags: 0,
client_data: cd,
}
}
#[test]
fn frame_round_trips_every_codec_and_shuffle() {
for ts in [1usize, 2, 4, 8, 3, 32] {
for n in [0usize, 5, 100, 1000, 70_000, 300_001] {
if n * ts > 1 << 20 && ts > 1 {
continue;
}
let data: Vec<u8> = (0..n * ts)
.map(|i| ((i / ts) % 200) as u8 ^ (i % ts) as u8)
.collect();
for codec in [1u32, 3, 4, 5] {
for shuffle in [0u32, 1, 2] {
for level in [0u32, 5] {
let f = desc(vec![2, 2, ts as u32, 0, level, shuffle, codec]);
let ctx = FilterContext {
filter: &f,
element_size: ts,
max_output: data.len(),
};
let enc = blosc_encode(&data, &ctx).unwrap();
let dec = blosc_decode(&enc, &ctx).unwrap_or_else(|e| {
panic!("ts={ts} n={n} codec={codec} shuffle={shuffle}: {e}")
});
assert!(
dec == data,
"ts={ts} n={n} codec={codec} shuffle={shuffle} level={level}"
);
}
}
}
}
}
}
#[test]
fn rejects_bad_frames() {
let data = vec![9u8; 50_000];
let f = desc(vec![2, 2, 4, 0, 5, 1, 1]);
let ctx = FilterContext {
filter: &f,
element_size: 4,
max_output: data.len(),
};
let enc = blosc_encode(&data, &ctx).unwrap();
assert!(blosc_decode(&enc[..enc.len() - 3], &ctx).is_err());
let small = FilterContext {
max_output: 49_999,
..ctx
};
assert!(blosc_decode(&enc, &small).is_err());
let mut v3 = enc.clone();
v3[0] = 3;
assert!(blosc_decode(&v3, &ctx).is_err());
let f0 = desc(vec![2, 2, 4, 0, 5, 1, 0]);
let ctx0 = FilterContext { filter: &f0, ..ctx };
assert!(blosc_encode(&data, &ctx0).is_err());
}
/// A frame that declares no data, for a chunk that has some.
#[test]
fn empty_frame_for_a_non_empty_chunk_is_an_error() {
let mut frame = vec![2u8, 1, 0x20, 4];
for v in [0u32, 64, 16] {
frame.extend_from_slice(&v.to_le_bytes());
}
assert_eq!(blosc_decompress(&frame, 64).unwrap(), b"");
let f = desc(vec![2, 2, 4, 64, 5, 1, 1]);
let ctx = FilterContext {
filter: &f,
element_size: 4,
max_output: 64,
};
assert!(blosc_decode(&frame, &ctx).is_err());
}
/// A frame whose header claims a compressed size smaller than the
/// header itself, not stored raw: an error, not an arithmetic overflow
/// (it panicked in debug builds).
#[test]
fn frame_size_below_the_header_is_an_error() {
let mut frame = vec![2u8, 1, 1 << 5, 4];
for v in [64u32, 64, 8] {
frame.extend_from_slice(&v.to_le_bytes());
}
frame.extend_from_slice(&[0; 40]);
assert!(blosc_decompress(&frame, 1000).is_err());
for cbytes in 0..16u32 {
frame[12..16].copy_from_slice(&cbytes.to_le_bytes());
assert!(blosc_decompress(&frame, 1000).is_err(), "cbytes={cbytes}");
}
}
/// A BloscLZ frame (our encoder cannot write one): a single block,
/// one stream, no shuffle.
fn blosclz_frame() -> Vec<u8> {
let stream = [2, b'a', b'b', b'c', (6 << 5), 2, 0, b'Z'];
let mut f = vec![2u8, 1, 0, 1];
for v in [12u32, 12, (HEADER + 4 + 4 + stream.len()) as u32] {
f.extend_from_slice(&v.to_le_bytes());
}
f.extend_from_slice(&((HEADER + 4) as u32).to_le_bytes());
f.extend_from_slice(&(stream.len() as u32).to_le_bytes());
f.extend_from_slice(&stream);
f
}
/// Random and mutated frames, every codec and shuffle: errors are fine,
/// panics are not.
#[test]
fn fuzzed_frames_never_panic() {
let limit = 6000;
let data: Vec<u8> = (0..1500u32).flat_map(|i| (i / 5).to_le_bytes()).collect();
let mut seeds = vec![blosclz_frame()];
for codec in [1u32, 3, 4, 5] {
for shuffle in [0u32, 1, 2] {
for (ts, n) in [(4usize, data.len()), (4, 520), (1, 300), (2, 4)] {
let f = desc(vec![2, 2, ts as u32, 0, 5, shuffle, codec]);
let ctx = FilterContext {
filter: &f,
element_size: ts,
max_output: n,
};
seeds.push(blosc_encode(&data[..n], &ctx).unwrap());
}
}
}
// Stored raw.
let f = desc(vec![2, 2, 4, 0, 0, 1, 1]);
let ctx = FilterContext {
filter: &f,
element_size: 4,
max_output: 64,
};
seeds.push(blosc_encode(&data[..64], &ctx).unwrap());
crate::test_fuzz::fuzz_decoder(0xb10, &seeds, 30_000, limit, |s| {
blosc_decompress(s, limit)
});
}
/// BloscLZ streams on their own, random and mutated.
#[test]
fn fuzzed_blosclz_streams_never_panic() {
let seed = blosclz_frame()[HEADER + 8..].to_vec();
let mut out = [0u8; 64];
crate::test_fuzz::fuzz_decoder(0xb11, &[seed], 30_000, 64, |s| {
let n = blosclz_decompress(s, &mut out);
if n == 0 {
Err(err("malformed"))
} else {
Ok(out[..n].to_vec())
}
});
}
}
+132
View File
@@ -0,0 +1,132 @@
//! bzip2 (HDF5 filter 307, PyTables' `H5Zbzip2.c`, hdf5plugin's `BZip2`).
//!
//! The chunk is one bzip2 stream; `cd_values[0]` is the block size (1-9,
//! the compression level). Decoded with the `bzip2` crate's default backend,
//! `libbz2-rs-sys`, a pure-Rust port of libbzip2.
use crate::error::FormatError;
use crate::filter_registry::FilterContext;
fn err(msg: &str) -> FormatError {
FormatError::DecompressionError(format!("bzip2: {msg}"))
}
/// Decode a bzip2-filtered chunk, refusing output beyond the chunk size.
pub(crate) fn bzip2_decode(input: &[u8], ctx: &FilterContext<'_>) -> Result<Vec<u8>, FormatError> {
use bzip2::{Decompress, Status};
let limit = ctx.output_limit();
let max_capacity = limit.saturating_add(1);
let hint = if ctx.max_output != 0 {
ctx.max_output
} else {
input.len().saturating_mul(4)
};
let mut out = Vec::new();
out.try_reserve_exact(hint.clamp(1, max_capacity))
.map_err(|_| err("cannot allocate the output buffer"))?;
let mut dec = Decompress::new(false);
loop {
let (in_before, out_before) = (dec.total_in(), dec.total_out());
let status = dec
.decompress_vec(&input[in_before as usize..], &mut out)
.map_err(|e| err(&e.to_string()))?;
if out.len() > limit {
return Err(err("output exceeds the chunk size"));
}
if status == Status::StreamEnd {
return Ok(out);
}
if out.len() == out.capacity() {
let grow = out
.capacity()
.min(max_capacity.saturating_sub(out.capacity()))
.max(1);
out.try_reserve_exact(grow)
.map_err(|_| err("cannot allocate the output buffer"))?;
} else if dec.total_in() as usize >= input.len()
|| (dec.total_in(), dec.total_out()) == (in_before, out_before)
{
return Err(err("truncated stream"));
}
}
}
/// Encode a chunk as one bzip2 stream at block size `cd_values[0]`
/// (default 9, as hdf5plugin).
pub(crate) fn bzip2_encode(input: &[u8], ctx: &FilterContext<'_>) -> Result<Vec<u8>, FormatError> {
use bzip2::{Action, Compress, Compression, Status};
let level = ctx.client_data().first().copied().unwrap_or(9).clamp(1, 9);
let cerr = |m: String| FormatError::CompressionError(format!("bzip2: {m}"));
let mut enc = Compress::new(Compression::new(level), 0);
// bzip2's worst case is about 1% + 600 bytes over the input.
let mut out = Vec::with_capacity(input.len() + input.len() / 100 + 600);
loop {
let consumed = enc.total_in() as usize;
let status = enc
.compress_vec(&input[consumed..], &mut out, Action::Finish)
.map_err(|e| cerr(e.to_string()))?;
if status == Status::StreamEnd {
return Ok(out);
}
if out.len() == out.capacity() {
out.reserve(out.capacity().max(4096));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::filter_pipeline::{FILTER_BZIP2, FilterDescription};
fn desc(level: u32) -> FilterDescription {
FilterDescription {
filter_id: FILTER_BZIP2,
name: None,
flags: 0,
client_data: vec![level],
}
}
#[test]
fn round_trips_and_bounds() {
let data: Vec<u8> = (0..100_000u32)
.flat_map(|i| (i % 777).to_le_bytes())
.collect();
for level in [1, 5, 9] {
let f = desc(level);
let ctx = FilterContext {
filter: &f,
element_size: 4,
max_output: data.len(),
};
let enc = bzip2_encode(&data, &ctx).unwrap();
assert!(enc.len() < data.len() / 4);
assert_eq!(bzip2_decode(&enc, &ctx).unwrap(), data);
// Truncated, and larger than the chunk: errors, not data.
assert!(bzip2_decode(&enc[..enc.len() / 2], &ctx).is_err());
let small = FilterContext {
max_output: data.len() - 1,
..ctx
};
assert!(bzip2_decode(&enc, &small).is_err());
}
}
/// Random and mutated streams: errors are fine, panics are not.
#[test]
fn fuzzed_streams_never_panic() {
let f = desc(9);
let data: Vec<u8> = (0..4000u32).flat_map(|i| (i % 91).to_le_bytes()).collect();
let ctx = FilterContext {
filter: &f,
element_size: 4,
max_output: data.len(),
};
let seeds = vec![
bzip2_encode(&data, &ctx).unwrap(),
bzip2_encode(&data[..40], &ctx).unwrap(),
];
crate::test_fuzz::fuzz_decoder(0xb2, &seeds, 3_000, data.len(), |s| bzip2_decode(s, &ctx));
}
}
+260
View File
@@ -0,0 +1,260 @@
//! LZF (HDF5 filter 32000) — h5py's built-in compression filter
//! (`compression="lzf"`), in pure Rust.
//!
//! The chunk is one raw LZF stream (liblzf 3.x format, no header). The
//! stream is a sequence of instructions, each starting with a control byte:
//!
//! * `000LLLLL` — a literal run: the next `L + 1` bytes (1..=32) are copied.
//! * `LLLOOOOO [E] OOOOOOOO` — a back reference: copy `len + 2` bytes from
//! `distance` bytes back, where `len` is the top three bits (1..=6), or
//! `7 + E` when they are all ones, and `distance` is the 13-bit offset
//! (high five bits in the control byte, low eight in the last byte) plus 1.
//!
//! h5py's filter (`lzf_filter.c`) records the chunk's size in bytes in
//! `cd_values[2]` (slots 0 and 1 hold the filter and liblzf versions) and
//! sizes its output buffer from it.
#[cfg(not(feature = "std"))]
extern crate alloc;
#[cfg(not(feature = "std"))]
use alloc::{format, vec, vec::Vec};
use crate::error::FormatError;
use crate::filter_registry::FilterContext;
/// `H5PY_FILTER_LZF_VERSION`, written to `cd_values[0]`.
pub const LZF_FILTER_VERSION: u32 = 4;
/// `LZF_VERSION` (liblzf 1.5), written to `cd_values[1]`.
pub const LZF_API_VERSION: u32 = 0x0105;
const MAX_LITERAL: usize = 32;
const MAX_OFFSET: usize = 1 << 13;
const MAX_REF: usize = (1 << 8) + (1 << 3);
const HASH_LOG: u32 = 14;
fn err(msg: &str) -> FormatError {
FormatError::DecompressionError(format!("lzf: {msg}"))
}
/// Decode an LZF-filtered chunk.
pub(crate) fn lzf_decode(input: &[u8], ctx: &FilterContext<'_>) -> Result<Vec<u8>, FormatError> {
let limit = ctx.output_limit();
let hint = match ctx.client_data().get(2) {
Some(&n) if n != 0 => n as usize,
_ => input.len().saturating_mul(2),
};
lzf_decompress(input, hint.min(limit), limit)
}
/// Decompress a raw LZF stream, refusing to produce more than `limit` bytes.
pub fn lzf_decompress(
input: &[u8],
size_hint: usize,
limit: usize,
) -> Result<Vec<u8>, FormatError> {
let mut out: Vec<u8> = Vec::new();
out.try_reserve(size_hint)
.map_err(|_| err("cannot allocate the output buffer"))?;
let mut ip = 0usize;
while ip < input.len() {
let ctrl = input[ip] as usize;
ip += 1;
if ctrl < 32 {
let run = ctrl + 1;
let lit = input
.get(ip..ip + run)
.ok_or_else(|| err("literal run past the end of the input"))?;
if out.len() + run > limit {
return Err(err("output exceeds the chunk size"));
}
out.extend_from_slice(lit);
ip += run;
} else {
let mut len = ctrl >> 5;
if len == 7 {
len += *input
.get(ip)
.ok_or_else(|| err("truncated back reference"))?
as usize;
ip += 1;
}
let low = *input
.get(ip)
.ok_or_else(|| err("truncated back reference"))? as usize;
ip += 1;
let distance = ((ctrl & 0x1f) << 8) + low + 1;
let len = len + 2;
if distance > out.len() {
return Err(err("back reference before the start of the output"));
}
if out.len() + len > limit {
return Err(err("output exceeds the chunk size"));
}
let start = out.len() - distance;
if distance >= len {
out.extend_from_within(start..start + len);
} else {
// Overlapping copy: repeats the last `distance` bytes.
for k in 0..len {
let b = out[start + k];
out.push(b);
}
}
}
}
Ok(out)
}
/// Encode a chunk with the LZF filter.
pub(crate) fn lzf_encode(input: &[u8], _ctx: &FilterContext<'_>) -> Result<Vec<u8>, FormatError> {
Ok(lzf_compress(input))
}
fn hash3(b: &[u8]) -> usize {
let v = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
(v.wrapping_mul(2_654_435_761) >> (32 - HASH_LOG)) as usize
}
fn flush_literals(out: &mut Vec<u8>, lit: &[u8]) {
for run in lit.chunks(MAX_LITERAL) {
out.push((run.len() - 1) as u8);
out.extend_from_slice(run);
}
}
/// Compress `input` into a raw LZF stream any liblzf decoder reads.
///
/// Incompressible input grows by one byte per 32. (h5py's own filter gives
/// up on such a chunk and stores it unfiltered; storing the slightly larger
/// stream is equally readable.)
pub fn lzf_compress(input: &[u8]) -> Vec<u8> {
let n = input.len();
let mut out = Vec::with_capacity(n + n / MAX_LITERAL + 1);
let mut table = vec![0u32; 1 << HASH_LOG];
let mut lit_start = 0usize;
let mut i = 0usize;
while i + 2 < n {
let h = hash3(&input[i..]);
let cand = table[h] as usize;
table[h] = (i + 1) as u32;
if cand != 0 {
let r = cand - 1;
let distance = i - r;
if distance <= MAX_OFFSET && input[r..r + 3] == input[i..i + 3] {
let max_len = (n - i).min(MAX_REF);
let mut len = 3;
while len < max_len && input[r + len] == input[i + len] {
len += 1;
}
flush_literals(&mut out, &input[lit_start..i]);
let code = len - 2;
let off = distance - 1;
if code < 7 {
out.push(((code << 5) | (off >> 8)) as u8);
} else {
out.push(((7 << 5) | (off >> 8)) as u8);
out.push((code - 7) as u8);
}
out.push((off & 0xff) as u8);
// Index the positions the match covered so later data can
// refer back into it.
let end = i + len;
let mut j = i + 1;
while j < end && j + 2 < n {
table[hash3(&input[j..])] = (j + 1) as u32;
j += 1;
}
i = end;
lit_start = i;
continue;
}
}
i += 1;
}
flush_literals(&mut out, &input[lit_start..]);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn round_trip(data: &[u8]) {
let c = lzf_compress(data);
assert_eq!(lzf_decompress(&c, data.len(), data.len()).unwrap(), data);
}
#[test]
fn round_trips() {
round_trip(b"");
round_trip(b"a");
round_trip(b"abcabcabcabcabcabcabcabcabcabcabcabc");
round_trip(&[7u8; 10_000]);
let noise: Vec<u8> = (0..70_000u32)
.map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8)
.collect();
round_trip(&noise);
let ramp: Vec<u8> = (0..100_000u32)
.flat_map(|i| (i % 1000).to_le_bytes())
.collect();
round_trip(&ramp);
}
#[test]
fn compresses_repetitive_data() {
let data = [42u8; 4096];
assert!(lzf_compress(&data).len() < 100);
}
/// The chunk h5py 3.16's bundled liblzf writes for
/// `b"hello hello hello hello"` (read back with `read_direct_chunk`): a
/// 7-byte literal, a 14-byte back reference 6 bytes back (extended
/// length), and a 2-byte literal.
#[test]
fn decodes_liblzf_output() {
let stream = b"\x06hello h\xe0\x05\x05\x01lo";
assert_eq!(
lzf_decompress(stream, 23, 23).unwrap(),
b"hello hello hello hello"
);
}
#[test]
fn rejects_corrupt_streams() {
// Back reference before the start.
assert!(lzf_decompress(&[0x20, 0x00], 10, 10).is_err());
// Literal run past the end.
assert!(lzf_decompress(&[0x05, 1, 2], 10, 10).is_err());
// Output over the limit.
let c = lzf_compress(&[1u8; 100]);
assert!(lzf_decompress(&c, 10, 99).is_err());
}
/// Random and mutated streams: errors are fine, panics are not.
#[test]
fn fuzzed_streams_never_panic() {
let seeds: Vec<Vec<u8>> = [
b"hello hello hello hello".to_vec(),
vec![7u8; 3000],
(0..2000u32).flat_map(|i| (i % 37).to_le_bytes()).collect(),
(0..500u32)
.map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8)
.collect(),
]
.iter()
.map(|d| lzf_compress(d))
.collect();
for limit in [0usize, 23, 4096, 8000] {
crate::test_fuzz::fuzz_decoder(
0x1f2 + limit as u64,
&seeds[..1],
5_000,
limit.max(23),
|s| lzf_decompress(s, limit, limit.max(23)),
);
}
crate::test_fuzz::fuzz_decoder(0x1f3, &seeds, 20_000, 8000, |s| {
lzf_decompress(s, 8000, 8000)
});
}
}
+174 -37
View File
@@ -1,19 +1,40 @@
//! SZIP (libaec Adaptive Entropy Coding) decompression. //! SZIP (libaec Adaptive Entropy Coding) decompression.
//! //!
//! Gated by the `szip` feature which links against the system libaec library. //! Gated by the `szip` feature which links against the system libaec library.
//!
//! libhdf5's SZIP filter (`H5Zszip.c`) prefixes each chunk with its
//! uncompressed size and hands the rest to szlib's `SZ_BufftoBuffDecompress`.
//! libaec implements that call (`sz_compat.c`) on top of `aec_buffer_decode`
//! with some reshaping — 32/64-bit samples are coded as byte planes of 8-bit
//! samples, and scanlines that are not a whole number of blocks are padded —
//! which [`szip_decompress`] reproduces so its output matches libhdf5's.
#[cfg(not(feature = "std"))] #[cfg(not(feature = "std"))]
use alloc::vec::Vec; use alloc::vec::Vec;
use crate::error::FormatError; use crate::error::FormatError;
/// Decompress SZIP-compressed data using libaec. /// `SZ_MSB_OPTION_MASK`: samples are big-endian.
#[cfg(feature = "szip")]
const SZ_MSB_OPTION_MASK: u32 = 16;
/// `SZ_NN_OPTION_MASK`: nearest-neighbour preprocessing.
#[cfg(feature = "szip")]
const SZ_NN_OPTION_MASK: u32 = 32;
/// Decompress one SZIP-filtered chunk.
/// ///
/// `cd` is the HDF5 SZIP filter client data (matches `H5Z_SZIP_PARM_*` indices): /// `cd` is the HDF5 SZIP filter client data (`H5Z_SZIP_PARM_*` indices):
/// cd[0] = options mask (`H5_SZIP_NN_OPTION_MASK = 0x20` enables NN preprocessing) /// cd[0] = options mask (`SZ_*_OPTION_MASK`: 16 = MSB byte order,
/// cd[1] = pixels per block (H5Z_SZIP_PARM_PPB; 8, 10, 16, or 32) /// 32 = nearest-neighbour preprocessing; K13/EC/LSB/RAW bits carry
/// cd[2] = bits per sample (H5Z_SZIP_PARM_BPP; element bit width) /// no decoding information for libaec)
/// cd[3] = pixels per scan line (H5Z_SZIP_PARM_PPS; informational only) /// cd[1] = pixels per block
/// cd[2] = bits per pixel (sample precision, rounded up to 32 or 64 above
/// 24 by libhdf5)
/// cd[3] = pixels per scanline
///
/// The chunk is a 4-byte little-endian uncompressed size followed by the
/// szlib stream.
#[cfg_attr(not(feature = "szip"), allow(dead_code))]
pub(crate) fn szip_decompress( pub(crate) fn szip_decompress(
_data: &[u8], _data: &[u8],
_cd: &[u32], _cd: &[u32],
@@ -33,62 +54,174 @@ pub(crate) fn szip_decompress(
#[cfg(feature = "szip")] #[cfg(feature = "szip")]
fn szip_decode_impl(data: &[u8], cd: &[u32], chunk_size: usize) -> Result<Vec<u8>, FormatError> { fn szip_decode_impl(data: &[u8], cd: &[u32], chunk_size: usize) -> Result<Vec<u8>, FormatError> {
if cd.len() < 3 { let err = |m: &str| FormatError::ChunkedReadError(format!("szip: {m}"));
return Err(FormatError::ChunkedReadError( if cd.len() < 4 {
"szip: missing client data".into(), return Err(err("missing client data"));
));
} }
let options = cd[0]; let options = cd[0];
let pixels_per_block = cd[1]; let pixels_per_block = cd[1] as usize;
let bits_per_sample = cd[2]; // H5Z_SZIP_PARM_BPP let bits_per_pixel = cd[2];
if bits_per_sample == 0 || bits_per_sample > 32 { let pixels_per_scanline = cd[3] as usize;
return Err(FormatError::ChunkedReadError( if !(1..=32).contains(&bits_per_pixel) && bits_per_pixel != 64 {
"szip: invalid bits per sample".into(), return Err(err("invalid bits per sample"));
));
} }
if chunk_size == 0 { if pixels_per_block == 0 || pixels_per_scanline == 0 {
return Err(FormatError::ChunkedReadError( return Err(err("invalid block or scanline size"));
"szip: unknown output size".into(),
));
} }
if data.is_empty() { if data.len() < 4 {
return Err(FormatError::ChunkedReadError("szip: empty input".into())); return Err(err("chunk too short"));
} }
// H5Zszip.c: UINT32DECODE of the uncompressed size, then the stream.
let dest_len = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize;
let limit = if chunk_size != 0 {
chunk_size
} else {
crate::filters::MAX_DECOMPRESS_SIZE
};
if dest_len > limit {
return Err(err("declared size exceeds chunk size"));
}
let stream = &data[4..];
// Map HDF5 option mask to libaec flags. // --- libaec sz_compat.c: SZ_BufftoBuffDecompress ---
// HDF5 always stores SZIP data in MSB order, so AEC_DATA_MSB is unconditional. let rsi = pixels_per_scanline.div_ceil(pixels_per_block);
// H5_SZIP_NN_OPTION_MASK (0x20): NN differential preprocessing. let mut flags = 0;
let mut flags: u32 = libaec_sys::AEC_DATA_MSB; if options & SZ_MSB_OPTION_MASK != 0 {
if options & 0x20 != 0 { flags |= libaec_sys::AEC_DATA_MSB;
}
if options & SZ_NN_OPTION_MASK != 0 {
flags |= libaec_sys::AEC_DATA_PREPROCESS; flags |= libaec_sys::AEC_DATA_PREPROCESS;
} }
let pad_scanline = !pixels_per_scanline.is_multiple_of(pixels_per_block);
let deinterleave = bits_per_pixel == 32 || bits_per_pixel == 64;
let bits_per_sample = if deinterleave { 8 } else { bits_per_pixel };
let pixel_size = match bits_per_sample {
17.. => 4,
9.. => 2,
_ => 1,
};
let scanlines = (dest_len / pixel_size).div_ceil(pixels_per_scanline);
let buf_size = if pad_scanline {
rsi.checked_mul(pixels_per_block)
.and_then(|n| n.checked_mul(pixel_size))
.and_then(|n| n.checked_mul(scanlines))
.filter(|&n| n <= crate::filters::MAX_DECOMPRESS_SIZE.max(limit))
.ok_or_else(|| err("scanline padding too large"))?
} else {
dest_len
};
let mut out = vec![0u8; chunk_size]; let mut buf = vec![0u8; buf_size];
let mut strm = libaec_sys::AecStream::zeroed(); let mut strm = libaec_sys::AecStream::zeroed();
strm.next_in = data.as_ptr(); strm.next_in = stream.as_ptr();
strm.avail_in = data.len(); strm.avail_in = stream.len();
strm.next_out = out.as_mut_ptr(); strm.next_out = buf.as_mut_ptr();
strm.avail_out = chunk_size; strm.avail_out = buf_size;
strm.bits_per_sample = bits_per_sample; strm.bits_per_sample = bits_per_sample;
strm.block_size = pixels_per_block; strm.block_size = pixels_per_block as u32;
strm.rsi = 128; // HDF5 default: 128 blocks per reference sample interval strm.rsi = rsi as u32;
strm.flags = flags; strm.flags = flags;
// SAFETY: next_in/avail_in and next_out/avail_out describe live buffers
// (`stream` and `buf`) that outlive the call.
let result = unsafe { libaec_sys::aec_buffer_decode(&mut strm) }; let result = unsafe { libaec_sys::aec_buffer_decode(&mut strm) };
if result != 0 { if result != 0 {
return Err(FormatError::DecompressionError(format!( return Err(FormatError::DecompressionError(format!(
"szip: libaec error {result}" "szip: libaec error {result}"
))); )));
} }
let decoded_len = chunk_size - strm.avail_out; let mut total_out = strm.total_out;
out.truncate(decoded_len); if pad_scanline {
let line = pixels_per_scanline * pixel_size;
let padded_line = rsi * pixels_per_block * pixel_size;
// remove_padding: compact each padded line down to `line` bytes.
let mut i = line;
let mut j = padded_line;
while j < total_out {
let end = (j + line).min(buf.len());
buf.copy_within(j..end, i);
i += line;
j += padded_line;
}
total_out = scanlines * line;
}
if total_out < dest_len {
return Err(err("stream decoded to fewer bytes than declared"));
}
buf.truncate(dest_len);
if deinterleave {
// deinterleave_buffer: byte planes back into words.
let w = (bits_per_pixel / 8) as usize;
let n = dest_len / w;
let mut out = vec![0u8; dest_len];
for i in 0..n {
for j in 0..w {
out[i * w + j] = buf[j * n + i];
}
}
Ok(out) Ok(out)
} else {
Ok(buf)
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[cfg(feature = "szip")]
fn unhex(s: &str) -> Vec<u8> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
.collect()
}
/// SZIP chunks written by libhdf5, decoded exactly as libhdf5 decodes
/// them. Each case: fixture, chunk byte offset and size (from h5py's
/// `get_chunk_info`), the filter's cd_values, and the chunk's values as
/// h5py reads them (file byte order, hex). Before the fix every one of
/// these came back as garbage or zeros (or "invalid bits per sample" for
/// 64-bit): the 4-byte size prefix was fed to libaec, 32/64-bit samples
/// were not de-interleaved from byte planes, the reference sample
/// interval was fixed at 128 instead of derived from the scanline, padded
/// scanlines were not unpadded, and LE data was decoded as MSB.
#[cfg(feature = "szip")]
#[test]
fn szip_decodes_libhdf5_chunks_exactly() {
/// (name, file, chunk offset, chunk size, cd_values, decoded hex)
type Case<'a> = (&'a str, &'a [u8], usize, usize, [u32; 4], &'a str);
let noencoder: &[u8] = include_bytes!("../tests/fixtures/filters/noencoder.h5");
let le_data: &[u8] = include_bytes!("../tests/fixtures/filters/le_data.h5");
let h5py: &[u8] = include_bytes!("../tests/fixtures/filters/szip_h5py.h5");
#[rustfmt::skip]
let cases: &[Case] = &[
// <i4, 10 px/scanline over 4 px/block: padded scanlines + byte planes.
("noencoder /noencoder_szip_dset.h5", noencoder, 6040, 16, [168, 4, 32, 10],
"00000000010000000200000003000000040000000500000006000000070000000800000009000000"),
// <f4, LSB + NN.
("le_data /Szip_float_data_le", le_data, 55224, 48, [169, 4, 32, 12],
"abaaaa3eabaa2a3f0000803fabaa2a3f0000803fabaaaa3f0000803fabaaaa3f5555d53fabaaaa3f5555d53f00000040"),
// >f4, MSB + NN.
("le_data /Szip_float_data_be", le_data, 55396, 48, [177, 4, 32, 12],
"3eaaaaab3f2aaaab3f8000003f2aaaab3f8000003faaaaab3f8000003faaaaab3fd555553faaaaab3fd5555540000000"),
// <f8 (64-bit), NN.
("szip_h5py /f8", h5py, 4016, 100, [169, 8, 64, 10],
"00000000000008c000000000000008c000000000000008c000000000000008c000000000000004c000000000000004c000000000000004c000000000000004c000000000000000c000000000000000c000000000000000c000000000000000c0000000000000f8bf000000000000f8bf000000000000f8bf000000000000f8bf000000000000f0bf000000000000f0bf000000000000f0bf000000000000f0bf000000000000e0bf000000000000e0bf000000000000e0bf000000000000e0bf0000000000000000000000000000000000000000000000000000000000000000000000000000e03f000000000000e03f000000000000e03f000000000000e03f000000000000f03f000000000000f03f000000000000f03f000000000000f03f000000000000f83f000000000000f83f000000000000f83f000000000000f83f"),
// <i8 (64-bit), entropy coding without NN.
("szip_h5py /i8", h5py, 4188, 53, [141, 4, 64, 10],
"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000030000000000000003000000000000000300000000000000030000000000000003000000000000000300000000000000030000000000000006000000000000000600000000000000060000000000000006000000000000000600000000000000060000000000000006000000000000000600000000000000090000000000000009000000000000000900000000000000090000000000000009000000000000000900000000000000090000000000000009000000000000000c000000000000000c000000000000000c000000000000000c000000000000000c000000000000000c000000000000000c000000000000000c00000000000000"),
// <u2, 35 px/scanline over 8 px/block: padded scanlines, 16-bit samples.
("szip_h5py /u2", h5py, 4308, 43, [169, 8, 16, 35],
"00000000000000006100610061006100c200c200c200c20023012301230123018401840184018401e501e501e501e5014602460246024602a702a702a702a702080308030803"),
];
for (name, file, off, len, cd, want) in cases {
let want = unhex(want);
let got = szip_decompress(&file[*off..off + len], cd, want.len())
.unwrap_or_else(|e| panic!("{name}: {e:?}"));
assert_eq!(got, want, "{name}");
}
}
#[test] #[test]
fn szip_disabled_returns_unsupported() { fn szip_disabled_returns_unsupported() {
#[cfg(not(feature = "szip"))] #[cfg(not(feature = "szip"))]
@@ -132,6 +265,8 @@ mod tests {
assert_eq!(rc, 0, "aec_buffer_encode failed: {rc}"); assert_eq!(rc, 0, "aec_buffer_encode failed: {rc}");
let enc_len = encoded.len() - enc.avail_out; let enc_len = encoded.len() - enc.avail_out;
encoded.truncate(enc_len); encoded.truncate(enc_len);
// H5Zszip.c prefixes the stream with the uncompressed size.
encoded.splice(0..0, (original.len() as u32).to_le_bytes());
// Decode through our public interface. // Decode through our public interface.
// cd[0]=0 (no NN bit 0x20), cd[1]=8 (ppb), cd[2]=8 (bpp), cd[3]=1024 (pps). // cd[0]=0 (no NN bit 0x20), cd[1]=8 (ppb), cd[2]=8 (bpp), cd[3]=1024 (pps).
@@ -163,6 +298,8 @@ mod tests {
assert_eq!(rc, 0, "aec_buffer_encode with NN failed: {rc}"); assert_eq!(rc, 0, "aec_buffer_encode with NN failed: {rc}");
let enc_len = encoded.len() - enc.avail_out; let enc_len = encoded.len() - enc.avail_out;
encoded.truncate(enc_len); encoded.truncate(enc_len);
// H5Zszip.c prefixes the stream with the uncompressed size.
encoded.splice(0..0, (original.len() as u32).to_le_bytes());
// cd[0] = 0x20 (H5_SZIP_NN_OPTION_MASK) → decoder must set AEC_DATA_PREPROCESS. // cd[0] = 0x20 (H5_SZIP_NN_OPTION_MASK) → decoder must set AEC_DATA_PREPROCESS.
let cd = [0x20u32, 8, 8, 1024]; let cd = [0x20u32, 8, 8, 1024];
+28 -73
View File
@@ -6,6 +6,7 @@ extern crate alloc;
#[cfg(not(feature = "std"))] #[cfg(not(feature = "std"))]
use alloc::{format, vec, vec::Vec}; use alloc::{format, vec, vec::Vec};
use crate::chunk_grid::ChunkGrid;
use crate::chunked_read::ChunkInfo; use crate::chunked_read::ChunkInfo;
use crate::error::FormatError; use crate::error::FormatError;
@@ -151,13 +152,13 @@ pub fn read_fixed_array_chunks(
file_data: &[u8], file_data: &[u8],
header: &FixedArrayHeader, header: &FixedArrayHeader,
dataset_dims: &[u64], dataset_dims: &[u64],
max_dims: Option<&[u64]>,
chunk_dimensions: &[u32], chunk_dimensions: &[u32],
element_size: u32, element_size: u32,
offset_size: u8, offset_size: u8,
_length_size: u8, _length_size: u8,
) -> Result<Vec<ChunkInfo>, FormatError> { ) -> Result<Vec<ChunkInfo>, FormatError> {
let db_offset = header.data_block_address as usize; let db_offset = header.data_block_address as usize;
let rank = chunk_dimensions.len();
// Parse data block header: FADB(4) + version(1) + client_id(1) + header_address(offset_size) // Parse data block header: FADB(4) + version(1) + client_id(1) + header_address(offset_size)
let db_header_size = 4 + 1 + 1 + offset_size as usize; let db_header_size = 4 + 1 + 1 + offset_size as usize;
@@ -198,19 +199,10 @@ pub fn read_fixed_array_chunks(
)) ))
}; };
// Compute chunk offsets based on index. // The index is laid out over the chunk grid of the *maximum* dimensions
// Chunks are stored in row-major order within the dataset space. // (row-major), so a dataset smaller than its maxshape has gaps.
let mut num_chunks_per_dim = Vec::with_capacity(rank); let dims_u64: Vec<u64> = chunk_dimensions.iter().map(|&d| d as u64).collect();
for d_idx in 0..rank { let grid = ChunkGrid::fixed_array(dataset_dims, max_dims, &dims_u64)?;
let ch_dim = chunk_dimensions[d_idx] as u64;
if ch_dim == 0 {
return Err(FormatError::ChunkedReadError(
"chunk dimension is zero".into(),
));
}
let ds_dim = dataset_dims[d_idx];
num_chunks_per_dim.push(ds_dim.div_ceil(ch_dim));
}
let chunk_byte_size: u64 = let chunk_byte_size: u64 =
chunk_dimensions.iter().map(|&d| d as u64).product::<u64>() * element_size as u64; chunk_dimensions.iter().map(|&d| d as u64).product::<u64>() * element_size as u64;
@@ -226,7 +218,11 @@ pub fn read_fixed_array_chunks(
header.element_size, header.element_size,
chunk_byte_size, chunk_byte_size,
)? { )? {
let offsets = index_to_chunk_offsets(i, &num_chunks_per_dim, chunk_dimensions); // A slot beyond the current extent is ignored, as the
// library does.
let Some(offsets) = grid.offsets(i as u64) else {
return Ok(());
};
chunks.push(ChunkInfo { chunks.push(ChunkInfo {
chunk_size, chunk_size,
filter_mask, filter_mask,
@@ -367,27 +363,6 @@ fn parse_fa_element(
} }
} }
/// Convert a linear chunk index to N-dimensional chunk offsets in dataset space.
fn index_to_chunk_offsets(
index: usize,
num_chunks_per_dim: &[u64],
chunk_dimensions: &[u32],
) -> Vec<u64> {
let rank = num_chunks_per_dim.len();
let mut offsets = vec![0u64; rank];
let mut remaining = index as u64;
for d in (0..rank).rev() {
let nchunks = num_chunks_per_dim[d];
if nchunks == 0 {
continue;
}
let chunk_idx = remaining % nchunks;
remaining /= nchunks;
offsets[d] = chunk_idx * chunk_dimensions[d] as u64;
}
offsets
}
/// Read a variable-length little-endian unsigned integer. /// Read a variable-length little-endian unsigned integer.
fn read_variable_length(data: &[u8], size: usize) -> Result<u64, FormatError> { fn read_variable_length(data: &[u8], size: usize) -> Result<u64, FormatError> {
if size > 8 || data.len() < size { if size > 8 || data.len() < size {
@@ -416,44 +391,21 @@ mod tests {
#[test] #[test]
fn index_to_offsets_1d() { fn index_to_offsets_1d() {
let num_chunks = vec![5u64]; let g = ChunkGrid::fixed_array(&[100], None, &[20]).unwrap();
let chunk_dims = vec![20u32]; assert_eq!(g.offsets(0).unwrap(), vec![0]);
assert_eq!(index_to_chunk_offsets(0, &num_chunks, &chunk_dims), vec![0]); assert_eq!(g.offsets(1).unwrap(), vec![20]);
assert_eq!( assert_eq!(g.offsets(4).unwrap(), vec![80]);
index_to_chunk_offsets(1, &num_chunks, &chunk_dims),
vec![20]
);
assert_eq!(
index_to_chunk_offsets(4, &num_chunks, &chunk_dims),
vec![80]
);
} }
#[test] #[test]
fn index_to_offsets_2d() { fn index_to_offsets_2d() {
// 10x6 dataset with 4x3 chunks => ceil(10/4)=3, ceil(6/3)=2 => 6 chunks // 10x6 dataset with 4x3 chunks => ceil(10/4)=3, ceil(6/3)=2 => 6 chunks
let num_chunks = vec![3u64, 2]; let g = ChunkGrid::fixed_array(&[10, 6], None, &[4, 3]).unwrap();
let chunk_dims = vec![4u32, 3]; assert_eq!(g.offsets(0).unwrap(), vec![0, 0]);
assert_eq!( assert_eq!(g.offsets(1).unwrap(), vec![0, 3]);
index_to_chunk_offsets(0, &num_chunks, &chunk_dims), assert_eq!(g.offsets(2).unwrap(), vec![4, 0]);
vec![0, 0] assert_eq!(g.offsets(3).unwrap(), vec![4, 3]);
); assert_eq!(g.offsets(5).unwrap(), vec![8, 3]);
assert_eq!(
index_to_chunk_offsets(1, &num_chunks, &chunk_dims),
vec![0, 3]
);
assert_eq!(
index_to_chunk_offsets(2, &num_chunks, &chunk_dims),
vec![4, 0]
);
assert_eq!(
index_to_chunk_offsets(3, &num_chunks, &chunk_dims),
vec![4, 3]
);
assert_eq!(
index_to_chunk_offsets(5, &num_chunks, &chunk_dims),
vec![8, 3]
);
} }
#[test] #[test]
@@ -517,7 +469,7 @@ mod tests {
let read = |f: &[u8], fahd: usize| -> Result<Vec<ChunkInfo>, FormatError> { let read = |f: &[u8], fahd: usize| -> Result<Vec<ChunkInfo>, FormatError> {
let h = FixedArrayHeader::parse(f, fahd, 8, 8)?; let h = FixedArrayHeader::parse(f, fahd, 8, 8)?;
read_fixed_array_chunks(f, &h, &[60], &[20], 8, 8, 8) read_fixed_array_chunks(f, &h, &[60], None, &[20], 8, 8, 8)
}; };
let (clean, fahd) = build(); let (clean, fahd) = build();
@@ -562,7 +514,7 @@ mod tests {
let db = 0x100usize; let db = 0x100usize;
buf[db..db + 4].copy_from_slice(b"FADB"); buf[db..db + 4].copy_from_slice(b"FADB");
let header = FixedArrayHeader::parse(&buf, fahd, 8, 8).unwrap(); let header = FixedArrayHeader::parse(&buf, fahd, 8, 8).unwrap();
let r = read_fixed_array_chunks(&buf, &header, &[100], &[20], 8, 8, 8); let r = read_fixed_array_chunks(&buf, &header, &[100], None, &[20], 8, 8, 8);
assert!(r.is_err()); assert!(r.is_err());
} }
@@ -579,7 +531,7 @@ mod tests {
stamp_checksum(&mut buf, fahd, fahd + 24); stamp_checksum(&mut buf, fahd, fahd + 24);
buf[0x80..0x84].copy_from_slice(b"FADB"); buf[0x80..0x84].copy_from_slice(b"FADB");
let header = FixedArrayHeader::parse(&buf, fahd, 8, 8).unwrap(); let header = FixedArrayHeader::parse(&buf, fahd, 8, 8).unwrap();
let r = read_fixed_array_chunks(&buf, &header, &[100], &[20], 8, 8, 8); let r = read_fixed_array_chunks(&buf, &header, &[100], None, &[20], 8, 8, 8);
assert!(r.is_err()); assert!(r.is_err());
} }
@@ -602,7 +554,7 @@ mod tests {
data_block_address: (usize::MAX - 4) as u64, data_block_address: (usize::MAX - 4) as u64,
}; };
let buf = vec![0u8; 64]; let buf = vec![0u8; 64];
let r = read_fixed_array_chunks(&buf, &header, &[100], &[20], 8, 8, 8); let r = read_fixed_array_chunks(&buf, &header, &[100], None, &[20], 8, 8, 8);
assert!(r.is_err()); assert!(r.is_err());
} }
@@ -664,6 +616,7 @@ mod tests {
&file_data, &file_data,
&header, &header,
&ds_dims, &ds_dims,
None,
&chunk_dims, &chunk_dims,
8, 8,
offset_size, offset_size,
@@ -740,6 +693,7 @@ mod tests {
&file_data, &file_data,
&header, &header,
&ds_dims, &ds_dims,
None,
&chunk_dims, &chunk_dims,
8, 8,
offset_size, offset_size,
@@ -840,6 +794,7 @@ mod tests {
&file_data, &file_data,
&header, &header,
&ds_dims, &ds_dims,
None,
&chunk_dims, &chunk_dims,
8, 8,
offset_size, offset_size,
+400 -86
View File
@@ -1,12 +1,14 @@
//! HDF5 Fractal Heap parsing for v2 group link storage. //! HDF5 Fractal Heap parsing for v2 group link storage.
#[cfg(not(feature = "std"))] #[cfg(not(feature = "std"))]
use alloc::vec::Vec; use alloc::{format, vec::Vec};
#[cfg(feature = "checksum")] #[cfg(feature = "checksum")]
use byteorder::{ByteOrder, LittleEndian}; use byteorder::{ByteOrder, LittleEndian};
use crate::btree_v2::{BTreeV2Header, collect_btree_v2_records};
use crate::error::FormatError; use crate::error::FormatError;
use crate::filter_pipeline::FilterPipeline;
/// Parsed fractal heap header (signature "FRHP"). /// Parsed fractal heap header (signature "FRHP").
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -33,6 +35,23 @@ pub struct FractalHeapHeader {
pub current_rows_in_root_indirect_block: u16, pub current_rows_in_root_indirect_block: u16,
/// Total number of managed objects. /// Total number of managed objects.
pub managed_objects_count: u64, pub managed_objects_count: u64,
/// Address of the v2 B-tree indexing "huge" objects (undefined address
/// when the heap has none). Huge objects are those larger than
/// `max_managed_object_size`; they live outside the heap's blocks.
pub huge_btree_address: u64,
/// The heap's I/O filter pipeline, if it has one. It applies to managed
/// direct blocks and to huge objects.
pub filter_pipeline: Option<FilterPipeline>,
/// Stored (filtered) size of the root direct block; meaningful only when
/// the heap is filtered and its root is a direct block.
pub root_direct_block_filtered_size: u64,
/// Filter mask of the root direct block (bit *i* set = filter *i*
/// skipped); meaningful only when the heap is filtered.
pub root_direct_block_filter_mask: u32,
/// Size of addresses in the file ("Size of Offsets").
pub offset_size: u8,
/// Size of lengths in the file ("Size of Lengths").
pub length_size: u8,
} }
fn read_offset(data: &[u8], pos: usize, size: u8) -> Result<u64, FormatError> { fn read_offset(data: &[u8], pos: usize, size: u8) -> Result<u64, FormatError> {
@@ -79,6 +98,38 @@ fn is_undefined(val: u64, offset_size: u8) -> bool {
} }
} }
/// Little-endian unsigned integer of up to 8 bytes.
fn le_uint(bytes: &[u8]) -> u64 {
bytes
.iter()
.take(8)
.enumerate()
.fold(0u64, |acc, (i, &b)| acc | (u64::from(b) << (i * 8)))
}
fn heap_error(msg: &str) -> FormatError {
FormatError::ChunkedReadError(format!("fractal heap: {msg}"))
}
/// Heap ID type, from bits 4-5 of an ID's first byte (libhdf5's
/// `H5HF_ID_TYPE_MASK`, 0x30); bits 6-7 are the ID version, which must be 0.
const HEAP_ID_MANAGED: u8 = 0;
const HEAP_ID_HUGE: u8 = 1;
const HEAP_ID_TINY: u8 = 2;
/// The type (0 managed, 1 huge, 2 tiny) of a heap ID from its first byte,
/// refusing an ID version other than 0.
fn heap_id_type(first: u8) -> Result<u8, FormatError> {
if first >> 6 != 0 {
return Err(heap_error("unsupported heap ID version"));
}
Ok((first >> 4) & 0x03)
}
/// v2 B-tree record types indexing a heap's huge objects.
const BTREE_HUGE_INDIRECT: u8 = 1;
const BTREE_HUGE_INDIRECT_FILTERED: u8 = 2;
impl FractalHeapHeader { impl FractalHeapHeader {
/// Parse a fractal heap header at the given offset. /// Parse a fractal heap header at the given offset.
pub fn parse( pub fn parse(
@@ -122,11 +173,17 @@ impl FractalHeapHeader {
]); ]);
pos += 4; pos += 4;
// Skip several fixed fields: next_huge_object_id(ls), btree_huge_objects_address(os), // next_huge_object_id (length_size)
// free_space_managed_blocks(ls), managed_block_free_space_manager_address(os), ensure_len(file_data, pos, ls)?;
pos += ls;
// btree_huge_objects_address (offset_size)
let huge_btree_address = read_offset(file_data, pos, offset_size)?;
pos += os;
// Skip: free_space_managed_blocks(ls), managed_block_free_space_manager_address(os),
// managed_space_in_heap(ls), allocated_managed_space_in_heap(ls), // managed_space_in_heap(ls), allocated_managed_space_in_heap(ls),
// direct_block_allocation_iterator_offset(ls) // direct_block_allocation_iterator_offset(ls)
let skip_size = 5 * ls + 2 * os; let skip_size = 4 * ls + os;
ensure_len(file_data, pos, skip_size)?; ensure_len(file_data, pos, skip_size)?;
pos += skip_size; pos += skip_size;
@@ -134,14 +191,9 @@ impl FractalHeapHeader {
let managed_objects_count = read_offset(file_data, pos, length_size)?; let managed_objects_count = read_offset(file_data, pos, length_size)?;
pos += ls; pos += ls;
// huge_objects_size (length_size) // huge_objects_size, huge_objects_count, tiny_objects_size,
pos += ls; // tiny_objects_count (length_size each)
// huge_objects_count (length_size) pos += 4 * ls;
pos += ls;
// tiny_objects_size (length_size)
pos += ls;
// tiny_objects_count (length_size)
pos += ls;
// table_width (2) // table_width (2)
ensure_len(file_data, pos, 2)?; ensure_len(file_data, pos, 2)?;
@@ -175,16 +227,28 @@ impl FractalHeapHeader {
ensure_len(file_data, pos, 2)?; ensure_len(file_data, pos, 2)?;
let current_rows_in_root_indirect_block = let current_rows_in_root_indirect_block =
u16::from_le_bytes([file_data[pos], file_data[pos + 1]]); u16::from_le_bytes([file_data[pos], file_data[pos + 1]]);
#[allow(unused_variables, unused_mut, unused_assignments)] pos += 2;
let mut pos = pos + 2;
// Skip IO filter encoded info if present // With I/O filters: root direct block's filtered size (length_size),
// its filter mask (4), then the encoded filter pipeline message.
let mut filter_pipeline = None;
let mut root_direct_block_filtered_size = 0;
let mut root_direct_block_filter_mask = 0;
if io_filter_encoded_length > 0 { if io_filter_encoded_length > 0 {
// root_block_filter_info_size (length_size) + filter_mask (4) root_direct_block_filtered_size = read_offset(file_data, pos, length_size)?;
#[allow(unused_assignments)] pos += ls;
{ ensure_len(file_data, pos, 4)?;
pos += ls + 4; root_direct_block_filter_mask = u32::from_le_bytes([
} file_data[pos],
file_data[pos + 1],
file_data[pos + 2],
file_data[pos + 3],
]);
pos += 4;
let n = io_filter_encoded_length as usize;
ensure_len(file_data, pos, n)?;
filter_pipeline = Some(FilterPipeline::parse(&file_data[pos..pos + n])?);
pos += n;
} }
// Validate header checksum // Validate header checksum
@@ -200,6 +264,8 @@ impl FractalHeapHeader {
}); });
} }
} }
#[cfg(not(feature = "checksum"))]
let _ = pos;
Ok(FractalHeapHeader { Ok(FractalHeapHeader {
heap_id_length, heap_id_length,
@@ -213,13 +279,19 @@ impl FractalHeapHeader {
root_block_address, root_block_address,
current_rows_in_root_indirect_block, current_rows_in_root_indirect_block,
managed_objects_count, managed_objects_count,
huge_btree_address,
filter_pipeline,
root_direct_block_filtered_size,
root_direct_block_filter_mask,
offset_size,
length_size,
}) })
} }
/// Decode a managed heap ID into (offset_in_heap, object_length). /// Decode a managed heap ID into (offset_in_heap, object_length).
/// ///
/// The heap ID layout for managed objects (type 0): /// The heap ID layout for managed objects (type 0):
/// - Byte 0: bits 6-7 = type (0), bits 4-5 = version (0), bits 0-3 = reserved /// - Byte 0: bits 6-7 = version (0), bits 4-5 = type (0), bits 0-3 = reserved
/// - Bytes 1+: offset (max_heap_size bits, LE) then length (remaining bits, LE) /// - Bytes 1+: offset (max_heap_size bits, LE) then length (remaining bits, LE)
pub fn decode_managed_id(&self, id_bytes: &[u8]) -> Result<(u64, u64), FormatError> { pub fn decode_managed_id(&self, id_bytes: &[u8]) -> Result<(u64, u64), FormatError> {
if id_bytes.is_empty() { if id_bytes.is_empty() {
@@ -229,8 +301,8 @@ impl FractalHeapHeader {
}); });
} }
let id_type = (id_bytes[0] >> 6) & 0x03; let id_type = heap_id_type(id_bytes[0])?;
if id_type != 0 { if id_type != HEAP_ID_MANAGED {
return Err(FormatError::InvalidHeapIdType(id_type)); return Err(FormatError::InvalidHeapIdType(id_type));
} }
@@ -269,12 +341,183 @@ impl FractalHeapHeader {
Ok((heap_offset, length_val)) Ok((heap_offset, length_val))
} }
/// Read a managed object from the heap given its raw heap ID bytes. /// Read any object from the heap given its raw heap ID bytes: managed
/// (stored in the heap's blocks), huge (stored outside them, found
/// directly from the ID or through the huge-object v2 B-tree, optionally
/// filtered) or tiny (stored in the ID itself).
///
/// Despite its name this accepts every ID type; `offset_size` must match
/// the one the header was parsed with.
pub fn read_managed_object( pub fn read_managed_object(
&self, &self,
file_data: &[u8], file_data: &[u8],
id_bytes: &[u8], id_bytes: &[u8],
offset_size: u8, offset_size: u8,
) -> Result<Vec<u8>, FormatError> {
let Some(&first) = id_bytes.first() else {
return Err(FormatError::UnexpectedEof {
expected: 1,
available: 0,
});
};
match heap_id_type(first)? {
HEAP_ID_MANAGED => self.read_heap_managed(file_data, id_bytes, offset_size),
HEAP_ID_HUGE => self.read_huge_object(file_data, id_bytes),
HEAP_ID_TINY => self.read_tiny_object(id_bytes),
other => Err(FormatError::InvalidHeapIdType(other)),
}
}
/// Whether a huge object's ID holds its address and length directly
/// (libhdf5 does this when they fit in the ID), rather than a key into
/// the huge-object B-tree.
fn huge_ids_direct(&self) -> bool {
let room = usize::from(self.heap_id_length).saturating_sub(1);
let os = usize::from(self.offset_size);
let ls = usize::from(self.length_size);
if self.filter_pipeline.is_some() {
room >= os + ls + 4 + ls
} else {
room >= os + ls
}
}
/// Read a huge object (heap ID type 1).
fn read_huge_object(&self, file_data: &[u8], id: &[u8]) -> Result<Vec<u8>, FormatError> {
let os = usize::from(self.offset_size);
let ls = usize::from(self.length_size);
// (address, stored length, filter mask, decoded length); the last two
// only matter for a filtered heap.
let (addr, stored_len, mask, mem_len) = if self.huge_ids_direct() {
let body = &id[1..];
let need = if self.filter_pipeline.is_some() {
os + ls + 4 + ls
} else {
os + ls
};
ensure_len(body, 0, need)?;
let addr = le_uint(&body[..os]);
let len = le_uint(&body[os..os + ls]);
if self.filter_pipeline.is_some() {
let mask = u32::from_le_bytes([
body[os + ls],
body[os + ls + 1],
body[os + ls + 2],
body[os + ls + 3],
]);
let mem = le_uint(&body[os + ls + 4..os + ls + 4 + ls]);
(addr, len, mask, mem)
} else {
(addr, len, 0, len)
}
} else {
let key_len = (usize::from(self.heap_id_length).saturating_sub(1)).min(8);
ensure_len(id, 1, key_len)?;
let key = le_uint(&id[1..1 + key_len]);
self.find_huge_record(file_data, key)?
};
let start = usize::try_from(addr).map_err(|_| heap_error("huge object address"))?;
let len = usize::try_from(stored_len).map_err(|_| heap_error("huge object length"))?;
ensure_len(file_data, start, len)?;
let stored = &file_data[start..start + len];
match &self.filter_pipeline {
None => Ok(stored.to_vec()),
Some(pipeline) => {
let mem = usize::try_from(mem_len).map_err(|_| heap_error("huge object size"))?;
let out = crate::filters::decompress_chunk_masked(stored, pipeline, mem, 1, mask)?;
if out.len() != mem {
return Err(heap_error("filtered huge object decoded to the wrong size"));
}
Ok(out)
}
}
}
/// Look up huge object `key` in the huge-object v2 B-tree, returning
/// (address, stored length, filter mask, decoded length).
fn find_huge_record(
&self,
file_data: &[u8],
key: u64,
) -> Result<(u64, u64, u32, u64), FormatError> {
if is_undefined(self.huge_btree_address, self.offset_size) {
return Err(heap_error(
"huge object ID but the heap has no huge-object index",
));
}
let hdr = BTreeV2Header::parse(
file_data,
self.huge_btree_address as usize,
self.offset_size,
self.length_size,
)?;
let os = usize::from(self.offset_size);
let ls = usize::from(self.length_size);
let filtered = self.filter_pipeline.is_some();
let (expected_type, rec_len) = if filtered {
(BTREE_HUGE_INDIRECT_FILTERED, os + ls + 4 + ls + ls)
} else {
(BTREE_HUGE_INDIRECT, os + ls + ls)
};
if hdr.tree_type != expected_type || usize::from(hdr.record_size) < rec_len {
return Err(heap_error("unexpected huge-object B-tree record type"));
}
let records =
collect_btree_v2_records(file_data, &hdr, self.offset_size, self.length_size)?;
for rec in &records {
let d = &rec.data;
if d.len() < rec_len {
continue;
}
let addr = le_uint(&d[..os]);
let len = le_uint(&d[os..os + ls]);
if filtered {
let mask = u32::from_le_bytes([
d[os + ls],
d[os + ls + 1],
d[os + ls + 2],
d[os + ls + 3],
]);
let mem = le_uint(&d[os + ls + 4..os + 2 * ls + 4]);
let id = le_uint(&d[os + 2 * ls + 4..os + 3 * ls + 4]);
if id == key {
return Ok((addr, len, mask, mem));
}
} else {
let id = le_uint(&d[os + ls..os + 2 * ls]);
if id == key {
return Ok((addr, len, 0, len));
}
}
}
Err(heap_error("huge object not found in its B-tree"))
}
/// Read a tiny object (heap ID type 2), stored in the ID itself.
fn read_tiny_object(&self, id: &[u8]) -> Result<Vec<u8>, FormatError> {
// libhdf5 uses a one-byte length (low 4 bits of byte 0) unless the ID
// is long enough to need 12 bits, which then borrow byte 1.
let extended = usize::from(self.heap_id_length).saturating_sub(1) > 17;
let (len, start) = if extended {
ensure_len(id, 0, 2)?;
(
((usize::from(id[0] & 0x0F)) << 8 | usize::from(id[1])) + 1,
2,
)
} else {
(usize::from(id[0] & 0x0F) + 1, 1)
};
ensure_len(id, start, len)?;
Ok(id[start..start + len].to_vec())
}
/// Read a managed object (heap ID type 0).
fn read_heap_managed(
&self,
file_data: &[u8],
id_bytes: &[u8],
offset_size: u8,
) -> Result<Vec<u8>, FormatError> { ) -> Result<Vec<u8>, FormatError> {
let (heap_offset, obj_len) = self.decode_managed_id(id_bytes)?; let (heap_offset, obj_len) = self.decode_managed_id(id_bytes)?;
@@ -289,12 +532,15 @@ impl FractalHeapHeader {
// Root is a direct block // Root is a direct block
self.read_from_direct_block( self.read_from_direct_block(
file_data, file_data,
self.root_block_address as usize, DirectBlock {
self.starting_block_size, addr: self.root_block_address as usize,
0, // block offset in heap = 0 for root size: self.starting_block_size,
heap_offset: 0,
filtered_size: self.root_direct_block_filtered_size,
filter_mask: self.root_direct_block_filter_mask,
},
heap_offset, heap_offset,
obj_len as usize, obj_len as usize,
offset_size,
) )
} else { } else {
// Root is an indirect block — limit recursion to 64 levels // Root is an indirect block — limit recursion to 64 levels
@@ -313,27 +559,41 @@ impl FractalHeapHeader {
/// Read an object from a direct block. /// Read an object from a direct block.
/// ///
/// The heap offset is relative to the start of the block (including its header), /// The heap offset is relative to the start of the block (including its
/// so we just add it to the block address minus the block's heap offset. /// header), so we just add it to the block address minus the block's heap
#[allow(clippy::too_many_arguments)] /// offset. A filtered heap stores each direct block (header included)
/// through its filter pipeline, so the block is decoded first.
fn read_from_direct_block( fn read_from_direct_block(
&self, &self,
file_data: &[u8], file_data: &[u8],
block_addr: usize, block: DirectBlock,
_block_size: u64,
block_heap_offset: u64,
target_offset: u64, target_offset: u64,
length: usize, length: usize,
_offset_size: u8,
) -> Result<Vec<u8>, FormatError> { ) -> Result<Vec<u8>, FormatError> {
if target_offset < block_heap_offset { if target_offset < block.heap_offset {
return Err(FormatError::UnexpectedEof { return Err(FormatError::UnexpectedEof {
expected: block_heap_offset as usize, expected: block.heap_offset as usize,
available: target_offset as usize, available: target_offset as usize,
}); });
} }
let local_offset = (target_offset - block_heap_offset) as usize; let local_offset = (target_offset - block.heap_offset) as usize;
let pos = block_addr if let Some(pipeline) = &self.filter_pipeline {
let stored_len = usize::try_from(block.filtered_size)
.map_err(|_| heap_error("direct block size"))?;
let size = usize::try_from(block.size).map_err(|_| heap_error("direct block size"))?;
ensure_len(file_data, block.addr, stored_len)?;
let decoded = crate::filters::decompress_chunk_masked(
&file_data[block.addr..block.addr + stored_len],
pipeline,
size,
1,
block.filter_mask,
)?;
ensure_len(&decoded, local_offset, length)?;
return Ok(decoded[local_offset..local_offset + length].to_vec());
}
let pos = block
.addr
.checked_add(local_offset) .checked_add(local_offset)
.ok_or(FormatError::UnexpectedEof { .ok_or(FormatError::UnexpectedEof {
expected: usize::MAX, expected: usize::MAX,
@@ -371,19 +631,13 @@ impl FractalHeapHeader {
let iblock_header = 5 + offset_size as usize + block_offset_bytes; let iblock_header = 5 + offset_size as usize + block_offset_bytes;
let mut pos = iblock_addr + iblock_header; let mut pos = iblock_addr + iblock_header;
// Compute block sizes for each row using the doubling table
let tw = self.table_width as u64; let tw = self.table_width as u64;
let nrows_usize = nrows as usize; let nrows_usize = nrows as usize;
// Build table of (block_size, heap_offset) for each child entry
let mut current_heap_offset = iblock_heap_offset; let mut current_heap_offset = iblock_heap_offset;
// Rows below max_direct_rows hold direct blocks; rows at/above hold // Rows below max_direct_rows hold direct blocks; rows at/above hold
// child indirect blocks. (NOT the FRHP "starting rows" field.) // child indirect blocks. (NOT the FRHP "starting rows" field.)
let start_indirect = self.max_direct_rows(); let start_indirect = self.max_direct_rows();
// Read child addresses for direct block rows
let max_direct_rows = nrows_usize.min(start_indirect); let max_direct_rows = nrows_usize.min(start_indirect);
for row in 0..max_direct_rows { for row in 0..max_direct_rows {
@@ -393,48 +647,66 @@ impl FractalHeapHeader {
let child_addr = read_offset(file_data, pos, offset_size)?; let child_addr = read_offset(file_data, pos, offset_size)?;
pos += offset_size as usize; pos += offset_size as usize;
if self.io_filter_encoded_length > 0 { // A filtered heap stores each direct block's filtered size
// filtered_size(length_size) + filter_mask(4) // (length_size) and filter mask (4) after its address.
// Skip for now - we don't handle filtered direct blocks in fractal heaps let (filtered_size, filter_mask) = if self.filter_pipeline.is_some() {
pos += 4; // filter_mask - simplified let size = read_offset(file_data, pos, self.length_size)?;
} pos += usize::from(self.length_size);
ensure_len(file_data, pos, 4)?;
let mask = u32::from_le_bytes([
file_data[pos],
file_data[pos + 1],
file_data[pos + 2],
file_data[pos + 3],
]);
pos += 4;
(size, mask)
} else {
(0, 0)
};
if !is_undefined(child_addr, offset_size) { let block_end = current_heap_offset.saturating_add(block_size);
let block_end = current_heap_offset + block_size; if !is_undefined(child_addr, offset_size)
if target_offset >= current_heap_offset && target_offset < block_end { && target_offset >= current_heap_offset
&& target_offset < block_end
{
return self.read_from_direct_block( return self.read_from_direct_block(
file_data, file_data,
child_addr as usize, DirectBlock {
block_size, addr: child_addr as usize,
current_heap_offset, size: block_size,
heap_offset: current_heap_offset,
filtered_size,
filter_mask,
},
target_offset, target_offset,
length, length,
offset_size,
); );
} }
} current_heap_offset = block_end;
current_heap_offset += block_size;
} }
} }
// If we have indirect block rows // Rows at and above `start_indirect` hold child indirect blocks. A
// child in row r spans exactly that row's block size of heap space,
// so it has as many rows as a table of that total size needs.
for row in start_indirect..nrows_usize { for row in start_indirect..nrows_usize {
let _block_size = self.block_size_for_row(row); let child_space = self.block_size_for_row(row);
let child_nrows = row - start_indirect + 1; let child_nrows = self.rows_for_size(child_space);
for _col in 0..tw { for _col in 0..tw {
let child_addr = read_offset(file_data, pos, offset_size)?; let child_addr = read_offset(file_data, pos, offset_size)?;
pos += offset_size as usize; pos += offset_size as usize;
if !is_undefined(child_addr, offset_size) { let block_end = current_heap_offset.saturating_add(child_space);
// Calculate total heap space covered by this indirect block child if !is_undefined(child_addr, offset_size)
let total_child_space = self.indirect_block_heap_size(child_nrows); && target_offset >= current_heap_offset
let block_end = current_heap_offset + total_child_space; && target_offset < block_end
if target_offset >= current_heap_offset && target_offset < block_end { {
return self.read_from_indirect_block( return self.read_from_indirect_block(
file_data, file_data,
child_addr as usize, child_addr as usize,
child_nrows as u16, child_nrows,
current_heap_offset, current_heap_offset,
target_offset, target_offset,
length, length,
@@ -442,11 +714,7 @@ impl FractalHeapHeader {
depth_remaining - 1, depth_remaining - 1,
); );
} }
current_heap_offset += total_child_space; current_heap_offset = block_end;
} else {
let total_child_space = self.indirect_block_heap_size(child_nrows);
current_heap_offset += total_child_space;
}
} }
} }
@@ -475,25 +743,34 @@ impl FractalHeapHeader {
log2 + 2 log2 + 2
} }
/// Rows an indirect block needs to span `size` bytes of heap space:
/// `log2(size) - log2(starting_block_size * table_width) + 1`, as
/// libhdf5's `H5HF__dtable_size_to_rows`.
fn rows_for_size(&self, size: u64) -> u16 {
let log2 = |v: u64| 63u32.saturating_sub(v.max(1).leading_zeros());
let first_row_bits = log2(self.starting_block_size) + log2(u64::from(self.table_width));
(log2(size).saturating_sub(first_row_bits) + 1) as u16
}
/// Get block size for a given row in the doubling table. /// Get block size for a given row in the doubling table.
fn block_size_for_row(&self, row: usize) -> u64 { fn block_size_for_row(&self, row: usize) -> u64 {
let sbs = self.starting_block_size; let sbs = self.starting_block_size;
if row <= 1 { if row <= 1 {
sbs sbs
} else { } else {
sbs * (1u64 << (row - 1)) sbs.saturating_mul(1u64.checked_shl((row - 1) as u32).unwrap_or(u64::MAX))
} }
} }
}
/// Total heap space covered by an indirect block with the given number of rows. /// A managed direct block's location, extent and (for a filtered heap) its
fn indirect_block_heap_size(&self, nrows: usize) -> u64 { /// stored size and filter mask.
let tw = self.table_width as u64; struct DirectBlock {
let mut total = 0u64; addr: usize,
for row in 0..nrows { size: u64,
total += self.block_size_for_row(row) * tw; heap_offset: u64,
} filtered_size: u64,
total filter_mask: u32,
}
} }
#[cfg(test)] #[cfg(test)]
@@ -641,7 +918,7 @@ mod tests {
let hdr = FractalHeapHeader::parse(&file_data, 0, 8, 8).unwrap(); let hdr = FractalHeapHeader::parse(&file_data, 0, 8, 8).unwrap();
// Build a managed heap ID: // Build a managed heap ID:
// byte 0: type=0 (bits 6-7 = 00), version=0 (bits 4-5), reserved (bits 0-3) // byte 0: version=0 (bits 6-7), type=0 (bits 4-5), reserved (bits 0-3)
// bytes 1-6: offset (max_heap_size=16 bits) then length (remaining bits) // bytes 1-6: offset (max_heap_size=16 bits) then length (remaining bits)
// For offset=0, length=13: // For offset=0, length=13:
// payload = offset | (length << 16) = 0 | (13 << 16) = 0x000D0000 // payload = offset | (length << 16) = 0 | (13 << 16) = 0x000D0000
@@ -705,9 +982,46 @@ mod tests {
fn invalid_heap_id_type() { fn invalid_heap_id_type() {
let (file_data, _) = build_simple_heap(8, 8); let (file_data, _) = build_simple_heap(8, 8);
let hdr = FractalHeapHeader::parse(&file_data, 0, 8, 8).unwrap(); let hdr = FractalHeapHeader::parse(&file_data, 0, 8, 8).unwrap();
// Type = 1 (tiny) in bits 6-7 // Type = 1 (huge) in bits 4-5 is not a managed ID
let id = vec![0x40u8, 0, 0, 0, 0, 0, 0]; // bit 6 set = type 1 let id = vec![0x10u8, 0, 0, 0, 0, 0, 0];
let err = hdr.decode_managed_id(&id).unwrap_err(); let err = hdr.decode_managed_id(&id).unwrap_err();
assert_eq!(err, FormatError::InvalidHeapIdType(1)); assert_eq!(err, FormatError::InvalidHeapIdType(1));
} }
#[test]
fn tiny_object_is_read_from_the_id() {
let (file_data, _) = build_simple_heap(8, 8);
let hdr = FractalHeapHeader::parse(&file_data, 0, 8, 8).unwrap();
// Type 2 (0x20), length - 1 in the low 4 bits, data after.
let id = [0x20 | 2, b'a', b'b', b'c', 0, 0, 0];
assert_eq!(hdr.read_managed_object(&file_data, &id, 8).unwrap(), b"abc");
// A length running past the ID is an error, not a short read.
let id = [0x20 | 9, b'a', b'b', b'c', 0, 0, 0];
assert!(hdr.read_managed_object(&file_data, &id, 8).is_err());
}
#[test]
fn huge_object_with_a_direct_id() {
// With IDs long enough for an address and a length, libhdf5 stores
// huge objects' location in the ID instead of the huge-object B-tree.
let (mut file_data, _) = build_simple_heap(8, 8);
let mut hdr = FractalHeapHeader::parse(&file_data, 0, 8, 8).unwrap();
hdr.heap_id_length = 17;
file_data[900..905].copy_from_slice(b"huge!");
let mut id = vec![0x10u8];
id.extend_from_slice(&900u64.to_le_bytes());
id.extend_from_slice(&5u64.to_le_bytes());
assert_eq!(
hdr.read_managed_object(&file_data, &id, 8).unwrap(),
b"huge!"
);
}
#[test]
fn unknown_heap_id_version_is_refused() {
let (file_data, _) = build_simple_heap(8, 8);
let hdr = FractalHeapHeader::parse(&file_data, 0, 8, 8).unwrap();
let id = [0x40u8, 0, 0, 0, 0, 0, 0];
assert!(hdr.read_managed_object(&file_data, &id, 8).is_err());
}
} }
+72 -5
View File
@@ -45,9 +45,16 @@ pub fn resolve_v1_group_entries(
)?; )?;
let mut entries = Vec::new(); let mut entries = Vec::new();
let mut heap_checked = false;
for snod_addr in snod_addrs { for snod_addr in snod_addrs {
let snod = SymbolTableNode::parse(file_data, snod_addr as usize, offset_size)?; let snod = SymbolTableNode::parse(file_data, snod_addr as usize, offset_size)?;
for entry in &snod.entries { for entry in &snod.entries {
// Like libhdf5, look at the heap's free list only once a name is
// needed: an empty group with a damaged heap still lists.
if !heap_checked {
heap.validate_free_list(file_data, length_size)?;
heap_checked = true;
}
let name = heap.read_string(file_data, entry.link_name_offset)?; let name = heap.read_string(file_data, entry.link_name_offset)?;
entries.push(GroupEntry { entries.push(GroupEntry {
name, name,
@@ -73,6 +80,53 @@ pub fn find_v1_soft_link(
offset_size: u8, offset_size: u8,
length_size: u8, length_size: u8,
) -> Result<Option<String>, FormatError> { ) -> Result<Option<String>, FormatError> {
let mut found = None;
for_each_v1_soft_link(
file_data,
sym_table_msg,
offset_size,
length_size,
|link_name| link_name == name,
|_, target| {
found = Some(target);
false
},
)?;
Ok(found)
}
/// Every soft link in a v1 group, as `(name, target path)`.
pub fn v1_soft_links(
file_data: &[u8],
sym_table_msg: &SymbolTableMessage,
offset_size: u8,
length_size: u8,
) -> Result<Vec<(String, String)>, FormatError> {
let mut links = Vec::new();
for_each_v1_soft_link(
file_data,
sym_table_msg,
offset_size,
length_size,
|_| true,
|name, target| {
links.push((String::from(name), target));
true
},
)?;
Ok(links)
}
/// Visit the soft links of a v1 group whose name passes `wanted`, with their
/// target paths, until `visit` returns false.
fn for_each_v1_soft_link(
file_data: &[u8],
sym_table_msg: &SymbolTableMessage,
offset_size: u8,
length_size: u8,
wanted: impl Fn(&str) -> bool,
mut visit: impl FnMut(&str, String) -> bool,
) -> Result<(), FormatError> {
let heap = LocalHeap::parse( let heap = LocalHeap::parse(
file_data, file_data,
sym_table_msg.local_heap_address as usize, sym_table_msg.local_heap_address as usize,
@@ -85,13 +139,19 @@ pub fn find_v1_soft_link(
offset_size, offset_size,
length_size, length_size,
)?; )?;
let mut heap_checked = false;
for snod_addr in snod_addrs { for snod_addr in snod_addrs {
let snod = SymbolTableNode::parse(file_data, snod_addr as usize, offset_size)?; let snod = SymbolTableNode::parse(file_data, snod_addr as usize, offset_size)?;
for entry in &snod.entries { for entry in &snod.entries {
if entry.cache_type != CACHE_TYPE_SOFT_LINK { if entry.cache_type != CACHE_TYPE_SOFT_LINK {
continue; continue;
} }
if heap.read_string(file_data, entry.link_name_offset)? != name { if !heap_checked {
heap.validate_free_list(file_data, length_size)?;
heap_checked = true;
}
let name = heap.read_string(file_data, entry.link_name_offset)?;
if !wanted(&name) {
continue; continue;
} }
let value_offset = u32::from_le_bytes([ let value_offset = u32::from_le_bytes([
@@ -100,12 +160,19 @@ pub fn find_v1_soft_link(
entry.scratch_pad[2], entry.scratch_pad[2],
entry.scratch_pad[3], entry.scratch_pad[3],
]); ]);
return heap let target = heap.read_string(file_data, u64::from(value_offset))?;
.read_string(file_data, u64::from(value_offset)) if !visit(&name, target) {
.map(Some); return Ok(());
} }
} }
Ok(None) }
Ok(())
}
/// Whether a v1 symbol-table entry is a soft link (no object header of its
/// own; its target path is in the local heap).
pub fn is_v1_soft_link(entry: &GroupEntry) -> bool {
entry.cache_type == CACHE_TYPE_SOFT_LINK
} }
/// Extract the SymbolTableMessage from an object header's messages. /// Extract the SymbolTableMessage from an object header's messages.
+145 -21
View File
@@ -38,6 +38,24 @@ pub fn resolve_v2_group_entries(
} }
} }
/// First user-defined link type (HDF5 reserves 2-63; 64 is external).
const FIRST_USER_DEFINED_LINK_TYPE: u8 = 65;
/// Parse a Link message, or `None` for a user-defined link (type 65-255).
///
/// A user-defined link's target is only meaningful to the application that
/// registered its class, so, like libhdf5 without that class, we cannot
/// follow it. Leaving it out lets the rest of the group be listed and
/// resolved instead of one such link failing the whole group; reserved
/// types (2-63) are still an error.
fn parse_link(data: &[u8], offset_size: u8) -> Result<Option<LinkMessage>, FormatError> {
match LinkMessage::parse(data, offset_size) {
Ok(link) => Ok(Some(link)),
Err(FormatError::InvalidLinkType(t)) if t >= FIRST_USER_DEFINED_LINK_TYPE => Ok(None),
Err(e) => Err(e),
}
}
/// Extract link entries from Link messages directly in the object header (compact storage). /// Extract link entries from Link messages directly in the object header (compact storage).
fn resolve_compact_entries( fn resolve_compact_entries(
object_header: &ObjectHeader, object_header: &ObjectHeader,
@@ -46,7 +64,9 @@ fn resolve_compact_entries(
let mut entries = Vec::new(); let mut entries = Vec::new();
for msg in &object_header.messages { for msg in &object_header.messages {
if msg.msg_type == MessageType::Link { if msg.msg_type == MessageType::Link {
let link = LinkMessage::parse(&msg.data, offset_size)?; let Some(link) = parse_link(&msg.data, offset_size)? else {
continue;
};
if let LinkTarget::Hard { if let LinkTarget::Hard {
object_header_address, object_header_address,
} = link.link_target } = link.link_target
@@ -98,7 +118,9 @@ fn for_each_dense_link(
// Read managed object from fractal heap // Read managed object from fractal heap
let link_data = fh.read_managed_object(file_data, id_bytes, offset_size)?; let link_data = fh.read_managed_object(file_data, id_bytes, offset_size)?;
visit(LinkMessage::parse(&link_data, offset_size)?); if let Some(link) = parse_link(&link_data, offset_size)? {
visit(link);
}
} }
Ok(()) Ok(())
} }
@@ -178,7 +200,9 @@ fn find_symbolic_link(
} else { } else {
for msg in &object_header.messages { for msg in &object_header.messages {
if msg.msg_type == MessageType::Link { if msg.msg_type == MessageType::Link {
let link = LinkMessage::parse(&msg.data, offset_size)?; let Some(link) = parse_link(&msg.data, offset_size)? else {
continue;
};
if link.name == name && is_symbolic(&link.link_target) { if link.name == name && is_symbolic(&link.link_target) {
found = Some(link.link_target); found = Some(link.link_target);
} }
@@ -231,32 +255,135 @@ pub fn resolve_path_any(
superblock: &Superblock, superblock: &Superblock,
path: &str, path: &str,
) -> Result<u64, FormatError> { ) -> Result<u64, FormatError> {
resolve_path_following_links(file_data, superblock, path, 0) resolve_path_following_links(
file_data,
superblock,
superblock.root_group_address,
path,
0,
)
}
/// Resolve `path` relative to the group at `group_address` (an absolute path
/// starts at the root group instead), following soft links. This is how a
/// relative soft link's target is resolved: from the group holding the link.
pub fn resolve_path_from(
file_data: &[u8],
superblock: &Superblock,
group_address: u64,
path: &str,
) -> Result<u64, FormatError> {
let start = if path.starts_with('/') {
superblock.root_group_address
} else {
group_address
};
resolve_path_following_links(file_data, superblock, start, path, 0)
}
/// The children of the group at `group_address` that can be opened, as h5py
/// lists them: hard links, and soft links resolved to the object they point
/// at (under the soft link's own name). Links that cannot be followed are
/// left out rather than failing the listing — a dangling or cyclic soft link
/// (h5py lists its name but cannot open it), an external link (another
/// file), and a user-defined link. An object header that is not a group has
/// no children.
///
/// Any other error, such as a corrupt structure met while resolving a soft
/// link, is returned.
pub fn resolve_group_children(
file_data: &[u8],
superblock: &Superblock,
group_address: u64,
) -> Result<Vec<GroupEntry>, FormatError> {
let os = superblock.offset_size;
let ls = superblock.length_size;
let header = ObjectHeader::parse(file_data, group_address as usize, os, ls)?;
let mut entries = Vec::new();
let mut soft = Vec::new();
if is_v1_group(&header) {
let sym_msg = header
.messages
.iter()
.find(|m| m.msg_type == MessageType::SymbolTable)
.ok_or_else(|| FormatError::PathNotFound(String::from("no symbol table message")))?;
let stm = SymbolTableMessage::parse(&sym_msg.data, os)?;
let all = group_v1::resolve_v1_group_entries(file_data, &stm, os, ls)?;
if all.iter().any(group_v1::is_v1_soft_link) {
soft = group_v1::v1_soft_links(file_data, &stm, os, ls)?;
}
entries.extend(all.into_iter().filter(|e| !group_v1::is_v1_soft_link(e)));
} else if is_v2_group(&header) {
let mut visit = |link: LinkMessage| match link.link_target {
LinkTarget::Hard {
object_header_address,
} => entries.push(GroupEntry {
name: link.name,
object_header_address,
cache_type: 0,
}),
LinkTarget::Soft { target_path } => soft.push((link.name, target_path)),
LinkTarget::External { .. } => {}
};
let link_info = find_link_info(&header, os)?;
if let Some(fh_addr) = link_info.fractal_heap_address {
for_each_dense_link(file_data, &link_info, fh_addr, os, ls, visit)?;
} else {
for msg in &header.messages {
if msg.msg_type == MessageType::Link
&& let Some(link) = parse_link(&msg.data, os)?
{
visit(link);
}
}
}
}
for (name, target) in soft {
match resolve_path_from(file_data, superblock, group_address, &target) {
Ok(object_header_address) => entries.push(GroupEntry {
name,
object_header_address,
cache_type: 0,
}),
// Dangling, cyclic, or ending in another file: not openable here.
Err(
FormatError::PathNotFound(_)
| FormatError::NestingDepthExceeded
| FormatError::ExternalLinkUnsupported { .. },
) => {}
Err(e) => return Err(e),
}
}
Ok(entries)
} }
/// Soft links followed while resolving one path. Guards against link cycles /// Soft links followed while resolving one path. Guards against link cycles
/// (`a -> b -> a`), which are legal to create. /// (`a -> b -> a`), which are legal to create.
const MAX_SOFT_LINK_DEPTH: u8 = 16; const MAX_SOFT_LINK_DEPTH: u8 = 16;
/// Walk `path` from the group at `start`, following soft links.
fn resolve_path_following_links( fn resolve_path_following_links(
file_data: &[u8], file_data: &[u8],
superblock: &Superblock, superblock: &Superblock,
start: u64,
path: &str, path: &str,
depth: u8, depth: u8,
) -> Result<u64, FormatError> { ) -> Result<u64, FormatError> {
let components: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); let components: Vec<&str> = path
.split('/')
.filter(|s| !s.is_empty() && *s != ".")
.collect();
if components.is_empty() { if components.is_empty() {
return Ok(superblock.root_group_address); return Ok(start);
} }
let os = superblock.offset_size; let os = superblock.offset_size;
let ls = superblock.length_size; let ls = superblock.length_size;
let root_header = let mut current_addr = start;
ObjectHeader::parse(file_data, superblock.root_group_address as usize, os, ls)?; let mut current_header = ObjectHeader::parse(file_data, start as usize, os, ls)?;
let mut current_addr = superblock.root_group_address;
let mut current_header = root_header;
for (i, component) in components.iter().enumerate() { for (i, component) in components.iter().enumerate() {
let entries = resolve_group_entries(file_data, &current_header, os, ls)?; let entries = resolve_group_entries(file_data, &current_header, os, ls)?;
@@ -280,20 +407,17 @@ fn resolve_path_following_links(
} }
// A relative target is relative to the group holding // A relative target is relative to the group holding
// the link; then the rest of the original path. // the link; then the rest of the original path.
let mut full = String::new(); let from = if target_path.starts_with('/') {
if !target_path.starts_with('/') { superblock.root_group_address
for parent in &components[..i] { } else {
full.push('/'); current_addr
full.push_str(parent); };
} let mut full = target_path;
}
full.push('/');
full.push_str(&target_path);
for rest in &components[i + 1..] { for rest in &components[i + 1..] {
full.push('/'); full.push('/');
full.push_str(rest); full.push_str(rest);
} }
resolve_path_following_links(file_data, superblock, &full, depth + 1) resolve_path_following_links(file_data, superblock, from, &full, depth + 1)
} }
Some(LinkTarget::External { Some(LinkTarget::External {
filename, filename,
+35 -5
View File
@@ -26,12 +26,13 @@
//! use clawhdf5_format::{signature, superblock, object_header, group_v2, //! use clawhdf5_format::{signature, superblock, object_header, group_v2,
//! datatype, dataspace, data_layout, data_read, message_type::MessageType}; //! datatype, dataspace, data_layout, data_read, message_type::MessageType};
//! //!
//! let file_data = std::fs::read("output.h5").unwrap(); //! let bytes = std::fs::read("output.h5").unwrap();
//! let sig = signature::find_signature(&file_data).unwrap(); //! // Addresses are relative to the superblock: skip any user block.
//! let sb = superblock::Superblock::parse(&file_data, sig).unwrap(); //! let (_user_block, file_data) = signature::split_user_block(&bytes).unwrap();
//! let addr = group_v2::resolve_path_any(&file_data, &sb, "data").unwrap(); //! let sb = superblock::Superblock::parse(file_data, 0).unwrap();
//! let addr = group_v2::resolve_path_any(file_data, &sb, "data").unwrap();
//! let hdr = object_header::ObjectHeader::parse( //! let hdr = object_header::ObjectHeader::parse(
//! &file_data, addr as usize, sb.offset_size, sb.length_size).unwrap(); //! file_data, addr as usize, sb.offset_size, sb.length_size).unwrap();
//! ``` //! ```
//! //!
//! # Features //! # Features
@@ -42,6 +43,14 @@
//! | `checksum` | yes | Jenkins lookup3 checksum validation | //! | `checksum` | yes | Jenkins lookup3 checksum validation |
//! | `deflate` | yes | Deflate (gzip) compression via `flate2` | //! | `deflate` | yes | Deflate (gzip) compression via `flate2` |
//! | `provenance` | yes | SHINES provenance — SHA-256 hashing & verification | //! | `provenance` | yes | SHINES provenance — SHA-256 hashing & verification |
//! | `lzf` | yes | LZF filter (32000), h5py's `compression="lzf"` |
//! | `bitshuffle` | no | Bitshuffle filter (32008), none/LZ4/Zstandard |
//! | `bzip2` | no | bzip2 filter (307) |
//! | `blosc` | no | Blosc 1 filter (32001) |
//! | `plugin-filters` | no | The four above |
//!
//! Filters are looked up by ID in [`filter_registry`], which also takes
//! codecs registered at run time for other IDs.
#![cfg_attr(not(feature = "std"), no_std)] #![cfg_attr(not(feature = "std"), no_std)]
@@ -54,6 +63,7 @@ pub mod btree_v1;
pub mod btree_v2; pub mod btree_v2;
pub mod checksum; pub mod checksum;
pub mod chunk_cache; pub mod chunk_cache;
mod chunk_grid;
pub mod chunk_index; pub mod chunk_index;
pub mod chunked_read; pub mod chunked_read;
pub mod chunked_write; pub mod chunked_write;
@@ -69,7 +79,16 @@ pub mod extensible_array;
pub mod file_writer; pub mod file_writer;
pub mod fill_value; pub mod fill_value;
pub mod filter_pipeline; pub mod filter_pipeline;
pub mod filter_registry;
pub mod filters; pub mod filters;
#[cfg(any(feature = "bitshuffle", feature = "blosc"))]
mod filters_bitshuffle;
#[cfg(feature = "blosc")]
pub mod filters_blosc;
#[cfg(feature = "bzip2")]
mod filters_bzip2;
#[cfg(feature = "lzf")]
pub mod filters_lzf;
mod filters_szip; mod filters_szip;
pub mod fixed_array; pub mod fixed_array;
pub mod float16; pub mod float16;
@@ -98,7 +117,18 @@ pub mod shared_message;
pub mod signature; pub mod signature;
pub mod superblock; pub mod superblock;
pub mod symbol_table; pub mod symbol_table;
#[cfg(all(
test,
any(
feature = "lzf",
feature = "bitshuffle",
feature = "bzip2",
feature = "blosc"
)
))]
mod test_fuzz;
pub mod type_builders; pub mod type_builders;
pub mod vds;
pub mod vl_data; pub mod vl_data;
#[cfg(feature = "provenance")] #[cfg(feature = "provenance")]
+97 -2
View File
@@ -87,6 +87,57 @@ impl LocalHeap {
}) })
} }
/// Walk the free list the way libhdf5 does when it loads a heap's data
/// (`H5HL__fl_deserialize`), rejecting a heap whose free list points
/// outside the data segment. libhdf5 refuses such a heap ("bad heap free
/// list"), and names read from it would be garbage.
///
/// libhdf5 only loads a heap when it needs a name from it (an empty
/// group's broken heap goes unnoticed), so call this before the first
/// [`Self::read_string`], not on parse.
///
/// The end of the list is `H5HL_FREE_NULL` (1); an all-ones value (the
/// undefined address) is accepted as "no free list" too.
pub fn validate_free_list(&self, file_data: &[u8], length_size: u8) -> Result<(), FormatError> {
const FREE_NULL: u64 = 1;
let ls = length_size as usize;
let undefined = if ls >= 8 {
u64::MAX
} else {
(1u64 << (8 * ls)) - 1
};
let size = self.data_segment_size;
let seg = self.data_segment_address;
let mut next = self.free_list_head_offset;
// Each free block holds two lengths, so a list longer than this
// revisits a block: a cycle.
let max_blocks = size / (2 * ls as u64) + 1;
let mut walked = 0u64;
while next != FREE_NULL && next != undefined {
if next >= size || walked >= max_blocks {
return Err(FormatError::InvalidLocalHeapFreeList);
}
walked += 1;
let at = seg
.checked_add(next)
.and_then(|a| usize::try_from(a).ok())
.ok_or(FormatError::InvalidLocalHeapFreeList)?;
let block_offset = next;
next = read_offset(file_data, at, length_size)?;
if next == 0 {
return Err(FormatError::InvalidLocalHeapFreeList);
}
let block_size = read_offset(file_data, at + ls, length_size)?;
if block_offset
.checked_add(block_size)
.is_none_or(|end| end > size)
{
return Err(FormatError::InvalidLocalHeapFreeList);
}
}
Ok(())
}
/// Read a null-terminated string from the heap's data segment at the given byte offset. /// Read a null-terminated string from the heap's data segment at the given byte offset.
pub fn read_string(&self, file_data: &[u8], string_offset: u64) -> Result<String, FormatError> { pub fn read_string(&self, file_data: &[u8], string_offset: u64) -> Result<String, FormatError> {
let seg_addr = self.data_segment_address as usize; let seg_addr = self.data_segment_address as usize;
@@ -162,8 +213,8 @@ mod tests {
// data_segment_size // data_segment_size
write_val(&mut file, pos, data_seg_size as u64, length_size); write_val(&mut file, pos, data_seg_size as u64, length_size);
pos += length_size as usize; pos += length_size as usize;
// free_list_head_offset // free_list_head_offset: H5HL_FREE_NULL (no free space)
write_val(&mut file, pos, 0xFFFFFFFF, length_size); write_val(&mut file, pos, 1, length_size);
pos += length_size as usize; pos += length_size as usize;
// data_segment_address // data_segment_address
write_val(&mut file, pos, data_seg_offset as u64, offset_size); write_val(&mut file, pos, data_seg_offset as u64, offset_size);
@@ -243,6 +294,50 @@ mod tests {
assert_eq!(s, "test"); assert_eq!(s, "test");
} }
/// Heap with data segment `[a, b, c, 0-padding]` whose free list starts
/// at `head` and has one block `(next, size)` at offset 8.
fn heap_with_free_block(head: u64, next: u64, size: u64) -> Vec<u8> {
let mut file = build_heap_file(0, 100, &["abcdefg"], 8, 8);
file.resize(200, 0);
write_val(&mut file, 8, 32, 8); // data segment size
write_val(&mut file, 16, head, 8);
write_val(&mut file, 108, next, 8);
write_val(&mut file, 116, size, 8);
file
}
#[test]
fn free_list_inside_the_segment_is_accepted() {
let file = heap_with_free_block(8, 1, 24);
let heap = LocalHeap::parse(&file, 0, 8, 8).unwrap();
heap.validate_free_list(&file, 8).unwrap();
assert_eq!(heap.read_string(&file, 0).unwrap(), "abcdefg");
// An all-ones head is "no free list" too.
let file = heap_with_free_block(u64::MAX, 0, 0);
let heap = LocalHeap::parse(&file, 0, 8, 8).unwrap();
assert!(heap.validate_free_list(&file, 8).is_ok());
}
#[test]
fn bad_free_list_is_rejected_like_libhdf5() {
for (head, next, size, why) in [
(40, 1, 8, "head past the segment"),
(8, 1, 25, "block runs past the segment"),
(8, 0, 8, "next offset of zero"),
(8, 8, 8, "cycle"),
(8, 999, 8, "next past the segment"),
] {
let file = heap_with_free_block(head, next, size);
// The header itself parses; the free list is checked on use.
let heap = LocalHeap::parse(&file, 0, 8, 8).unwrap();
assert_eq!(
heap.validate_free_list(&file, 8).unwrap_err(),
FormatError::InvalidLocalHeapFreeList,
"{why}"
);
}
}
#[test] #[test]
fn invalid_version() { fn invalid_version() {
let mut file = build_heap_file(0, 100, &["x"], 8, 8); let mut file = build_heap_file(0, 100, &["x"], 8, 8);
+523 -121
View File
@@ -108,10 +108,20 @@ impl ObjectHeader {
return Err(FormatError::InvalidObjectHeaderVersion(version)); return Err(FormatError::InvalidObjectHeaderVersion(version));
} }
let num_messages = LittleEndian::read_u16(&data[offset + 2..offset + 4]); let num_messages = LittleEndian::read_u16(&data[offset + 2..offset + 4]) as usize;
let reference_count = LittleEndian::read_u32(&data[offset + 4..offset + 8]); let reference_count = LittleEndian::read_u32(&data[offset + 4..offset + 8]);
let header_data_size = LittleEndian::read_u32(&data[offset + 8..offset + 12]) as usize; let header_data_size = LittleEndian::read_u32(&data[offset + 8..offset + 12]) as usize;
// libhdf5 (H5O__prefix_deserialize): a header with messages needs room
// for at least one message header, and one without has an empty chunk.
if (num_messages > 0 && header_data_size < V1_MSG_HEADER_SIZE)
|| (num_messages == 0 && header_data_size > 0)
{
return Err(FormatError::InvalidObjectHeader(
"bad object header chunk size",
));
}
// Pad to 8-byte alignment: header prefix is 12 bytes, pad to 16 // Pad to 8-byte alignment: header prefix is 12 bytes, pad to 16
let padding = 4; // pad 12-byte prefix to 16-byte alignment let padding = 4; // pad 12-byte prefix to 16-byte alignment
let msg_start = offset let msg_start = offset
@@ -124,69 +134,23 @@ impl ObjectHeader {
ensure_len(data, msg_start, header_data_size)?; ensure_len(data, msg_start, header_data_size)?;
let mut messages = Vec::new(); let mut messages = Vec::new();
let mut pos = msg_start; let chunk0_count = Self::parse_v1_chunk(
let msg_end =
msg_start
.checked_add(header_data_size)
.ok_or(FormatError::UnexpectedEof {
expected: usize::MAX,
available: data.len(),
})?;
for _ in 0..num_messages {
if pos + 8 > msg_end {
break;
}
let msg_type_raw = LittleEndian::read_u16(&data[pos..pos + 2]);
let msg_data_size = LittleEndian::read_u16(&data[pos + 2..pos + 4]) as usize;
let msg_flags = data[pos + 4];
// reserved(3) at pos+5..pos+8
pos += 8;
ensure_len(data, pos, msg_data_size)?;
let msg_type = MessageType::from_u16(msg_type_raw);
// Check if unknown + must-understand (bit 3 of msg_flags)
if let MessageType::Unknown(id) = msg_type
&& msg_flags & 0x08 != 0
{
return Err(FormatError::UnsupportedMessage(id));
}
if msg_type != MessageType::Nil {
messages.push(HeaderMessage {
msg_type,
size: msg_data_size,
flags: msg_flags,
creation_order: None,
data: data[pos..pos + msg_data_size].to_vec(),
});
}
pos += msg_data_size;
// Follow continuations
if msg_type == MessageType::ObjectHeaderContinuation {
let cont_msg_data = &messages
.last()
.ok_or(FormatError::InvalidObjectHeaderSignature)?
.data;
if cont_msg_data.len() >= (offset_size as usize + length_size as usize) {
let cont_offset = read_offset(cont_msg_data, 0, offset_size)? as usize;
let cont_length =
read_offset(cont_msg_data, offset_size as usize, length_size)? as usize;
// Parse continuation block (v1: just raw messages, no signature)
let cont_msgs = Self::parse_v1_continuation(
data, data,
cont_offset, msg_start,
cont_length, header_data_size,
offset_size, offset_size,
length_size, length_size,
32, // max continuation depth MAX_V1_CONTINUATION_DEPTH,
&mut messages,
)?; )?;
messages.extend(cont_msgs); // libhdf5 reads every message in the first chunk and refuses a header
} // whose prefix claims fewer than that (continuation chunks are read
} // later and not held to the count). Stopping after the claimed number
// silently dropped the rest.
if chunk0_count > num_messages {
return Err(FormatError::InvalidObjectHeader(
"bad object header message count",
));
} }
Ok(ObjectHeader { Ok(ObjectHeader {
@@ -201,76 +165,87 @@ impl ObjectHeader {
}) })
} }
fn parse_v1_continuation( /// Parse the messages of one version-1 chunk (`length` bytes at
/// `offset`, no signature), following continuation messages as they are
/// met. Returns how many messages (NIL ones included) this chunk itself
/// holds.
///
/// A version-1 chunk is filled with messages whose sizes are multiples of
/// 8; libhdf5 refuses a message that is not aligned, that runs past the
/// end of the chunk, or leftover bytes too few for a message header (a
/// "gap", which only version 2 allows).
#[allow(clippy::too_many_arguments)]
fn parse_v1_chunk(
data: &[u8], data: &[u8],
offset: usize, offset: usize,
length: usize, length: usize,
offset_size: u8, offset_size: u8,
length_size: u8, length_size: u8,
depth_remaining: u16, depth_remaining: u16,
) -> Result<Vec<HeaderMessage>, FormatError> { messages: &mut Vec<HeaderMessage>,
) -> Result<usize, FormatError> {
if depth_remaining == 0 { if depth_remaining == 0 {
return Err(FormatError::NestingDepthExceeded); return Err(FormatError::NestingDepthExceeded);
} }
ensure_len(data, offset, length)?; ensure_len(data, offset, length)?;
let mut messages = Vec::new(); let end = offset + length;
let mut pos = offset; let mut pos = offset;
let end = offset.saturating_add(length); let mut count = 0usize;
while pos + 8 <= end { while pos < end {
if end - pos < V1_MSG_HEADER_SIZE {
return Err(FormatError::InvalidObjectHeader(
"gap found in early version of file format",
));
}
let msg_type_raw = LittleEndian::read_u16(&data[pos..pos + 2]); let msg_type_raw = LittleEndian::read_u16(&data[pos..pos + 2]);
let msg_data_size = LittleEndian::read_u16(&data[pos + 2..pos + 4]) as usize; let msg_data_size = LittleEndian::read_u16(&data[pos + 2..pos + 4]) as usize;
let msg_flags = data[pos + 4]; let msg_flags = data[pos + 4];
pos += 8; // reserved(3) at pos+5..pos+8
pos += V1_MSG_HEADER_SIZE;
if pos + msg_data_size > end { if !msg_data_size.is_multiple_of(8) {
break; return Err(FormatError::InvalidObjectHeader("message not aligned"));
} }
if msg_data_size > end - pos {
return Err(FormatError::InvalidObjectHeader(
"message size exceeds buffer end",
));
}
let body = &data[pos..pos + msg_data_size];
check_message(1, msg_type_raw, msg_flags, body, offset_size, length_size)?;
count += 1;
let msg_type = MessageType::from_u16(msg_type_raw); let msg_type = MessageType::from_u16(msg_type_raw);
if let MessageType::Unknown(id) = msg_type
&& msg_flags & 0x08 != 0
{
return Err(FormatError::UnsupportedMessage(id));
}
if msg_type != MessageType::Nil { if msg_type != MessageType::Nil {
messages.push(HeaderMessage { messages.push(HeaderMessage {
msg_type, msg_type,
size: msg_data_size, size: msg_data_size,
flags: msg_flags, flags: msg_flags,
creation_order: None, creation_order: None,
data: data[pos..pos + msg_data_size].to_vec(), data: body.to_vec(),
}); });
} }
pos += msg_data_size; pos += msg_data_size;
// Recursive continuations // Follow continuations (v1 continuation chunks are just raw
// messages, no signature); check_message has checked the body.
if msg_type == MessageType::ObjectHeaderContinuation { if msg_type == MessageType::ObjectHeaderContinuation {
let cont_msg_data = &messages let cont_offset = read_offset(body, 0, offset_size)? as usize;
.last() let cont_length = read_offset(body, offset_size as usize, length_size)? as usize;
.ok_or(FormatError::InvalidObjectHeaderSignature)? Self::parse_v1_chunk(
.data;
if cont_msg_data.len() >= (offset_size as usize + length_size as usize) {
let cont_offset = read_offset(cont_msg_data, 0, offset_size)? as usize;
let cont_length =
read_offset(cont_msg_data, offset_size as usize, length_size)? as usize;
let cont_msgs = Self::parse_v1_continuation(
data, data,
cont_offset, cont_offset,
cont_length, cont_length,
offset_size, offset_size,
length_size, length_size,
depth_remaining - 1, depth_remaining - 1,
messages,
)?; )?;
messages.extend(cont_msgs);
}
} }
} }
Ok(messages) Ok(count)
} }
fn parse_v2( fn parse_v2(
@@ -287,6 +262,11 @@ impl ObjectHeader {
return Err(FormatError::InvalidObjectHeaderVersion(version)); return Err(FormatError::InvalidObjectHeaderVersion(version));
} }
let flags = data[offset + 5]; let flags = data[offset + 5];
if flags & !V2_HDR_ALL_FLAGS != 0 {
return Err(FormatError::InvalidObjectHeader(
"unknown object header status flag(s)",
));
}
let mut pos = offset + 6; let mut pos = offset + 6;
@@ -306,7 +286,14 @@ impl ObjectHeader {
// Optional attribute storage thresholds (flags bit 4) // Optional attribute storage thresholds (flags bit 4)
if flags & 0x10 != 0 { if flags & 0x10 != 0 {
ensure_len(data, pos, 4)?; ensure_len(data, pos, 4)?;
// max_compact_attrs(2) + min_dense_attrs(2) — read but don't store for now // max_compact_attrs(2) + min_dense_attrs(2) — checked, not stored
let max_compact = LittleEndian::read_u16(&data[pos..pos + 2]);
let min_dense = LittleEndian::read_u16(&data[pos + 2..pos + 4]);
if max_compact < min_dense {
return Err(FormatError::InvalidObjectHeader(
"bad object header attribute phase change values",
));
}
pos += 4; pos += 4;
} }
@@ -321,6 +308,14 @@ impl ObjectHeader {
ensure_len(data, pos, chunk_size_width as usize)?; ensure_len(data, pos, chunk_size_width as usize)?;
let chunk0_size = read_offset(data, pos, chunk_size_width)? as usize; let chunk0_size = read_offset(data, pos, chunk_size_width)? as usize;
pos += chunk_size_width as usize; pos += chunk_size_width as usize;
// Bit 2: attribute creation order tracked → messages include creation order field
let has_creation_order = flags & 0x04 != 0;
let msg_header_size = if has_creation_order { 6 } else { 4 };
if chunk0_size > 0 && chunk0_size < msg_header_size {
return Err(FormatError::InvalidObjectHeader(
"bad object header chunk size",
));
}
let chunk0_msg_start = pos; let chunk0_msg_start = pos;
let chunk0_msg_end = pos let chunk0_msg_end = pos
@@ -344,9 +339,6 @@ impl ObjectHeader {
} }
} }
// Bit 2: attribute creation order tracked → messages include creation order field
let has_creation_order = flags & 0x04 != 0;
// Parse messages from chunk0 // Parse messages from chunk0
let mut messages = Vec::new(); let mut messages = Vec::new();
let mut continuations = Vec::new(); let mut continuations = Vec::new();
@@ -405,8 +397,20 @@ impl ObjectHeader {
) -> Result<(), FormatError> { ) -> Result<(), FormatError> {
let msg_header_size = if has_creation_order { 6 } else { 4 }; let msg_header_size = if has_creation_order { 6 } else { 4 };
let mut pos = start; let mut pos = start;
let mut null_count = 0usize;
while pos + msg_header_size <= end { while pos < end {
// Leftover bytes too few for a message header are a gap, which
// libhdf5 allows only in a chunk without NIL messages (a writer
// that leaves a gap had no NIL message to put the space in).
if end - pos < msg_header_size {
if null_count != 0 {
return Err(FormatError::InvalidObjectHeader(
"gap in chunk with no null messages",
));
}
break;
}
let msg_type_raw = data[pos] as u16; let msg_type_raw = data[pos] as u16;
let msg_data_size = LittleEndian::read_u16(&data[pos + 1..pos + 3]) as usize; let msg_data_size = LittleEndian::read_u16(&data[pos + 1..pos + 3]) as usize;
let msg_flags = data[pos + 3]; let msg_flags = data[pos + 3];
@@ -417,36 +421,36 @@ impl ObjectHeader {
}; };
pos += msg_header_size; pos += msg_header_size;
if pos + msg_data_size > end { // `end` is where the messages stop and the checksum starts.
// Could be padding at end of chunk // libhdf5 bounds a message by the chunk including its checksum,
break; // but a message that runs into the checksum still fails there:
// its loop stops at the checksum, and reading the checksum from
// past its start overruns the chunk ("ran off end of input
// buffer while decoding"). Both refuse it; only the text
// differs.
if msg_data_size > end - pos {
return Err(FormatError::InvalidObjectHeader(
"message size exceeds buffer end",
));
} }
let body = &data[pos..pos + msg_data_size];
check_message(2, msg_type_raw, msg_flags, body, offset_size, length_size)?;
let msg_type = MessageType::from_u16(msg_type_raw); let msg_type = MessageType::from_u16(msg_type_raw);
if let MessageType::Unknown(id) = msg_type
&& msg_flags & 0x08 != 0
{
return Err(FormatError::UnsupportedMessage(id));
}
let msg_data = data[pos..pos + msg_data_size].to_vec();
if msg_type == MessageType::ObjectHeaderContinuation { if msg_type == MessageType::ObjectHeaderContinuation {
// Parse continuation offset/length from message data // check_message has checked the body holds both fields.
if msg_data.len() >= (offset_size as usize + length_size as usize) { let cont_off = read_offset(body, 0, offset_size)? as usize;
let cont_off = read_offset(&msg_data, 0, offset_size)? as usize; let cont_len = read_offset(body, offset_size as usize, length_size)? as usize;
let cont_len =
read_offset(&msg_data, offset_size as usize, length_size)? as usize;
continuations.push((cont_off, cont_len)); continuations.push((cont_off, cont_len));
} } else if msg_type == MessageType::Nil {
} else if msg_type != MessageType::Nil { null_count += 1;
} else {
messages.push(HeaderMessage { messages.push(HeaderMessage {
msg_type, msg_type,
size: msg_data_size, size: msg_data_size,
flags: msg_flags, flags: msg_flags,
creation_order, creation_order,
data: msg_data, data: body.to_vec(),
}); });
} }
@@ -509,6 +513,151 @@ impl ObjectHeader {
} }
} }
/// Size of a version-1 message header: type(2) + size(2) + flags(1) + reserved(3).
const V1_MSG_HEADER_SIZE: usize = 8;
/// How deep version-1 continuation chunks may chain (malformed-data guard).
const MAX_V1_CONTINUATION_DEPTH: u16 = 32;
/// Every defined version-2 object header status flag (libhdf5
/// `H5O_HDR_ALL_FLAGS`): chunk-0 size width (bits 0-1), attribute creation
/// order tracked/indexed, attribute phase-change values, times stored.
const V2_HDR_ALL_FLAGS: u8 = 0x3F;
// Header message flag bits (libhdf5 `H5O_MSG_FLAG_*`). Bit 0 (constant) needs
// no check. Bit 3 (fail if unknown and the file is opened for writing) never
// fails a read: the parser only ever reads, as libhdf5 ignores it for a
// read-only open.
const MSG_FLAG_SHARED: u8 = 0x02;
const MSG_FLAG_DONTSHARE: u8 = 0x04;
const MSG_FLAG_FAIL_IF_UNKNOWN_AND_OPEN_FOR_WRITE: u8 = 0x08;
const MSG_FLAG_MARK_IF_UNKNOWN: u8 = 0x10;
const MSG_FLAG_WAS_UNKNOWN: u8 = 0x20;
const MSG_FLAG_SHAREABLE: u8 = 0x40;
/// Fail if the message is unknown, whatever the access mode.
const MSG_FLAG_FAIL_IF_UNKNOWN_ALWAYS: u8 = 0x80;
/// Message type ids libhdf5 has a class for (`H5O_msg_class_g`): 0x00-0x18
/// except 0x09 (a test-only "bogus" message). Anything else is an unknown
/// message.
fn is_known_message(id: u16) -> bool {
id <= 0x18 && id != 0x09
}
/// Message classes that may be shared (`H5O_SHARE_IS_SHARABLE`): dataspace,
/// datatype, the two fill-value messages, filter pipeline and attribute.
fn is_shareable_message(id: u16) -> bool {
matches!(id, 0x01 | 0x03 | 0x04 | 0x05 | 0x0B | 0x0C)
}
/// Check one header message the way libhdf5 does while it loads an object
/// header (`H5O__chunk_deserialize`), so an object libhdf5 refuses to open is
/// refused here too instead of being read from a corrupt header:
///
/// - contradictory flag combinations;
/// - an unknown message the file says no reader may skip (bit 7). This had
/// bits 3 and 7 the wrong way round once, failing objects libhdf5 reads
/// and reading ones it refuses (`tbogus.h5`);
/// - a known message whose class cannot be shared, flagged shared or
/// shareable (`cve-2016-4332`);
/// - the messages libhdf5 decodes while loading the header, whose decode
/// errors fail the load: continuation, reference count (which a version-1
/// header cannot hold), and both modification-time messages.
fn check_message(
header_version: u8,
id: u16,
flags: u8,
body: &[u8],
offset_size: u8,
length_size: u8,
) -> Result<(), FormatError> {
let bad_flags = FormatError::InvalidObjectHeader("bad flag combination for message");
if flags & MSG_FLAG_SHARED != 0 && flags & MSG_FLAG_DONTSHARE != 0 {
return Err(bad_flags);
}
if flags & MSG_FLAG_WAS_UNKNOWN != 0
&& (flags & MSG_FLAG_FAIL_IF_UNKNOWN_AND_OPEN_FOR_WRITE != 0
|| flags & MSG_FLAG_MARK_IF_UNKNOWN == 0)
{
return Err(bad_flags);
}
if !is_known_message(id) {
if flags & MSG_FLAG_FAIL_IF_UNKNOWN_ALWAYS != 0 {
return Err(FormatError::UnsupportedMessage(id));
}
return Ok(());
}
if flags & (MSG_FLAG_SHARED | MSG_FLAG_SHAREABLE) != 0 && !is_shareable_message(id) {
return Err(FormatError::InvalidObjectHeader(
"message of unshareable class flagged as shareable",
));
}
let overrun = FormatError::InvalidObjectHeader("ran off end of input buffer while decoding");
match id {
// Continuation: address + length, and the chunk cannot be empty.
0x10 => {
if body.len() < offset_size as usize + length_size as usize {
return Err(overrun);
}
if read_offset(body, offset_size as usize, length_size)? == 0 {
return Err(FormatError::InvalidObjectHeader(
"invalid continuation chunk size (0)",
));
}
}
// Reference count: version-2 headers only; version 0 then a u32.
0x16 => {
if header_version == 1 {
return Err(FormatError::InvalidObjectHeader(
"object header version does not support reference count message",
));
}
match body.first() {
None => return Err(overrun),
Some(0) => {}
Some(_) => {
return Err(FormatError::InvalidObjectHeader(
"bad version number for reference count message",
));
}
}
if body.len() < 5 {
return Err(overrun);
}
}
// Old modification time: "YYYYMMDDhhmmss" and 2 reserved bytes.
0x0E => {
if body.len() < 16 {
return Err(overrun);
}
if !body[..14].iter().all(u8::is_ascii_digit) {
return Err(FormatError::InvalidObjectHeader(
"badly formatted modification time message",
));
}
}
// New modification time: version 1, 3 reserved bytes, u32 seconds.
0x12 => {
match body.first() {
None => return Err(overrun),
Some(1) => {}
Some(_) => {
return Err(FormatError::InvalidObjectHeader(
"bad version number for mtime message",
));
}
}
if body.len() < 8 {
return Err(overrun);
}
}
_ => {}
}
Ok(())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -523,11 +672,14 @@ mod tests {
// Calculate total header message data size // Calculate total header message data size
let mut msg_bytes = Vec::new(); let mut msg_bytes = Vec::new();
for (mtype, mdata, mflags) in messages { for (mtype, mdata, mflags) in messages {
// v1 message sizes are multiples of 8 (the data is zero-padded).
let padded = mdata.len().div_ceil(8) * 8;
msg_bytes.extend_from_slice(&mtype.to_le_bytes()); // type(2) msg_bytes.extend_from_slice(&mtype.to_le_bytes()); // type(2)
msg_bytes.extend_from_slice(&(mdata.len() as u16).to_le_bytes()); // size(2) msg_bytes.extend_from_slice(&(padded as u16).to_le_bytes()); // size(2)
msg_bytes.push(*mflags); // flags(1) msg_bytes.push(*mflags); // flags(1)
msg_bytes.extend_from_slice(&[0u8; 3]); // reserved(3) msg_bytes.extend_from_slice(&[0u8; 3]); // reserved(3)
msg_bytes.extend_from_slice(mdata); // data msg_bytes.extend_from_slice(mdata); // data
msg_bytes.resize(msg_bytes.len() + padded - mdata.len(), 0);
} }
let mut buf = Vec::new(); let mut buf = Vec::new();
@@ -617,9 +769,10 @@ mod tests {
let hdr = ObjectHeader::parse(&data, 0, 8, 8).unwrap(); let hdr = ObjectHeader::parse(&data, 0, 8, 8).unwrap();
assert_eq!(hdr.messages.len(), 2); assert_eq!(hdr.messages.len(), 2);
assert_eq!(hdr.messages[0].msg_type, MessageType::Dataspace); assert_eq!(hdr.messages[0].msg_type, MessageType::Dataspace);
assert_eq!(hdr.messages[0].data, vec![1, 2, 3, 4]); // v1 message data is padded to a multiple of 8 bytes.
assert_eq!(hdr.messages[0].data, vec![1, 2, 3, 4, 0, 0, 0, 0]);
assert_eq!(hdr.messages[1].msg_type, MessageType::DataLayout); assert_eq!(hdr.messages[1].msg_type, MessageType::DataLayout);
assert_eq!(hdr.messages[1].data, vec![5, 6]); assert_eq!(hdr.messages[1].data[..2], [5, 6]);
} }
#[test] #[test]
@@ -632,14 +785,263 @@ mod tests {
} }
#[test] #[test]
fn parse_v1_unknown_must_understand_errors() { fn parse_v1_unknown_fail_always_errors() {
// Bit 3 of msg_flags = must understand // Bit 7 of msg_flags = fail if unknown, whatever the access mode.
let messages = [(0x00FFu16, &[0xAA][..], 0x08u8)]; let messages = [(0x00FFu16, &[0xAA][..], 0x80u8)];
let data = build_v1_header(&messages, 8, 8); let data = build_v1_header(&messages, 8, 8);
let err = ObjectHeader::parse(&data, 0, 8, 8).unwrap_err(); let err = ObjectHeader::parse(&data, 0, 8, 8).unwrap_err();
assert_eq!(err, FormatError::UnsupportedMessage(0x00FF)); assert_eq!(err, FormatError::UnsupportedMessage(0x00FF));
} }
#[test]
fn parse_v1_unknown_fail_on_write_is_ignored_when_reading() {
// Bit 3 = fail if unknown *and the file is opened for writing*. This
// parser only reads, so libhdf5 (read-only) opens such an object and
// so must we. Bits 4/5 (mark if unknown / was unknown) never fail.
for flags in [0x08u8, 0x10, 0x30] {
let messages = [(0x00FFu16, &[0xAA][..], flags)];
let data = build_v1_header(&messages, 8, 8);
let hdr = ObjectHeader::parse(&data, 0, 8, 8).unwrap();
assert_eq!(hdr.messages[0].msg_type, MessageType::Unknown(0x00FF));
}
}
#[test]
fn contradictory_message_flags_are_refused() {
// libhdf5: "bad flag combination for message" for shared + don't
// share, was-unknown without mark-if-unknown, and was-unknown with
// fail-if-unknown-on-write.
for flags in [0x06u8, 0x20, 0x38] {
let data = build_v1_header(&[(0x00FFu16, &[0xAA][..], flags)], 8, 8);
assert_eq!(
ObjectHeader::parse(&data, 0, 8, 8).unwrap_err(),
FormatError::InvalidObjectHeader("bad flag combination for message"),
"flags {flags:#x}"
);
let data = build_v2_header(0x00, &[(0xF0, &[1, 2], flags)], None);
assert_eq!(
ObjectHeader::parse(&data, 0, 8, 8).unwrap_err(),
FormatError::InvalidObjectHeader("bad flag combination for message"),
"flags {flags:#x}"
);
}
}
#[test]
fn unshareable_message_flagged_shareable_is_refused() {
// A layout (0x08) or modification time (0x12) message cannot be
// shared; bit 1 (shared) or bit 6 (shareable) on one is corruption
// (cve-2016-4332). A datatype (0x03) may be shareable.
let mtime = [1u8, 0, 0, 0, 0x10, 0x20, 0x30, 0x40];
for (id, flags) in [(0x08u16, 0x40u8), (0x08, 0x02), (0x12, 0x40)] {
let data = build_v1_header(&[(id, &mtime[..], flags)], 8, 8);
assert_eq!(
ObjectHeader::parse(&data, 0, 8, 8).unwrap_err(),
FormatError::InvalidObjectHeader(
"message of unshareable class flagged as shareable"
),
"id {id:#x} flags {flags:#x}"
);
}
let data = build_v1_header(&[(0x03, &[0u8; 8][..], 0x40)], 8, 8);
assert!(ObjectHeader::parse(&data, 0, 8, 8).is_ok());
// An unknown message is never checked for shareability.
let data = build_v1_header(&[(0x00FF, &[0u8; 8][..], 0x40)], 8, 8);
assert!(ObjectHeader::parse(&data, 0, 8, 8).is_ok());
}
#[test]
fn v1_message_must_be_aligned() {
// cve-2018-13873: a v1 message whose size is not a multiple of 8.
let mut data = build_v1_header(&[(0x01, &[0u8; 8][..], 0)], 8, 8);
data[16 + 2] = 7; // size field of the only message
assert_eq!(
ObjectHeader::parse(&data, 0, 8, 8).unwrap_err(),
FormatError::InvalidObjectHeader("message not aligned")
);
}
#[test]
fn message_overrunning_its_chunk_is_refused() {
// It used to end the chunk quietly, dropping this message and any
// after it.
let mut data = build_v1_header(&[(0x01, &[0u8; 8][..], 0)], 8, 8);
data[16 + 2] = 16;
data.resize(data.len() + 64, 0);
assert_eq!(
ObjectHeader::parse(&data, 0, 8, 8).unwrap_err(),
FormatError::InvalidObjectHeader("message size exceeds buffer end")
);
let mut data = build_v2_header(0x00, &[(0x01, &[1, 2], 0)], None);
data[7 + 1] = 9; // size of the only message (after OHDR, ver, flags, chunk size)
let chk = crate::checksum::jenkins_lookup3(&data[..data.len() - 4]);
let n = data.len();
data[n - 4..].copy_from_slice(&chk.to_le_bytes());
assert_eq!(
ObjectHeader::parse(&data, 0, 8, 8).unwrap_err(),
FormatError::InvalidObjectHeader("message size exceeds buffer end")
);
}
#[test]
fn v1_gap_after_last_message_is_refused() {
// Fewer than 8 bytes left over: a gap, which only version 2 allows.
let mut data = build_v1_header(&[(0x01, &[0u8; 8][..], 0)], 8, 8);
data[8] += 4; // header_data_size
data.extend_from_slice(&[0u8; 4]);
assert_eq!(
ObjectHeader::parse(&data, 0, 8, 8).unwrap_err(),
FormatError::InvalidObjectHeader("gap found in early version of file format")
);
}
#[test]
fn v1_chunk_holding_more_messages_than_the_prefix_says_is_refused() {
// cve-2024-32619: the prefix says 1 message, the chunk holds 2. The
// second used to be dropped silently.
let mut data = build_v1_header(&[(0x01, &[0u8; 8][..], 0), (0x03, &[0u8; 8][..], 0)], 8, 8);
data[2] = 1;
assert_eq!(
ObjectHeader::parse(&data, 0, 8, 8).unwrap_err(),
FormatError::InvalidObjectHeader("bad object header message count")
);
// Fewer in the chunk than the prefix says is fine (the rest may be in
// continuation chunks; libhdf5 only enforces that with strict checks).
data[2] = 3;
assert_eq!(
ObjectHeader::parse(&data, 0, 8, 8).unwrap().messages.len(),
2
);
}
#[test]
fn v1_prefix_chunk_size_must_fit_the_message_count() {
let mut data = build_v1_header(&[], 8, 8);
data[8] = 8; // no messages but a non-empty chunk
data.extend_from_slice(&[0u8; 8]);
assert_eq!(
ObjectHeader::parse(&data, 0, 8, 8).unwrap_err(),
FormatError::InvalidObjectHeader("bad object header chunk size")
);
}
#[test]
fn v1_header_cannot_hold_a_reference_count_message() {
// cve-2018-11204.
let data = build_v1_header(&[(0x16, &[0, 2, 0, 0, 0][..], 0)], 8, 8);
assert_eq!(
ObjectHeader::parse(&data, 0, 8, 8).unwrap_err(),
FormatError::InvalidObjectHeader(
"object header version does not support reference count message"
)
);
let data = build_v2_header(0x00, &[(0x16, &[0, 2, 0, 0, 0], 0)], None);
assert!(ObjectHeader::parse(&data, 0, 8, 8).is_ok());
}
#[test]
fn modification_time_messages_are_decoded_with_the_header() {
// cve-2024-33873 (version 0) and cve-2024-33874 (empty message).
for (body, why) in [
(
&[0u8, 0, 0, 0, 1, 2, 3, 4][..],
"bad version number for mtime message",
),
(&[][..], "ran off end of input buffer while decoding"),
] {
let data = build_v2_header(0x00, &[(0x12, body, 0)], None);
assert_eq!(
ObjectHeader::parse(&data, 0, 8, 8).unwrap_err(),
FormatError::InvalidObjectHeader(why)
);
}
let data = build_v2_header(0x00, &[(0x12, &[1, 0, 0, 0, 1, 2, 3, 4], 0)], None);
assert!(ObjectHeader::parse(&data, 0, 8, 8).is_ok());
// The old (0x0E) message is 14 ASCII digits and 2 reserved bytes.
let data = build_v1_header(&[(0x0E, &b"20110414214255\0\0"[..], 0)], 8, 8);
assert!(ObjectHeader::parse(&data, 0, 8, 8).is_ok());
let data = build_v1_header(&[(0x0E, &b"2011041421425x\0\0"[..], 0)], 8, 8);
assert_eq!(
ObjectHeader::parse(&data, 0, 8, 8).unwrap_err(),
FormatError::InvalidObjectHeader("badly formatted modification time message")
);
}
#[test]
fn continuation_message_must_hold_a_nonempty_chunk() {
let mut cont = [0u8; 16];
cont[..8].copy_from_slice(&64u64.to_le_bytes());
let data = build_v2_header(0x00, &[(0x10, &cont, 0)], None);
assert_eq!(
ObjectHeader::parse(&data, 0, 8, 8).unwrap_err(),
FormatError::InvalidObjectHeader("invalid continuation chunk size (0)")
);
let data = build_v2_header(0x00, &[(0x10, &cont[..8], 0)], None);
assert_eq!(
ObjectHeader::parse(&data, 0, 8, 8).unwrap_err(),
FormatError::InvalidObjectHeader("ran off end of input buffer while decoding")
);
}
#[test]
fn v2_prefix_is_checked() {
let data = build_v2_header(0x40, &[(0x01, &[1], 0)], None);
assert_eq!(
ObjectHeader::parse(&data, 0, 8, 8).unwrap_err(),
FormatError::InvalidObjectHeader("unknown object header status flag(s)")
);
// build_v2_header writes max_compact 8, min_dense 6; swap them.
let mut data = build_v2_header(0x10, &[(0x01, &[1], 0)], None);
data[6] = 6;
data[8] = 8;
let chk = crate::checksum::jenkins_lookup3(&data[..data.len() - 4]);
let n = data.len();
data[n - 4..].copy_from_slice(&chk.to_le_bytes());
assert_eq!(
ObjectHeader::parse(&data, 0, 8, 8).unwrap_err(),
FormatError::InvalidObjectHeader("bad object header attribute phase change values")
);
}
#[test]
fn v2_gap_is_allowed_only_without_nil_messages() {
// Three bytes after the last message: a gap (a message header is 4).
let mut data = build_v2_header(0x00, &[(0x01, &[1, 2, 3], 0), (0x03, &[], 0)], None);
// Turn the empty datatype message (4 header bytes) into a 3-byte gap
// by shrinking the chunk.
let n = data.len();
data.truncate(n - 5);
data[6] -= 1;
let chk = crate::checksum::jenkins_lookup3(&data);
data.extend_from_slice(&chk.to_le_bytes());
assert_eq!(
ObjectHeader::parse(&data, 0, 8, 8).unwrap().messages.len(),
1
);
let mut data = build_v2_header(0x00, &[(0x00, &[1, 2, 3], 0), (0x03, &[], 0)], None);
let n = data.len();
data.truncate(n - 5);
data[6] -= 1;
let chk = crate::checksum::jenkins_lookup3(&data);
data.extend_from_slice(&chk.to_le_bytes());
assert_eq!(
ObjectHeader::parse(&data, 0, 8, 8).unwrap_err(),
FormatError::InvalidObjectHeader("gap in chunk with no null messages")
);
}
#[test]
fn parse_v2_unknown_message_flags() {
let data = build_v2_header(0x00, &[(0xF0, &[1, 2], 0x08)], None);
assert!(ObjectHeader::parse(&data, 0, 8, 8).is_ok());
let data = build_v2_header(0x00, &[(0xF0, &[1, 2], 0x80)], None);
assert_eq!(
ObjectHeader::parse(&data, 0, 8, 8).unwrap_err(),
FormatError::UnsupportedMessage(0xF0)
);
}
#[test] #[test]
fn parse_v2_no_timestamps_one_message() { fn parse_v2_no_timestamps_one_message() {
let data = build_v2_header(0x00, &[(0x01, &[10, 20], 0)], None); let data = build_v2_header(0x00, &[(0x01, &[10, 20], 0)], None);
@@ -1,11 +1,17 @@
//! Object header writer for v2 format. //! Object header writer for v2 format.
#[cfg(not(feature = "std"))] #[cfg(not(feature = "std"))]
use alloc::vec::Vec; use alloc::{format, vec::Vec};
use crate::checksum::jenkins_lookup3; use crate::checksum::jenkins_lookup3;
use crate::error::FormatError;
use crate::message_type::MessageType; use crate::message_type::MessageType;
/// Largest message payload a v2 object header can describe: the per-message
/// size field is 2 bytes. A bigger message cannot be encoded at all — writing
/// its size truncated to 16 bits produced files libhdf5 refuses.
pub const MAX_MESSAGE_SIZE: usize = u16::MAX as usize;
/// Writer for v2 object headers with proper checksums. /// Writer for v2 object headers with proper checksums.
pub struct ObjectHeaderWriter { pub struct ObjectHeaderWriter {
messages: Vec<(MessageType, Vec<u8>, u8)>, // (type, data, msg_flags) messages: Vec<(MessageType, Vec<u8>, u8)>, // (type, data, msg_flags)
@@ -30,7 +36,22 @@ impl ObjectHeaderWriter {
} }
/// Serialize the complete v2 object header (OHDR + messages + checksum). /// Serialize the complete v2 object header (OHDR + messages + checksum).
pub fn serialize(&self) -> Vec<u8> { ///
/// Fails with [`FormatError::SerializationError`] when a message is larger
/// than [`MAX_MESSAGE_SIZE`] (e.g. an attribute over ~64 KiB, which would
/// need dense attribute storage), rather than writing a corrupt header.
pub fn serialize(&self) -> Result<Vec<u8>, FormatError> {
if let Some((msg_type, data, _)) = self
.messages
.iter()
.find(|(_, data, _)| data.len() > MAX_MESSAGE_SIZE)
{
return Err(FormatError::SerializationError(format!(
"{msg_type:?} message is {} bytes; an object header message holds at most \
{MAX_MESSAGE_SIZE} bytes",
data.len()
)));
}
// Calculate total message bytes: each message has type(1) + size(2) + flags(1) + data // Calculate total message bytes: each message has type(1) + size(2) + flags(1) + data
let msg_bytes_total: usize = self let msg_bytes_total: usize = self
.messages .messages
@@ -80,7 +101,7 @@ impl ObjectHeaderWriter {
let checksum = jenkins_lookup3(&buf); let checksum = jenkins_lookup3(&buf);
buf.extend_from_slice(&checksum.to_le_bytes()); buf.extend_from_slice(&checksum.to_le_bytes());
buf Ok(buf)
} }
} }
@@ -125,15 +146,22 @@ impl BatchObjectHeaderWriter {
/// Compute the serialized size of each header without actually serializing. /// Compute the serialized size of each header without actually serializing.
/// Returns sizes in the same order as headers were added. /// Returns sizes in the same order as headers were added.
pub fn compute_sizes(&self) -> Vec<usize> { pub fn compute_sizes(&self) -> Result<Vec<usize>, FormatError> {
self.headers.iter().map(|h| h.serialize().len()).collect() self.headers
.iter()
.map(|h| h.serialize().map(|b| b.len()))
.collect()
} }
/// Serialize all headers into a single contiguous buffer. /// Serialize all headers into a single contiguous buffer.
/// Returns `(combined_bytes, offsets)` where `offsets[i]` is the byte /// Returns `(combined_bytes, offsets)` where `offsets[i]` is the byte
/// offset of header `i` within the combined buffer. /// offset of header `i` within the combined buffer.
pub fn serialize_all(&self) -> (Vec<u8>, Vec<usize>) { pub fn serialize_all(&self) -> Result<(Vec<u8>, Vec<usize>), FormatError> {
let serialized: Vec<Vec<u8>> = self.headers.iter().map(|h| h.serialize()).collect(); let serialized: Vec<Vec<u8>> = self
.headers
.iter()
.map(|h| h.serialize())
.collect::<Result<_, _>>()?;
let total: usize = serialized.iter().map(|s| s.len()).sum(); let total: usize = serialized.iter().map(|s| s.len()).sum();
let mut buf = Vec::with_capacity(total); let mut buf = Vec::with_capacity(total);
let mut offsets = Vec::with_capacity(serialized.len()); let mut offsets = Vec::with_capacity(serialized.len());
@@ -141,7 +169,7 @@ impl BatchObjectHeaderWriter {
offsets.push(buf.len()); offsets.push(buf.len());
buf.extend_from_slice(s); buf.extend_from_slice(s);
} }
(buf, offsets) Ok((buf, offsets))
} }
} }
@@ -159,7 +187,7 @@ mod tests {
#[test] #[test]
fn empty_header_roundtrip() { fn empty_header_roundtrip() {
let writer = ObjectHeaderWriter::new(); let writer = ObjectHeaderWriter::new();
let bytes = writer.serialize(); let bytes = writer.serialize().unwrap();
let hdr = ObjectHeader::parse(&bytes, 0, 8, 8).unwrap(); let hdr = ObjectHeader::parse(&bytes, 0, 8, 8).unwrap();
assert_eq!(hdr.version, 2); assert_eq!(hdr.version, 2);
assert_eq!(hdr.messages.len(), 0); assert_eq!(hdr.messages.len(), 0);
@@ -170,7 +198,7 @@ mod tests {
let mut writer = ObjectHeaderWriter::new(); let mut writer = ObjectHeaderWriter::new();
writer.add_message(MessageType::Dataspace, vec![1, 2, 3, 4]); writer.add_message(MessageType::Dataspace, vec![1, 2, 3, 4]);
writer.add_message(MessageType::Datatype, vec![5, 6]); writer.add_message(MessageType::Datatype, vec![5, 6]);
let bytes = writer.serialize(); let bytes = writer.serialize().unwrap();
let hdr = ObjectHeader::parse(&bytes, 0, 8, 8).unwrap(); let hdr = ObjectHeader::parse(&bytes, 0, 8, 8).unwrap();
assert_eq!(hdr.messages.len(), 2); assert_eq!(hdr.messages.len(), 2);
assert_eq!(hdr.messages[0].msg_type, MessageType::Dataspace); assert_eq!(hdr.messages[0].msg_type, MessageType::Dataspace);
@@ -184,12 +212,30 @@ mod tests {
let mut writer = ObjectHeaderWriter::new(); let mut writer = ObjectHeaderWriter::new();
// Add a message with >255 bytes of payload // Add a message with >255 bytes of payload
writer.add_message(MessageType::Datatype, vec![0xAA; 300]); writer.add_message(MessageType::Datatype, vec![0xAA; 300]);
let bytes = writer.serialize(); let bytes = writer.serialize().unwrap();
let hdr = ObjectHeader::parse(&bytes, 0, 8, 8).unwrap(); let hdr = ObjectHeader::parse(&bytes, 0, 8, 8).unwrap();
assert_eq!(hdr.messages.len(), 1); assert_eq!(hdr.messages.len(), 1);
assert_eq!(hdr.messages[0].data.len(), 300); assert_eq!(hdr.messages[0].data.len(), 300);
} }
#[test]
fn oversized_message_is_an_error_not_a_truncated_size() {
// 65535 bytes is the largest encodable payload.
let mut writer = ObjectHeaderWriter::new();
writer.add_message(MessageType::Attribute, vec![0; MAX_MESSAGE_SIZE]);
let bytes = writer.serialize().unwrap();
let hdr = ObjectHeader::parse(&bytes, 0, 8, 8).unwrap();
assert_eq!(hdr.messages[0].data.len(), MAX_MESSAGE_SIZE);
// One byte more used to be written with its size wrapped to 0.
let mut writer = ObjectHeaderWriter::new();
writer.add_message(MessageType::Attribute, vec![0; MAX_MESSAGE_SIZE + 1]);
assert!(matches!(
writer.serialize(),
Err(FormatError::SerializationError(_))
));
}
#[test] #[test]
fn batch_writer_serialize_all() { fn batch_writer_serialize_all() {
let mut batch = BatchObjectHeaderWriter::new(); let mut batch = BatchObjectHeaderWriter::new();
@@ -204,7 +250,7 @@ mod tests {
batch.add(w2); batch.add(w2);
assert_eq!(batch.len(), 2); assert_eq!(batch.len(), 2);
let (buf, offsets) = batch.serialize_all(); let (buf, offsets) = batch.serialize_all().unwrap();
assert_eq!(offsets.len(), 2); assert_eq!(offsets.len(), 2);
assert_eq!(offsets[0], 0); assert_eq!(offsets[0], 0);
@@ -222,7 +268,7 @@ mod tests {
fn batch_writer_empty() { fn batch_writer_empty() {
let batch = BatchObjectHeaderWriter::new(); let batch = BatchObjectHeaderWriter::new();
assert!(batch.is_empty()); assert!(batch.is_empty());
let (buf, offsets) = batch.serialize_all(); let (buf, offsets) = batch.serialize_all().unwrap();
assert!(buf.is_empty()); assert!(buf.is_empty());
assert!(offsets.is_empty()); assert!(offsets.is_empty());
} }
+82 -16
View File
@@ -10,7 +10,7 @@
use crate::chunked_read::ChunkInfo; use crate::chunked_read::ChunkInfo;
use crate::error::FormatError; use crate::error::FormatError;
use crate::filter_pipeline::FilterPipeline; use crate::filter_pipeline::FilterPipeline;
use crate::filters::decompress_chunk; use crate::filters::decompress_chunk_exact;
use crate::lane_partition::{self, LaneStats, PartitionStats}; use crate::lane_partition::{self, LaneStats, PartitionStats};
/// Threshold: only use parallel decompression when chunk count exceeds this. /// Threshold: only use parallel decompression when chunk count exceeds this.
@@ -84,11 +84,14 @@ pub fn decompress_chunks_lane_partitioned(
} }
let raw_chunk = &file_data[c_addr..c_addr + size]; let raw_chunk = &file_data[c_addr..c_addr + size];
let decompressed = if chunk_info.filter_mask == 0 { let decompressed = decompress_chunk_exact(
decompress_chunk(raw_chunk, pipeline, chunk_total_bytes, element_size)? raw_chunk,
} else { pipeline,
raw_chunk.to_vec() chunk_total_bytes,
}; element_size,
chunk_info.filter_mask,
&chunk_info.offsets,
)?;
stats.chunks_processed += 1; stats.chunks_processed += 1;
stats.compressed_bytes += size as u64; stats.compressed_bytes += size as u64;
@@ -158,11 +161,14 @@ pub fn decompress_chunks_parallel(
} }
let raw_chunk = &file_data[c_addr..c_addr + size]; let raw_chunk = &file_data[c_addr..c_addr + size];
let decompressed = if chunk_info.filter_mask == 0 { let decompressed = decompress_chunk_exact(
decompress_chunk(raw_chunk, pipeline, chunk_total_bytes, element_size)? raw_chunk,
} else { pipeline,
raw_chunk.to_vec() chunk_total_bytes,
}; element_size,
chunk_info.filter_mask,
&chunk_info.offsets,
)?;
Ok(DecompressedChunk { Ok(DecompressedChunk {
index, index,
@@ -200,11 +206,14 @@ pub fn decompress_chunks_sequential(
let raw_chunk = &file_data[c_addr..c_addr + size]; let raw_chunk = &file_data[c_addr..c_addr + size];
let decompressed = if let Some(pl) = pipeline { let decompressed = if let Some(pl) = pipeline {
if chunk_info.filter_mask == 0 { decompress_chunk_exact(
decompress_chunk(raw_chunk, pl, chunk_total_bytes, element_size)? raw_chunk,
} else { pl,
raw_chunk.to_vec() chunk_total_bytes,
} element_size,
chunk_info.filter_mask,
&chunk_info.offsets,
)?
} else { } else {
raw_chunk.to_vec() raw_chunk.to_vec()
}; };
@@ -212,3 +221,60 @@ pub fn decompress_chunks_sequential(
} }
Ok(result) Ok(result)
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::filter_pipeline::{FILTER_SHUFFLE, FilterDescription};
/// Eight shuffled 32-byte chunks; chunk 5 is stored short when `short`.
fn chunks(short: bool) -> (Vec<u8>, Vec<ChunkInfo>) {
let mut file = Vec::new();
let mut infos = Vec::new();
for i in 0..8u64 {
let len = if short && i == 5 { 16 } else { 32 };
infos.push(ChunkInfo {
chunk_size: len as u32,
filter_mask: 0,
offsets: vec![i * 8],
address: file.len() as u64,
});
file.extend(core::iter::repeat_n(i as u8, len));
}
(file, infos)
}
/// Every parallel decoder refuses a chunk that decodes short, naming it.
#[test]
fn short_decoded_chunk_is_an_error() {
let pipeline = FilterPipeline {
version: 2,
filters: vec![FilterDescription {
filter_id: FILTER_SHUFFLE,
name: None,
flags: 0,
client_data: vec![4],
}],
};
let (file, good) = chunks(false);
assert_eq!(
decompress_chunks_parallel(&file, &good, &pipeline, 32, 4).unwrap()[5],
[5u8; 32]
);
let (file, bad) = chunks(true);
let errs = [
decompress_chunks_lane_partitioned(&file, &bad, &pipeline, 32, 4, 1, Some(3))
.map(|_| ())
.unwrap_err(),
decompress_chunks_parallel(&file, &bad, &pipeline, 32, 4)
.map(|_| ())
.unwrap_err(),
decompress_chunks_sequential(&file, &bad, Some(&pipeline), 32, 4)
.map(|_| ())
.unwrap_err(),
];
for e in errs {
assert!(e.to_string().contains("[40]"), "{e}");
}
}
}
+12 -5
View File
@@ -22,7 +22,7 @@ use crate::data_read::extract_selection_from_buffer;
use crate::dataspace::Dataspace; use crate::dataspace::Dataspace;
use crate::error::FormatError; use crate::error::FormatError;
use crate::filter_pipeline::FilterPipeline; use crate::filter_pipeline::FilterPipeline;
use crate::filters::decompress_chunk; use crate::filters::{all_filters_skipped, decompress_chunk_exact};
use crate::selection::Selection; use crate::selection::Selection;
/// The smallest axis-aligned box containing every selected element, as /// The smallest axis-aligned box containing every selected element, as
@@ -325,12 +325,19 @@ pub fn read_selection(
expected: at.saturating_add(chunk.chunk_size as usize), expected: at.saturating_add(chunk.chunk_size as usize),
available: file_data.len(), available: file_data.len(),
})?; })?;
// Mirrors the full-read path: a non-zero filter mask means the // Mirrors the full-read path: filter-mask bit i set means
// chunk was stored unfiltered. // filter i was not applied to this chunk.
let decoded; let decoded;
let data: &[u8] = match pipeline { let data: &[u8] = match pipeline {
Some(pl) if chunk.filter_mask == 0 => { Some(pl) if !all_filters_skipped(pl, chunk.filter_mask) => {
decoded = decompress_chunk(raw, pl, chunk_bytes, elem_size as u32)?; decoded = decompress_chunk_exact(
raw,
pl,
chunk_bytes,
elem_size as u32,
chunk.filter_mask,
&chunk.offsets[..rank],
)?;
&decoded &decoded
} }
_ => raw, _ => raw,
+2 -2
View File
@@ -43,7 +43,7 @@ impl Default for DatasetCreateProps {
fletcher32: false, fletcher32: false,
lz4: false, lz4: false,
zstd_level: None, zstd_level: None,
fill_time: FillTime::Alloc, fill_time: FillTime::IfSet,
compact: false, compact: false,
alignment: 0, alignment: 0,
} }
@@ -335,7 +335,7 @@ mod tests {
fn dcpl_defaults() { fn dcpl_defaults() {
let dcpl = DatasetCreateProps::new(); let dcpl = DatasetCreateProps::new();
assert!(dcpl.chunk_dims.is_none()); assert!(dcpl.chunk_dims.is_none());
assert_eq!(dcpl.fill_time, FillTime::Alloc); assert_eq!(dcpl.fill_time, FillTime::IfSet);
assert!(!dcpl.compact); assert!(!dcpl.compact);
} }
+389 -97
View File
@@ -229,44 +229,47 @@ impl Selection {
/// self-describing in length, so the count lets a caller walk a packed list /// self-describing in length, so the count lets a caller walk a packed list
/// of selections — as the Virtual Dataset global-heap block does). /// of selections — as the Virtual Dataset global-heap block does).
/// ///
/// Only the forms needed for VDS assembly are decoded: `ALL`, `NONE`, and /// Decodes `ALL`, `NONE`, and hyperslabs at every version libhdf5 writes
/// **regular** hyperslabs serialized at **version 3** (the encoding HDF5 /// (1: irregular, 4-byte coordinates — the default-format encoding; 2:
/// 1.10+/2.0 emit). Point selections, irregular hyperslabs, and older /// regular, 8-byte; 3: either, variable width). A regular hyperslab maps
/// hyperslab versions return an error rather than mis-decoding. /// to [`Selection::Hyperslab`]; an *irregular* one (a union of blocks)
/// maps to a single-block hyperslab when it has one block, and otherwise to
/// [`Selection::Points`] listing the union in row-major order (the order
/// libhdf5 iterates it in). Unlimited counts/blocks decode as `u64::MAX`
/// (see [`SerializedSelection::decode`] for the raw form). Point
/// selections are refused: libhdf5 does not allow them in virtual datasets
/// either.
pub fn decode_serialized(data: &[u8]) -> Result<(Selection, usize), FormatError> { pub fn decode_serialized(data: &[u8]) -> Result<(Selection, usize), FormatError> {
if data.len() < 8 { let (raw, len) = SerializedSelection::decode(data)?;
return Err(FormatError::UnexpectedEof { let sel = match raw {
expected: 8, SerializedSelection::All => Selection::All,
available: data.len(), SerializedSelection::None => Selection::None,
}); SerializedSelection::Regular {
start,
stride,
count,
block,
} => Selection::Hyperslab {
start,
stride,
count,
block,
},
SerializedSelection::Blocks { rank, starts, ends } => {
if starts.len() == rank {
let block = starts.iter().zip(&ends).map(|(&s, &e)| e - s + 1).collect();
Selection::Hyperslab {
start: starts,
stride: vec![1; rank],
count: vec![1; rank],
block,
} }
let sel_type = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
let version = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
match sel_type {
// ALL / NONE: type(4) + version(4) + reserved(4) + length(4) = 16 bytes.
3 | 0 => {
if data.len() < 16 {
return Err(FormatError::UnexpectedEof {
expected: 16,
available: data.len(),
});
}
let sel = if sel_type == 3 {
Selection::All
} else { } else {
Selection::None Selection::Points(blocks_union_coords(rank, &starts, &ends)?)
}
}
}; };
Ok((sel, 16)) Ok((sel, len))
}
2 => decode_hyperslab_serialized(data, version),
1 => Err(FormatError::ChunkedReadError(
"VDS point selections are not supported".into(),
)),
_ => Err(FormatError::ChunkedReadError(
"unknown dataspace selection type".into(),
)),
}
} }
/// Enumerate the selected element indices of a **1-D** dataspace of the /// Enumerate the selected element indices of a **1-D** dataspace of the
@@ -314,6 +317,11 @@ impl Selection {
"VDS selection rank does not match dataspace rank".into(), "VDS selection rank does not match dataspace rank".into(),
)); ));
} }
if count.iter().chain(block.iter()).any(|&v| v == UNLIMITED) {
return Err(FormatError::ChunkedReadError(
"unlimited selection must be clipped before it is enumerated".into(),
));
}
// Selected coordinates along each dimension, in order. // Selected coordinates along each dimension, in order.
let mut per_dim: Vec<Vec<u64>> = Vec::with_capacity(rank); let mut per_dim: Vec<Vec<u64>> = Vec::with_capacity(rank);
for d in 0..rank { for d in 0..rank {
@@ -400,59 +408,174 @@ impl Selection {
} }
} }
/// Decode an `H5S_SEL_HYPER` selection in its serialized form. Only version-3 /// Hyperslab count/block value meaning "unlimited" (`H5S_UNLIMITED`).
/// **regular** hyperslabs are supported. pub const UNLIMITED: u64 = u64::MAX;
fn decode_hyperslab_serialized(
data: &[u8], /// Largest number of elements an irregular selection is expanded to when it
version: u32, /// is converted to a point list by [`Selection::decode_serialized`].
) -> Result<(Selection, usize), FormatError> { const MAX_EXPANDED_POINTS: u64 = 1 << 26;
if version != 3 {
return Err(FormatError::ChunkedReadError( /// A selection exactly as `H5S_select_serialize` stores it, before it is
"only version-3 hyperslab selections are supported".into(), /// applied to any dataspace.
)); ///
/// Unlike [`Selection`] this keeps an irregular hyperslab as its list of
/// blocks, and a regular hyperslab's count/block may be [`UNLIMITED`] (the
/// unlimited selections used by unlimited and "printf" virtual dataset
/// mappings).
#[derive(Debug, Clone, PartialEq)]
pub enum SerializedSelection {
/// `H5S_SEL_ALL`.
All,
/// `H5S_SEL_NONE`.
None,
/// A regular hyperslab. `count[d]` or `block[d]` may be [`UNLIMITED`].
Regular {
start: Vec<u64>,
stride: Vec<u64>,
count: Vec<u64>,
block: Vec<u64>,
},
/// An irregular hyperslab: the union of `starts.len() / rank` blocks, each
/// given by its first (`starts`) and last (`ends`, inclusive) coordinate,
/// flattened block-major.
Blocks {
rank: usize,
starts: Vec<u64>,
ends: Vec<u64>,
},
}
fn sel_err(msg: &str) -> FormatError {
FormatError::ChunkedReadError(msg.into())
}
/// Bounds-checked little-endian reader over a serialized selection.
struct SelReader<'a> {
data: &'a [u8],
pos: usize,
}
impl SelReader<'_> {
fn take(&mut self, n: usize) -> Result<&[u8], FormatError> {
let end = self.pos.checked_add(n).filter(|&e| e <= self.data.len());
let end = end.ok_or(FormatError::UnexpectedEof {
expected: self.pos.saturating_add(n),
available: self.data.len(),
})?;
let s = &self.data[self.pos..end];
self.pos = end;
Ok(s)
} }
// type(4) ver(4) flags(1) enc_size(1) rank(4) [start,stride,count,block]*rank
if data.len() < 14 { fn uint(&mut self, size: usize) -> Result<u64, FormatError> {
return Err(FormatError::UnexpectedEof { let bytes = self.take(size)?;
expected: 14, Ok(bytes
available: data.len(), .iter()
}); .enumerate()
.fold(0u64, |v, (i, &b)| v | (b as u64) << (i * 8)))
} }
let flags = data[8];
let enc_size = data[9] as usize; fn remaining(&self) -> usize {
// Bit 0 set => regular hyperslab. Irregular hyperslabs list explicit blocks. self.data.len() - self.pos
if flags & 0x01 == 0 {
return Err(FormatError::ChunkedReadError(
"irregular VDS hyperslab selections are not supported".into(),
));
} }
if enc_size != 2 && enc_size != 4 && enc_size != 8 { }
return Err(FormatError::ChunkedReadError(
"unsupported hyperslab coordinate encoding size".into(), impl SerializedSelection {
)); /// Decode a serialized selection, returning it and the number of bytes it
} /// occupies. Mirrors libhdf5's `H5S_select_deserialize`: `ALL`/`NONE` and
let rank = u32::from_le_bytes([data[10], data[11], data[12], data[13]]) as usize; /// hyperslab versions 1-3 are decoded; point selections (which libhdf5
// HDF5 caps dataspace rank at 32 (H5S_MAX_RANK). Reject anything larger so a /// refuses in virtual datasets) and malformed input are errors.
// corrupt rank can't drive a huge allocation or read loop. pub fn decode(data: &[u8]) -> Result<(SerializedSelection, usize), FormatError> {
if rank > 32 { let mut r = SelReader { data, pos: 0 };
return Err(FormatError::ChunkedReadError( let sel_type = r.uint(4)?;
"hyperslab selection rank exceeds maximum (32)".into(), let version = r.uint(4)?;
)); match sel_type {
} // ALL / NONE: type(4) + version(4) + reserved(4) + length(4).
let mut pos = 14; 0 | 3 => {
let read_coord = |data: &[u8], pos: usize| -> Result<u64, FormatError> { r.take(8)?;
if pos + enc_size > data.len() { let sel = if sel_type == 3 {
return Err(FormatError::UnexpectedEof { SerializedSelection::All
expected: pos + enc_size, } else {
available: data.len(), SerializedSelection::None
});
}
let mut v = 0u64;
for (i, &b) in data[pos..pos + enc_size].iter().enumerate() {
v |= (b as u64) << (i * 8);
}
Ok(v)
}; };
Ok((sel, r.pos))
}
2 => {
let sel = decode_hyperslab(&mut r, version)?;
Ok((sel, r.pos))
}
1 => Err(sel_err(
"VDS point selections are not supported (libhdf5 rejects them too)",
)),
_ => Err(sel_err("unknown dataspace selection type")),
}
}
/// The single dimension in which this selection is unlimited, if any.
pub fn unlimited_dim(&self) -> Option<usize> {
match self {
SerializedSelection::Regular { count, block, .. } => count
.iter()
.zip(block)
.position(|(&c, &b)| c == UNLIMITED || b == UNLIMITED),
_ => None,
}
}
/// The rank the selection was serialized with (`None` for ALL/NONE, which
/// carry no rank).
pub fn rank(&self) -> Option<usize> {
match self {
SerializedSelection::Regular { start, .. } => Some(start.len()),
SerializedSelection::Blocks { rank, .. } => Some(*rank),
_ => None,
}
}
}
/// `H5S__hyper_deserialize`: after the type and version words.
fn decode_hyperslab(r: &mut SelReader, version: u64) -> Result<SerializedSelection, FormatError> {
const REGULAR: u8 = 0x01;
let (flags, enc_size) = match version {
// v1: reserved(4) + length(4), always irregular, 4-byte coordinates.
1 => {
r.take(8)?;
(0u8, 4usize)
}
// v2: flags(1) + length(4), 8-byte coordinates.
2 => {
let flags = r.take(1)?[0];
r.take(4)?;
(flags, 8)
}
// v3: flags(1) + encoding size(1).
3 => {
let flags = r.take(1)?[0];
let enc = r.take(1)?[0] as usize;
(flags, enc)
}
_ => return Err(sel_err("unsupported hyperslab selection version")),
};
if flags & !REGULAR != 0 {
return Err(sel_err("unknown hyperslab selection flags"));
}
if !matches!(enc_size, 2 | 4 | 8) {
return Err(sel_err("unsupported hyperslab coordinate encoding size"));
}
let rank = r.uint(4)? as usize;
// HDF5 caps dataspace rank at 32 (H5S_MAX_RANK). Reject anything else so a
// corrupt rank can't drive a huge allocation or read loop.
if rank == 0 || rank > 32 {
return Err(sel_err("hyperslab selection rank must be 1..=32"));
}
// The all-ones value of the encoding width means "unlimited".
let unlim_raw = if enc_size == 8 {
u64::MAX
} else {
(1u64 << (enc_size * 8)) - 1
};
if flags & REGULAR != 0 {
let (mut start, mut stride, mut count, mut block) = ( let (mut start, mut stride, mut count, mut block) = (
Vec::with_capacity(rank), Vec::with_capacity(rank),
Vec::with_capacity(rank), Vec::with_capacity(rank),
@@ -460,24 +583,104 @@ fn decode_hyperslab_serialized(
Vec::with_capacity(rank), Vec::with_capacity(rank),
); );
for _ in 0..rank { for _ in 0..rank {
start.push(read_coord(data, pos)?); start.push(r.uint(enc_size)?);
pos += enc_size; stride.push(r.uint(enc_size)?);
stride.push(read_coord(data, pos)?); let c = r.uint(enc_size)?;
pos += enc_size; count.push(if c == unlim_raw { UNLIMITED } else { c });
count.push(read_coord(data, pos)?); let b = r.uint(enc_size)?;
pos += enc_size; block.push(if b == unlim_raw { UNLIMITED } else { b });
block.push(read_coord(data, pos)?);
pos += enc_size;
} }
Ok(( let unlimited = count
Selection::Hyperslab { .iter()
.zip(&block)
.filter(|&(&c, &b)| c == UNLIMITED || b == UNLIMITED)
.count();
if unlimited > 1 {
return Err(sel_err(
"hyperslab selection is unlimited in more than one dimension",
));
}
for d in 0..rank {
// Overlapping blocks are not a valid regular hyperslab.
if count[d] > 1 && block[d] != UNLIMITED && block[d] > stride[d] {
return Err(sel_err("regular hyperslab blocks overlap"));
}
}
return Ok(SerializedSelection::Regular {
start, start,
stride, stride,
count, count,
block, block,
}, });
pos, }
))
// Irregular: number of blocks, then each block's start and end corners.
let nblocks = r.uint(enc_size)?;
let per_block = (rank * 2 * enc_size) as u64;
// Untrusted count: it must fit in what is left of the buffer.
if nblocks
.checked_mul(per_block)
.is_none_or(|need| need > r.remaining() as u64)
{
return Err(FormatError::UnexpectedEof {
expected: r
.pos
.saturating_add(nblocks.saturating_mul(per_block) as usize),
available: r.data.len(),
});
}
let n = nblocks as usize * rank;
let (mut starts, mut ends) = (Vec::with_capacity(n), Vec::with_capacity(n));
for _ in 0..nblocks {
for _ in 0..rank {
starts.push(r.uint(enc_size)?);
}
for _ in 0..rank {
ends.push(r.uint(enc_size)?);
}
}
if starts.iter().zip(&ends).any(|(s, e)| e < s) {
return Err(sel_err("hyperslab block ends before it starts"));
}
Ok(SerializedSelection::Blocks { rank, starts, ends })
}
/// The coordinates of the union of the given blocks, in row-major order.
fn blocks_union_coords(
rank: usize,
starts: &[u64],
ends: &[u64],
) -> Result<Vec<Vec<u64>>, FormatError> {
let mut total = 0u64;
for (s, e) in starts.chunks_exact(rank).zip(ends.chunks_exact(rank)) {
let vol = s
.iter()
.zip(e)
.try_fold(1u64, |acc, (&s, &e)| acc.checked_mul(e - s + 1));
total = vol
.and_then(|v| total.checked_add(v))
.filter(|&t| t <= MAX_EXPANDED_POINTS)
.ok_or_else(|| sel_err("irregular hyperslab selection is too large to expand"))?;
}
let mut out = Vec::with_capacity(total as usize);
for (s, e) in starts.chunks_exact(rank).zip(ends.chunks_exact(rank)) {
let mut cur = s.to_vec();
'block: loop {
out.push(cur.clone());
for d in (0..rank).rev() {
if cur[d] < e[d] {
cur[d] += 1;
continue 'block;
}
cur[d] = s[d];
}
break;
}
}
// Lexicographic order of coordinates is row-major order.
out.sort_unstable();
out.dedup();
Ok(out)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -642,11 +845,100 @@ mod tests {
} }
#[test] #[test]
fn decode_irregular_hyperslab_rejected() { fn decode_truncated_irregular_hyperslab_is_error() {
// Irregular, rank 1, but the block count is missing.
let bytes = [0x02u8, 0, 0, 0, 0x03, 0, 0, 0, 0x00, 0x02, 0x01, 0, 0, 0]; let bytes = [0x02u8, 0, 0, 0, 0x03, 0, 0, 0, 0x00, 0x02, 0x01, 0, 0, 0];
assert!(Selection::decode_serialized(&bytes).is_err()); assert!(Selection::decode_serialized(&bytes).is_err());
} }
/// Version 1 as libhdf5 writes it for the default (earliest) format bounds:
/// type, version, reserved(4), length(4), rank(4), nblocks(4), then each
/// block's start and inclusive end corner as 4-byte values.
fn v1_blocks(rank: u32, blocks: &[(&[u32], &[u32])]) -> Vec<u8> {
let mut b = Vec::new();
for w in [2u32, 1, 0, 0, rank, blocks.len() as u32] {
b.extend_from_slice(&w.to_le_bytes());
}
for (s, e) in blocks {
for v in s.iter().chain(e.iter()) {
b.extend_from_slice(&v.to_le_bytes());
}
}
b
}
#[test]
fn decode_v1_irregular_single_block() {
// Exactly what h5py/HDF5 2.0 writes for `[0:4]` with default libver.
let bytes = v1_blocks(1, &[(&[0], &[3])]);
let (sel, used) = Selection::decode_serialized(&bytes).unwrap();
assert_eq!(used, bytes.len());
assert_eq!(sel.iter_linear_1d(8).unwrap(), vec![0, 1, 2, 3]);
}
#[test]
fn decode_v1_irregular_union_is_row_major() {
// Blocks given out of order and overlapping still enumerate once each,
// in row-major order (libhdf5 iterates the union, not the list).
let bytes = v1_blocks(2, &[(&[1, 0], &[1, 1]), (&[0, 2], &[1, 2])]);
let (sel, used) = Selection::decode_serialized(&bytes).unwrap();
assert_eq!(used, bytes.len());
// (0,2) (1,0) (1,1) (1,2) in a 2x3 space.
assert_eq!(sel.iter_linear(&[2, 3]).unwrap(), vec![2, 3, 4, 5]);
}
#[test]
fn decode_v2_regular_with_unlimited_count() {
// v2: flags(1) + length(4), then 8-byte start/stride/count/block.
let mut b = Vec::new();
b.extend_from_slice(&2u32.to_le_bytes());
b.extend_from_slice(&2u32.to_le_bytes());
b.push(0x01);
b.extend_from_slice(&36u32.to_le_bytes());
b.extend_from_slice(&1u32.to_le_bytes());
for v in [0u64, 10, u64::MAX, 10] {
b.extend_from_slice(&v.to_le_bytes());
}
let (raw, used) = SerializedSelection::decode(&b).unwrap();
assert_eq!(used, b.len());
assert_eq!(raw.unlimited_dim(), Some(0));
assert_eq!(
raw,
SerializedSelection::Regular {
start: vec![0],
stride: vec![10],
count: vec![UNLIMITED],
block: vec![10],
}
);
// An unclipped unlimited selection cannot be enumerated.
let (sel, _) = Selection::decode_serialized(&b).unwrap();
assert!(sel.iter_linear_1d(100).is_err());
}
#[test]
fn decode_v3_two_byte_all_ones_is_unlimited() {
let bytes = [
0x02, 0, 0, 0, 0x03, 0, 0, 0, 0x01, 0x02, 0x01, 0, 0, 0, //
0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0xFF, 0xFF,
];
let (raw, _) = SerializedSelection::decode(&bytes).unwrap();
assert_eq!(raw.unlimited_dim(), Some(0));
}
#[test]
fn decode_irregular_block_count_beyond_buffer_is_error() {
let mut b = v1_blocks(1, &[(&[0], &[3])]);
b[20..24].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(Selection::decode_serialized(&b).is_err());
}
#[test]
fn decode_point_selection_is_refused() {
let bytes = [1u8, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
assert!(Selection::decode_serialized(&bytes).is_err());
}
#[test] #[test]
fn iter_linear_2d_block_row_major() { fn iter_linear_2d_block_row_major() {
// A 2x2 block at the top-left of a 4x4 space => linear 0,1,4,5. // A 2x2 block at the top-left of a 4x4 space => linear 0,1,4,5.
+120 -16
View File
@@ -154,13 +154,29 @@ pub fn is_shared(msg_flags: u8) -> bool {
/// ///
/// When the shared flag is set on a message, the data contains a reference /// When the shared flag is set on a message, the data contains a reference
/// instead of the actual message content. /// instead of the actual message content.
///
/// Assumes the file's length size equals its offset size, which only matters
/// for version-1 references; use [`parse_shared_ref_sized`] when the
/// superblock's length size is known.
pub fn parse_shared_ref(data: &[u8], offset_size: u8) -> Result<SharedMessageRef, FormatError> { pub fn parse_shared_ref(data: &[u8], offset_size: u8) -> Result<SharedMessageRef, FormatError> {
parse_shared_ref_sized(data, offset_size, offset_size)
}
/// [`parse_shared_ref`] with the superblock's length size, which locates the
/// object header address in a version-1 reference.
pub fn parse_shared_ref_sized(
data: &[u8],
offset_size: u8,
length_size: u8,
) -> Result<SharedMessageRef, FormatError> {
ensure_len(data, 0, 2)?; ensure_len(data, 0, 2)?;
let version = data[0]; let version = data[0];
let ref_type = data[1]; let ref_type = data[1];
// Layouts (HDF5 spec IV.A.2 "Shared Message", and libhdf5's decoder): // Layouts (HDF5 spec IV.A.2 "Shared Message", and libhdf5's decoder):
// v1: version, type, reserved(6), address — always "committed" // v1: version, type, reserved(6), then an old-style symbol table
// entry: link-name offset(length_size), object header address,
// cache type(4), reserved(4), scratch(16) — always "committed"
// v2: version, type, address — always "committed" // v2: version, type, address — always "committed"
// v3: version, type, then a fractal-heap ID if type == SOHM, otherwise // v3: version, type, then a fractal-heap ID if type == SOHM, otherwise
// an address // an address
@@ -177,7 +193,7 @@ pub fn parse_shared_ref(data: &[u8], offset_size: u8) -> Result<SharedMessageRef
}) })
}; };
match version { match version {
1 => address_at(2 + 6), 1 => address_at(2 + 6 + length_size as usize),
2 => address_at(2), 2 => address_at(2),
3 if ref_type == SHARE_TYPE_SOHM => { 3 if ref_type == SHARE_TYPE_SOHM => {
ensure_len(data, 2, FHEAP_ID_LEN)?; ensure_len(data, 2, FHEAP_ID_LEN)?;
@@ -225,9 +241,12 @@ pub fn parse_sohm_table_message(
/// Parse the SOHM table structure (signature "SMTB") from the file. /// Parse the SOHM table structure (signature "SMTB") from the file.
/// ///
/// Each index entry: index_type(1) + mesg_types(2) + min_mesg_size(4) + /// Each index entry: version(1) + index_type(1) + mesg_types(2) +
/// list_max(2) + btree_min(2) + num_messages(2) + index_addr(offset_size) + /// min_mesg_size(4) + list_max(2) + btree_min(2) + num_messages(2) +
/// heap_addr(offset_size) /// index_addr(offset_size) + heap_addr(offset_size)
///
/// The leading per-index version byte (0) was missing here, so every field
/// after it was read one byte off — verified against an HDF5 2.0 file.
pub fn parse_sohm_table( pub fn parse_sohm_table(
file_data: &[u8], file_data: &[u8],
table_addr: usize, table_addr: usize,
@@ -240,11 +259,16 @@ pub fn parse_sohm_table(
} }
let mut pos = table_addr + 4; let mut pos = table_addr + 4;
let os = offset_size as usize; let os = offset_size as usize;
let entry_size = 1 + 2 + 4 + 2 + 2 + 2 + os + os; // 13 + 2*offset_size let entry_size = 1 + 1 + 2 + 4 + 2 + 2 + 2 + os + os; // 14 + 2*offset_size
let mut indexes = Vec::with_capacity(nindexes as usize); let mut indexes = Vec::with_capacity(nindexes as usize);
for _ in 0..nindexes { for _ in 0..nindexes {
ensure_len(file_data, pos, entry_size)?; ensure_len(file_data, pos, entry_size)?;
let version = file_data[pos];
if version != 0 {
return Err(FormatError::InvalidSohmTableVersion(version));
}
pos += 1;
let index_type = file_data[pos]; let index_type = file_data[pos];
pos += 1; pos += 1;
let mesg_types = u16::from_le_bytes([file_data[pos], file_data[pos + 1]]); let mesg_types = u16::from_le_bytes([file_data[pos], file_data[pos + 1]]);
@@ -381,6 +405,68 @@ pub fn parse_sohm_btree_entries(
// ---- SOHM resolution ---- // ---- SOHM resolution ----
/// Find the SOHM index that handles the given message type. /// Find the SOHM index that handles the given message type.
/// Load a file's SOHM table: superblock → superblock extension → Shared
/// Message Table message → SMTB. `Ok(None)` when the file has no superblock
/// extension or no shared-message table.
pub fn load_sohm_table(
file_data: &[u8],
offset_size: u8,
length_size: u8,
) -> Result<Option<SohmTable>, FormatError> {
let sig = crate::signature::find_signature(file_data)?;
let sb = crate::superblock::Superblock::parse(file_data, sig)?;
let Some(ext_addr) = sb
.superblock_extension_address
.filter(|&a| !is_undefined(a, offset_size))
else {
return Ok(None);
};
let ext = ObjectHeader::parse(file_data, ext_addr as usize, offset_size, length_size)?;
let Some(msg) = ext
.messages
.iter()
.find(|m| m.msg_type == MessageType::SharedMessageTable)
else {
return Ok(None);
};
let table_msg = parse_sohm_table_message(&msg.data, offset_size)?;
parse_sohm_table(
file_data,
table_msg.table_address as usize,
table_msg.nindexes,
offset_size,
)
.map(Some)
}
/// Like [`message_data`], but also follows references into the file's SOHM
/// heap (shared object header messages), loading the SOHM table on demand.
pub fn message_data_with_sohm<'a>(
file_data: &[u8],
msg: &'a crate::object_header::HeaderMessage,
offset_size: u8,
length_size: u8,
) -> Result<Cow<'a, [u8]>, FormatError> {
if !is_shared(msg.flags) {
return Ok(Cow::Borrowed(&msg.data));
}
let shared_ref = parse_shared_ref_sized(&msg.data, offset_size, length_size)?;
let table = if shared_ref.heap_id.is_some() {
load_sohm_table(file_data, offset_size, length_size)?
} else {
None
};
resolve_shared_message_with_sohm(
file_data,
&shared_ref,
msg.msg_type,
offset_size,
length_size,
table.as_ref(),
)
.map(Cow::Owned)
}
fn find_index_for_msg_type(table: &SohmTable, msg_type: MessageType) -> Option<&SohmIndex> { fn find_index_for_msg_type(table: &SohmTable, msg_type: MessageType) -> Option<&SohmIndex> {
let type_bit = 1u16 << msg_type.to_u16(); let type_bit = 1u16 << msg_type.to_u16();
table table
@@ -444,7 +530,7 @@ pub fn message_data<'a>(
if !is_shared(msg.flags) { if !is_shared(msg.flags) {
return Ok(Cow::Borrowed(&msg.data)); return Ok(Cow::Borrowed(&msg.data));
} }
let shared_ref = parse_shared_ref(&msg.data, offset_size)?; let shared_ref = parse_shared_ref_sized(&msg.data, offset_size, length_size)?;
resolve_shared_message( resolve_shared_message(
file_data, file_data,
&shared_ref, &shared_ref,
@@ -459,7 +545,8 @@ pub fn message_data<'a>(
/// ///
/// For type 1/3 (shared in another object header), reads the target object header /// For type 1/3 (shared in another object header), reads the target object header
/// and finds the message of the specified type. /// and finds the message of the specified type.
/// For type 2 (SOHM), uses the fractal heap from the SOHM table. /// For type 2 (SOHM), uses the fractal heap from the file's SOHM table,
/// loaded from the superblock extension on demand.
pub fn resolve_shared_message( pub fn resolve_shared_message(
file_data: &[u8], file_data: &[u8],
shared_ref: &SharedMessageRef, shared_ref: &SharedMessageRef,
@@ -467,13 +554,18 @@ pub fn resolve_shared_message(
offset_size: u8, offset_size: u8,
length_size: u8, length_size: u8,
) -> Result<Vec<u8>, FormatError> { ) -> Result<Vec<u8>, FormatError> {
let table = if shared_ref.heap_id.is_some() {
load_sohm_table(file_data, offset_size, length_size)?
} else {
None
};
resolve_shared_message_with_sohm( resolve_shared_message_with_sohm(
file_data, file_data,
shared_ref, shared_ref,
target_msg_type, target_msg_type,
offset_size, offset_size,
length_size, length_size,
None, table.as_ref(),
) )
} }
@@ -579,15 +671,26 @@ mod tests {
#[test] #[test]
fn parse_v1_ref() { fn parse_v1_ref() {
let mut data = Vec::new(); // Datatype message of `/group1/dset2` in HDF5's `tcompound.h5`
data.push(1); // version // (written in 2000): version 1, six reserved bytes, then an old-style
data.push(0); // type // symbol table entry — link-name offset 0x10, object header address
data.extend_from_slice(&[0u8; 6]); // reserved // 0x590 (the committed datatype `/type1`), cache type, reserved and
data.extend_from_slice(&0x5678u64.to_le_bytes()); // scratch.
let mut data = vec![1, 0, 0, 0, 0, 0, 0, 0];
data.extend_from_slice(&0x10u64.to_le_bytes());
data.extend_from_slice(&0x590u64.to_le_bytes());
data.extend_from_slice(&[0; 24]);
let shared = parse_shared_ref(&data, 8).unwrap(); let shared = parse_shared_ref_sized(&data, 8, 8).unwrap();
assert_eq!(shared.version, 1); assert_eq!(shared.version, 1);
assert_eq!(shared.object_header_address, Some(0x5678)); assert_eq!(shared.object_header_address, Some(0x590));
// The name offset is a length: 4 bytes here, then an 8-byte address.
let mut data = vec![1, 0, 0, 0, 0, 0, 0, 0];
data.extend_from_slice(&0x10u32.to_le_bytes());
data.extend_from_slice(&0x590u64.to_le_bytes());
let shared = parse_shared_ref_sized(&data, 8, 4).unwrap();
assert_eq!(shared.object_header_address, Some(0x590));
} }
#[test] #[test]
@@ -707,6 +810,7 @@ mod tests {
let mut buf = Vec::new(); let mut buf = Vec::new();
buf.extend_from_slice(b"SMTB"); buf.extend_from_slice(b"SMTB");
for idx in indexes { for idx in indexes {
buf.push(0); // version
buf.push(idx.index_type); buf.push(idx.index_type);
buf.extend_from_slice(&idx.mesg_types.to_le_bytes()); buf.extend_from_slice(&idx.mesg_types.to_le_bytes());
buf.extend_from_slice(&idx.min_mesg_size.to_le_bytes()); buf.extend_from_slice(&idx.min_mesg_size.to_le_bytes());
+36
View File
@@ -11,6 +11,16 @@ pub const HDF5_SIGNATURE: [u8; 8] = [0x89, b'H', b'D', b'F', b'\r', b'\n', 0x1A,
/// (powers of two starting at 512, plus offset 0). /// (powers of two starting at 512, plus offset 0).
/// ///
/// Returns the byte offset where the signature was found. /// Returns the byte offset where the signature was found.
///
/// A non-zero offset means the file starts with a *user block*, and every
/// address inside the file is relative to the superblock's position, not to
/// byte 0 (libhdf5 uses the signature's position as the base address even
/// when the stored base-address field disagrees). The parsers in this crate
/// take addresses as indices into `file_data`, so they must be handed the
/// bytes from the signature on — use [`split_user_block`]. [`Superblock::parse`]
/// refuses a non-zero offset for this reason.
///
/// [`Superblock::parse`]: crate::superblock::Superblock::parse
pub fn find_signature(data: &[u8]) -> Result<usize, FormatError> { pub fn find_signature(data: &[u8]) -> Result<usize, FormatError> {
// Check offset 0 // Check offset 0
if data.len() >= 8 && data[..8] == HDF5_SIGNATURE { if data.len() >= 8 && data[..8] == HDF5_SIGNATURE {
@@ -29,6 +39,17 @@ pub fn find_signature(data: &[u8]) -> Result<usize, FormatError> {
Err(FormatError::SignatureNotFound) Err(FormatError::SignatureNotFound)
} }
/// Split a file into its user block and its HDF5 bytes.
///
/// Returns `(user_block, hdf5)`: `user_block` is everything before the
/// superblock signature (empty for most files) and `hdf5` is the rest, in
/// which every HDF5 address is a plain index. Pass `hdf5` as `file_data` to
/// every parser in this crate, and parse the superblock at offset 0 of it.
pub fn split_user_block(data: &[u8]) -> Result<(&[u8], &[u8]), FormatError> {
let offset = find_signature(data)?;
Ok(data.split_at(offset))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -88,6 +109,21 @@ mod tests {
assert_eq!(find_signature(&data), Err(FormatError::SignatureNotFound)); assert_eq!(find_signature(&data), Err(FormatError::SignatureNotFound));
} }
#[test]
fn split_user_block_rebases_at_the_signature() {
let mut data = vec![7u8; 1024];
data[512..520].copy_from_slice(&HDF5_SIGNATURE);
let (ub, hdf5) = split_user_block(&data).unwrap();
assert_eq!(ub.len(), 512);
assert_eq!(hdf5.len(), 512);
assert_eq!(&hdf5[..8], &HDF5_SIGNATURE);
data[..8].copy_from_slice(&HDF5_SIGNATURE);
let (ub, hdf5) = split_user_block(&data).unwrap();
assert!(ub.is_empty());
assert_eq!(hdf5.len(), 1024);
}
#[test] #[test]
fn signature_prefers_earliest() { fn signature_prefers_earliest() {
// Signature at both 0 and 512, should return 0 // Signature at both 0 and 512, should return 0
+91 -5
View File
@@ -39,7 +39,13 @@ pub struct Superblock {
pub superblock_extension_address: Option<u64>, pub superblock_extension_address: Option<u64>,
/// CRC32C checksum (v2/v3 only). /// CRC32C checksum (v2/v3 only).
pub checksum: Option<u32>, pub checksum: Option<u32>,
/// Page size for page-buffer mode (v4 only). `None` for v0–v3. /// Page size of the non-standard "version 4" superblock layout (v4 only).
/// `None` for v0–v3.
///
/// HDF5 has no superblock version 4 — libhdf5 refuses it. A real paged
/// file is a v2/v3 superblock whose extension holds a File Space Info
/// message (what `FileWriter::with_page_size` writes). This field is kept
/// only so such files written by older clawhdf5 versions still parse.
pub page_size: Option<u32>, pub page_size: Option<u32>,
} }
@@ -94,6 +100,42 @@ pub mod swmr_flags {
} }
impl Superblock { impl Superblock {
/// Where the HDF5 data ends, relative to the superblock, for a file of
/// `file_len` bytes whose superblock is at `user_block` (both counted
/// from the start of the file), with libhdf5's truncation check
/// (`H5F__super_read`).
///
/// The superblock records the end of the file's data as an absolute
/// address. A file shorter than that was truncated, and libhdf5 refuses
/// to open it ("truncated file"); so does this, with
/// [`FormatError::TruncatedFile`]. Bytes past that address are not part
/// of the file: libhdf5 fails any read of them ("addr overflow" /
/// "address plus size exceeds file eoa"), so a reader should parse only
/// the data up to the returned end. As libhdf5 does for a SWMR reader,
/// the check is skipped for a version-3 superblock whose writer is still
/// writing it in SWMR mode (it extends the file as it goes); the data
/// then ends at the end of the file.
///
/// When the superblock's recorded base address differs from where the
/// superblock actually is (a user block added or removed after the file
/// was written), libhdf5 moves the recorded end of file by the same
/// amount, and so does this.
pub fn data_end(&self, user_block: u64, file_len: u64) -> Result<u64, FormatError> {
let eof =
i128::from(self.eof_address) - i128::from(self.base_address) + i128::from(user_block);
if eof < 0 || eof > i128::from(file_len) {
if self.version >= 3 && self.is_swmr_write() {
return Ok(file_len.saturating_sub(user_block));
}
return Err(FormatError::TruncatedFile {
stored_eof: u64::try_from(eof).unwrap_or(self.eof_address),
actual_len: file_len,
});
}
// 0 <= eof <= file_len, so it fits a u64.
Ok((eof as u64).saturating_sub(user_block))
}
/// Whether the file was opened with write access when the superblock was written. /// Whether the file was opened with write access when the superblock was written.
pub fn is_write_access(&self) -> bool { pub fn is_write_access(&self) -> bool {
self.consistency_flags & swmr_flags::WRITE_ACCESS != 0 self.consistency_flags & swmr_flags::WRITE_ACCESS != 0
@@ -127,8 +169,9 @@ impl Superblock {
/// Serialize this superblock to bytes. /// Serialize this superblock to bytes.
/// ///
/// Writes v2/v3 format, or v4 (with `page_size`) when `self.version == 4`. /// Writes v2/v3 format, or the non-standard v4 (with `page_size`) when
/// Computes and appends Jenkins lookup3 checksum. /// `self.version == 4` — which no HDF5 library opens; see
/// [`Self::page_size`]. Computes and appends Jenkins lookup3 checksum.
pub fn serialize(&self) -> Vec<u8> { pub fn serialize(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(48); let mut buf = Vec::with_capacity(48);
buf.extend_from_slice(&HDF5_SIGNATURE); buf.extend_from_slice(&HDF5_SIGNATURE);
@@ -167,8 +210,18 @@ impl Superblock {
/// Parse a superblock from `data` starting at `signature_offset`. /// Parse a superblock from `data` starting at `signature_offset`.
/// ///
/// The signature must be present at the given offset. /// The signature must be present at the given offset, and that offset
/// must be 0: every address in an HDF5 file is relative to the
/// superblock, so when a file has a user block (signature at 512, 1024,
/// …) the caller must pass the bytes from the signature on — see
/// [`crate::signature::split_user_block`] — and use that slice as
/// `file_data` everywhere. A non-zero offset is refused with
/// [`FormatError::UserBlockNotStripped`] because the addresses in the
/// returned superblock would otherwise be applied to the wrong bytes.
pub fn parse(data: &[u8], signature_offset: usize) -> Result<Superblock, FormatError> { pub fn parse(data: &[u8], signature_offset: usize) -> Result<Superblock, FormatError> {
if signature_offset != 0 {
return Err(FormatError::UserBlockNotStripped(signature_offset as u64));
}
let d = data let d = data
.get(signature_offset..) .get(signature_offset..)
.ok_or(FormatError::UnexpectedEof { .ok_or(FormatError::UnexpectedEof {
@@ -520,6 +573,30 @@ mod tests {
buf buf
} }
#[test]
fn data_end_refuses_truncated_files_like_libhdf5() {
// build_v2_bytes records base 0, end of file 2048.
let sb = Superblock::parse(&build_v2_bytes(8, 2), 0).unwrap();
assert_eq!(sb.data_end(0, 2048), Ok(2048));
// Bytes past the recorded end are not part of the file.
assert_eq!(sb.data_end(0, 4096), Ok(2048));
assert_eq!(
sb.data_end(0, 2047),
Err(FormatError::TruncatedFile {
stored_eof: 2048,
actual_len: 2047
})
);
// A user block added in front after the file was written (the
// recorded base address is still 0): the end moves with it.
assert_eq!(sb.data_end(512, 2560), Ok(2048));
assert!(sb.data_end(512, 2559).is_err());
// A v3 superblock still being written in SWMR mode is not checked.
let mut swmr = Superblock::parse(&build_v2_bytes(8, 3), 0).unwrap();
swmr.consistency_flags = swmr_flags::WRITE_ACCESS | swmr_flags::SWMR_WRITE;
assert_eq!(swmr.data_end(0, 1000), Ok(1000));
}
#[test] #[test]
fn parse_v0_8byte_offsets() { fn parse_v0_8byte_offsets() {
let data = build_v0_bytes(8); let data = build_v0_bytes(8);
@@ -669,7 +746,16 @@ mod tests {
let mut data = vec![0u8; 1024]; let mut data = vec![0u8; 1024];
let v0 = build_v0_bytes(8); let v0 = build_v0_bytes(8);
data[512..512 + v0.len()].copy_from_slice(&v0); data[512..512 + v0.len()].copy_from_slice(&v0);
let sb = Superblock::parse(&data, 512).unwrap(); // Addresses are relative to the superblock, so parsing in place
// (where they would be applied to the whole buffer) is refused...
assert_eq!(
Superblock::parse(&data, 512),
Err(FormatError::UserBlockNotStripped(512))
);
// ...and the caller parses the bytes from the signature on.
let (ub, hdf5) = crate::signature::split_user_block(&data).unwrap();
assert_eq!(ub.len(), 512);
let sb = Superblock::parse(hdf5, 0).unwrap();
assert_eq!(sb.version, 0); assert_eq!(sb.version, 0);
assert_eq!(sb.root_group_address, 96); assert_eq!(sb.root_group_address, 96);
} }
+149
View File
@@ -0,0 +1,149 @@
//! Mutation fuzzing for the filter decoders (tests only).
//!
//! A decoder fed a random or mutated frame may fail, but must not panic —
//! tests build with overflow checks and debug assertions, so an unchecked
//! subtraction, multiplication or shift on a header field, or an
//! out-of-range slice, fails the test — and must not return more than its
//! output limit.
#[cfg(not(feature = "std"))]
extern crate alloc;
#[cfg(not(feature = "std"))]
use alloc::vec::Vec;
use crate::error::FormatError;
/// xorshift64*: deterministic, so a failure reproduces.
pub(crate) struct Rng(u64);
impl Rng {
pub(crate) fn new(seed: u64) -> Rng {
Rng(seed.max(1))
}
pub(crate) fn next_u64(&mut self) -> u64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
}
/// Uniform in `0..n` (`n` > 0).
pub(crate) fn below(&mut self, n: usize) -> usize {
(self.next_u64() % n as u64) as usize
}
pub(crate) fn bytes(&mut self, n: usize) -> Vec<u8> {
(0..n).map(|_| self.next_u64() as u8).collect()
}
/// A u32 that tends to hit edge cases in size and offset fields.
fn interesting_u32(&mut self, len: usize) -> u32 {
match self.below(10) {
0 => 0,
1 => 1,
2 => self.below(20) as u32,
3 => 15 + self.below(3) as u32,
4 => u32::MAX - self.below(16) as u32,
5 => 1 << self.below(32),
6 => (len as u32)
.wrapping_add(self.below(9) as u32)
.wrapping_sub(4),
7 => i32::MAX as u32,
_ => self.next_u64() as u32,
}
}
}
/// One to four random edits of `seed`.
pub(crate) fn mutate(rng: &mut Rng, seed: &[u8]) -> Vec<u8> {
let mut v = seed.to_vec();
for _ in 0..1 + rng.below(4) {
let len = v.len();
match rng.below(9) {
0 if len > 0 => {
let i = rng.below(len);
v[i] ^= 1 << rng.below(8);
}
1 if len > 0 => {
let i = rng.below(len);
v[i] = rng.next_u64() as u8;
}
2 if len > 0 => {
let i = rng.below(len);
v[i] = [0, 0xff, 0x7f, 0x80, 0x20, 0x1f][rng.below(6)];
}
// A size or offset field: little- or big-endian, anywhere, but
// most often in the first 32 bytes where headers live.
3 | 4 if len >= 4 => {
let span = if rng.below(2) == 0 { len.min(32) } else { len };
let i = rng.below(span - 3);
let x = rng.interesting_u32(len);
let b = if rng.below(2) == 0 {
x.to_le_bytes()
} else {
x.to_be_bytes()
};
v[i..i + 4].copy_from_slice(&b);
}
5 if len > 0 => v.truncate(rng.below(len)),
6 => {
let n = 1 + rng.below(64);
let extra = rng.bytes(n);
v.extend_from_slice(&extra);
}
7 if len > 1 => {
let a = rng.below(len);
let b = a + rng.below(len - a);
let copy = v[a..b].to_vec();
let at = rng.below(len);
v.splice(at..at, copy);
}
_ if len > 0 => {
let i = rng.below(len);
v[i] = v[i].wrapping_add(1 + rng.below(3) as u8);
}
_ => v.push(rng.next_u64() as u8),
}
}
v
}
/// Feed `iters` inputs to `decode`: mostly mutations of `seeds`, some pure
/// noise and some truncated seeds. Asserts only "no panic, output within
/// `limit`".
pub(crate) fn fuzz_decoder(
seed: u64,
seeds: &[Vec<u8>],
iters: usize,
limit: usize,
mut decode: impl FnMut(&[u8]) -> Result<Vec<u8>, FormatError>,
) {
assert!(!seeds.is_empty());
let mut rng = Rng::new(seed);
for s in seeds {
// The seeds themselves must be valid, or the fuzz explores nothing.
decode(s).expect("seed frame must decode");
}
for _ in 0..iters {
let input = match rng.below(16) {
0 => {
let n = rng.below(96);
rng.bytes(n)
}
1 => {
let s = &seeds[rng.below(seeds.len())];
s[..rng.below(s.len() + 1)].to_vec()
}
_ => {
let s = &seeds[rng.below(seeds.len())];
mutate(&mut rng, s)
}
};
if let Ok(out) = decode(&input) {
assert!(out.len() <= limit, "decoded {} > limit {limit}", out.len());
}
}
}
+147 -17
View File
@@ -15,31 +15,83 @@ use crate::datatype::{
/// Controls when fill values are written to dataset storage. /// Controls when fill values are written to dataset storage.
/// ///
/// Corresponds to the HDF5 fill value message's "fill time" field. /// Corresponds to the HDF5 fill value message's "fill time" field
/// (`H5D_fill_time_t`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FillTime { pub enum FillTime {
/// Never write fill values (0x02). Avoids initialization overhead /// Never write fill values (`H5D_FILL_TIME_NEVER`). Avoids
/// for datasets that will be fully written before any read. /// initialization overhead for datasets that will be fully written
/// before any read.
Never, Never,
/// Write fill values at allocation time (0x0a). This is the default /// Write fill values when storage is allocated (`H5D_FILL_TIME_ALLOC`).
/// and matches the HDF5 C library's behavior.
#[default]
Alloc, Alloc,
/// Write fill values only when the fill value has been explicitly set (0x06). /// Write fill values at allocation only if one was set explicitly
/// (`H5D_FILL_TIME_IFSET`). The default, as in the HDF5 C library.
#[default]
IfSet, IfSet,
} }
/// Space allocation time written with every fill value message: late
/// (`H5D_ALLOC_TIME_LATE`), bits 0-1 of the flags byte.
const ALLOC_TIME_LATE: u8 = 2;
impl FillTime { impl FillTime {
/// Serialize to the byte used in the fill value message (version 3). /// Serialize to the flags byte of a version 3 fill value message: the
/// space allocation time (late) in bits 0-1 and the fill time in bits
/// 2-3 (`H5D_FILL_TIME_ALLOC` = 0, `NEVER` = 1, `IFSET` = 2).
///
/// This used to put `Never` in the ALLOC slot, `Alloc` in IFSET and
/// `IfSet` in NEVER, so libhdf5 saw every choice as a different one.
pub fn to_byte(self) -> u8 { pub fn to_byte(self) -> u8 {
ALLOC_TIME_LATE | (self.code() << 2)
}
/// Decode the fill time from a version 3 fill value message's flags.
pub fn from_byte(flags: u8) -> Option<FillTime> {
match (flags >> 2) & 0x03 {
0 => Some(FillTime::Alloc),
1 => Some(FillTime::Never),
2 => Some(FillTime::IfSet),
_ => None,
}
}
fn code(self) -> u8 {
match self { match self {
FillTime::Never => 0x02, FillTime::Alloc => 0,
FillTime::Alloc => 0x0a, FillTime::Never => 1,
FillTime::IfSet => 0x06, FillTime::IfSet => 2,
} }
} }
} }
/// Serialize a version 3 Fill Value message for a dataset of `dt`: the fill
/// time, and the user-defined fill value if there is one (bit 5).
pub(crate) fn fill_value_message(
fill_time: FillTime,
value: Option<&[u8]>,
dt: &Datatype,
) -> Result<Vec<u8>, crate::error::FormatError> {
let mut msg = vec![3, fill_time.to_byte()];
if let Some(value) = value {
if matches!(dt, Datatype::VariableLength { .. }) {
return Err(crate::error::FormatError::SerializationError(
"a fill value for a variable-length datatype is not supported".into(),
));
}
if value.len() != dt.type_size() as usize {
return Err(crate::error::FormatError::DataSizeMismatch {
expected: dt.type_size() as usize,
actual: value.len(),
});
}
msg[1] |= 0x20; // fill value defined
msg.extend_from_slice(&(value.len() as u32).to_le_bytes());
msg.extend_from_slice(value);
}
Ok(msg)
}
// ---- Datatype constructors ---- // ---- Datatype constructors ----
pub fn make_f64_type() -> Datatype { pub fn make_f64_type() -> Datatype {
@@ -332,7 +384,11 @@ pub(crate) fn build_attr_message(name: &str, value: &AttrValue) -> AttributeMess
raw_data: data.clone(), raw_data: data.clone(),
}, },
AttrValue::String(s) => { AttrValue::String(s) => {
let bytes = s.as_bytes(); // A fixed-length string type must be at least 1 byte: libhdf5
// rejects size 0 ("invalid datatype size") and with it every
// attribute on the object. h5py stores "" as one NUL byte.
let mut bytes = s.as_bytes().to_vec();
bytes.resize(bytes.len().max(1), 0);
AttributeMessage { AttributeMessage {
name: name.to_string(), name: name.to_string(),
datatype: Datatype::String { datatype: Datatype::String {
@@ -341,11 +397,12 @@ pub(crate) fn build_attr_message(name: &str, value: &AttrValue) -> AttributeMess
charset: CharacterSet::Utf8, charset: CharacterSet::Utf8,
}, },
dataspace: scalar_ds(), dataspace: scalar_ds(),
raw_data: bytes.to_vec(), raw_data: bytes,
} }
} }
AttrValue::StringArray(arr) => { AttrValue::StringArray(arr) => {
let max_len = arr.iter().map(|s| s.len()).max().unwrap_or(0); // At least 1 byte per element, as for a single string.
let max_len = arr.iter().map(|s| s.len()).max().unwrap_or(0).max(1);
let mut raw = Vec::new(); let mut raw = Vec::new();
for s in arr { for s in arr {
let mut b = s.as_bytes().to_vec(); let mut b = s.as_bytes().to_vec();
@@ -431,8 +488,10 @@ pub struct DatasetBuilder {
pub(crate) data: Option<Vec<u8>>, pub(crate) data: Option<Vec<u8>>,
pub(crate) attrs: Vec<(String, AttrValue)>, pub(crate) attrs: Vec<(String, AttrValue)>,
pub(crate) chunk_options: ChunkOptions, pub(crate) chunk_options: ChunkOptions,
/// Controls when fill values are written. Default is `FillTime::Alloc`. /// Controls when fill values are written. Default is `FillTime::IfSet`.
pub(crate) fill_time: FillTime, pub(crate) fill_time: FillTime,
/// User-defined fill value: one element's bytes, as stored.
pub(crate) fill_value: Option<Vec<u8>>,
/// Use compact (inline) storage: data is stored in the object header. /// Use compact (inline) storage: data is stored in the object header.
/// Only valid when raw data is <= 65536 bytes and dataset is not chunked. /// Only valid when raw data is <= 65536 bytes and dataset is not chunked.
pub(crate) compact: bool, pub(crate) compact: bool,
@@ -459,6 +518,7 @@ impl DatasetBuilder {
attrs: Vec::new(), attrs: Vec::new(),
chunk_options: ChunkOptions::default(), chunk_options: ChunkOptions::default(),
fill_time: FillTime::default(), fill_time: FillTime::default(),
fill_value: None,
compact: false, compact: false,
alignment: 0, alignment: 0,
virtual_sources: None, virtual_sources: None,
@@ -671,7 +731,67 @@ impl DatasetBuilder {
self self
} }
/// Enable Pcodec lossless numerical compression (clawhdf5 filter ID 32023). /// Compress with a plugin filter ([`PluginFilter`]), in the format the
/// libhdf5 plugin reads (h5py, hdf5plugin). Implies chunked storage.
/// Each filter needs its cargo feature (`lzf`, ...); writing fails with
/// `UnsupportedFilter` without it.
///
/// [`PluginFilter`]: crate::chunked_write::PluginFilter
pub fn with_plugin_filter(&mut self, filter: crate::chunked_write::PluginFilter) -> &mut Self {
self.chunk_options.plugin = Some(filter);
self
}
/// Enable LZF compression (filter 32000) — h5py's built-in
/// `compression="lzf"`. Implies chunked storage; shuffle is applied
/// first unless `.without_shuffle()`. Requires the `lzf` cargo feature.
pub fn with_lzf(&mut self) -> &mut Self {
self.with_plugin_filter(crate::chunked_write::PluginFilter::Lzf)
}
/// Enable bitshuffle (filter 32008) with `compression` after the bit
/// transpose, in bitshuffle's default block size. Implies chunked
/// storage; no byte shuffle is added. Requires the `bitshuffle` cargo
/// feature.
pub fn with_bitshuffle(
&mut self,
compression: crate::chunked_write::BitshuffleCompression,
) -> &mut Self {
self.with_plugin_filter(crate::chunked_write::PluginFilter::Bitshuffle {
block_size: 0,
compression,
})
}
/// Enable bzip2 (filter 307) at block size `level` (1-9). Implies
/// chunked storage; shuffle is applied first unless
/// `.without_shuffle()`. Requires the `bzip2` cargo feature.
pub fn with_bzip2(&mut self, level: u32) -> &mut Self {
self.with_plugin_filter(crate::chunked_write::PluginFilter::Bzip2 { level })
}
/// Enable Blosc (filter 32001) with `codec` at `level` (0-9) after
/// `shuffle`. Implies chunked storage; no extra HDF5 shuffle is added.
/// Requires the `blosc` cargo feature.
pub fn with_blosc(
&mut self,
codec: crate::chunked_write::BloscCodec,
level: u32,
shuffle: crate::chunked_write::BloscShuffle,
) -> &mut Self {
self.with_plugin_filter(crate::chunked_write::PluginFilter::Blosc {
codec,
level,
shuffle,
})
}
/// Enable Pcodec lossless numerical compression (private clawhdf5 filter
/// ID 480).
///
/// **Not interoperable:** pcodec has no registered HDF5 filter ID and no
/// libhdf5 plugin, so h5py and other HDF5 readers cannot read the
/// dataset — only clawhdf5 built with the `pcodec` feature can.
/// ///
/// Pcodec achieves 30–94% better compression ratio than Zstd for f32/f64 /// Pcodec achieves 30–94% better compression ratio than Zstd for f32/f64
/// columns at 1–5 GiB/s decompression speed (arXiv:2502.06112). Requires /// columns at 1–5 GiB/s decompression speed (arXiv:2502.06112). Requires
@@ -715,10 +835,20 @@ impl DatasetBuilder {
self self
} }
/// Set the dataset's fill value: what readers return for storage that
/// was never written (e.g. after the dataset is extended). `value` is one
/// element's bytes as stored — the dataset datatype's size and byte order
/// (`(-1i32).to_le_bytes()` for an `i32` dataset). A size mismatch, or a
/// variable-length datatype, makes `finish` fail.
pub fn with_fill_value(&mut self, value: &[u8]) -> &mut Self {
self.fill_value = Some(value.to_vec());
self
}
/// Use compact (inline) storage for this dataset. /// Use compact (inline) storage for this dataset.
/// ///
/// The raw data is stored directly in the dataset's object header rather /// The raw data is stored directly in the dataset's object header rather
/// than as a separate data blob. Only effective when raw data <= 65536 bytes /// than as a separate data blob. Only effective when raw data <= 65531 bytes
/// and the dataset is not chunked. /// and the dataset is not chunked.
pub fn compact(&mut self) -> &mut Self { pub fn compact(&mut self) -> &mut Self {
self.compact = true; self.compact = true;
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -148,7 +148,12 @@ pub fn read_vl_strings(
Ok(result) Ok(result)
} }
/// Resolve VL byte sequences from raw data. /// Resolve VL sequences from raw data, returning each element's bytes.
///
/// Each element is the sequence's full encoding — element count × base type
/// size bytes, in the base type's byte order — so a sequence of `i32` yields
/// four bytes per value. Decode it with the base type (e.g.
/// [`crate::data_read::read_as_i64`]).
pub fn read_vl_bytes( pub fn read_vl_bytes(
file_data: &[u8], file_data: &[u8],
raw_data: &[u8], raw_data: &[u8],
@@ -177,8 +182,10 @@ pub fn read_vl_bytes(
}, },
)?; )?;
let len = (vl.length as usize).min(obj.data.len()); // The heap object holds the whole sequence. `vl.length` counts
result.push(obj.data[..len].to_vec()); // elements, not bytes, so it is only the byte length when the base
// type is one byte wide.
result.push(obj.data.clone());
} }
Ok(result) Ok(result)
+13
View File
@@ -0,0 +1,13 @@
# Filter conformance fixtures
Files written by libhdf5 (and its registered filter plugins), used by the
filter regression tests in `src/filters.rs` to compare our decoders against
the values h5py/libhdf5 read from the same bytes. Chunk byte ranges quoted in
the tests come from h5py's `DatasetID.get_chunk_info`.
| File | Origin | Licence |
|------|--------|---------|
| `h5ex_d_lz4.h5` | HDF Group `HDF5Examples/C/H5FLT/tfiles/h5ex_d_lz4.h5` (hdf5 repository) | HDF5 licence (BSD-3-Clause style) |
| `noencoder.h5` | HDF Group `test/testfiles/noencoder.h5` (hdf5 repository) | HDF5 licence (BSD-3-Clause style) |
| `le_data.h5` | HDF Group `test/testfiles/le_data.h5` (hdf5 repository) | HDF5 licence (BSD-3-Clause style) |
| `szip_h5py.h5` | Written for these tests with h5py 3 / libhdf5 2.0.0 (libaec szip): `f8` (8x10, chunks 4x10, `('nn', 8)`), `i8` (8x10, chunks 4x10, `('ec', 4)`), `u2` (70, chunks 35, `('nn', 8)`) | Same as this repository |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,49 @@
"""Generate shared_fill_value.h5: datasets whose Fill Value message is
*shared*, in the two ways libhdf5 can share one.
- /sohm_a, /sohm_b: the file has a shared-object-header-message (SOHM) index
for fill values, so libhdf5 stores the fill value (-7, int32) in the SOHM
heap and /sohm_b's header holds only a reference to it. Chunked, with only
the first chunk written, so the rest reads as the fill value.
- /unwritten_a, /unwritten_b: the same, never written: no storage at all,
read entirely as the fill value.
h5py has no API for SOHM indexes, so the file creation property list is
configured by calling the libhdf5 bundled in the h5py wheel through ctypes.
Written with h5py 3.16.0 / HDF5 2.0.0. Re-run only to regenerate:
python gen_shared_fill.py shared_fill_value.h5
"""
import ctypes
import glob
import os
import sys
import h5py
import numpy as np
libdir = os.path.join(os.path.dirname(os.path.dirname(h5py.__file__)), "h5py.libs")
libs = [p for p in glob.glob(os.path.join(libdir, "libhdf5*.so*")) if "_hl" not in os.path.basename(p)]
lib = ctypes.CDLL(libs[0])
lib.H5open()
H5O_SHMESG_FILL_FLAG = 1 << 0x0005
fcpl = h5py.h5p.create(h5py.h5p.FILE_CREATE)
lib.H5Pset_shared_mesg_nindexes.argtypes = [ctypes.c_int64, ctypes.c_uint]
lib.H5Pset_shared_mesg_index.argtypes = [ctypes.c_int64, ctypes.c_uint, ctypes.c_uint, ctypes.c_uint]
assert lib.H5Pset_shared_mesg_nindexes(fcpl.id, 1) >= 0
assert lib.H5Pset_shared_mesg_index(fcpl.id, 0, H5O_SHMESG_FILL_FLAG, 0) >= 0
fapl = h5py.h5p.create(h5py.h5p.FILE_ACCESS)
fapl.set_libver_bounds(h5py.h5f.LIBVER_LATEST, h5py.h5f.LIBVER_LATEST)
fid = h5py.h5f.create(sys.argv[1].encode(), h5py.h5f.ACC_TRUNC, fcpl=fcpl, fapl=fapl)
with h5py.File(fid) as f:
# Chunked, with only the first chunk written: the rest reads as fill.
# libhdf5 keeps the first copy of a message in its own header; the second
# identical one (the `_b` datasets) is the SOHM reference.
for name in ("sohm_a", "sohm_b"):
d = f.create_dataset(name, shape=(8,), chunks=(4,), dtype="<i4", fillvalue=-7)
d[:4] = np.arange(4)
for name in ("unwritten_a", "unwritten_b"):
f.create_dataset(name, shape=(3,), dtype="<i4", fillvalue=-7)
+13
View File
@@ -0,0 +1,13 @@
# Legacy (HDF5 1.4/1.6-era) fixtures
Unmodified copies of the HDF Group's own test files from
https://github.com/HDFGroup/hdf5 at a3cf1ea82cc7a66e50029a688121e1b105a7ce88
(BSD-style license, see that repository's `LICENSE`). Current libraries cannot
write these structures, so they are kept as files.
| File | Upstream path | Exercises |
|---|---|---|
| `deflate.h5` | `test/testfiles/deflate.h5` | Data Layout message v1, chunked + deflate (v1 B-tree index) |
| `h5ex_g_iterate.h5` | `HDF5Examples/C/H5G/h5ex_g_iterate.h5` | Data Layout message v2, contiguous; an unallocated dataset |
| `tarrold.h5` | `test/testfiles/tarrold.h5` | Compound datatype v1 members with legacy array dimensions |
| `tcompound.h5` | `tools/test/testfiles/tcompound.h5` | Version-1 shared messages (committed datatypes); compound v1 array members with data |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More