Commit Graph
100 Commits
Author SHA1 Message Date
osobh 7447dce121 Merge branch 'feat/p3-editor-coverage' into feat/p3-remote-editor
# Conflicts:
#	CHANGELOG.md
#	CLAUDE.md
#	docs/design/range-reads.md
2026-09-26 19:17:46 -05:00
osobh 93e2d5f365 Merge branch 'feat/p3-m3-remote' into feat/p3-remote-editor
# Conflicts:
#	crates/clawhdf5/tests/storage_equivalence.rs
2026-09-26 19:17:32 -05:00
osobh 2893b6c974 Merge branch 'feat/p3-m2-raw-data' into feat/p3-remote-editor 2026-09-26 19:17:13 -05:00
osobhandClaude Opus 5.5 ea0508aaa5 format: no truncating u64 -> usize casts in gather_storage and ExtentBytes
check-32bit-casts.sh flagged two casts added by the previous commits; both
values are bounded (checked by gather_storage's first walk, and built from
a usize fetch length), so they go through addr::saturating_usize.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 19:05:45 -05:00
osobhandClaude Opus 5.5 75444950f3 clawhdf5: storage harness compares errors, not just failures
The facade equivalence harness turned every data-read error into "Err", so
it could not see File::open_storage failing differently from File::open
(a Storage or ContiguousStorageRequired error where the mmap path gives a
decode error, say).

- value() keeps the whole error. The only allowance is for a line on which
  File::open itself varies between opens — the chunk cache lists a damaged
  dataset's chunks in hash-map order, so which failing chunk a full read
  reports varies (cve-2025-2310.h5, the one corpus file where this shows):
  both sides must fail there, and a fresh File::open (up to 64) must
  reproduce the storage's exact error. Open errors were already compared
  in full; they still agree.
- The storage transcript may not contain ContiguousStorageRequired.
- More selections: a strided hyperslab (every third row) through
  read_f64_selection, and out-of-order points through read_selection and
  read_i64_selection.
- harness_compares_errors_not_just_failures checks the harness itself:
  two different errors are different values, and a difference File::open
  does not produce is reported.

With full errors the harness passes on the 61 fixtures and on the corpus
(701 files, 621 open).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 19:04:56 -05:00
osobhandClaude Opus 5.5 8236b0e30a edit: skip heap blocks too small for an attribute, as libhdf5 does
An attribute needing a heap block larger than the next one was refused
("skipping blocks too small for an object", "a first object too large for
the starting block"); once an object's move to dense storage was refused
it refused every new attribute, so 24% of set_attr calls in the review's
random workload failed.

Following H5HF__hdr_update_iter, H5HF__man_iblock_root_create/_double and
H5HF__hdr_skip_blocks, the smaller blocks are now skipped: the iterator
moves past them and they become an indirect free section with a first
row section (serialized, class 1, as H5HF__sect_indirect_serialize writes
it) and ghost normal rows, added as returned space so it merges with a
range skipped just before it (H5HF__sect_indirect_merge_row). Later
objects that best-fit a row section get a block created there
(H5HF__man_iblock_alloc_row / H5HF__sect_indirect_reduce_row: from the
start or end of the range, or from its middle, which splits it, with
libhdf5's span bookkeeping). Heaps with such sections, as libhdf5 writes
them, are now read too (they were refused at open).

dense_skipped_blocks_match_libhdf5 drives every path (merge, split, end,
last entry, row wrap) on earliest/v110/latest files against libhdf5
doing the same edits one session each; heaps, free sections and index
B-trees are equal after every phase. The refusal test now checks the
skip against libhdf5 and keeps a real refusal (last object in a block);
clawhdf5-written heaps get 1-4 KiB attributes too. The three tests fail
on the previous fheap.rs. Random workload refusals: 24% -> 2.2%, all the
documented last-object-in-a-block case.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 18:50:23 -05:00
osobhandClaude Opus 5.5 c2ae7846c9 docs: label the h5repack sizes in the append-waste figures
306 104 and 49 930 are h5repack of the editor's file; the text read as if
they were h5repack of libhdf5's, which measures 305 954 and 50 188. Both
are now given, from measure_append_waste rerun on 2026-09-26 (file sizes
unchanged).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 18:50:23 -05:00
osobhandClaude Opus 5.5 a69c5be8b2 format: read object header chunks from a queue, one buffer at a time
The version-1 chunk walk nested continuation chunks depth-first and kept
every enclosing chunk's buffer alive, up to 65 536 chunks. With storage
that hands out owned buffers (CountingStorage, the Storage trait, remote
storage) a crafted chain of chunks nested in each other read and held the
square of the file's size (a 192 KB file read 768 MB).

Chunks are now read from a FIFO queue of (address, length) pairs in the
order their continuation messages are found, as H5O_protect does and as
the editor's header walker already did, each buffer released before the
next read. In both header versions a chunk starting at an address seen
before (cycle) is refused, and so are chunks adding up to more than the
file, which bounds a header's reads by the file's size. Overlap itself is
allowed: libhdf5 reads cve-2025-7067.h5, whose continuation chunk overlaps
chunk 0 (refusing overlap cost that conformance file).

Tests: the nested chain is refused having read at most the file (it read
n^2 bytes before); a 3000-chunk chain reads each chunk once; a chunk's
messages follow the whole previous chunk (they were inserted at the
continuation message); an overlapping continuation chunk is read.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 18:50:23 -05:00
osobhandClaude Opus 5.5 930921e8cb edit: shrink by visiting the chunks that exist
prune_plan stored one Vec<u64> for every chunk coordinate of the region a
shrink cuts off, existing or not, so a sparse dataset exhausted memory
(about 62 bytes per coordinate; (4, 2e7) with chunks (1, 1) took 2.5 GB,
larger extents never finished). It now places each existing chunk in
H5D__chunk_prune_by_extent's walk (its pass, then its coordinates) and
sorts, which gives the same chunks, order and actions in memory and time
proportional to the chunks that exist.

A unit test checks the plan against the full walk (kept as the test's
reference) for 3000 random extents and chunk subsets. The interop test
shrinks a (4, 10^12) dataset with chunks (1, 1) and 9 chunks (v1 and v2
B-tree): 0.56 s and 43 MB peak; the old code aborted on allocation under an
8 GB limit.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 18:50:23 -05:00
osobhandClaude Opus 5.5 159e588550 filters: Fletcher-32 as libhdf5 computes it
Our checksum reduced its sums with `% 65535`; libhdf5's
H5_checksum_fletcher32 folds them with `(s & 0xffff) + (s >> 16)`, which
leaves 0xffff where the modulo leaves 0. On about one chunk in 32768
libhdf5 refused the chunks we wrote and we refused the chunks it wrote.
Every release since v2.1.0 is affected.

clawhdf5_format::checksum::fletcher32 is a port of H5_checksum_fletcher32
and the filter's only implementation. Verification also accepts the
byte-swapped form libhdf5 accepts (1.6.2 and earlier) and the `% 65535`
form earlier releases wrote, so their files stay readable.

The new interop test compares the checksum with libhdf5's own function
(ctypes) on every 1- and 2-byte input and 40 000 random and fold-heavy
inputs, and moves fold-case chunks between h5py and FileBuilder/FileEditor
in both directions; with the old filters.rs the three file tests fail.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 18:50:23 -05:00
osobhandClaude Opus 5.5 6185874f9c format, clawhdf5: cut every Storage read to the range asked for
ExtentBytes and read_exact_at/read_upto rejected short results but passed
longer-than-asked ones through, and FileData forwarded them too, so a
Storage that broke read_at's contract by returning extra bytes had them
decoded or returned as data (a contiguous dataset read gained 37 junk
bytes). gather_storage alone trimmed.

- storage::exact_len (new, pub): a read of len bytes as exactly len — cut
  when longer, an error when short. read_exact_at, read_upto and
  ExtentBytes (so chunk fetches and selection gathers) go through it.
- FileData cuts a backend's answer to what it asked for before laying the
  cache image over it.
- Tests: over a storage that appends 37 junk bytes to every read, every
  format-crate fixture reads exactly as from the slice
  (overlong_reads_are_cut_to_the_range_asked_for), and every facade
  fixture opens and reads through File::open_storage as through File::open
  (overlong_storage_reads_identically). Both failed before.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 18:43:05 -05:00
osobhandClaude Opus 5.5 89e7977943 clawhdf5: parse local files through the slice, and cache the contiguous view
Since M2 the facade handed the metadata parsers its FileData view, so a
local file ran the parsers monomorphised for FileData, whose Storage impl
worked out contiguous() (patched and overlay checks, two range conversions)
on every structure read. A metadata walk of h5stat_newgrat.h5 (35,001
groups: open, entries and attrs of each) was about 4.5% slower than at
8f59b2e.

- FileData works out its contiguous slice once at open (a borrow of its own
  heap/mapped buffer, kept as a pointer; see the SAFETY notes).
- with_bytes! hands the in-memory slice to the format parsers when the file
  has one (header parsing, attributes, group listings and lookups, path
  resolution, shared messages, VL decoding), so local files run the [u8]
  parsers as before; storage-backed files still get FileData.

Provisional A/B on tank (load 5-11), best of 30, 5 alternating rounds:
walk 26.07-26.37 ms at 8f59b2e, 27.24-28.03 ms before this commit,
26.52-26.98 ms after. Caching alone did not move it (27.02-27.47 ms); the
dispatch did. File::open read_f32 on 32M f32 (contiguous, chunked, gzip)
stays within noise of 8f59b2e.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 18:41:10 -05:00
osobhandClaude Opus 5.5 67e72b30d7 clawhdf5-remote: clippy clean with every feature set
checked_div in the test server's throttle, a slice for the single range
of fetch_first, and dead-code allowances for the redaction helpers in a
build with neither http nor a cloud store.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 18:37:47 -05:00
osobhandClaude Opus 5.5 0e98ffc498 docs: remote files after the adversarial review
CHANGELOG, the clawhdf5-remote and h5rs READMEs and the remote-files
known issues: redirect rules, scaled timeouts (min_speed), URL redaction,
claimed lengths never allocated (download, --max-download), a 200 for a
small file accepted, and ObjectStoreStorage from any thread.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 18:37:05 -05:00
osobhandClaude Opus 5.5 efb88f94e3 h5rs: check URL opens the remote file once
open_arg_whole opened and parsed the remote file through open_arg, then
opened it again to download it, so every `h5rs check URL` probed the
server twice. It now opens the storage once and downloads through the
same block cache (whose first block the probe already filled).

Test: check --data of a file within one block costs exactly one request
(two before).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 18:36:35 -05:00
osobhandClaude Opus 5.5 ef480746da clawhdf5-remote: a failed first fetch is Error::Remote
cached() mapped an error of its open-time prefetch (a network error, a
changed file) to Error::Hdf5(Format(Storage)), misclassifying it for
callers that match on the variant. It is now Error::Remote
(RemoteError::Backend with the backend's message). open_object and the
s3/gs/az URLs fetch the first block of an ObjectStoreStorage directly, so
their errors keep their kind (FileChanged, ObjectStore).

Test: cached() over a backend whose reads fail gives Error::Remote(Backend)
(Error::Hdf5 before).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 18:36:35 -05:00
osobhandClaude Opus 5.5 c5b2afbc35 clawhdf5-remote: ObjectStoreStorage works from any thread
It refused whenever Handle::try_current() was Ok, which is also the case
inside spawn_blocking threads — so the workaround its own error message
recommended failed the same way, and the backend could only be used from
a bare std::thread in a tokio application.

Reads are now spawned on the storage's own runtime and the caller waits on
a channel: the future never runs on the caller's thread, so neither a
spawn_blocking thread nor a current-thread runtime can deadlock or panic
(a read inside a runtime blocks that thread, like any blocking call; the
docs still recommend spawn_blocking there).

Tests: a read in spawn_blocking of a multi-thread runtime and a read inside
a current-thread runtime's task give File::open's values (both errors
before).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 18:35:36 -05:00
osobhandClaude Opus 5.5 b086dc3c2b format: read a contiguous selection's runs merged across small gaps
gather_storage merged only runs that touch, so a strided selection of a
contiguous dataset over a Storage became one range (and one owned Vec) per
element: a stride-2 read of 32M f32 through File::open_storage made
16,777,232 read_at calls, took 2.0 s and peaked at 2.09 GB.

The selection is now walked twice. The first walk checks the runs and plans
spans: runs in increasing order at most 4 KiB apart (GATHER_GAP_BYTES) are
read as one span up to 8 MiB (GATHER_SPAN_BYTES; a longer run is split), so
nothing is stored per run. The spans are fetched in RAW_BATCH_BYTES batches
while the second walk copies each run out of its span. Same checks and
errors as before.

The same read is now 32 reads and 0.31 s (File::open: 0.08 s).
contiguous_read_interop: every h5py-checked selection is also read through
File::open_storage and must give libhdf5's bytes; a new test bounds the
range reads of strided, blocked, column and point selections (stride 2: at
most 1 data read; 563,200 before).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 18:34:31 -05:00
osobhandClaude Opus 5.5 30a1ed6b9c clawhdf5-remote: request timeouts scale with the body
timeout_global (60 s) covered a whole request, and a request can carry
8 MiB (max_request): below about 140 KB/s every block run timed out, was
retried from scratch and failed, so a slow link could not read remote
files at all.

HttpOptions::timeout (now 30 s) bounds connecting and receiving the
response headers; the body gets timeout + its size at the new
HttpOptions::min_speed (16 KiB/s by default: 94 s for a 1 MiB block).
A slow but moving link is not cut off; a stalled one still fails.
(ureq has no idle timeout; its body timeout is a total budget.)

The test server can throttle bodies and stall mid-body. Test: a 256 KiB
block at 256 KiB/s reads with a 300 ms timeout (it failed before), and a
body stalled for 20 s fails in under 5 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 18:34:31 -05:00
osobhandClaude Opus 5.5 61e34927dc clawhdf5-remote: a 200 covering the requested range is the whole file
The first request asks for bytes=0-1048575. RFC 9110 lets a server answer
200 when the range covers the whole representation, so a file under
1 MiB on a server that does support ranges could be refused as 'does not
support range requests'. A 200 whose Content-Length (or, without one, its
body, read at most that far) is within the range asked for is now kept as
the whole file and read from memory; a longer one is still refused unless
allow_full_download is set.

Test: a 9968-byte file served with 200 opens in one request with the
transcript of File::open (it was refused before); with a 4096-byte first
request it is still refused.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 18:32:15 -05:00
osobhandClaude Opus 5.5 680c90b3a8 clawhdf5-remote: redirects are followed safely
ureq's defaults followed up to 10 redirects, including from https to plain
http, and forwarded the custom HttpOptions::headers (X-Api-Key, Cookie,
...) to whatever host a redirect named — only Authorization was stripped.

HttpStorage now follows redirects itself (ureq's max_redirects is 0):
- at most HttpOptions::max_redirects per request (default 5; 0 refuses
  any redirect), then RemoteError::Redirect;
- never from https to another scheme, nor to a non-http(s) URL;
- once a redirect leaves the URL's origin (scheme, host, port), none of
  the custom headers is sent any more (Authorization included);
- each hop counts as a request; errors show the target redacted.

Tests: a redirect to another local port reads the right data and the
target never sees X-Api-Key or Authorization (it did before); a
same-origin redirect keeps them; a loop stops after 6 requests; 0 refuses;
unit tests for target resolution, the https downgrade and origins.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 18:31:39 -05:00
osobhandClaude Opus 5.5 7d629f49e3 format: bound and batch every chunk fetch over Storage
Only the full read split its chunk fetches into 64 MiB batches. The
selection path, the indexed read and the parallel_read decoders fetched
every chunk's stored bytes in one read_ranges call, each extent bounded only
by the file length, so a crafted chunk index pointing many chunks at one
large extent made File::open_storage hold chunks x extent bytes (3.3 GB from
a 16.8 MB file) before the first decode error.

- storage::for_each_extent_batch is now the one way raw-data reads fetch
  chunk bytes: batches of at most RAW_BATCH_BYTES (now pub), each decoded
  before the next is fetched. Used by the full, cached, indexed, selection
  and parallel_read paths; the sweep read uses read_extent per chunk.
- ExtentReq carries each chunk's claimed extent (bounds-checked as before,
  same errors) and the prefix actually fetched:
  filters::stored_chunk_limit — the chunk size if unfiltered, else each
  applied filter's worst-case growth (n + n/4 + 4096 per codec; unbounded
  only for an application-registered codec). The in-memory path cuts the
  slice it decodes the same way, so both paths still agree.
- tests/raw_fetch_bounds.rs: a crafted chunked_large.h5 (ten chunks all
  claiming 20 MiB at one padding blob) read through every path over a
  storage that records the largest single fetch; and 160 MiB of legitimate
  unfiltered chunks fetched batch by batch. Before: one 80 MiB fetch
  (selection) and one 160 MiB fetch; after: within the budget.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 18:30:30 -05:00
osobhandClaude Opus 5.5 c04e34620e clawhdf5-remote, h5rs: URLs' credentials are never shown
Every RemoteError message and HttpStorage's Debug output held the URL as
given, with any user:password@ and the query string — for a presigned
S3/GCS/Azure URL, its signature or token. An application logging the
error leaked the credential.

- New clawhdf5_remote::redact_url: no userinfo, no fragment, query values
  replaced by REDACTED (plain key names kept).
- HttpStorage formats every message with the redacted URL, and scrubs the
  URL's secret parts from errors of the HTTP client (whose texts can echo
  the URI); Debug shows the redacted URL. storage_for_url's and the object
  store URL errors are redacted too. HttpStorage::url() still returns the
  URL as given, documented as not for logging.
- h5rs prints FILE arguments that are URLs redacted: in errors and in
  dump/stat/check/diff output.
- The test server can force a status and send a wrong Content-Range.

Tests: 404, 403 (at open and on a read), wrong Content-Range (at open and
on a read), no range support, encoded body, ETag change, timeout,
connection closed and bad scheme errors, Display and Debug, contain none
of the secrets; h5rs likewise for every subcommand.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 18:30:02 -05:00
osobhandClaude Opus 5.5 8df5b209a7 clawhdf5-remote, h5rs: never allocate a length the server only claims
h5rs check URL read the whole file with one read_at(0, len), len being
whatever Content-Range said. BlockCache listed every block index of the
span and preallocated len bytes: a server claiming 2^62 bytes for a 10 KB
file made h5rs abort (memory allocation of 35184372088832 bytes failed).

- BlockCache: a read spanning more than the budget (or eight max_requests)
  is fetched piece by piece and not kept, its output growing only as
  data arrives; read_ranges falls back to that per range; prefetch is
  clamped to the budget.
- New clawhdf5_remote::download(storage, max_bytes): refuses a claimed
  length above the limit (RemoteError::TooLarge) before any request, then
  reads in 64 MiB steps. New RemoteError::Backend for read errors.
- h5rs check downloads through it, with --max-download N (default 1 GiB).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 18:27:14 -05:00
osobhandClaude Opus 5.5 e8aaf050be clawhdf5-remote: no overflow on lengths near u64::MAX
A server can claim any length in Content-Range. block_len computed
start + block_size, which overflowed in the last blocks of a file claimed
to be near u64::MAX (a panic in debug builds, a wrapped value in
release); insert() multiplied block indices unchecked. The cache's block
arithmetic is now saturating/checked, and a run that does not split into
whole blocks is an error instead of an endless loop or a slice panic.

The test server gains fake_total (claim a length, serve zeros past the
data); a test reads the last bytes of such files and opens a file whose
superblock EOF and root addresses sit near u64::MAX.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 18:24:48 -05:00
osobhandClaude Opus 5.5 5062b907bd clawhdf5-remote tests: the server counts only requests for its files
A local port scanner's GET / reached the test listeners and was counted,
failing the exact request budgets (and consuming injected 503s). Requests
for paths the server does not serve are now answered 404 without being
counted, delayed or failed; the query string is not part of the path.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 18:23:27 -05:00
osobhandClaude Opus 5.5 4f5697fdd9 clawhdf5-remote: readers waiting on a failed fetch get its error
A reader that waited for another reader's fetch of a block got "the
fetch of this block failed" when that fetch failed, not why: a file
replaced on the server while open was reported as FileChanged to one
thread and as an anonymous failure to the others. The fetch's error is
now handed to every reader waiting on it.

Regression test: four threads read the same block from a slow backend
whose fetches fail with a "changed while open" error; each gets that
error (it failed for the waiters before this change).

Also fixes the ignore-Range test, broken by the previous commit: the
test server now counts a body before sending it, so "the refused body
was not read" is checked as "refused at the first response".

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 17:31:26 -05:00
osobhandClaude Opus 5.5 955dd1c691 docs: range-read milestone M3 — remote files
README: "Reading remote files" (open_url, the range_server and read_url
examples with their real output against the fixtures, h5rs on URLs), the
crate in the crate map and the unreleased highlights. CHANGELOG: the
clawhdf5-remote crate, h5rs URLs, File::storage and
VlResolver::element_in, with the request counts over the conformance
corpus (tank, 2026-09-26, the command given). known-issues: the M2
range-read entry updated (the cache now exists; h5rs reads through
storage) and a new entry for the remote backends' limits (no Python or
browser URLs yet, fixed block size, cloud stores not run against a real
bucket, validators, credentials). Design doc: M3 status with the choices
that differ from the plan (a crate rather than a clawhdf5-io feature,
ureq for HTTP so the default build has no C) and the corpus counts.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 17:29:09 -05:00
osobhandClaude Opus 5.5 ebe51f8e97 clawhdf5-remote tests: open + list and a dataset read counted apart
The per-file report now separates a tree view (open, every group's
entries, every dataset's shape and type) from reading the largest
dataset under 64 MiB, and checks the budget the design's testing section
asks for: listing the IMERG file (file A of docs/design/range-reads.md
section 2) takes at most 3 requests when CLAWHDF5_REMOTE_CORPUS includes
it. The test server now counts a response's bytes before sending it: a
client could read a body and reset the counters before the server thread
had added it, so the counts of the next file were occasionally too high.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 17:26:57 -05:00
osobhandClaude Opus 5.5 4e8109770d tests: shrink and regrow datasets allocated early
Datasets with early allocation and unlimited dimensions (Extensible
Array, version-2 B-tree, version-1 B-tree under earliest), unfiltered
and deflated: the random resize workload gives the values h5py gets and
the same chunk index shape, with every chunk a growth brings in
allocated and filled as H5D__chunk_allocate does.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 17:25:28 -05:00
osobhandClaude Opus 5.5 c513f7e6d7 h5rs: URLs as FILE arguments (feature remote)
With the `remote` feature (`remote-https` for https://), ls, dump, stat
and diff take an http(s):// (or s3://, gs://, az:// with those
clawhdf5-remote features) URL wherever they take a file, and read it by
range requests through clawhdf5-remote's block cache. check validates
every byte, so it downloads a remote file whole and checks it as before.
Without the feature a URL is a clean error naming it.

The tools read the file through File::storage instead of as_bytes: object
headers, shared messages, attributes, v1 and v2 group links, dense
storage (fractal heaps and v2 B-trees), path resolution, chunk listings
and variable-length values go through the format crate's *_in functions,
and the fractal-heap block verifier reads each block through the storage
(a read failure of a remote file is reported as a problem, not as "past
the end of the file"). A local file's storage is its mapped bytes, so its
reads are still slices. stat's file size comes from the opened file, so
it is right for a URL.

Tests: tests/remote.rs serves fixtures (old and new formats, a paged
file, a metadata cache image, a multi-block fractal heap, compounds, v1
groups) with the clawhdf5-remote test server and requires every
subcommand's output and exit status for the URL to equal the local
file's, and diff of the two to be clean; 404s, non-HDF5 bodies and
https without its feature are clean errors. Local output is unchanged:
the old and new h5rs print the same for ls -r -v, dump, stat and check
--data on the 747 conformance and CVE corpus files (tank, 2026-09-26;
the dumps of h5diff_hyper1/2.h5 were too large for the comparison
script, their ls, stat and check agree), except cve-2025-2310.h5, whose
dump error messages differ between runs of the old binary too (which
failing chunk is reported first).

ci-test.sh lints h5rs with remote-https, runs the URL tests and checks
h5rs with remote for C.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 17:23:53 -05:00
osobhandClaude Opus 5.5 a4f586e657 format: VlResolver::element_in and string_element_in over any Storage
VlResolver::element and string_element return slices of the whole file,
so they exist only for a resolver over &[u8]. Their *_in forms work for
any Storage (a remote file): the element's bytes borrowed from the
resolver's cache of heap collections, with the same null-element, NUL
and size checks. h5rs decodes variable-length values with them.

Test: over a read_at-only storage they give what element/string_element
give over the slice, for a string with an embedded NUL, a null element
and an element whose heap object has the wrong size.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 17:20:50 -05:00
osobhandClaude Opus 5.5 c54c64cc9b clawhdf5: File::storage gives the file's view as a Storage
The bytes File::as_bytes returns (from the superblock on, bounded by the
recorded end of file, a metadata cache image laid over), as a
&(dyn Storage + Send + Sync) for every backend. Code that parses the file
itself with the clawhdf5_format *_in functions — h5rs does — can then
read a file opened with File::open_storage (a remote file) as well as a
local one; in memory its as_contiguous() is as_bytes(), so local reads
stay slices.

Test: for every fixture, File::open's storage() is as_bytes() as its
contiguous view, and File::open_storage over a read_at-only storage gives
the same bytes through storage().read_at.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 17:16:35 -05:00
osobhandClaude Opus 5.5 4ff3e40fea clawhdf5-remote: object stores through object_store (S3, GCS, Azure)
ObjectStoreStorage (feature `object-store`, pure Rust) reads one object
of any object_store store by ranged get_opts, pinned at open by a head
request: If-Match with its ETag (and the ETag and size of every response
compared), else its version or modification time. A change is
RemoteError::FileChanged. object_store is async and Storage is not, so
the storage owns a small multi-threaded tokio runtime (two workers) and
blocks the calling thread on it; the ranges of one read_ranges call are
fetched concurrently (up to 8). From inside another tokio runtime it
refuses with RemoteError::Usage instead of blocking a worker, and it
shuts its runtime down in the background on drop so dropping it in async
code does not panic.

open_object(store, path, options) opens a file through a block cache
(first block prefetched); open_url accepts s3://, gs:// and az:// with
the `s3`, `gcs` and `azure` features, configured from the environment by
object_store's from_env builders. Those pull object_store's cloud clients
and aws-lc-rs (C), so they are opt-in; without them the URL is a clean
UnsupportedScheme error naming the feature.

Tests against object_store's in-memory and local-file stores (no cloud):
every fixture's transcript equals File::open's, a multi-block object is
fetched in coalesced block runs, an object replaced while open is an
error, and a missing object or a read from inside a runtime is a clean
error. ci-test.sh lints all backends, runs these tests (with s3 for its
URL parsing test) and checks object-store for C in the no-C step.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 17:15:06 -05:00
osobhandClaude Opus 5.5 0aca0eb724 docs: editor coverage — version-2 B-trees, shrinking, dense attributes, reuse
CHANGELOG (Unreleased): the new FileEditor operations, space reuse, and
the two reader fixes (implicit index grid, object-header continuation
chains). known-issues: the editor's remaining refusals (skipped heap
blocks, heaps with filters or child indirect blocks, freeing a heap
block, implicit-index insertions, ...) and the append-waste sizes before
and after reuse (measure_append_waste, tank 2026-09-26; file sizes are
deterministic). range-reads design: status note on the reader changes.
README and CLAUDE.md: what the editor covers and how to test it.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 17:13:31 -05:00
osobhandClaude Opus 5.5 db2554dd81 clawhdf5-remote: block cache and HTTP range reads (open_url)
Range-read milestone M3, first half: a new crate with the block cache the
design makes mandatory for remote files and an HTTP backend, so
open_url("http://...") gives a clawhdf5::File over File::open_storage.

BlockCache wraps any Storage: aligned blocks (1 MiB by default, the size
docs/design/range-reads.md section 2 measured), LRU with a byte budget,
the missing blocks of one read_at/read_ranges fetched with one backend
read_ranges call as runs of consecutive blocks (a one-block gap filled to
merge runs, each request at most 8 MiB), and reads that miss more than
half the budget not kept. Thread-safe without holding the lock across a
fetch: a block being fetched is in flight, a second reader waits for it
instead of fetching it again, and a failed fetch fails its waiters and is
not cached. A backend holding the file in memory passes through.

HttpStorage (ureq, no TLS by default; `https` adds rustls with ring):
opening is one ranged GET of the first block, whose Content-Range gives
the length (the cache keeps the bytes). The file is pinned by a strong
ETag (If-Match), else Last-Modified (If-Unmodified-Since), and its length,
checked on every response: a change is RemoteError::FileChanged, never
mixed data. A server that ignores Range is refused without reading the
body unless a full download is allowed. Connection errors, timeouts,
408/429/5xx and short bodies are retried with exponential backoff;
Accept-Encoding: identity, and an encoded body is refused. read_ranges
fetches its ranges in parallel.

Tests (a std-only HTTP/1.1 server in tests/common/server.rs, also the
range_server example): every fixture read over HTTP gives File::open's
transcript (CLAWHDF5_REMOTE_CORPUS adds the conformance corpus), with
request counts per file with and without the cache; an h5py-written file
against libhdf5's values; a multi-block file fetched in whole blocks, each
once; a server ignoring Range; a file replaced mid-read (ETag,
Last-Modified, length only); truncated bodies and 503s (retried, then an
error, never cached); a slow server with 8 concurrent readers (no block
fetched twice); bad URLs, 404, encoded bodies, non-HDF5 data. The cache
has unit tests for coalescing, splitting, LRU order, large reads,
failures and concurrent in-flight dedup.

ci-test.sh: clawhdf5-remote joins the no-C default-build check, and its
https feature is linted.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 17:13:28 -05:00
osobhandClaude Opus 5.5 955fdb660d tests: resize and write clawhdf5-written datasets on every chunk index
Version-2 B-tree (its writer's own node size and single-leaf layout),
Extensible Array and Fixed Array datasets written by FileBuilder, with
and without deflate, resized up and down along both dimensions and
written at random against a model; h5py, h5dump and h5rs check read the
result, and h5py resizes and rewrites every dataset afterwards.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 17:12:14 -05:00
osobhandClaude Opus 5.5 304aed5813 tests: random editor operations include shrinking, 2-D growth, dense attributes
random_operations_match_a_model (edit_interop) now drives, on every
libver (earliest, v114, latest) and filter set (none, gzip + shuffle +
fletcher32, LZF):

- a dataset with one unlimited dimension and one with two (a version-2
  B-tree chunk index under v114/latest), resized to random shapes that
  shrink and grow any resizable dimension, with block and point writes;
- attributes on the first dataset under 20 names with values of random
  types and sizes (scalars, int64 arrays, short strings, strings above
  the heap's managed limit), so they move to dense storage on version-2
  headers and are replaced by values of other sizes;

against a model where shrunk-away elements that come back read as the
fill value, compared with our reader and with h5py/numpy every 40 steps,
with h5dump and h5rs check; h5py then grows both datasets and adds an
attribute. The attribute check also compares h5py's attribute count with
libhdf5's object info. CLAWHDF5_EDIT_SEED reruns the workloads with other
random choices (seeds 1000-4000 pass).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 17:11:44 -05:00
osobhandClaude Opus 5.5 1ffd013de9 edit: delete a heap's huge-object B-tree with its last huge object
libhdf5 deletes a fractal heap's huge-object B-tree when the heap is
closed with no huge object left (H5HF__huge_term) and starts huge IDs
over. The editor left the empty tree, and a read-only libhdf5 then
failed to list the object's attributes: closing the heap tried to delete
the tree ("no write intent on file"), and h5dump failed the same way.

Replacing an object's last huge attribute (one above the heap's 4 KiB
managed limit) now deletes the tree (header and nodes freed), resets the
next huge ID and the wrapped flag, as libhdf5 does; a later huge
attribute creates a new tree. Bt2::delete frees a whole tree.

Found by the extended random-operation test; regression:
last_huge_attribute_replaced (fails before: h5dump, h5py listing), which
also compares the heap with libhdf5's after the same replacement.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 17:11:36 -05:00
osobhandClaude Opus 5.5 dc9cfba6bb edit: reuse space freed earlier in the editing session
A FileEditor now keeps the space its edits free — a filtered chunk that
moved, chunks a shrink removed, B-tree nodes merged away, a heap's
replaced root indirect block or free-space section info, huge objects
replaced — and later edits allocate from it (best fit, lowest address
among equals, zeroed) before growing the file. An edit never reuses what
it frees itself: until it is committed the file still refers to that
space. Reused blocks are written in the commit's first phase with the
space past the old end of file (nothing on disk refers to them yet),
before any existing byte changes, so the crash-safety ordering holds.
Space still free when the editor is dropped is leaked, as libhdf5 leaks
it without a persistent free-space manager (files that have one, or use
paged aggregation, are still refused at open).
FileEditor::reusable_bytes reports what is left to reuse.

Tests: FreeList merging and best fit, the edit-local rule and the commit
split (image unit tests); a shrink followed by regrowth writing the same
data reuses every removed chunk and leaves the file size unchanged,
while one editor per edit grows the file, h5py/h5dump/h5rs check read
both and h5py continues (freed_space_is_reused_within_a_session).

measure_append_waste (edit_interop, ignored), same workload, one editor,
before -> after (bytes; libhdf5 in brackets), on tank 2026-09-26:
gzip chunks 1024, 1000 appends of 100: 307210 -> 306780 (306058);
gzip chunks 4096, 2000 appends of 10: 119684 -> 79829 (50292);
unfiltered unchanged (no space is freed).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 17:04:15 -05:00
osobhandClaude Opus 5.5 773f427f16 edit: dense attributes, compact-to-dense transition, creation order
FileEditor::set_attr now handles every attribute storage libhdf5 uses
for version-2 object headers:

- objects that track (and index) attribute creation order: compact
  attributes carry their creation index in the message header, the
  Attribute Info message its maximum;
- the move to dense storage when an object reaches its compact limit (or
  an attribute is too large for a header message), as H5O__attr_create
  does it: a new fractal heap, name index (v2 B-tree type 8) and, when
  creation order is indexed, creation-order index (type 9); the compact
  attributes moved over in header message order, their messages freed;
- objects already in dense storage (h5py- or clawhdf5-written): new
  attributes inserted (H5A__dense_insert), an attribute replaced by one of
  the same encoded size rewritten in its heap object (H5A__dense_write),
  otherwise removed from both indexes and the heap and inserted anew.

edit/fheap.rs follows H5HF: managed objects go to the best-fitting free
section of the heap's free-space manager (FSHD/FSSE, kept as libhdf5
keeps it — sorted sections, counts, section info reallocated when its
size changes, the manager deleted when empty); otherwise to a new direct
block: the root direct block of an empty heap, else the block at the
allocation iterator in the root indirect block (created from the root
direct block, doubled as needed), with libhdf5's managed-space, allocated
space, free space and iterator bookkeeping. Objects above the managed
limit are huge objects in their own space, indexed by the huge-object
B-tree (type 1). Removed objects return their space merged with adjacent
free space. Refused before anything is written: heaps with I/O filters,
child indirect blocks, an object larger than the next heap block
(libhdf5 skips blocks and records them as free space), free sections
other than those inside direct blocks, removing a direct block's last
object (libhdf5 frees the block), directly addressed huge objects.

Attributes are encoded as libhdf5 does when h5py opens a file r+ (low
bound "earliest"): message version 1 (3 for non-ASCII names), simple
dataspaces with their maximum dimensions. Header chunks are now visited
in libhdf5's order (FIFO), which is also the order attributes move to
dense storage in.

Tests (edit_coverage_interop): 40 attributes on each of a plain group, a
group tracking and indexing creation order, and a dataset (earliest,
v110, latest), some above the 4 KiB managed limit, then same-size
rewrites: the heap statistics, free-space sections and both index
B-trees node for node equal libhdf5's doing the same through h5py; then
replacements of other sizes, h5py adds/deletes/rewrites; h5py, h5dump,
h5rs check and our reader agree throughout, h5py's attribute count
included. clawhdf5-written dense storage (tracked and untracked) is
extended the same way; refusals leave the file byte for byte as it was.
edit_interop's attribute test now expects dense storage and tracked
creation order to work.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 16:58:08 -05:00
osobhandClaude Opus 5.5 7e5e920c72 format: read object headers with long continuation chains
libhdf5 follows any number of continuation chunks, and a header that is
full gains one per message added (each new chunk holding the next
continuation message), so a version-1 header with a few dozen attributes
added one at a time is a chain dozens of chunks long. The reader recursed
once per chunk and refused a chain deeper than 32 (NestingDepthExceeded):
h5py read such files, we did not. Version-2 headers stopped at 256
continuation chunks.

Version-1 chunks are now followed with an explicit stack (the same
depth-first message order as before), version-2 ones as before; both
refuse a chunk address seen twice (a cycle, what the limits guarded
against) and more than 65 536 chunks.

Regression: long_v1_continuation_chains_are_read (a 200-chunk chain),
v1_continuation_cycles_are_refused; the dense-attribute interop test's
'earliest' case produces such a chain.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 16:56:09 -05:00
osobhandClaude Opus 5.5 f191dc09d5 docs: range-read milestone M2 — changelog, limits, design status
CHANGELOG (Unreleased): File::open_storage, raw data and v2 B-trees over
Storage, the tests and their corpus results (2026-09-26, tank; conformance
600 of 697, results.json identical to 8f59b2e). known-issues: what
open_storage does not do yet (no remote backend or block cache, read_at
counts of a one-pass read, v1 group lookups, whole-file VDS sources,
zero-copy methods, SWMR growth, hash-order error choice on damaged chunked
datasets). Design: M2 status and the choices made.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 16:49:42 -05:00
osobhandClaude Opus 5.5 1c3ef98828 clawhdf5: File::open_storage reads any Storage through the full API
File::open_storage(Arc<dyn Storage + Send + Sync>) opens a file served by
any backend: groups, datasets, attributes, read_*, selections, VL data
and virtual datasets (external sources through the new
File::set_vds_resolver) all read through Storage::read_at/read_ranges.
The file's view (user block skipped, bounded by the recorded end of
file) is itself a Storage; File::open and File::from_bytes keep their
mmap and in-memory paths, now as that view's as_contiguous() fast path.
A storage-backed file's metadata cache image is laid over each read it
covers (new CacheImage::entries), as libhdf5 loads it.

The typed readers keep their fast paths over any storage: a contiguous
dataset is read in one piece and converted (read_f64 and friends), and a
contiguous native selection reads only its runs
(data_read::read_selection_native_in). Zero-copy methods answer
ContiguousStorageRequired when the bytes are not in memory, and
File::as_bytes panics there (File::contiguous_bytes is the fallible
form).

Test: tests/storage_equivalence.rs reads every fixture, and with
CLAWHDF5_STORAGE_CORPUS every conformance-corpus file, through File::open
and through open_storage over a read_at-only CountingStorage — tree,
attributes, and every dataset's values several ways — and requires
identical transcripts; it prints the read_at calls and bytes a one-pass
read costs per file.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 16:41:47 -05:00
osobhandClaude Opus 5.5 e9c71e5d2e edit: version-2 B-tree chunk indexes, shrinking, early allocation
FileEditor can now:

- add, move and resize chunks of datasets with two or more unlimited
  dimensions (version-2 B-tree chunk index, record types 10/11). The new
  edit/btree2.rs follows libhdf5's H5B2 code: H5B2_update (modify, or
  insert into a leaf with room, or fall back to H5B2__insert), the
  preemptive split/redistribute loop with its two retries, split1,
  split_root (depth growth, node geometry per depth), redistribute2/3,
  cumulative record counts and the pointer widths H5B2__hdr_init derives,
  a checksum per node. Removal (H5B2_remove: merge2/3, redistribution,
  root collapse, the internal-record swap with a leaf) is there too. A
  dataset without an index yet gets one from the layout message's node
  size and split/merge percentages.
- shrink a chunked dataset along any dimension (resize to a smaller
  shape), as H5D__set_extent / H5D__chunk_prune_by_extent do: the same
  chunks visited in the same order; chunks wholly outside the new extent
  are removed from the index (version-1 B-tree: H5B_remove with its
  sibling key and link fix-ups and the empty-root case; version-2
  B-tree; Fixed/Extensible Array elements reset to the fill element;
  an implicit index keeps its chunks, as libhdf5 does) and their space
  noted as free; the part of each partial edge chunk outside the extent
  is overwritten with the fill value, so elements that come back after
  a later growth read as fill.
- under early allocation, allocate and fill the chunks a growth brings in
  (H5D__chunk_allocate), which an implicit index needs: libhdf5 refills
  them, and they may hold the data of chunks pruned earlier.

Tests (crates/clawhdf5-tools/tests/edit_coverage_interop.rs): growth in
both dimensions of v110/latest files, unfiltered and deflated, gives
node-for-node the version-2 B-tree libhdf5 builds (h5py with its chunk
cache off, so chunks enter the index in the editor's order), through a
depth increase; random chunk order; 60 random shrink/grow/write steps on
Extensible Array, version-2 B-tree, Fixed Array, 1-D and implicit
datasets (earliest/v110/latest, with and without gzip+shuffle) give the
values h5py gets doing the same and the same index shape (version-1 and
version-2 B-tree node shapes, Extensible Array statistics); h5py r+
continues on every result; h5dump and h5rs check accept them.
edit_interop's version-2 B-tree case now appends instead of expecting a
refusal; shrinking is no longer an error in edit_tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 16:35:26 -05:00
osobhandClaude Opus 5.5 17201e279d format: implicit chunk index addresses over the maximum chunk grid
libhdf5 allocates an implicit index's chunks for the whole maximum
extent and places chunk `scaled` at its row-major position in the
maximum chunk grid (H5D__none_idx_get_addr, max_down_chunks). The reader
used the current grid, so a dataset below its maximum shape with more
chunk columns at its maximum read other chunks' values from the second
chunk row on (h5py early allocation, fixed maxshape).

generate_implicit_chunks_in_grid takes the maximum dimensions;
generate_implicit_chunks keeps its signature (grid = current extent).
Regression: implicit_chunks_use_the_maximum_grid here, and
implicit_index_below_its_maximum_reads_like_libhdf5 (h5py file) with the
editor tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 16:35:06 -05:00
osobhandClaude Opus 5.5 3fa5ed1dda format: raw data, VDS and VL data over Storage
Every raw-data path has a generic *_in core, with the &[u8] functions as
thin wrappers: data_read (read_raw_data*, read_raw_data_selection,
read_chunked_native), chunked_read (the v1 B-tree chunk index, list_chunks,
the full, cached, sweep and indexed reads), parallel_read, partial_read,
fill_value (read_full_with_fill, apply_to_unallocated_chunks; and
dataset_fill_value_from_storage is now generic), vds (the virtual file
through Storage, external sources still through the resolver),
vl_data (VlResolver<'a, S = [u8]>, read_vl_strings_in, read_vl_bytes_in),
AttributeMessage::read_vl_strings_in and provenance::verify_dataset_in.

With the whole file in memory nothing changes: chunks and contiguous data
are sliced from it as before. Otherwise a chunked read lists its chunks,
fetches their stored bytes with one Storage::read_ranges call per 64 MiB
batch (chunks the cache already holds are not fetched), then decodes as
today; a selection fetches only the chunks it overlaps, and a contiguous
selection only its runs. Each extent's bounds error is the one the slice
code gave, reported when that extent is reached, so errors keep their
order.

Tests: the equivalence harness now reads every dataset's values (whole,
fill-aware, cached, indexed, three selections, VDS, VL strings and
sequences) through the read_at-only storage and requires the slice
results (all 653 corpus files agree); a misbehaving storage (a failing
Nth read, short reads) only ever yields errors or the right values; and
chunked reads are checked to use one read_ranges call.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 16:28:01 -05:00
osobhandClaude Opus 5.5 42894bf93b format: v2 B-trees, dense groups and group listings over Storage
BTreeV2Header::parse_in, collect_btree_v2_records_in and
find_btree_v2_records_in read one bounded window per node (its size is
known from the parent before the node is read; a count stretched past
node_size is checked against the end of the file first), with the
whole-file bounds errors unchanged. With them, dense attributes, a SOHM
B-tree index and huge fractal-heap objects no longer answer
ContiguousStorageRequired, and group_v1/group_v2 listings, lookups and
path resolution get *_in cores (resolve_group_children_in,
resolve_child_in, resolve_path_any_in, ...). The &[u8] functions are
thin wrappers, as in M1.

The equivalence harness now fails on any ContiguousStorageRequired and
compares v2 B-tree headers, records and descents, group listings, child
lookups and paths; a unit test compares a two-level tree through a
read_at-only storage truncated at every length and with every node byte
flipped.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 16:13:01 -05:00
osobhandClaude Opus 5.5 0645dcf173 docs: data-read throughput unchanged by the Storage conversion
CI / test-arm64 (pull_request) Successful in 1m30s
CI / test (pull_request) Successful in 14m22s
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 15:22:54 -05:00
osobhandClaude Opus 5.5 cadd27df5b docs: ObjectHeader::parse A/B rechecked on an idle machine
+6-7% (about 4 ns per header) is real; symbol-table nodes -17%, group
B-tree walk -16%, facade listing -2.4%: local metadata reads are net
slightly faster than main.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 15:15:32 -05:00
osobhandClaude Opus 5.5 b49ec39aff docs: conformance report after range-read M0/M1, ZFP and in-place editing (600 of 697 ok)
Regenerated on tank: ok 599 -> 600 (h5ex_d_zfp.h5), our-error 4 -> 3,
mismatch 2, no panics, hangs, crashes or OOM. Baseline raised.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 15:06:18 -05:00
osobh 8fadb9f424 Merge branch 'feat/p3-in-place-modify' into feat/p3-range-zfp-edit
# Conflicts:
#	CHANGELOG.md
#	crates/clawhdf5-py/src/lib.rs
#	crates/clawhdf5/src/error.rs
2026-09-26 14:52:55 -05:00
osobh c233fbca6e Merge branch 'feat/p3-zfp' into feat/p3-range-zfp-edit
# Conflicts:
#	CHANGELOG.md
#	crates/clawhdf5-format/Cargo.toml
2026-09-26 14:52:22 -05:00
osobh 437e81cfff Merge branch 'feat/p3-storage-trait' into feat/p3-range-zfp-edit
# Conflicts:
#	CHANGELOG.md
#	crates/clawhdf5-format/src/attribute.rs
#	crates/clawhdf5-format/src/btree_v1.rs
#	crates/clawhdf5-format/src/data_layout.rs
#	crates/clawhdf5-format/src/extensible_array.rs
#	crates/clawhdf5-format/src/fixed_array.rs
#	crates/clawhdf5-format/src/fractal_heap.rs
#	crates/clawhdf5-format/src/local_heap.rs
#	crates/clawhdf5-format/src/shared_message.rs
2026-09-26 14:51:51 -05:00
osobh 234dd3e36c Merge branch 'perf/p3-indexed-lookups' into feat/p3-range-zfp-edit 2026-09-26 14:46:14 -05:00
osobhandClaude Opus 5.5 0e8522cfad clawhdf5-format: the writer skips optional filters that fail, as libhdf5 does
FileBuilder stored every chunk of an LZF or Blosc dataset through the
filter with filter mask 0. libhdf5 counts LZF and Blosc output no smaller
than the chunk as a failure of the optional filter and stores the chunk raw
with the filter's mask bit set. For an LZF chunk whose stream was exactly
the chunk's size, the first libhdf5 rewrite stored raw data at the same
size and kept our stale mask 0 in the index, and h5py could no longer read
the dataset.

precompress_chunks now runs chunks through compress_chunk_masked (as
FileEditor does since f7e2ab1), sequentially and on the parallel path, and
build_chunked_data_from_precompressed records each chunk's real mask in
every index the writer builds: single chunk (layout field), Fixed Array and
Extensible Array filtered elements, and version-2 B-tree type 11 records
(create_datasets_parallel goes through the same path). The writer builds
no version-1 B-tree or implicit index. PrecompressedChunks::chunks gains
the mask. Files whose chunks all compress are byte-identical.

Latent only in the unreleased LZF/Blosc writer (added 2026-09-26); no
tagged release writes either filter.

Tests:
- plugin_filters_interop skipped_optional_filters_are_masked_as_libhdf5_masks_them:
  LZF, shuffle+LZF+fletcher32 and Blosc over random, compressible and
  alternating chunks in every index; masks equal an h5py-written twin's;
  h5py r+ rewrites and extends them; h5py, h5dump and our reader read
  every value. Before: 20 of 24 datasets had masks other than h5py's, and
  with that check disabled h5py failed to read the rewritten datasets
  ("filter returned failure during read").
- plugin_filters_interop files_whose_chunks_all_compress_are_unchanged:
  pins the pre-fix bytes of five all-compressing files.
- chunked_write skipped_lzf_chunks_are_masked_in_every_index (fails before:
  mask 0, want 2).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 14:45:51 -05:00
osobhandClaude Opus 5.5 895c79a2fe docs: M1 changelog after review: probe identity, speed, bounded reads
- Per-file probe output is identical for 696 of 697 files, not all:
  cve-2025-2310.h5's error string depends on which parallel chunk decode
  fails first, at f2ff2c4 as on this branch.
- The parser cores are generic (S: Storage + ?Sized); provisional A/B
  numbers against f2ff2c4, including the one bench that still shows
  ObjectHeader::parse slower when old and new are separate binaries.
- Reads sized by untrusted fields are bounded; the harness only accepts
  the known whole-file fallbacks.
- range-reads.md records why M1 went generic rather than &dyn.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 14:34:35 -05:00
osobhandClaude Opus 5.5 76c97f6c94 format: bound Storage reads that hostile size fields could stretch
On a backend without the file in memory, a structure read whose length
comes from untrusted header fields was clamped only by the end of the
file, so a crafted size made one read (and copy) of up to the rest of
the file. Each such read now covers what the parser actually uses:

- local heap names: read in growing pieces (64 bytes first, then 4x)
  up to the end of the data segment, instead of the rest of the segment
  per name (quadratic for a big symbol-table group);
- fractal heap indirect blocks: the doubling-table geometry locates the
  entry covering the object, and the first read ends at that entry; only
  if it is unallocated does the walk read the rest of the block (it
  visits every entry then). One walk implementation serves both;
- paged fixed/extensible array data blocks over 1 MiB: the prefix and
  page bitmap, then each page in use on its own (smaller blocks are
  still one read);
- blocks under one checksum (non-paged array data blocks, extensible
  array index and super blocks): the bounds check that comes first (the
  checksum's; the page bitmap's for a super block) is made against the
  file length before reading (Window::check_extent), so a block claimed
  past the end of the file costs no read. With the checksum feature off
  the parser has no such first check and the old read stands.

Other windows were already bounded (the superblock and object header
prefixes, the fractal heap header by a u16, SOHM tables by u8/u16
counts) or are exact reads checked against the file length first.
In memory nothing changes: the pieces are borrowed slices.

Tests: CountingStorage over a crafted heap (16 MiB file, width and rows
0xFFFF: under 1 KiB read, 16.7 MB before), a heap segment claiming 64 MiB
(one 64-byte read per short name), long names at every piece boundary,
a fixed array block claimed past the end of a 16 MiB file (under 64
bytes read), and in the equivalence harness an h5py file with a 2.4 MB
fixed array block and a >1 MiB extensible array block, whole and cut at
97 points: every chunk index agrees with the slice read and the largest
takes 205 KB (2.4 MB and 1.2 MB when read whole).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 14:31:46 -05:00
osobhandClaude Opus 5.5 e5359354b7 format: equivalence harness only accepts the known whole-file fallbacks
The harness counted ContiguousStorageRequired from any parser as an
allowed difference, so a converted module that wrongly fell back to the
whole file would still pass. It now accepts the error only from the
three sites that are not converted yet (dense attribute storage, a SOHM
B-tree index, huge fractal-heap objects, all found through a v2 B-tree)
and only in the checks that can reach them; anything else fails with
the check and the site named.

Checked by making LocalHeap::parse_in return the error first: the
fixture and h5py runs fail ("local heap fell back to the whole file").

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 14:22:42 -05:00
osobhandClaude Opus 5.5 052098bf36 format: monomorphise the Storage parsers so local files stay as fast
Every `*_in` core and the read helpers take `file: &S` with
`S: Storage + ?Sized` instead of `&dyn Storage`, and the `&[u8]`
wrappers pass the slice itself, so they compile to a `[u8]` instance:
`as_contiguous()` inlines to `Some(self)` and each structure read is the
slice code's bounds check again, with no indirect call. `&dyn Storage`
still works (`S = dyn Storage`); there is one parser implementation.

Also, so the structure reads cost no more than the slice checks did:
- ObjectHeader::parse_in reads the prefix once (signature included)
  instead of the signature and then the prefix: two reads for a
  one-chunk header instead of three on a range backend;
- the symbol-table node and group B-tree (v1) loops walk their entries
  with chunks_exact over the bytes read, and the node's redundant second
  bounds check is gone (the entries' read is the check, same error);
- a version-1 header's message list is sized from its (capped) count.
Same results and errors; the unit and equivalence tests are unchanged.

New Criterion bench `clawhdf5/benches/local_metadata_bench.rs` over a
400-group version-1 file written by h5py (new fixture
`v1_groups_400.h5`): ObjectHeader::parse, symbol-table nodes, the group
B-tree walk and a facade listing, using only APIs that exist at f2ff2c4
so it builds there for an A/B.

Provisional A/B against f2ff2c4 (busy machine, not for docs): both
builds linked into one binary and timed in alternation, 200 rounds;
median ratio new/old: facade listing -0.5% to -3.5% (was +14%),
ObjectHeader::parse +1% to +2% (was +25%), symbol-table nodes -18%,
group B-tree walk -18%, local-heap names and resolve_group_children
within +-1.5%. An old-vs-old-copy run shows +-2% from code layout alone.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 14:22:37 -05:00
osobhandClaude Opus 5.5 fe377266e1 clawhdf5: FileEditor unmaps the file before an edit writes it
Each edit planned over the reader's memory map of the file and committed
while that File, and the Image's &[u8] over the mapping, were still alive,
writing the same file through the editor's descriptor. Nothing read the
mapping during the writes, but a shared slice whose memory changes
underneath it is undefined behaviour under Rust's aliasing rules.

Image::into_plan now detaches the edit's writes (patches, end of
allocation) into a Plan that owns all of its bytes and borrows nothing;
edit() takes the user-block size, drops the File — unmapping the file —
and only then commits the Plan. The invariant is documented in the image
module and the editor's module docs.

Test: edit::tests::file_is_not_mapped_while_an_edit_writes_it checks
/proc/self/maps at the moment each commit starts (write, resize, set_attr):
never mapped. With the commit moved back before the reader is dropped (the
previous order) it reports all three commits with the file mapped.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 14:14:58 -05:00
osobhandClaude Opus 5.5 b668878129 clawhdf5: FileEditor reports filters it cannot run as Error::Unsupported
A dataset whose filter this build cannot encode (scale-offset, N-Bit, SZIP;
a plugin filter the build lacks) failed with Error::Format("unsupported
filter: 6"), although the editor documents every refused edit as
Error::Unsupported, and the Python bindings raised ValueError rather than
NotImplementedError. Every edit now maps FormatError::UnsupportedFilter to
Error::Unsupported; the file is left untouched as before.

Test: edit_interop unencodable_filters_are_unsupported — h5py scale-offset
datasets (integer with chunks, integer never written, float D-scale):
Error::Unsupported naming the filter, and the file byte for byte unchanged.
Fails on the previous editor (Format(UnsupportedFilter(6))).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 14:13:45 -05:00
osobhandClaude Opus 5.5 485bea0f4f clawhdf5: set_attr adds the Attribute Info message a version-2 header needs
libhdf5 counts a version-2 object header's attributes through its Attribute
Info message (0x15) and reports none when the header has none. set_attr gave
v110/latest groups, the root group and datasets without attributes an
attribute message only, so h5py listed the attribute but len(obj.attrs) and
H5Oget_info's num_attrs said 0, and stayed wrong after h5py r+ added more.

Like H5O__attr_create, the edit now adds the message when a version-2 header
lacks it, in the same planned edit: version 0, the header's creation-order
track/index flags, maximum creation index 0, undefined fractal heap and
B-tree addresses, message flag DONTSHARE — byte for byte what libhdf5
writes. It goes before the attribute (libhdf5's order) when free space
holds both, else after it, so a continuation chunk made for the attribute
also takes it.

Test: edit_interop attribute_count_in_version_2_headers — v110 and latest
files, attributes set on the root group, groups and datasets with and
without existing attributes: h5py's len/num_attrs/list/values, h5dump -A
and our reader agree, also after h5py r+ adds attributes up to and past the
compact limit. Fails on the previous editor (h5py len 0).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 14:12:35 -05:00
osobhandClaude Opus 5.5 1ea9132e10 docs: changelog note for B-tree v2 internal node checksums
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 14:11:47 -05:00
osobhandClaude Opus 5.5 04a7f6f6c7 read: of two links with one name, the first wins everywhere
A valid group has one link per name, but a damaged or hand-made one can
have two. resolve_child followed the first soft link of the name, the
listing skipped a dangling one and listed the name via a later link, and
path resolution followed the last symbolic link: three answers. All now
take the first link of the name (header message order in a compact group,
name index order in a dense one) and ignore the rest, even if the first
dangles. That is libhdf5's rule for compact groups (H5G__compact_lookup
stops at the first Link message); h5py opens nothing for a dangling first
link although a later one resolves. For a dense group libhdf5
binary-searches the index and may land on another of several exact
duplicates; documented on first_link_named. find_symbolic_link's v2 branch
was dead (only v1 groups reach it) and is now v1-only.

Test: an h5py compact group with soft links dup_A (dangling, or to /d) and
dup_B (the other), dup_B renamed to dup_A in the header and re-checksummed.
Lookup, path and listing through all three readers match h5py for both
orders. With the old group_v2.rs the path lookup returned 42 where h5py
opens nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 14:11:47 -05:00
osobhandClaude Opus 5.5 5b3d32b37d format: test the address overflow path on 64-bit hosts
addr::to_usize's error branch only ran where usize is narrower than u64,
and no such target runs tests in CI, so on x86_64 the test checked only
that every u64 fits. to_usize and saturating_usize are now the usize
instances of generic to_index/saturating_index; the test runs the same
code with u32 standing in for a 32-bit usize: values past u32::MAX
(including one an `as` cast would wrap to 0x1234) are Overflow, and the
saturating form clamps. A mutant that truncates instead fails the test;
the old addr.rs does not provide the helper the test needs.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 14:09:37 -05:00
osobhandClaude Opus 5.5 f7e2ab12f2 clawhdf5: FileEditor skips optional filters that fail, as libhdf5 does
The editor stored every chunk through the whole pipeline with filter mask 0.
For LZF that did not shrink a chunk, h5py instead stores it raw with the
filter's mask bit set. A chunk the editor stored LZF-encoded at exactly the
raw size was then rewritten raw by libhdf5 at the same size; libhdf5 does not
touch the index entry when the size is unchanged, so the stale mask 0 stayed
and h5py (and h5dump) could no longer read the dataset.

clawhdf5_format::filters::compress_chunk_masked runs the pipeline as
H5Z_pipeline does: an optional filter (H5Z_FLAG_OPTIONAL) that fails is
skipped and its bit set, a mandatory one fails the write, and LZF/Blosc
output no smaller than the input counts as failure, as in the reference
filters (their output buffer is the input's size). Deflate, LZ4, Zstd,
bitshuffle and bzip2 never fail on size in libhdf5 and are kept as before.

Test: edit_interop optional_filters_that_fail_are_skipped — the reviewer's
repro at every libver: the editor stores the chunk exactly as h5py does
(mask 1, size 5; shuffle+LZF+fletcher32 mask 2), h5py r+ rewrites and
extends the datasets, and h5py, h5dump and our reader read every value.
Fails on the previous editor (mask 0; h5dump cannot read /u8).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 14:09:34 -05:00
osobhandClaude Opus 5.5 b6cbd2319f format: checked chunk addresses on the parallel read path
Three `chunk_info.address as usize` casts behind the `parallel` feature
survived the conversion, because check-32bit-casts.sh linted only default
features plus plugin-filters. On a 32-bit target with rayon a chunk address
past 4 GiB still wrapped onto another part of the file. They go through
addr::to_usize now, and the lane index (h % n, always < n) through
saturating_usize.

The script now lints no default features, default features, and every
optional feature but szip (wasm32; the set with zstd, which does not build
for wasm32, on the host, where the lint reports the same casts). With the
old parallel_read.rs/lane_partition.rs it fails listing the four casts; the
old script passed them. CHANGELOG and the design note give the exact count
(119) and what is not covered.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 14:08:57 -05:00
osobhandClaude Opus 5.5 92c8285549 format: verify B-tree v2 internal node checksums
Only leaves and the header were checked. Harmless while every lookup read
the whole tree, but the indexed lookup prunes children by the keys stored
in internal nodes, so one corrupted byte there could route a name to the
wrong child and report it missing with no error. A BTIN whose lookup3
checksum does not match is now ChecksumMismatch on every read (lookups and
full traversals), as in libhdf5.

Test: one byte of the root BTIN of the 35 001-link h5py group's name index
changed -> lookups, paths and listings through File, MmapFile and LazyFile
all fail with ChecksumMismatch, and h5py refuses both. Before, lookups
returned Ok.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 14:07:01 -05:00
osobhandClaude Opus 5.5 d2b25f154f format: in-memory fast path in the Storage read helpers
read_exact_at and read_upto (and so Window::read) ask as_contiguous()
first and slice the file directly when the backend holds it in memory:
one dynamic call per structure read instead of two or three (len,
read_at, then len again for errors). Same results and errors.

Provisional (busy machine, not for docs): a listing that walks 400
symbol-table groups through the facade went from about 18% to about 14%
slower than before the Storage conversion; the extra cost is a few tens
of nanoseconds per structure read, which the facade's per-lookup
re-listing (range-reads.md M0) multiplies.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 13:41:26 -05:00
osobhandClaude Opus 5.5 85efde0b4a test: rustfmt the lookup tests
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 13:36:02 -05:00
osobhandClaude Opus 5.5 677dc5ec7c docs: FileEditor — changelog, limits and leaked space, README example
known-issues records what the editor refuses, that freed space is never
reused (append-workload file sizes measured 2026-09-26 on tank with the
ignored measure_append_waste test; sizes are deterministic), and that there
is no journal.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 13:35:06 -05:00
osobhandClaude Opus 5.5 3c89a31df0 clawhdf5: FileEditor modifies existing files in place
New clawhdf5::FileEditor opens an HDF5 file (h5py-written at any libver,
HDF5 2.0 format included, or clawhdf5-written) under an exclusive flock
and changes only what an edit touches:
- write_selection/write_all/write_values: compact, contiguous (also
  late-allocated) and chunked datasets, any selection. Chunks are decoded,
  updated and re-encoded; a filtered chunk that no longer fits moves to the
  end of the file unless it is the file's last structure, which grows in
  place. New chunks go into v1 B-tree, Extensible Array (paged data blocks
  included), Fixed Array and single-chunk indexes, created on first use.
- resize: grow chunked datasets up to maxshape.
- set_attr: add/replace compact attributes, in a NIL slot or a new
  continuation chunk.
Each edit is planned in an in-memory image and refused whole
(Error::Unsupported) when any part is unsupported (v2 B-tree / implicit
new chunks, shrinking, vlen/reference data, dense or order-tracked
attributes, cache images, paged/persistent free space). Commit writes and
syncs new space before patching existing bytes. Layout v5 (HDF5 2.0)
array indexes use 8-byte filtered chunk sizes, as libhdf5 does.

Error gains Unsupported/InvalidArgument/Locked and is #[non_exhaustive];
the Python bindings map them. build_attr_message is public.

Tests (h5py, h5dump, h5rs check --data after every round; h5py r+
afterwards): appends crossing EA super/data blocks and B-tree splits, the
same B-tree node counts and EA statistics as libhdf5 for the same writes
(in order, reversed and shuffled; paged blocks), every layout and chunk
index overwritten under random selections, attributes to continuation
chunks, random operations against a model, refused edits leave the file
byte-identical, locking.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 13:35:06 -05:00
osobhandClaude Opus 5.5 1b4a93f65a docs: indexed name lookups and checked address conversion (M0)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 13:33:24 -05:00
osobhandClaude Opus 5.5 b41583113a format: no truncating u64 -> usize casts
Every `u64 as usize` cast in clawhdf5-format (115 on wasm32) now goes
through addr::to_usize for values read from the file — addresses, lengths,
counts, dimensions: FormatError::Overflow where the value does not fit
instead of wrapping onto another part of the file on a 32-bit target — or
addr::saturating_usize for counts bounded by something in memory (codec
progress counters, writer sizes), which fail a bounds check or allocation
rather than wrap. A chunk whose offset does not fit lies outside the
dataset and is skipped; partial reads treat such an offset as out of the
buffers. On 64-bit targets nothing changes.

scripts/check-32bit-casts.sh (run by ci-test.sh) lints the wasm32 build
with clippy's cast_possible_truncation and fails on any u64 -> usize
finding; before this commit it listed 115.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 13:33:24 -05:00
osobhandClaude Opus 5.5 02e89c1d2d read: look names up through the dense name indexes
Finding one link or attribute by name read every entry: Group::dataset and
Group::group (File, MmapFile, LazyFile) listed the whole group per call, and
path resolution scanned each group's links. Opening every child of a
35 001-link group by name decoded ~1.2e9 links.

Now a dense group's v2 B-tree name index (type 5, lookup3 hash of the
name) is descended to the records with the name's hash
(btree_v2::find_btree_v2_records reads only the nodes whose key interval
overlaps), and only those links are read and compared; all hash-equal
records are compared, so libhdf5's tie order does not matter. Dense
attributes the same through their type 8 index
(attribute::find_attribute_in_file, facade attr(name)); huge heap objects
through their ID-ordered index. group_v2::resolve_child returns what the
listing has under a name (soft links followed, dangling/external ones not
found). Group::entries and File::group_at hand out a listing's addresses.

The lookup-stats feature counts heap objects read. Tests: one lookup in
an h5py-written 35 001-link group with colliding hashes reads at most two
links (before: 35 001, failing), attribute lookups likewise (before: 3 000,
failing), every child opens through all three readers and matches h5py,
every link kind resolves as h5py resolves it in dense and compact groups,
300 huge attributes are found, and a range search matches a full scan at
every tree depth.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 13:33:19 -05:00
osobhandClaude Opus 5.5 5d17712adb docs: changelog and design status for range-read milestone M1 (part 1)
Lists the parsers now reading through Storage, what still needs the
whole file (v2 B-tree-indexed structures: a clean error; raw data: M2),
the equivalence harness, and the evidence that nothing changed: existing
tests, a byte-identical conformance results.json and per-file probe
output against f2ff2c4, and identical slice-API transcripts over 748
files between the two builds.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 13:29:11 -05:00
osobhandClaude Opus 5.5 476960f4b8 format: fix a clippy lint in the global heap test fixture
With Storage in scope, `data.len()` on a `&&[u8]` resolves to
Storage::len (already a u64), so `as u64` was a no-op cast; name the
slice method explicitly.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 13:11:46 -05:00
osobhandClaude Opus 5.5 24f0c71939 format: keep dataset_fill_value_in's &[u8] signature
Making dataset_fill_value_in generic over Storage broke callers that pass
an array (`include_bytes!`): a generic parameter does not unsize-coerce
`&[u8; N]`. It takes &[u8] again, as before this branch, and wraps the
new dataset_fill_value_from_storage(&dyn Storage, ..).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 13:11:46 -05:00
osobhandClaude Opus 5.5 5705866d40 format: equivalence harness for the Storage migration
tests/storage_equivalence.rs walks real files and runs every metadata
parser converted to Storage twice per object — over the file as a slice
and over a read_at-only CountingStorage — and requires identical results,
values and errors alike; the only allowed difference is the clean
ContiguousStorageRequired from the structures still indexed by a v2
B-tree (dense attributes, a SOHM B-tree index), which is counted. It
covers superblock and extension, cache image, SOHM table/list/B-tree,
object headers, attributes, fill values, shared messages, symbol-table
groups (local heap, B-tree, nodes, names), fractal heaps and their
objects, VDS mappings, and fixed/extensible array chunk indexes.

Inputs: every fixture; files h5py writes for what the fixtures lack
(extensible arrays with super blocks and paged data blocks, paged fixed
arrays, a 400-group v1 file with a user block, a 300-link dense group,
dense, shared and committed-type attributes, SOHM list and B-tree
indexes; honours CLAWHDF5_PYTHON / CLAWHDF5_REQUIRE_INTEROP); and, with
CLAWHDF5_STORAGE_CORPUS set, a corpus such as conformance/.cache/corpus.
Milestones M2/M3 add their parsers to check_object.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 13:09:32 -05:00
osobhandClaude Opus 5.5 2b7065998a docs: ZFP reads (changelog, README feature table, known issues)
ZFP (32013) was the one plugin filter still listed as UnsupportedFilter.
Conformance on tank, `conformance/run.sh --no-fetch` (2026-09-26): 600 of
697 files ok (baseline 599); h5ex_d_zfp.h5 is newly ok.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 13:08:47 -05:00
osobhandClaude Opus 5.5 6a535e2651 format: resolve virtual dataset mappings over Storage
DataLayout::resolve_vds_mappings_in reads the global heap collection
holding a virtual dataset's mappings through GlobalHeapCollection::
parse_in; resolve_vds_mappings is a wrapper. The rest of the data layout
module parses message payloads and does not read the file.

New test: the virtual dataset of vds_same_file.h5 resolves to the same
mappings through a read_at-only CountingStorage, in two reads.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 13:06:12 -05:00
osobhandClaude Opus 5.5 a65a2b7f18 format: resolve fill values over Storage
dataset_fill_value_in now takes the file as any Storage (generic, so
every &[u8] caller compiles unchanged) and follows a shared fill value
message through message_data_with_sohm_in. The raw-data helpers in the
module (read_full_with_fill, apply_to_unallocated_chunks) walk chunk
indexes and stay on &[u8] until milestone M2.

New test: the four datasets of shared_fill_value.h5, two with their
fill value in the SOHM heap, resolve identically through a read_at-only
CountingStorage.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 13:06:12 -05:00
osobhandClaude Opus 5.5 2c292404d2 format: read attributes over Storage
AttributeMessage::parse_in_storage, extract_attributes_full_in and
extract_attributes_tolerant_in take the file as &dyn Storage: shared
datatypes, dataspaces and attributes are resolved through the Storage
shared-message path, and dense attributes' fractal heap through
FractalHeapHeader::parse_in / read_managed_object_in. The dense-storage
name index is a v2 B-tree, which is not read over Storage yet: over a
backend without the whole file in memory it is a clean
ContiguousStorageRequired error, never a partial list. The &[u8]
functions are wrappers.

New test: every object in five h5py-written fixtures (compact, shared
and dense attributes) reads identically through a slice as Storage, and
through a read_at-only CountingStorage except the dense ones, which give
the clean error.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 13:05:48 -05:00
osobhandClaude Opus 5.5 ba3f476be6 format: resolve shared messages over Storage
Every shared-message entry point has an *_in(&dyn Storage, ..) core:
message_data(_with_sohm), resolve_shared_message(_with_sohm),
resolve_sohm_message, load_sohm_table, and the SMTB/SMLI parsers, which
read the signature and then all entries in one bounded read (the list's
open-ended &file_data[pos..] slice is gone). Object headers and the SOHM
fractal heap are read through their Storage parsers; a SOHM B-tree index
still needs the v2 B-tree over a slice, so over a backend without one it
is a clean ContiguousStorageRequired error. New signature::find_signature_in
probes the candidate offsets with 8-byte reads. The &[u8] functions are
wrappers.

New tests: SOHM tables and lists with 4- and 8-byte offsets, at two
offsets, cut at every length and with a bad signature, parse identically
through a read_at-only CountingStorage in at most two reads; the
signature search matches the slice search.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 13:02:58 -05:00
osobhandClaude Opus 5.5 ff6d644391 format: read extensible array chunk indexes over Storage
ExtensibleArrayHeader::parse_in reads the header in one read, and
read_extensible_array_chunks_in reads each index block, super block and
data block as a prefix read and then one window of the whole structure
(paged data blocks included); checksums and elements are checked in the
window with bounds errors reported as the whole-file checks did. The
&[u8] functions are wrappers. New test: an array with inline elements
and a data block, cut at every length and damaged in each structure,
reads identically through a read_at-only CountingStorage.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 13:01:11 -05:00
osobhandClaude Opus 5.5 cf2b408a63 format: read fixed array chunk indexes over Storage
FixedArrayHeader::parse_in reads the header in one read, and
read_fixed_array_chunks_in reads the data block's prefix and then the
whole block, paged or not, as one window; checksums and elements are
checked in it, with bounds errors reported as the whole-file checks did
(also in builds without the checksum feature, where the per-element
checks are the only ones). The open-ended &file_data[offset..] slices are
gone. The &[u8] functions are wrappers. New test: non-paged and paged,
filtered and unfiltered arrays, cut through the data block and with
damaged bytes, read identically through a read_at-only CountingStorage.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 12:57:08 -05:00
osobhandClaude Opus 5.5 d97b3d703a facade: zfp feature; ZFP read bit-exact against libhdf5 + libzfp
The facade forwards `zfp` and adds it to `plugin-filters`.
tests/zfp_interop.rs: h5py + hdf5plugin (H5Z-ZFP 1.1.1, zfp 1.0.1)
write 2205 datasets over 16 modes (rate, precision, accuracy,
reversible, expert settings at their edges) x int32/int64/float/double
x 1-4-D shapes with partial edge chunks, partial blocks and unit chunk
dimensions x smooth/noisy/wide-range/zero/inf-NaN data; clawhdf5 must
read each byte for byte as h5py does (read back after closing the file:
h5py returns a chunk still in libhdf5's cache without decoding it). A
second test swaps a file's header words to what a big-endian writer
stores and checks the byte-swapped values match h5py's.

The left-out-filter test now expects ZFP only in builds without it; CI
lints `zfp` alone and runs the new test with the plugin-filter step.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 12:57:02 -05:00
osobhandClaude Opus 5.5 23a4784e72 format: ZFP decoder (filter 32013), pure Rust, read-only
A port of the zfp 1.0.1 decoder and of H5Z-ZFP 1.1.1's decompression
path behind a new `zfp` feature (in `plugin-filters`): every mode
(fixed rate, precision, accuracy, reversible, expert), int32, int64,
float and double, 1-4 dimensional fields with partial blocks, and
headers written big-endian (values byte-swapped as H5Z-ZFP does).

H5Z-ZFP keeps the zfp header in cd_values (version word, then the
magic/metadata/mode bit stream); each chunk is the bare stream. The
decoder reproduces libzfp bit for bit: integer arithmetic wraps as
libzfp's, block exponents scale by exact powers of two, and the mode
goes through zfp_stream_mode as H5Z-ZFP hands it to zfp. A stream that
ends early is an error (libzfp reads past its buffer), as is a field
whose size is not the chunk's; the output is allocated only once the
stream holds a bit per block.

Tests: header/mode unit tests, and a counting-allocator fuzz of random
headers (expert parameters at their edges) and streams.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 12:56:07 -05:00
osobhandClaude Opus 5.5 a0160730f2 format: read fractal heaps over Storage
FractalHeapHeader::parse_in reads the header as one window (a second,
longer one when it holds an I/O filter pipeline); read_managed_object_in
reads direct blocks, indirect blocks (one window up to the last child
entry) and huge objects with bounded reads. The &[u8] methods are
wrappers. A huge object indexed by the huge-object v2 B-tree, which is
not converted yet, is a clean ContiguousStorageRequired error on a
backend without the whole file in memory (after the "no index" check,
so the error order is unchanged).

storage::Window (crate-internal) reads a window of a structure and
reports bounds failures exactly as the whole-file ensure_len did, and a
short read inside the file is now a Storage error rather than an EOF.

New tests: headers (with and without a filter pipeline) cut at every
length, and managed objects in a direct root and through an indirect
root, huge objects with direct IDs and tiny objects, give identical
results through a read_at-only CountingStorage.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 12:54:48 -05:00
osobhandClaude Opus 5.5 24cbf12f16 format: read group B-tree (v1) nodes over Storage
BTreeV1Node::parse_in reads a node's header and then its keys and
children, two bounded reads; collect_symbol_table_nodes_in walks the tree
over any Storage. The &[u8] functions are wrappers. New test: nodes with
siblings and 4- and 8-byte offsets cut at every length, and a two-level
tree with truncated leaves, give identical results through a read_at-only
CountingStorage (six reads for the three nodes).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 12:51:47 -05:00
osobhandClaude Opus 5.5 bcf3ae4856 format: read symbol table nodes over Storage
SymbolTableNode::parse_in reads the node's 8-byte header, checks the
entries against the end of the file as before, and reads them in one
bounded read. parse is a wrapper. New test: nodes with 4- and 8-byte
offsets, valid and with a bad version, at two offsets and cut at every
length, parse identically through a read_at-only CountingStorage.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 12:51:47 -05:00
osobhandClaude Opus 5.5 06625b7470 format: read global heap collections over Storage
GlobalHeapCollection::parse_in / parse_index_in read the header, check
the collection against the end of the file, and read the collection in
one bounded read; objects are indexed in it with file offsets, as before.
The &[u8] functions are wrappers. New test: collections with 4- and
8-byte lengths, one whose size runs past the file and one whose object
overruns it, at two offsets and cut at every length, give identical
results through a read_at-only CountingStorage.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 12:50:25 -05:00
osobhandClaude Opus 5.5 aab7ea9e8f format: rustfmt the Storage conversions
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 12:49:05 -05:00
osobhandClaude Opus 5.5 6a9bb02f37 format: read local heaps over Storage
LocalHeap::parse_in reads the header in one bounded read,
validate_free_list_in reads each free block's two lengths, and
read_string_in reads from the string to the end of the data segment
once and looks for the terminator there. The &[u8] methods are wrappers.
New test: a heap without free space, with a valid free block and with a
free block overrunning the segment, cut at every length, parse, validate
and read strings identically through a read_at-only CountingStorage.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 12:49:05 -05:00
osobhandClaude Opus 5.5 0d908facd3 format: read the superblock extension and cache image over Storage
read_superblock_extension_in, cache_image_state_in, CacheImage::decode_in
and CacheImage::block_in take &dyn Storage (whose length is the end of
file); the image block is one bounded read. The &[u8] functions are
wrappers; applying an image in place still needs the bytes in memory.
New test: extension messages, a cache image and a corrupt one decode to
the same results through a read_at-only CountingStorage.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 12:47:59 -05:00
osobhandClaude Opus 5.5 cd828725c7 format: parse object headers over Storage
ObjectHeader::parse_in(&dyn Storage, u64, ..) reads the signature, the
prefix (a window of at most 34 bytes for version 2) and then each chunk,
continuation chunks included, as one bounded read; the message loops run
unchanged on the chunk with chunk-relative positions. parse keeps its
&[u8] signature as a wrapper. Bounds errors are reported as before, with
absolute positions and the file's length. New test: headers of both
versions, with times, phase-change values, creation order and a
continuation chunk, and every truncation of each, parse identically
through a read_at-only CountingStorage.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 12:46:51 -05:00
osobhandClaude Opus 5.5 512a6a753f format: parse the superblock over Storage
Superblock::parse_in(&dyn Storage, offset) reads one bounded window of
128 bytes (the largest superblock is 100) and runs the existing version
parsers on it; parse and refresh_eof keep their &[u8] signatures as
wrappers. No behaviour change: on a file longer than the window no bounds
check can fail, and on a shorter one the window is the whole file. New
test: every version and truncation parses to the same result through a
read_at-only CountingStorage, in one read.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 12:44:52 -05:00
osobhandClaude Opus 5.5 6a4707d791 format: add the Storage trait; make the error enums non-exhaustive
Range-read milestone M1, first step (docs/design/range-reads.md §3(a)):
a synchronous, no_std read interface with u64 offsets, read_at returning
Cow<[u8]>, read_ranges, len and an as_contiguous fast path. Implemented
for [u8], Vec<u8>, &T, Box<T> and Arc<T>; slices serve borrowed bytes.
read_exact_at reproduces the parsers' UnexpectedEof bounds error exactly,
so converted modules keep their error values.

FormatError gains Storage(String) and ContiguousStorageRequired; it and
the facade Error are now #[non_exhaustive] (breaking for exhaustive
matches, noted in the changelog; the Python bindings' match gets a
wildcard arm).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 12:44:36 -05:00
osobhandClaude Opus 5.5 6248b411f0 format: one checked conversion from file address to index
addr::to_usize turns a 64-bit file address or length into a slice index,
failing with FormatError::Overflow where it does not fit usize (32-bit
targets such as wasm32) instead of truncating like an `as usize` cast.
Callers are converted in the following commits.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 12:43:49 -05:00