Skip to main content

hdf5_pure/
edit.rs

1//! In-place editing of an existing HDF5 file (issue #32, Group C).
2//!
3//! The in-place edit engine opens an existing file and adds objects, overwrites dataset
4//! values, or edits compact group attributes **in place**:
5//! new data and object headers are written at the end of the file, and the
6//! object headers of the touched groups (and their ancestors up to the root)
7//! are rewritten — also appended — so the superblock ends up pointing at the
8//! new root header. Nothing already in the file is moved, so the cost is
9//! proportional to what you add, not to the file size — unlike the
10//! read-everything-then-rebuild path through [`FileBuilder`](crate::FileBuilder).
11//!
12//! Both new datasets, new (sub)groups, and group attribute edits are supported,
13//! at any existing group path. Adding into a nested group `/a/b` rewrites `b`'s
14//! header (with the new link), then `a`'s header (repointing its link to `b`'s
15//! new location), then the root's — "relocation up the tree". This is always
16//! safe for *additions* because no surviving object is relocated except the
17//! groups on the path being edited, and those are reachable only through links
18//! this same commit rewrites (the root through the superblock); absolute
19//! object-reference addresses to other objects stay valid.
20//!
21//! Deletion ([`Group::delete`](crate::Group::delete), the HDF5 `H5Ldelete`) is the mirror image:
22//! the parent group's header is rebuilt without the removed link, relocated up
23//! the tree the same way, and the unlinked object (and its subtree) is freed —
24//! its blocks are returned to a session-local free list (see below). A deletion
25//! and an addition at the *same* path in one commit is a **replacement** (issue
26//! #305): the link is removed from the rebuilt parent before the new object's is
27//! appended, and the one superblock write publishes both, so a rotating store
28//! expresses a rotation as one commit and the path is never momentarily absent.
29//! The new object's storage is still appended rather than laid over the old
30//! one's — the original stays live until the superblock repoint, which is what
31//! makes a crash during the rotation land on one side or the other — so the
32//! space the deletion released is what a *later* commit draws on, by reuse or by
33//! truncation.
34//! Object copy ([`File::copy`](crate::File::copy), the HDF5 `H5Ocopy`) deep-copies
35//! a source subtree — appending fresh copies of every object, repointing internal
36//! links and the contiguous data address — and links the copy in like an
37//! addition; the headers are reproduced from their verbatim message bytes, so
38//! datatypes, dataspaces, and attributes stay byte-exact. A chunked (and filtered)
39//! dataset is copied with its chunk payloads and filter pipeline preserved
40//! byte-for-byte, its index rebuilt at the new location. The same machinery,
41//! [`File::copy_from`](crate::File::copy_from), copies an object **across two open files** — the
42//! source being a separate [`File`](crate::File) reader rather than the file being
43//! edited. Because the copy is byte-for-byte, the cross-file path refuses anything
44//! that embeds a source-file absolute address (variable-length or reference data,
45//! a committed datatype), which an in-file copy keeps valid by sharing the source
46//! file's heaps and objects.
47//!
48//! Value overwrite ([`Dataset::write`](crate::Dataset::write), the HDF5 `H5Dwrite`) replaces
49//! an **existing** dataset's values. The replacement's datatype and shape must
50//! match the on-disk dataset (an overwrite, not a reshape or retype); contiguous,
51//! compact, and chunked (including filtered) datasets are all supported, the chunk
52//! geometry and filter pipeline taken from the on-disk header. A same-length
53//! contiguous overwrite is the cheapest edit there is — the new bytes go straight
54//! into the existing data block, so no header is rewritten and the superblock root
55//! is not flipped, and the synced data write is the commit's linearization point.
56//! A chunked overwrite takes the same in-place path when every (re-encoded) chunk
57//! still fits its slot — always for unfiltered storage (chunk sizes are fixed by
58//! the unchanged shape), and for filtered storage when the re-encoded chunks match.
59//! When a length differs (a resized contiguous block, a filtered chunk that no
60//! longer fits, or a compact dataset) the dataset's storage is rebuilt and its
61//! header relocated like an addition: the new data and a rewritten header are
62//! appended, the data-layout message is repointed, the old storage is freed, and
63//! the parent group's link is patched. A relocating overwrite of a dataset
64//! reachable through more than one hard link is refused, since only the one named
65//! link could be repointed at the moved header.
66//!
67//! # Scope
68//!
69//! It is deliberately strict: rather than silently produce a degraded file, it
70//! refuses with [`Error::EditUnsupported`] any case it cannot reproduce
71//! faithfully. Requirements:
72//!
73//! - The file uses 8-byte offsets/lengths. A **userblock** (non-zero base
74//!   address, as every MATLAB v7.3 `.mat` file has) is supported: addresses are
75//!   read and written relative to the base and the userblock bytes are preserved
76//!   verbatim. Every edit works on a userblock file — value overwrites, additions
77//!   of contiguous and chunked/filtered datasets, in-place and relocating
78//!   overwrites of every layout (with the old storage reclaimed), object deletion
79//!   (with base-aware subtree reclaim), in-file copy, cross-file copy into a
80//!   userblock destination, group creation, compact attributes, and free-space
81//!   reuse. The one userblock-specific limitation left is cross-file copy *from* a
82//!   userblock source (the source must have base 0; see [`copy_from`](crate::File::copy_from)).
83//!   Any superblock version (0–3) is accepted: a version 0/1
84//!   (symbol-table) file is edited by converting each group on the edited path
85//!   to the latest format and repointing the superblock's root symbol-table
86//!   entry.
87//! - A version 2/3 group on an edited path stores its links compactly (not in a
88//!   dense fractal heap); headers split across continuation chunks (as the
89//!   reference C library often writes) are collapsed into a single chunk when
90//!   rewritten. A version 1 group is converted to a compact-link v2 header,
91//!   carrying its links and attributes over (other group messages — symbol
92//!   table, modification time — are dropped); an attribute it cannot reproduce
93//!   is refused.
94//! - An object that tracks **attribute creation order** (`track_order=True`,
95//!   and everything netCDF-4 writes) is edited like any other: its 6-byte
96//!   message records are walked and re-emitted, a new attribute takes the
97//!   object's next creation index, an overwrite keeps the one it had, and a
98//!   deletion leaves a gap rather than renumbering. A group that tracks **link**
99//!   creation order works the same way: a link added to it takes the next index
100//!   from the running maximum its Link Info message records, and a deletion
101//!   leaves a gap there too. Only an addition that would send such a group's
102//!   links *dense* is refused, since the creation-order B-tree that indexes them
103//!   is not written here.
104//! - Added datasets may be contiguous *or* chunked, with any filter the
105//!   whole-file writer supports (deflate, shuffle, fletcher32, scale-offset,
106//!   LZF, ZFP), and may declare extensible (maximum, optionally unlimited)
107//!   dimensions. A chunked dataset's data and index — and any filtered chunks —
108//!   are produced by the same builder the whole-file writer uses and appended at
109//!   end-of-file, so its object header is byte-identical to a freshly written
110//!   one. A dataset may be empty (zero-element) under either storage, which is
111//!   how an extensible dataset is created before the first
112//!   [`append_staged`](crate::Dataset::append_staged) fills it: a contiguous one
113//!   gets the undefined data address, a chunked one an index over zero chunks.
114//!   A provenance dataset (`with_provenance`) is
115//!   supported, its attributes computed the same way the whole-file writer
116//!   computes them. A contiguous dataset may carry a variable-length-string
117//!   payload (`with_vlen_strings`) or per-element object-reference targets
118//!   (`with_path_references`); chunking either is not supported. A
119//!   path-resolved reference may target any object this commit is not itself
120//!   still writing (an ancestor group, a same-depth sibling group ordered
121//!   later in the same commit, a copy destination or its interior, a
122//!   `write_dataset` target, or an object this commit deletes) — targeting
123//!   one of those is refused, up front and before any byte of the commit is
124//!   written, rather than resolved to a stale or wrong address; a path that
125//!   resolves nowhere at all becomes an undefined reference, matching the
126//!   whole-file writer. Every
127//!   added dataset must have a fixed-size datatype. Group, root, and **dataset**
128//!   attribute edits (`set_group_attr` / `set_dataset_attr`) may be fixed-size or
129//!   variable-length. An attribute set larger than an object header holds
130//!   compactly — more than [`MAX_COMPACT_ATTRS`] of them, or one whose message
131//!   overflows the header's 2-byte size field — is stored in a fractal heap, on
132//!   an object this engine adds and on one whose edit takes it past the
133//!   threshold alike, and an object already storing its attributes densely is
134//!   rebuilt the same way ([`plan_attr_ops`], issue #102). A dataset attribute
135//!   edit relocates the dataset header and so requires a single hard link.
136//! - A new group's parent must already exist or be created in the same session
137//!   (each level created explicitly); intermediate groups are not auto-created.
138//! - Rows can be appended to an existing chunked, unlimited, Extensible-Array
139//!   dataset **immediately and in place** with an in-place append (amortized O(1),
140//!   crash-atomic, no `commit`), interleaved with the staged edits above. A
141//!   target the fast path cannot handle — a userblock or pre-v2 file, an
142//!   unallocated index, a non-Extensible-Array or multi-hard-link dataset, a
143//!   a filtered dataset sitting on a partial trailing chunk — is refused with
144//!   [`Error::AppendInPlaceUnsupported`]; use the staged `append_dataset` instead.
145//!
146//! # Free-space reuse (issue #21)
147//!
148//! Each commit vacates space: the object headers it rewrites are superseded, and
149//! a deletion abandons its target's blocks. Those regions are recorded in a
150//! session-local free list and drawn on by later writes in the same session —
151//! a new object is written into a fitting freed region instead of growing the
152//! file, and when freed space forms a run reaching end-of-file the file is
153//! physically truncated. The reuse is crash-safe: it only ever overwrites space
154//! freed by an *earlier*, already-durable commit (never space the current commit
155//! is mid-way through freeing), and truncation happens only after the superblock
156//! recording the smaller end-of-file is itself durable. A commit that fails
157//! before its repoint gives back what it drew, so a failed attempt costs the
158//! session nothing.
159//!
160//! Both write paths draw on that list, through allocators of their own. The
161//! immediate [`Dataset::append`](crate::Dataset::append) allocates its chunks and
162//! index blocks through [`Store::alloc_raw`], which
163//! [`WriteEngine::immediate_reuse_allowed`] gates: an append has no superblock
164//! repoint to publish its allocations with, so it reuses only where the space it
165//! overwrites is dead on the disk as it stands (issue #349). On a file that
166//! **persists** its managers a hole in the list is one the disk still advertises,
167//! so the append does not spend it directly: it takes a batch out of the
168//! published managers first
169//! ([`WriteEngine::reserve_for_immediate_append`], issue #387) and spends only
170//! that, which makes the on-disk record true of every byte it writes at every
171//! instant. What the session does not spend goes back before the managers are
172//! next rewritten; a session that dies in between strands the remainder, which is
173//! accounted to nobody and so can never be handed out twice. The SWMR writer
174//! reuses nothing at all, for a reason publication does not reach: its readers
175//! may still be inside the region. Everything
176//! a *commit* places goes through the other allocator
177//! ([`WriteEngine::reserve`] and [`WriteEngine::place`]), including a chunked
178//! dataset's data region and a dense attribute heap. Those carry addresses of
179//! their own, so they are sized before they are placed and then built for the
180//! address they got — which is why they can land in a freed region at all
181//! (issue #261). A paged file (`H5F_FSPACE_STRATEGY_PAGE`) draws only from free
182//! space of the page type it is placing, so reuse cannot make metadata and raw
183//! data share a page — except from pages that are *wholly* free, which hold
184//! nothing of either type to be mixed with and so may be opened for whichever
185//! type asks ([`PagedEdit::alloc_typed`]). The free-space rewrite a commit
186//! performs is placed through the same allocator where anything fits, rather than
187//! at end-of-file (which on a paged file costs a page of its own), and that is
188//! what keeps a delete-and-recreate workload from growing by a tail per commit
189//! (issue #286 for the paged tail, issue #358 for the flat one).
190//!
191//! Reclaim is best-effort and conservative. Contiguous and chunked datasets
192//! (chunk index plus chunk data) and whole group subtrees are reclaimed; a
193//! deleted object whose blocks cannot be enumerated exhaustively —
194//! variable-length global-heap storage, dense attribute/link heaps, a
195//! non–version-2 header, a version 2 B-tree chunk index — is left as dead bytes
196//! rather than risk freeing a region that is still in use; under-reclaiming only
197//! wastes space, while over-reclaiming would corrupt.
198//!
199//! On a paged file that conservatism extends to anything whose *page type* is not
200//! established. A file another writer produced records free space this one cannot
201//! place — the reference library's generic-large manager holds metadata and raw
202//! alike, and it puts a chunk index among its metadata where this crate puts one
203//! beside the chunk data. Such space is kept and written back where it was found,
204//! but never handed to an allocation
205//! ([`PagedEdit::unclassified`], [`WriteEngine::index_is_provably_raw`]), because
206//! placing a byte of the wrong kind in a page is the one thing paging exists to
207//! prevent and no reader would report it.
208//!
209//! Space this editor vacates and cannot place is *dead* rather than lost
210//! ([`PagedEdit::dead`]): out of use, not yet reusable. A page whose every byte is
211//! free or dead holds nothing of either type, so it is promoted whole into the
212//! free lists ([`PagedEdit::promote_whole_free_pages`]) — the same rule that lets
213//! one page type claim a wholly free page from the other. That is what bounds a
214//! paged file under delete-and-recreate churn: reclaim lands at page granularity
215//! where the page type cannot be shown, rather than not at all (issue #388).
216//!
217//! Whether the free list outlives the session depends on how the file was
218//! created. For the default (non-persisting) file it is **not** persisted: it is
219//! forgotten on close, so reuse and shrinkage apply to churn within a session,
220//! and a single delete-then-close shrinks the file only when the freed bytes
221//! reach end-of-file. A file created with
222//! `H5Pset_file_space_strategy(persist = true)` instead **persists** its free
223//! space: `open` seeds the list from the on-disk free-space managers (the
224//! `FSHD`/`FSSE` blocks the superblock-extension File Space Info message points
225//! at), and each commit rewrites those managers, so freed regions survive
226//! close/reopen and are reused across sessions — by this crate and the reference
227//! C library alike. Such a commit *records* the freed space it keeps rather than
228//! forgetting it; the blocks holding the managers go into space an earlier commit
229//! freed, or past all live data when none fits, and the superblock is repointed
230//! last, so a crash before the repoint leaves the prior file wholly intact.
231//!
232//! Freed space that reaches the end of the file is given back there too
233//! (issue #418): the commit lowers the end-of-allocation to where the run starts,
234//! drops those sections from the managers it writes, and truncates the file once
235//! the smaller superblock is durable — page-aligned on a paged file, so the end
236//! of allocation stays a whole number of pages. Two things bound it. A few tails'
237//! worth of the run stays ([`release_trailing_run`]), because the manager blocks
238//! are rewritten by every commit and can never land in their own predecessor's
239//! extent, so a file trimmed closer than that sends its next tail past the end of
240//! the file instead of into the reserve; and a run that would give back less than
241//! it keeps is left alone, since
242//! trimming the top off a hole the next write was about to fill costs a whole
243//! object to return a fraction of one. A commit whose own tail had to be appended
244//! *above* the run it freed takes a second, tail-only rewrite to move the blocks
245//! down into it ([`WriteEngine::shrink_to_the_trailing_free_run`]) — the delete
246//! case, where the freed regions only become writable once the repoint that
247//! freed them is durable. Whole-file compaction that reclaims every hole at once
248//! is still the separate repack path.
249
250use std::borrow::Cow;
251use std::collections::{BTreeMap, HashMap, HashSet};
252use std::fs;
253use std::io::{Read, Seek, SeekFrom};
254use std::path::Path;
255
256use core::num::NonZeroUsize;
257
258use crate::address::BaseAddress;
259use crate::attribute_info::AttributeInfoMessage;
260use crate::checksum::jenkins_lookup3;
261use crate::chunk_index_inplace::{Located, Store, apply_ea_append, plan_ea_append};
262use crate::chunked_read::{
263    chunk_index_spans_from_source, enumerate_chunks_from_source, plan_dense_grid,
264};
265use crate::chunked_write::{
266    ChunkMeta, ChunkOptions, ChunkProvider, StorageAllocation, WrittenChunk, assemble_chunked_at,
267    build_extensible_array_at, chunked_data_len, compress_chunks, emit_chunked_data_verbatim,
268    extensible_array_len, full_chunk_bytes, plan_chunked_data_verbatim,
269    serialize_v4_extensible_array, split_into_chunks,
270};
271use crate::convert::TryToUsize;
272use crate::data_layout::DataLayout;
273use crate::dataspace::{Dataspace, DataspaceType};
274use crate::datatype::{
275    Datatype, DatatypeByteOrder, datatype_holds_file_address, datatype_holds_object_address,
276    embedded_reference_slots, stored_object_references,
277};
278use crate::error::{Error, FormatError, OBJECT_HEADER_MESSAGE_MAX};
279use crate::extensible_array::ExtensibleArrayHeader;
280use crate::file_create_properties::FileCreateProperties;
281use crate::file_lock::{self, FileLocking};
282use crate::file_space_info::{FileSpaceInfo, FileSpaceStrategy, NUM_FILE_FSM_MANAGERS};
283use crate::file_writer::{
284    DenseAttrCreationOrder, LENGTH_SIZE, OFFSET_SIZE, build_chunked_dataset_oh, build_dataset_oh,
285    make_link,
286};
287use crate::filter_pipeline::{
288    FILTER_DEFLATE, FILTER_FLETCHER32, FILTER_LZF, FILTER_SCALEOFFSET, FILTER_SHUFFLE,
289    FilterPipeline,
290};
291use crate::filters::{ChunkContext, FilterScratch, compress_chunk_with, decompress_chunk};
292use crate::free_space::{FreeList, trailing_run_start};
293use crate::free_space_manager::{
294    self, FreeSection, FsmHeader, PageType, PagedManagerPlan, SECT_CLASS_SIMPLE, align_up,
295    file_fsm_blocks_len, free_sections, fshd_len, plan_paged_managers, serialize_file_fsm,
296};
297use crate::group_v2::resolve_group_entries_from_source;
298use crate::image::{FileImage, HandleImage, MirrorImage, WriteBuffering};
299use crate::libver::LibVer;
300use crate::link_info::LinkInfoMessage;
301use crate::link_message::{LinkMessage, LinkTarget};
302use crate::message_type::MessageType;
303use crate::object_header::ObjectHeader;
304use crate::reader::FileAccessProperties;
305use crate::shared_message::DatatypeLocation;
306use crate::signature;
307use crate::source::{BaseOffsetSource, BytesSource, MetadataCacheConfig, Source};
308use crate::superblock::Superblock;
309use crate::type_builders::{
310    AttrValue, DatasetBuilder, ObjectRefPatch, ObjectRefTarget, VlStringStaging,
311    build_attr_message, build_global_heap_collections, make_f32_type, make_f64_type, make_i8_type,
312    make_i16_type, make_i32_type, make_i64_type, make_u8_type, make_u16_type, make_u32_type,
313    make_u64_type, patch_vl_refs, patch_vl_refs_masked, write_reference_address,
314};
315
316/// An undefined on-disk address (all bits set), HDF5's "no address" sentinel.
317const UNDEF: u64 = u64::MAX;
318
319/// The most free space an immediate in-place append takes out of a persisting
320/// file's on-disk managers in one draw, when the allocation in front of it needs
321/// less (issue #387).
322///
323/// Every draw costs a manager rewrite and a superblock repoint — a few kilobytes
324/// and two ordering barriers — so drawing per allocation would put a commit
325/// between every pair of chunks and make reuse cost more than the growth it
326/// avoids. A draw therefore takes as much as this of whatever holes can each hold
327/// the allocation, largest first, and a megabyte covers sixteen chunks at the
328/// 64 KiB scale the C library's own chunk-cache default is sized for, which puts
329/// the rewrite well outside the per-chunk path.
330///
331/// It is a cap, not a floor. A file whose holes are all smaller than this still
332/// has them drawn, one rewrite for however many of them fit under the cap: a
333/// floor here made every persisting file whose deleted objects were under a
334/// megabyte grow without bound, since no hole it left was ever spent
335/// (issue #413). What a draw refuses is only a hole too small for the allocation
336/// in hand, which nothing could be placed in. Every hole exists because a commit
337/// freed it, so the rewrites spent reusing them are bounded by the commits the
338/// caller already paid for.
339///
340/// It bounds one draw, not a session's total. A fresh draw is made whenever the
341/// largest reserved region is smaller than the allocation in hand, and the
342/// remainders of earlier draws are kept and still handed out, so what an abrupt
343/// end strands is the sum of every remainder still held: each one smaller than
344/// the allocation that forced the next draw, and one more of them possible per
345/// draw. Larger would amortize further and strand more; this is the point where
346/// the rewrite is already rare enough that making it rarer buys little.
347const APPEND_RESERVE_BYTES: u64 = 1 << 20;
348
349/// The refusal both address-side reference screens report: an object reference
350/// this commit writes names an object the same commit removes.
351///
352/// One constant because the screens differ only in where the address came from
353/// — [`WriteEngine::resolve_reference_target`] takes it from a builder's
354/// [`ObjectRefTarget::Raw`], [`screen_resolved_references`] reads it out of a
355/// dataset's or an attribute's element bytes — and a caller must not be able to
356/// tell them apart by the message. It is the address-side twin of the by-name refusal
357/// `resolve_reference_target` reports for a path, and shares its second clause.
358const REFERENCE_INTO_RECLAIMED_SPACE: &str = "a reference this commit writes holds the address of an object this commit deletes, or of \
359     one under it; the reference would be left pointing at storage the delete can reclaim";
360
361/// The refusal for the other half of [`InvalidatedAddresses`]: the object is
362/// still there, but not at that address any more.
363///
364/// It can offer a way out, which the removal refusal cannot: the target still
365/// exists, so a path names it. Whether the path *works* depends on which half of
366/// [`InvalidatedAddresses::moved`] the target came from, which is why the message
367/// names separate commits as well rather than promising the first.
368/// [`WriteEngine::resolve_reference_target`] resolves a **dirty group** once this
369/// commit has placed it, and reports "still writing" until then; a **relocating
370/// dataset write** it refuses outright, by exact match against `write_targets`,
371/// with no later point at which that changes.
372const REFERENCE_TO_A_MOVED_OBJECT: &str = "a reference this commit writes holds the pre-commit address of an object this commit \
373     rewrites elsewhere; name the target by path (`with_path_references`) so it resolves to \
374     where the object lands, or use separate commits";
375
376/// A shared (SOHM) attribute message is held in the file's shared-message table
377/// rather than in the object whose attribute it is, so rewriting the object's
378/// attributes would have to account for the table as well. Both attribute paths
379/// refuse it by the same name: the compact one in [`parse_compact_attr_name`],
380/// which walks the messages it is about to copy, and the dense one in
381/// [`plan_attr_ops`] — where the set is read through
382/// [`crate::attribute::extract_attributes_full_from_source`], which *resolves*
383/// such a message and would otherwise re-emit it into the new heap as a private
384/// one, leaving the table's reference count naming an attribute that no longer
385/// exists.
386///
387/// Since issue #417 the reader follows such a reference, so these are live
388/// refusals rather than backstops: what they hold back is the *table*, whose
389/// reference count this engine cannot yet decrement, not the read. `repack`
390/// rewrites the file with every shared message inline, which is the way through
391/// today.
392const SHARED_ATTRIBUTE_MESSAGE: &str =
393    "a target object has a shared attribute message (not editable in place yet)";
394
395/// A shared-message (SOHM) index record does not always name a heap entry: where
396/// the reference C library shares a message written into an object header it
397/// already holds open, the record names *that header* and the message stays
398/// where it is. Such a record is a stored address like any other, and it is the
399/// one no rewrite reaches — the index lives outside every object a commit
400/// rebuilds, and this engine writes no shared-message indexes. A commit that
401/// removed or moved the named header would leave the file's own index pointing
402/// at bytes that are no longer there, which the next library to share a message
403/// would follow.
404const SHARED_MESSAGE_INDEX_NAMES_A_MOVED_OBJECT: &str = "this file's shared-message (SOHM) index names an object header this commit \
405     removes or rewrites elsewhere, and the index cannot be updated in place; \
406     rewrite the file with `repack` instead";
407
408/// Refusal for an attribute whose references a commit repoints today and could
409/// not repoint from a heap. See [`plan_attr_ops`].
410const REFERENCE_ATTRIBUTE_WOULD_LEAVE_THE_HEADER: &str = "an attribute holding an object reference cannot be moved to dense \
411     (fractal-heap) storage, where a later commit could no longer repoint it when \
412     its target moves; keep this object within compact attribute storage";
413
414/// Maximum number of attributes an object header keeps inline; past this, HDF5
415/// switches the object to dense (fractal-heap) attribute storage, which this
416/// engine emits through [`plan_attr_ops`] and [`WriteEngine::place_dense_attrs`].
417///
418/// Taken from the whole-file writer's threshold rather than restated beside it:
419/// the two decide the same thing about the same attribute set, and a file whose
420/// objects were written by one and edited by the other must not disagree about
421/// where the eighth attribute lives.
422const MAX_COMPACT_ATTRS: usize = crate::file_writer::DENSE_ATTR_THRESHOLD;
423
424/// Recursion-depth cap for object copy, guarding against a stack overflow on a
425/// pathological or cyclic hard-link graph (HDF5 hard links can form cycles).
426/// Far deeper than any real group hierarchy.
427const MAX_COPY_DEPTH: u32 = 1000;
428
429/// Upper bound on the number of object headers walked when counting hard links
430/// across the file (issue #77 / reclaim safety). Far beyond any real file; a
431/// graph larger than this aborts the count, and the commit then leaves deleted
432/// objects unreclaimed (a safe leak) rather than risk an unbounded walk.
433const MAX_LINK_GRAPH_NODES: u32 = 1 << 24;
434
435/// Maximum number of object-header chunks to follow when gathering a header that
436/// spans continuation blocks, guarding against a cyclic continuation chain.
437/// Matches the reader's continuation-depth cap.
438const MAX_OH_CHUNKS: usize = 256;
439
440/// Maximum length of a version 2 object header's fixed prefix: signature (4) +
441/// version (1) + flags (1) + optional access/modification/change/birth times
442/// (16) + optional attribute phase-change thresholds (4) + the chunk-0 size
443/// field (up to 8). Reading this many bytes always covers the prefix, so
444/// [`oh_region_at`] can be handed one bounded window instead of a whole-file
445/// image.
446const OH_PREFIX_MAX: usize = 34;
447
448/// A path identified by its components (no leading/trailing empties); the root
449/// group is the empty vector.
450type PathKey = Vec<String>;
451
452/// A live [`BufferedAppender`](crate::BufferedAppender)'s hold on a dataset.
453///
454/// The appender accepts elements into memory and is the only thing that can
455/// write them, so any staged edit that would make its flush refuse turns
456/// accepted data into lost data at drop time. The claim lets the engine refuse
457/// that edit up front instead.
458struct AppenderClaim {
459    /// Identifies this claim for release; ids are never reused within a session.
460    token: u64,
461    /// The appender's dataset path, or `None` for a handle reached by object
462    /// reference. Such a handle is named by object-header address, which *any*
463    /// staged edit may move, so a path-less claim conflicts with everything —
464    /// exactly the rule `append_prepare` already applies to that target.
465    path: Option<PathKey>,
466}
467
468/// A variable-length group/root attribute staged by [`apply_compact_attr_ops`]
469/// and resolved in the apply loop.
470#[derive(Clone)]
471struct PendingVlAttr {
472    /// The attribute message, its global-heap references still placeholders.
473    msg: crate::attribute::AttributeMessage,
474    /// The collections whose real addresses those references need.
475    collections: Vec<Vec<u8>>,
476    /// The creation index this attribute keeps, taken from the message it
477    /// replaces on a header that tracks attribute creation order. `None` asks
478    /// for the object's next unused index when the message is appended — which
479    /// is every case on a header that does not track the order.
480    creation_index: Option<u16>,
481}
482
483/// Variable-length group/root attributes staged by [`apply_compact_attr_ops`].
484type PendingVlAttrs = Vec<PendingVlAttr>;
485
486/// Accumulates elements to append to an existing chunked, unlimited dataset via
487/// [`Dataset::append_staged`](crate::Dataset::append_staged), in call order along the dataset's first
488/// (axis-0) dimension.
489///
490/// It mirrors [`DatasetBuilder`]'s typed/generic vocabulary. Repeated typed or
491/// [`append_raw`](Self::append_raw) calls concatenate; each typed method also
492/// records the element datatype it implies, which `commit` checks against the
493/// dataset's on-disk datatype (a mismatch — including a mix of element types in
494/// one builder — is refused with [`Error::AppendUnsupported`], never written as
495/// garbage).
496pub struct AppendBuilder {
497    /// Accumulated little-endian element bytes to append, in call order.
498    raw: Vec<u8>,
499    /// The element datatype implied by the typed `append_*` calls, if any were
500    /// used. `None` when only [`append_raw`](Self::append_raw) was called (a raw
501    /// append is checked structurally — element-size alignment and little-endian
502    /// on-disk order — rather than by datatype equality).
503    elem_dt: Option<Datatype>,
504    /// Set when two typed calls implied different element datatypes; `commit`
505    /// refuses such a builder rather than write a mix of encodings.
506    dt_conflict: bool,
507}
508
509impl AppendBuilder {
510    pub(crate) fn new() -> Self {
511        Self {
512            raw: Vec::new(),
513            elem_dt: None,
514            dt_conflict: false,
515        }
516    }
517
518    /// Accumulated little-endian element bytes (for the general append writer,
519    /// which reuses this builder to gather typed/generic appends).
520    pub(crate) fn raw(&self) -> &[u8] {
521        &self.raw
522    }
523
524    /// The element datatype implied by typed appends, if any.
525    pub(crate) fn elem_dt(&self) -> Option<&Datatype> {
526        self.elem_dt.as_ref()
527    }
528
529    /// Whether two typed appends implied conflicting element datatypes.
530    pub(crate) fn dt_conflict(&self) -> bool {
531        self.dt_conflict
532    }
533
534    /// A builder holding a copy of this one's first `byte_len` bytes, carrying
535    /// the same element datatype so the prefix is type-checked exactly as the
536    /// whole would have been. Used by [`BufferedAppender`](crate::BufferedAppender)
537    /// to write out the chunk-aligned prefix of its buffer; it copies rather
538    /// than splits so a failed write leaves the buffer intact and the appender
539    /// can report precisely which elements did not land.
540    pub(crate) fn head(&self, byte_len: usize) -> Self {
541        Self {
542            raw: self.raw[..byte_len.min(self.raw.len())].to_vec(),
543            elem_dt: self.elem_dt.clone(),
544            dt_conflict: self.dt_conflict,
545        }
546    }
547
548    /// Discard the first `byte_len` buffered bytes (a prefix that reached the
549    /// file), keeping the element datatype.
550    pub(crate) fn drop_front(&mut self, byte_len: usize) {
551        self.raw.drain(..byte_len.min(self.raw.len()));
552    }
553
554    /// Consume the builder, yielding its accumulated element bytes. Used by
555    /// [`BufferedAppender::discard`](crate::BufferedAppender::discard) to hand
556    /// abandoned elements back to the caller.
557    pub(crate) fn into_raw(self) -> Vec<u8> {
558        self.raw
559    }
560
561    /// Cut the buffer back to `byte_len` bytes, keeping the element datatype.
562    /// [`BufferedAppender`](crate::BufferedAppender) uses this to undo a call
563    /// whose write was refused before it touched the file, so the refusal leaves
564    /// nothing buffered and a retry cannot append the same elements twice.
565    pub(crate) fn truncate(&mut self, byte_len: usize) {
566        self.raw.truncate(byte_len);
567    }
568
569    /// Record the datatype a typed append implies, flagging a conflict if an
570    /// earlier typed call implied a different one.
571    fn set_dt(&mut self, dt: Datatype) {
572        match &self.elem_dt {
573            Some(prev) if *prev != dt => self.dt_conflict = true,
574            Some(_) => {}
575            None => self.elem_dt = Some(dt),
576        }
577    }
578
579    /// Append already-little-endian element bytes verbatim. The concatenated
580    /// length must be a whole multiple of the dataset's on-disk element size, and
581    /// the dataset's element datatype must be little-endian; no datatype is
582    /// otherwise inferred. Prefer the typed methods when the element type is known.
583    pub fn append_raw(&mut self, bytes: &[u8]) -> &mut Self {
584        self.raw.extend_from_slice(bytes);
585        self
586    }
587
588    /// Generic append of a flat slice of any supported scalar type — the
589    /// counterpart of [`DatasetBuilder::with_data`](crate::DatasetBuilder::with_data).
590    pub fn append<T: crate::element::H5Element>(&mut self, data: &[T]) -> &mut Self {
591        T::append_into(self, data);
592        self
593    }
594}
595
596/// Generate the typed `append_*` methods: serialize each value little-endian and
597/// record the implied element datatype.
598macro_rules! append_typed {
599    ($($method:ident, $ty:ty, $make:ident;)*) => {
600        impl AppendBuilder {
601            $(
602                #[doc = concat!("Append `", stringify!($ty), "` values to the dataset.")]
603                pub fn $method(&mut self, data: &[$ty]) -> &mut Self {
604                    self.set_dt($make());
605                    // Reserved up front: serializing element by element into a
606                    // growing `Vec` re-allocated it ten times per call and copied
607                    // the batch twice over, which for an append loop is the whole
608                    // per-call cost (issue #228).
609                    self.raw.reserve(data.len() * core::mem::size_of::<$ty>());
610                    for &v in data {
611                        self.raw.extend_from_slice(&v.to_le_bytes());
612                    }
613                    self
614                }
615            )*
616        }
617    };
618}
619
620append_typed! {
621    append_f64, f64, make_f64_type;
622    append_f32, f32, make_f32_type;
623    append_i8, i8, make_i8_type;
624    append_i16, i16, make_i16_type;
625    append_i32, i32, make_i32_type;
626    append_i64, i64, make_i64_type;
627    append_u8, u8, make_u8_type;
628    append_u16, u16, make_u16_type;
629    append_u32, u32, make_u32_type;
630    append_u64, u64, make_u64_type;
631}
632
633/// Every edit a session has staged and not yet committed, as one value.
634///
635/// [`WriteEngine::commit`] takes the whole set out for the duration of an
636/// attempt and puts it back if that attempt refuses, so a refused commit costs
637/// the session no staged work — the same guarantee [`FreeSnapshot`] gives the
638/// free lists, for the same reason (issue #316). Keeping the vectors together
639/// rather than beside the engine's other fields is what makes that total: a
640/// staged kind added later participates by construction, where a tenth field
641/// would have to be remembered in a hand-written restore.
642#[derive(Default)]
643struct StagedEdits {
644    /// Datasets staged by `create_dataset`, as (parent group path, dataset).
645    ///
646    /// Flattened at staging rather than at commit: [`flatten_dataset`] is the
647    /// one step of the commit's preflight that *consumes* what it validates, so
648    /// running it here is what lets the preflight read the staged set without
649    /// destroying it (issue #316). It is a pure function of the builder, so the
650    /// guards it raises — a missing shape, data that does not match it, a
651    /// feature this engine cannot reproduce — are answered at the call that
652    /// stages the dataset, where the caller still has the context to fix them.
653    datasets: Vec<(PathKey, FlatDataset)>,
654    /// Value overwrites staged by `write_dataset`, as (full dataset path,
655    /// dataset). Each replaces an existing dataset's values in place; the new
656    /// datatype and shape must match the on-disk ones byte-exactly (this is a
657    /// value overwrite, not a reshape/retype). Applied on the next `commit`.
658    /// Flattened at staging, for the reason given on [`datasets`](Self::datasets);
659    /// the match against the on-disk dataset is a commit-time check and stays
660    /// one, since it reads the file.
661    writes: Vec<(PathKey, FlatDataset)>,
662    /// Appends staged by `append_dataset`, as (full dataset path, builder). Each
663    /// grows an existing chunked, unlimited, Extensible-Array-indexed dataset
664    /// along axis 0 by keeping its existing chunk data in place and rebuilding the
665    /// index over the kept plus newly-appended (and any rewritten trailing) chunks.
666    /// Applied on the next `commit`.
667    appends: Vec<(PathKey, AppendBuilder)>,
668    /// New groups staged by `create_group`, as full paths.
669    groups: Vec<PathKey>,
670    /// Group attribute edits staged as (group path, operation). The path may be
671    /// a group created in this same session.
672    group_attrs: Vec<(PathKey, AttrOp)>,
673    /// Dataset attribute edits staged as (full dataset path, operation), applied
674    /// on the next `commit`. Each relocates the dataset's object header (like a
675    /// relocating overwrite): the header is rebuilt with the compact-attribute
676    /// change, its single naming link is patched, and the old header freed — the
677    /// dataset's data and chunk index stay in place. The target must be an existing,
678    /// single-hard-link dataset using compact (not dense fractal-heap) attributes.
679    dataset_attrs: Vec<(PathKey, AttrOp)>,
680    /// Links staged for removal by `delete`, as full paths.
681    deletes: Vec<PathKey>,
682    /// Object copies staged by `copy`, as (source path, destination full path).
683    copies: Vec<(PathKey, PathKey)>,
684    /// Cross-file object copies staged by `copy_from`, as (destination full path,
685    /// the source subtree already read out of the other file). The subtree is read
686    /// — and foreign-address-screened — eagerly in `copy_from` (the source file is
687    /// borrowed only for that call), then linked in at the next `commit`.
688    cross_copies: Vec<(PathKey, CopyTree)>,
689    /// Where the creation at each full path sits in [`datasets`](Self::datasets)
690    /// / [`groups`](Self::groups), so a by-name question about the staged set
691    /// costs a hash rather than a scan of everything staged.
692    ///
693    /// The scan is what made a writer that stages one dataset per column and
694    /// then binds a handle to each quadratic in the number of columns (issue
695    /// #392). Both maps are derived from the vectors beside them and are kept in
696    /// step at the three places those change: the push that stages a creation,
697    /// the truncation [`rewind`](Self::rewind) makes, and the rebuild
698    /// [`withdraw_at`](Self::withdraw_at) makes after removing from the middle.
699    /// A path staged twice keeps its *first* position, which is the entry a scan
700    /// would have found; the commit refuses such a pair anyway.
701    dataset_at: HashMap<PathKey, usize>,
702    group_at: HashMap<PathKey, usize>,
703}
704
705/// The full path of a dataset staged under `parent` as `name`.
706fn child_key(parent: &[String], name: &str) -> PathKey {
707    let mut full = parent.to_vec();
708    full.push(name.to_string());
709    full
710}
711
712/// Whether the dataset staged under `parent` as `name` lies at or under
713/// `prefix`, without building its full path.
714fn dataset_under(parent: &[String], name: &str, prefix: &[String]) -> bool {
715    match prefix.len() {
716        n if n <= parent.len() => parent.starts_with(prefix),
717        n if n == parent.len() + 1 => {
718            parent[..] == prefix[..parent.len()] && prefix[parent.len()] == name
719        }
720        _ => false,
721    }
722}
723
724/// What a session has staged at a path, for a handle addressing an object the
725/// commit has not written yet.
726#[derive(Clone, Copy, PartialEq, Eq, Debug)]
727pub(crate) enum StagedKind {
728    Group,
729    Dataset,
730}
731
732/// A creation this session has staged at a path, and whether it takes that path
733/// over from the file.
734#[derive(Clone, Copy, PartialEq, Eq, Debug)]
735pub(crate) struct StagedObject {
736    pub(crate) kind: StagedKind,
737    /// Whether this same commit removes the link the file holds at that path.
738    ///
739    /// True makes the creation a *replacement* (issue #305). False means the
740    /// file holds no link there at all — a creation colliding with one that
741    /// survives is refused where it is staged, and would not be reported here
742    /// either, since the file's own object is what the name means.
743    pub(crate) replaces_link: bool,
744}
745
746/// One direct child a group gains from this session's staged creations.
747pub(crate) struct StagedChild {
748    pub(crate) name: String,
749    pub(crate) kind: StagedKind,
750    /// Whether a staged deletion removes the link this name has in the file, so
751    /// the creation supersedes it rather than colliding with it. See
752    /// [`StagedObject::replaces_link`].
753    pub(crate) replaces_link: bool,
754}
755
756/// What a staged dataset can say about itself before it is written.
757///
758/// Everything here is settled when the dataset is staged — [`flatten_dataset`]
759/// has already validated the builder — so a handle answers from it without
760/// reading the file, and the answers are what the commit goes on to write.
761pub(crate) struct StagedMeta {
762    pub(crate) datatype: Datatype,
763    pub(crate) dimensions: Vec<u64>,
764    /// `None` for a fixed-shape dataset, matching `Dataset::maxshape`.
765    pub(crate) maxshape: Option<Vec<u64>>,
766    pub(crate) chunked: bool,
767    /// Each staged filter's registered id and its `H5Z_FLAG_OPTIONAL` flag, in
768    /// pipeline order.
769    pub(crate) filters: Vec<(u16, bool)>,
770}
771
772/// Where a [`StagedEdits`] stood before a batch of staging calls, so a batch
773/// that fails partway can be undone (see [`StagedEdits::rewind`]).
774#[derive(Clone, Copy, Default, PartialEq, Eq)]
775struct StagedMark {
776    datasets: usize,
777    writes: usize,
778    appends: usize,
779    groups: usize,
780    group_attrs: usize,
781    dataset_attrs: usize,
782    deletes: usize,
783    copies: usize,
784    cross_copies: usize,
785}
786
787impl StagedEdits {
788    /// The length of every staged vector right now, for
789    /// [`rewind`](Self::rewind).
790    ///
791    /// Destructured rather than read field by field, here and in
792    /// [`rewind`](Self::rewind), so that the "a staged kind added later
793    /// participates by construction" claim on [`StagedEdits`] is enforced rather
794    /// than hoped for: a struct pattern naming fewer fields than the struct has
795    /// does not compile, and the unused binding a tenth kind would leave in
796    /// `rewind` is an error under the crate's `-D warnings`.
797    fn mark(&self) -> StagedMark {
798        let Self {
799            datasets,
800            writes,
801            appends,
802            groups,
803            group_attrs,
804            dataset_attrs,
805            deletes,
806            copies,
807            cross_copies,
808            // Derived from `datasets` and `groups`, and restored with them.
809            dataset_at: _,
810            group_at: _,
811        } = self;
812        StagedMark {
813            datasets: datasets.len(),
814            writes: writes.len(),
815            appends: appends.len(),
816            groups: groups.len(),
817            group_attrs: group_attrs.len(),
818            dataset_attrs: dataset_attrs.len(),
819            deletes: deletes.len(),
820            copies: copies.len(),
821            cross_copies: cross_copies.len(),
822        }
823    }
824
825    /// Drop everything staged since `mark`.
826    ///
827    /// Staging inside a batch only ever appends — every `stage_*` entry point
828    /// validates and then pushes — so truncating to the recorded lengths is an
829    /// exact undo of the calls made in between, and leaves anything staged
830    /// before them alone. The two operations that instead *change* what is
831    /// already staged — the fold
832    /// [`WriteEngine::stage_dataset_append_pending`] makes into a staged
833    /// creation, and the withdrawal [`withdraw_at`](Self::withdraw_at) makes for
834    /// [`WriteEngine::delete`] — would leave that untrue, so
835    /// [`WriteEngine::refuse_mid_batch`] refuses them while a batch is open
836    /// rather than leaving the claim to be hoped for.
837    ///
838    /// The by-path index is truncated with the vectors it describes, so a
839    /// rewound creation stops answering as well as stops existing.
840    fn rewind(&mut self, mark: StagedMark) {
841        for i in mark.datasets..self.datasets.len() {
842            let key = {
843                let (parent, fd) = &self.datasets[i];
844                child_key(parent, &fd.name)
845            };
846            // Only where it still points at the entry being dropped: a second
847            // creation at one path never displaced the first.
848            if self.dataset_at.get(&key) == Some(&i) {
849                self.dataset_at.remove(&key);
850            }
851        }
852        for i in mark.groups..self.groups.len() {
853            let key = self.groups[i].clone();
854            if self.group_at.get(&key) == Some(&i) {
855                self.group_at.remove(&key);
856            }
857        }
858        let StagedMark {
859            datasets,
860            writes,
861            appends,
862            groups,
863            group_attrs,
864            dataset_attrs,
865            deletes,
866            copies,
867            cross_copies,
868        } = mark;
869        self.datasets.truncate(datasets);
870        self.writes.truncate(writes);
871        self.appends.truncate(appends);
872        self.groups.truncate(groups);
873        self.group_attrs.truncate(group_attrs);
874        self.dataset_attrs.truncate(dataset_attrs);
875        self.deletes.truncate(deletes);
876        self.copies.truncate(copies);
877        self.cross_copies.truncate(cross_copies);
878    }
879
880    /// Stage a dataset creation, indexing it by its full path.
881    fn push_dataset(&mut self, parent: PathKey, fd: FlatDataset) {
882        self.dataset_at
883            .entry(child_key(&parent, &fd.name))
884            .or_insert(self.datasets.len());
885        self.datasets.push((parent, fd));
886    }
887
888    /// Stage a group creation, indexing it by its path.
889    fn push_group(&mut self, path: PathKey) {
890        self.group_at
891            .entry(path.clone())
892            .or_insert(self.groups.len());
893        self.groups.push(path);
894    }
895
896    /// Where the staged dataset at `path` sits, if there is one.
897    ///
898    /// The position the index gives is checked against the entry it names
899    /// rather than trusted: an index that fell behind the vector then reads as a
900    /// miss — the answer a scan would give — instead of as another dataset.
901    fn dataset_position(&self, path: &[String]) -> Option<usize> {
902        let i = *self.dataset_at.get(path)?;
903        let (parent, fd) = self.datasets.get(i)?;
904        dataset_under(parent, &fd.name, path).then_some(i)
905    }
906
907    /// The staged dataset at `path` (given as components), if there is one.
908    fn dataset_at(&self, path: &[String]) -> Option<&FlatDataset> {
909        let i = self.dataset_position(path)?;
910        self.datasets.get(i).map(|(_, fd)| fd)
911    }
912
913    /// The staged dataset at `path`, mutably.
914    fn dataset_at_mut(&mut self, path: &[String]) -> Option<&mut FlatDataset> {
915        let i = self.dataset_position(path)?;
916        self.datasets.get_mut(i).map(|(_, fd)| fd)
917    }
918
919    /// Whether a group creation is staged at `path`. Checked against the entry
920    /// the index names, for the reason [`dataset_position`](Self::dataset_position)
921    /// gives.
922    fn has_group_at(&self, path: &[String]) -> bool {
923        match self.group_at.get(path) {
924            Some(&i) => self.groups.get(i).is_some_and(|p| p[..] == *path),
925            None => false,
926        }
927    }
928
929    /// Whether a staged deletion removes the link at `path`, or the link to an
930    /// ancestor that carries it away.
931    fn deletes_cover(&self, path: &[String]) -> bool {
932        self.deletes.iter().any(|d| path.starts_with(&d[..]))
933    }
934
935    /// Whether a staged deletion hands `path` over to a creation staged there —
936    /// which is what makes the pair a *replacement* rather than a collision
937    /// (issue #305).
938    ///
939    /// Stricter than [`deletes_cover`](Self::deletes_cover), and deliberately:
940    /// a deletion of an *ancestor* only carries the name over when this same
941    /// session builds that ancestor again, which is exactly what the commit
942    /// requires of everything at or under a replaced path. Deleting `g` and
943    /// creating `g/x` without recreating `g` is a batch the commit refuses, so
944    /// the file's own `g/x` is still what that name means — reporting it as a
945    /// replacement would make a live, readable dataset answer
946    /// [`Error::NotCommitted`] and vanish from its group's listing until the
947    /// refusal.
948    ///
949    /// The exact deletion is the empty case of the same rule: with `d == path`
950    /// there is no ancestor between them to have been recreated.
951    fn deletes_hand_over(&self, path: &[String]) -> bool {
952        self.deletes.iter().any(|d| {
953            path.starts_with(&d[..]) && (d.len()..path.len()).all(|n| self.has_group_at(&path[..n]))
954        })
955    }
956
957    /// Withdraw every edit staged at or under `path`, as though the calls that
958    /// staged them had never been made, and report whether a *creation* was
959    /// among them.
960    ///
961    /// This is what lets [`WriteEngine::delete`] cancel an object this session
962    /// staged and has not written: there is nothing in the file to unlink, so a
963    /// deletion of it is a withdrawal. Deletions are deliberately left alone —
964    /// one under a withdrawn creation still names a link the file holds, and two
965    /// overlapping deletions are the commit's refusal to make rather than this
966    /// call's.
967    ///
968    /// Destructured for the reason [`mark`](Self::mark) is: a staged kind added
969    /// later has to be classified here rather than silently survive a
970    /// withdrawal.
971    fn withdraw_at(&mut self, path: &[String]) -> bool {
972        let before = {
973            let Self {
974                datasets,
975                writes,
976                appends,
977                groups,
978                group_attrs,
979                dataset_attrs,
980                deletes,
981                copies,
982                cross_copies,
983                dataset_at: _,
984                group_at: _,
985            } = self;
986            let before = datasets.len() + groups.len();
987            datasets.retain(|(parent, fd)| !dataset_under(parent, &fd.name, path));
988            writes.retain(|(p, _)| !p.starts_with(path));
989            appends.retain(|(p, _)| !p.starts_with(path));
990            groups.retain(|p| !p.starts_with(path));
991            group_attrs.retain(|(p, _)| !p.starts_with(path));
992            dataset_attrs.retain(|(p, _)| !p.starts_with(path));
993            copies.retain(|(_, dst)| !dst.starts_with(path));
994            cross_copies.retain(|(dst, _)| !dst.starts_with(path));
995            let _ = &deletes;
996            before
997        };
998        let withdrew = self.datasets.len() + self.groups.len() < before;
999        if withdrew {
1000            self.reindex();
1001        }
1002        withdrew
1003    }
1004
1005    /// Rebuild the by-path index from the vectors, after a removal from the
1006    /// middle shifted every position after it.
1007    fn reindex(&mut self) {
1008        self.dataset_at.clear();
1009        self.group_at.clear();
1010        for (i, (parent, fd)) in self.datasets.iter().enumerate() {
1011            self.dataset_at
1012                .entry(child_key(parent, &fd.name))
1013                .or_insert(i);
1014        }
1015        for (i, path) in self.groups.iter().enumerate() {
1016            self.group_at.entry(path.clone()).or_insert(i);
1017        }
1018    }
1019
1020    /// Whether nothing at all is staged.
1021    ///
1022    /// Asked as "is the mark the zero mark", which is exact — nothing is staged
1023    /// exactly when every vector has length zero — and leaves [`mark`](Self::mark)
1024    /// as the single place that names every vector. The two callers,
1025    /// [`WriteEngine::has_staged_edits`] and the commit's own no-op return, then
1026    /// cannot come to disagree, and a staged kind added later reaches both. (The
1027    /// commit's *fast path* asks a narrower question and spells its own subset
1028    /// out.)
1029    fn is_empty(&self) -> bool {
1030        self.mark() == StagedMark::default()
1031    }
1032}
1033
1034/// The in-place write engine behind the owned read-write [`File`](crate::File)
1035/// (its `Backend::Edit`).
1036///
1037/// Reads and edits the file through a [`FileImage`], which owns the writable
1038/// handle and decides how much of the file is resident. It carries two commit
1039/// models: staged tree edits applied by [`commit`](Self::commit), and immediate
1040/// crash-atomic in-place appends ([`append_inplace_gathered`](Self::append_inplace_gathered)).
1041pub(crate) struct WriteEngine {
1042    /// The file bytes this session reads and edits, behind the [`FileImage`]
1043    /// abstraction: reads go through its [`Source`] impl, and the write side —
1044    /// the end-of-file cursor, `append`, `write_at`, `truncate`, and the
1045    /// durability barriers — through its own primitives.
1046    ///
1047    /// Nothing in the engine assumes the whole file is resident, so one engine
1048    /// serves both a whole-file mirror and a file-backed image that holds only
1049    /// what it is reading (issue #198). [`image_slice`](Self::image_slice)
1050    /// exposes the mirror's buffer where a caller can exploit it.
1051    image: Box<dyn FileImage>,
1052    /// Absolute offset of the superblock signature in the file.
1053    sb_sig_off: usize,
1054    /// Parsed superblock. On-disk addresses are stored relative to `base_address`;
1055    /// the in-memory `root_group_address` is normalized to an absolute file offset
1056    /// on open and converted back to a base-relative address when serialized on
1057    /// commit. `base_address` equals the superblock's file location (`sb_sig_off`):
1058    /// 0 for a plain file, the userblock size for one with a userblock.
1059    superblock: Superblock,
1060    /// Every edit this session has staged and not yet applied. Held as one
1061    /// value so that [`commit`](WriteEngine::commit) can take the whole set out
1062    /// for the duration of an attempt and put it back when that attempt
1063    /// refuses; see [`StagedEdits`].
1064    staged: StagedEdits,
1065    /// Datasets with a live [`BufferedAppender`](crate::BufferedAppender), which
1066    /// holds accepted elements only it can write. A staged edit that would stop
1067    /// that appender from flushing is refused while the claim stands, rather
1068    /// than left to fail in the appender's `Drop`, where there is no caller to
1069    /// report it to and the buffer is simply lost. See `refuse_if_claimed`.
1070    appender_claims: Vec<AppenderClaim>,
1071    /// Monotonic id for the next claim, so releasing one is exact even when two
1072    /// appenders on different datasets are live at once.
1073    next_appender_token: u64,
1074    /// Session-local free-space tracker (issue #21). Holds regions vacated by
1075    /// prior commits in this session — superseded object headers and the blocks
1076    /// of deleted objects — so later commits reuse them instead of growing the
1077    /// file, and so a freed run reaching end-of-file can be truncated away. It
1078    /// starts empty on `open` for a non-persisting file: holes already present
1079    /// from earlier sessions or other tools are not tracked. When the file
1080    /// persists its free space (`persist` is `Some`), `open` instead seeds it
1081    /// from the on-disk free-space managers, so reuse spans sessions.
1082    free: FreeList,
1083    /// Space this session has taken *out* of the on-disk free-space managers so
1084    /// that an immediate in-place append may spend it (issue #387).
1085    ///
1086    /// Only a file that persists its free space uses this. Such a file records
1087    /// its holes on disk, and an append has no superblock repoint of its own to
1088    /// publish an allocation with — so an append that simply drew from
1089    /// [`free`](Self::free) would leave a manager advertising bytes a live chunk
1090    /// now occupies, through a clean close as much as a crash. The reserve is
1091    /// the answer: [`reserve_for_immediate_append`](Self::reserve_for_immediate_append)
1092    /// moves a batch of bytes out of `free` (or, on a paged file, out of
1093    /// [`PagedEdit`]'s raw list) and rewrites the managers *without* them, under
1094    /// the same crash-atomic superblock repoint every persisting commit uses.
1095    /// Only then may an append write there, because by then no durable record
1096    /// calls those bytes free.
1097    ///
1098    /// What is left unspent goes back — into `free` or `PagedEdit` — before the
1099    /// managers are next rewritten, which is [`release_reserve`](Self::release_reserve)
1100    /// at the next commit and at close. A session that dies in between strands
1101    /// the unspent remainder: it is accounted to nobody, so it is never handed
1102    /// out twice, and a later `H5repack` or whole-file rewrite recovers it. That
1103    /// is the same trade the module header records for
1104    /// [`finalize_persist`](Self::finalize_persist), on a surface bounded per
1105    /// draw — see [`APPEND_RESERVE_BYTES`], which is the most one draw takes,
1106    /// and note that this list holds the unspent remainder of every draw the
1107    /// session has made, not just the last.
1108    ///
1109    /// Empty for a non-persisting session, which reuses out of `free` directly:
1110    /// its list is in memory alone, so nothing on the disk claims those bytes
1111    /// are free and nothing has to be published before they are spent.
1112    reserved: FreeList,
1113    /// Whether this file has been *proved* to hold no object reference at all —
1114    /// the one thing that lets a commit skip the walk that repoints them
1115    /// (issue #324).
1116    ///
1117    /// Set by a walk that reaches every object and finds no reference-holding
1118    /// datatype; every later commit in this session then skips the walk
1119    /// outright, which matters because the walk is linear in the file's object
1120    /// count and the file that needs it is the exception.
1121    ///
1122    /// One bit rather than three states, because "not yet walked" and "walked
1123    /// and found one" are the same instruction to the caller: walk. Only the
1124    /// proof is worth carrying.
1125    ///
1126    /// Cleared by any commit that could introduce a reference the walk has not
1127    /// seen. Exactly one of [`StagedEdits`]' nine collections can: `datasets`,
1128    /// which carries a datatype and attributes the caller chose. The check
1129    /// covers four, and the other three are belt-and-braces rather than
1130    /// load-bearing — recorded here because a reason that does not hold is worse
1131    /// than no reason:
1132    ///
1133    /// - `writes` is a *value* overwrite, whose datatype must match the on-disk
1134    ///   dataset's exactly. A reference-typed one therefore overwrites a dataset
1135    ///   that already held references, which a file proved free of them does not
1136    ///   have. Checked anyway, because that match is a commit-time check running
1137    ///   after this one, so `fd.dt` here is still whatever the caller supplied.
1138    /// - `copies` re-emits an object *from this same file* — one the walk that
1139    ///   granted the proof had already visited and found reference-free.
1140    /// - `cross_copies` reads from another file, where `reject_foreign_addresses`
1141    ///   refuses a reference datatype outright.
1142    ///
1143    /// The remaining five cannot, and these reasons do hold:
1144    ///
1145    /// - `groups` stages a bare path: no datatype, no data, no attributes.
1146    /// - `group_attrs` and `dataset_attrs` carry an [`AttrValue`], which has no
1147    ///   reference variant — the reason a reference-typed attribute can only
1148    ///   have come from the reference C library.
1149    /// - `appends` grows an *existing* dataset along its own datatype, so it can
1150    ///   only add references to a dataset that already held them.
1151    /// - `deletes` removes objects and adds nothing.
1152    ///
1153    /// The commit fast path returns before the check and needs no reason of its
1154    /// own: it is taken only when every staged edit is a same-length in-place
1155    /// overwrite, which is the `writes` case above.
1156    ///
1157    /// A tenth collection would have to be classified the same way; the check
1158    /// sits beside the reference screen in `commit`, which iterates the same
1159    /// datatype-carrying collections.
1160    proved_free_of_references: bool,
1161    /// Free-space persistence read from the file's superblock extension on
1162    /// `open` (the file-creation `H5Pset_file_space_strategy(persist = true)`
1163    /// setting). `None` for the default non-persisting file; when `Some`, every
1164    /// [`commit`](Self::commit) rewrites the on-disk free-space managers so the
1165    /// free list survives close/reopen.
1166    persist: Option<PersistState>,
1167    /// Per-dataset geometry cache for the immediate O(1) in-place append
1168    /// ([`append_inplace_gathered`](Self::append_inplace_gathered)), keyed by the dataset's resolved
1169    /// **object-header address** (not its path, so two hard links to one dataset
1170    /// share one entry). Populated on the first append to a dataset and maintained
1171    /// across appends; cleared wholesale at the entry of every non-trivial
1172    /// [`commit`](Self::commit), since a commit can relocate a cached header or
1173    /// free the region it points into (see `commit`).
1174    located: HashMap<u64, LocatedState>,
1175    /// Global heap collections **this session placed** for a variable-length
1176    /// value overwrite, by the overwritten dataset's path, as absolute
1177    /// `(address, length)` extents.
1178    ///
1179    /// This is what lets the *next* overwrite of the same dataset reclaim the
1180    /// strings the previous one left, instead of growing the file by its whole
1181    /// payload on every commit forever (issue #321). Nothing else in the editor
1182    /// reclaims a collection, and the reason is sound: a collection can be
1183    /// shared between objects, and **nothing in the format proves otherwise**.
1184    /// The per-object reference count does not: an in-file
1185    /// [`copy`](Self::copy) of a variable-length dataset re-emits its
1186    /// element references verbatim, so two datasets name one collection with
1187    /// every object's count still 1 — and files with that shape are already in
1188    /// the wild.
1189    ///
1190    /// Provenance is therefore the whole proof: these collections were placed by
1191    /// this session, for this dataset, and were named by nothing else at the
1192    /// moment they were written. `invalidate_heap_provenance` is what keeps that
1193    /// true afterwards — every entry is dropped as soon as this session does
1194    /// anything that could name one of them a second time.
1195    ///
1196    /// The collections a dataset held when the session *opened* are never in
1197    /// here and are never reclaimed: their provenance is whatever wrote the
1198    /// file. `repack` is what recovers those.
1199    vl_overwrite_heaps: HashMap<PathKey, Vec<(u64, u64)>>,
1200    /// Heap collections superseded by a value overwrite this commit is applying,
1201    /// freed once the commit's superblock repoint has landed — never before, so
1202    /// a mid-commit crash leaves the prior root reaching bytes that are still
1203    /// there. Drained into the commit's `to_free` list; cleared at commit entry,
1204    /// so an attempt that fails partway frees nothing on the next one.
1205    superseded_heaps: Vec<(u64, u64)>,
1206    /// The bytes each same-length in-place overwrite of the commit in flight
1207    /// wrote over, so a commit that fails before its linearization point can put
1208    /// the dataset's values back (issue #344).
1209    ///
1210    /// Everything else a commit places is written where nothing reaches it until
1211    /// the superblock is repointed, so an attempt that stops short of the
1212    /// repoint is invisible. A same-length value overwrite is the exception:
1213    /// [`WritePlan::InPlace`] writes straight over the dataset's existing data
1214    /// block, which the *current* root already reaches, so there is no repoint
1215    /// to withhold and the new values are live the moment they land. This
1216    /// journal is how they are withheld anyway.
1217    ///
1218    /// Written only by
1219    /// [`write_inplace_journaled`](Self::write_inplace_journaled) and drained
1220    /// only by [`undo_inplace_writes`](Self::undo_inplace_writes). Every
1221    /// [`commit`](Self::commit) that returns leaves it empty, and one that
1222    /// unwinds does not, which is why the next commit clears it on entry rather
1223    /// than trusting it.
1224    ///
1225    /// **It costs a full copy of every value being replaced, read back off the
1226    /// file first.** Measured on the bounded backing, whose contract is not to
1227    /// hold the file in memory: a same-length overwrite of a 4 MB dataset made
1228    /// its commit read 4,000,197 bytes where the same commit without the
1229    /// journal read 197, and held the 4 MB until the commit ended. That is the
1230    /// price of the guarantee and it is charged on the success path too, since
1231    /// whether the bytes will be needed is not known until the attempt ends.
1232    /// `a_bounded_commit_reads_the_value_it_is_replacing` states the rule so
1233    /// the number is in the repository rather than a surprise.
1234    ///
1235    /// The alternative — planning a same-length overwrite as a relocating write,
1236    /// as a variable-length one already is — would cost nothing here and was
1237    /// rejected for a harder reason than cost: a relocating write is refused for
1238    /// a dataset with more than one hard link
1239    /// ([`count_incoming_hard_links`](Self::count_incoming_hard_links)), so it
1240    /// would start refusing overwrites this engine accepts today.
1241    inplace_undo: Vec<(usize, Vec<u8>)>,
1242    /// True when this engine was opened for SWMR writing
1243    /// ([`open_swmr_writer`](Self::open_swmr_writer)): the append engine then
1244    /// enforces the SWMR subset (unfiltered, chunk-aligned) so a concurrent
1245    /// reader never observes a torn view. `false` for an ordinary edit session.
1246    swmr_mode: bool,
1247    /// Paged-file state (`H5F_FSPACE_STRATEGY_PAGE`), read from the superblock
1248    /// extension at `open` regardless of the persist flag; `None` for the common
1249    /// non-paged file. When `Some`, [`commit`](Self::commit) takes a page-aware
1250    /// tail that keeps pages homogeneous and rewrites the per-page-type managers
1251    /// (issue #198). A paged file that does not *persist* its free space is still
1252    /// refused: see [`PagedEdit`].
1253    paged: Option<PagedEdit>,
1254    /// Set by the first [`commit`](Self::commit) that does any work. A commit can
1255    /// relocate an object header, and nothing on disk distinguishes a relocated
1256    /// header from the intact bytes it vacated — the old header still parses, and
1257    /// its data-layout message still points at the live chunk index. An
1258    /// [`AppendTarget::Header`] captured before that commit would therefore append
1259    /// successfully *into the dead header*, growing its dataspace while the live
1260    /// dataset stayed put, and report `Ok`. A path is re-resolved on every append
1261    /// and so survives a commit; a raw address does not, so it is refused once one
1262    /// has run.
1263    committed: bool,
1264    /// Object-header address for each path an in-place append has resolved in
1265    /// this session. A single `Dataset::append` asks for the target's geometry
1266    /// and then appends to it, and a loop of appends repeats that; without this
1267    /// the path would be walked from the root on every one of those steps.
1268    ///
1269    /// An in-place append never moves an object header — that is why
1270    /// [`located`](Self::located) can be keyed by address and survive appends —
1271    /// so only a commit can stale an entry, and it clears both together.
1272    resolved: HashMap<String, u64>,
1273    /// Whether this session splits a large in-place append into batches, trading
1274    /// whole-call crash atomicity for a peak memory that does not scale with the
1275    /// call. Set by [`open_rw_with_strategy`](Self::open_rw_with_strategy); see
1276    /// [`batch_elems`](Self::batch_elems).
1277    batched_appends: bool,
1278    /// Whether this session reads through a handle rather than a whole-file
1279    /// mirror. Set by [`open_rw_with_strategy`](Self::open_rw_with_strategy), and reported by
1280    /// [`File::edit_backing`](crate::File::edit_backing) so a caller who
1281    /// asked for [`MemoryStrategy::Auto`] can tell which one it got. Distinct
1282    /// from [`batched_appends`](Self::batched_appends), which is a crash-atomicity
1283    /// trade the bounded engine happens to make, not a statement about memory.
1284    bounded: bool,
1285    /// The on-disk format this session may write, resolved from the fapl's
1286    /// [`FileAccessProperties::with_libver_bounds`]. `None` — the default —
1287    /// means unconstrained: the session adds whatever the content needs, which
1288    /// is what lets a file the C library wrote under its own bounds be edited at
1289    /// all.
1290    ///
1291    /// Set below [`LibVer::V110`] it refuses content the older format cannot
1292    /// carry, the way the whole-file writer does. The file's *own* superblock
1293    /// version cannot stand in for this: a version 2 superblock says the file is
1294    /// 1.8-readable today, not that the caller wants it to stay that way, and
1295    /// deriving the ceiling from it would refuse the C-library-file edits of
1296    /// issue #101.
1297    ///
1298    /// [`FileAccessProperties::with_libver_bounds`]: crate::FileAccessProperties::with_libver_bounds
1299    libver_ceiling: Option<LibVer>,
1300    /// The file length when the on-disk free-space managers were last written,
1301    /// for a file that persists them. Every immediate in-place append grows the
1302    /// file past those managers and leaves them mid-file, so a session that ends
1303    /// with `image.len() != fsm_len` owes a rewrite; that is what
1304    /// [`finalize_persist`](Self::finalize_persist) settles at close. Meaningless
1305    /// (and untouched) when `persist` is `None`.
1306    fsm_len: u64,
1307    /// Whether the commit currently running has *asked* the disk to repoint the
1308    /// superblock at its new root. Set by whichever tail performs that write,
1309    /// immediately **before** issuing it, and read only by
1310    /// [`commit`](Self::commit) to decide whether a failure may roll anything
1311    /// back — the free lists (see [`FreeSnapshot`]), the staged set, and the
1312    /// values in [`inplace_undo`](Self::inplace_undo).
1313    ///
1314    /// Set before rather than after for the reason the whole rollback exists: a
1315    /// write that alters the file and then reports failure is a real device
1316    /// behaviour, so a write call returning an error does not mean its bytes
1317    /// are not on the disk. Once the publish has been issued, whether this
1318    /// commit is live is unknowable from here, and every rollback is unsound —
1319    /// undoing the values would tear a commit that did land, and re-offering
1320    /// the regions would hand out space that commit is using. Doing nothing is
1321    /// the only answer that is never actively wrong, and the error the caller
1322    /// gets is the write failure that says so (issue #344).
1323    ///
1324    /// It is a property of one publish, not of the session, so every publisher —
1325    /// `commit`, and the manager rewrite an append's draw makes — clears it on
1326    /// entry rather than trusting the previous run to have left it false.
1327    publish_attempted: bool,
1328    /// Whether a [`stage_atomically`](Self::stage_atomically) batch is open, so
1329    /// [`refuse_mid_batch`](Self::refuse_mid_batch) can keep
1330    /// [`StagedEdits::rewind`]'s truncation an exact undo.
1331    staging_batch: bool,
1332    /// How many times this session's staged set has been *consumed* by a commit
1333    /// rather than handed back to it.
1334    ///
1335    /// It is what tells a handle born onto a staged creation
1336    /// ([`staged_generation`](Self::staged_generation)) which of two things has
1337    /// happened since: the object it names was published, so it addresses the
1338    /// file now — or the staging was withdrawn under it, and it names nothing.
1339    /// Both leave the staged set without an entry at its path, and reading them
1340    /// the same way is what let such a handle silently retarget onto whatever
1341    /// the file held at that path.
1342    ///
1343    /// Advanced by [`commit`](Self::commit) alone, on exactly the path where the
1344    /// set does not go back: a commit that succeeds, and one that fails past its
1345    /// publish (whose objects are in the file). A commit refused before its
1346    /// first write restores the set and leaves this alone, so the handles it
1347    /// concerns stay pending and the batch can be committed again.
1348    staged_generation: u64,
1349    /// Superblock status flags this session raised on the file and holds for its
1350    /// lifetime — the SWMR pair, or the page buffer's crash mark — or `0` when it
1351    /// holds none.
1352    ///
1353    /// A clean commit publishes *this* rather than a literal zero. The zero was
1354    /// there to scrub a flag the file arrived carrying (one a crashed writer left
1355    /// behind), and it still does: this is `0` unless the session raised
1356    /// something itself. What it must not do is scrub a mark that is still
1357    /// standing for a reason, which a page-buffered session's is for every
1358    /// commit it makes (issue #308).
1359    ///
1360    /// Maintained only by [`set_consistency_flags`](Self::set_consistency_flags),
1361    /// which is the one place this crate writes that byte, so the field cannot
1362    /// drift from what is on the disk.
1363    held_status_flags: u32,
1364    /// Who owns this session's `fsync` cadence, from the fapl's
1365    /// [`FileAccessProperties::with_sync_policy`]. Consulted by
1366    /// [`barrier`](Self::barrier) and [`barrier_data`](Self::barrier_data) —
1367    /// every durability point the write paths define — and by nothing else, so
1368    /// [`sync_now`](Self::sync_now) can serve an explicit
1369    /// [`File::sync`](crate::File::sync) whatever it says.
1370    ///
1371    /// [`FileAccessProperties::with_sync_policy`]: crate::FileAccessProperties::with_sync_policy
1372    sync_policy: SyncPolicy,
1373}
1374
1375/// The free lists as they stood before a commit's apply loop drew from them.
1376///
1377/// A commit allocates as it goes — a free region it hands to an object header or
1378/// a chunk blob leaves the list immediately, so no two objects in the same commit
1379/// can be handed the same region. If the commit then fails, those regions are as
1380/// dead as they were before it started: nothing the commit wrote is reachable,
1381/// because the superblock still names the old root — and the one write that does
1382/// not wait for the repoint, a same-length value overwrite, is one
1383/// [`WriteEngine::undo_inplace_writes`] has already tried to put back by the
1384/// time this runs (issue #344). Restoring this snapshot gives them back instead
1385/// of leaking them for the rest of the session.
1386///
1387/// The restore is sound only *before* the repoint. After it, the objects written
1388/// into those regions are live, and handing the same addresses out again would
1389/// overwrite the tree the commit just published — which is why
1390/// [`WriteEngine::publish_attempted`] gates it.
1391struct FreeSnapshot {
1392    free: FreeList,
1393    /// The append reserve, which the persisting commit tail drains back into the
1394    /// lists above before it rewrites the managers. A commit that then fails
1395    /// before publishing leaves those managers as they were — still not listing
1396    /// the reserve — so restoring it is what keeps the session's picture of the
1397    /// disk exact (issue #387).
1398    reserved: FreeList,
1399    paged: Option<(FreeList, FreeList)>,
1400    /// The heap-collection provenance, rolled back with the free lists.
1401    ///
1402    /// [`resolve_overwrite_bytes`](WriteEngine::resolve_overwrite_bytes) records
1403    /// a placement in the *apply* phase, so a commit that fails after that and
1404    /// before its repoint hands the space back to the free list while leaving a
1405    /// record naming it. The next overwrite of that path would then free a
1406    /// region already handed out — the one piece of engine state naming file
1407    /// addresses that this rollback used to miss (issue #321).
1408    vl_overwrite_heaps: HashMap<PathKey, Vec<(u64, u64)>>,
1409}
1410
1411/// How much memory a read-write open may use to hold the file being edited.
1412///
1413/// The two read-write backends differ in memory, not in what they can express: a
1414/// *bounded* session reads through a handle and holds only what a commit is
1415/// building, while a *mirrored* session materializes the whole file in memory.
1416/// Bounded is the better default when it applies, but it cannot yet edit every
1417/// file — a pre-v2 (non-latest-format) superblock or a userblock still needs the
1418/// mirror.
1419///
1420/// This is what a caller says about that trade-off, on
1421/// [`FileAccessProperties::with_memory_strategy`](crate::FileAccessProperties::with_memory_strategy).
1422/// Leaving it unset lets the entry point decide: [`File::open_rw`](crate::File::open_rw)
1423/// prefers the bounded engine and falls back to the mirror ([`Auto`](Self::Auto)).
1424/// Stating [`Bounded`](Self::Bounded) refuses instead of falling back.
1425///
1426/// This is a *request*, so it is deliberately not the type a file answers with:
1427/// [`File::edit_backing`](crate::File::edit_backing) returns an [`EditBacking`],
1428/// which cannot express [`Auto`](Self::Auto).
1429///
1430/// Sealed: unlike [`FileLocking`] or [`FileSpaceStrategy`](crate::FileSpaceStrategy),
1431/// whose variant sets mirror a closed C-library enum, this is a policy this crate
1432/// invented, so a fourth strategy must not be a breaking change.
1433#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1434#[non_exhaustive]
1435pub enum MemoryStrategy {
1436    /// Never build a whole-file mirror. A file the bounded engine cannot edit is
1437    /// refused at open with [`Error::EditUnsupported`], before anything is
1438    /// staged.
1439    Bounded,
1440    /// Prefer the bounded engine, but fall back to the whole-file mirror for a
1441    /// file it cannot edit, rather than refusing. Memory then scales with the
1442    /// file, which is the cost of the file opening at all. What
1443    /// [`File::open_rw`](crate::File::open_rw) uses when nothing is asked for.
1444    ///
1445    /// Two files reach that fallback, and no others: one with a pre-v2
1446    /// (non-latest-format) superblock, and one with a userblock (a non-zero base
1447    /// address). Every other file a read-write open accepts is edited bounded,
1448    /// whatever its [`FileSpaceStrategy`](crate::FileSpaceStrategy), whether or
1449    /// not it persists its free space, and however large it is. A paged file with
1450    /// no persisted free space is refused by *both* backings rather than
1451    /// mirrored, so it is not a third case. The list being exhaustive is what
1452    /// makes "prefer bounded" a guarantee to budget against: wherever
1453    /// [`Bounded`](Self::Bounded) opens a file, `Auto` gives the same backing.
1454    Auto,
1455    /// Always build the whole-file mirror, whatever the file looks like. What
1456    /// [`File::open_rw`](crate::File::open_rw) did before it learned to dispatch.
1457    Mirrored,
1458}
1459
1460/// Which of the two read-write backends a file's editing session is actually
1461/// using, from [`File::edit_backing`](crate::File::edit_backing).
1462///
1463/// Deliberately a different type from [`MemoryStrategy`]: that one is what a
1464/// caller *asks* for and includes [`Auto`](MemoryStrategy::Auto), which is a
1465/// preference between these two rather than a third thing a session can be. A
1466/// single shared type would make `file.backing() == Auto` a comparison that
1467/// compiles and is false forever.
1468///
1469/// The two also evolve at different rates. A future `MemoryStrategy` may name a
1470/// new *policy* — a byte budget, a size threshold — without the set of backends
1471/// changing at all. Sealed for the rarer case that a third backend does appear.
1472#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1473#[non_exhaustive]
1474pub enum EditBacking {
1475    /// Reads through a file handle, holding only what a commit is building.
1476    /// Memory does not scale with the file.
1477    Bounded,
1478    /// Holds the whole file in memory for the life of the session.
1479    Mirrored,
1480}
1481
1482impl From<EditBacking> for MemoryStrategy {
1483    /// Turns an outcome back into the request that pins it, so a caller can
1484    /// reopen a file onto the backing it got the first time:
1485    /// `with_memory_strategy(file.edit_backing().unwrap().into())`.
1486    fn from(backing: EditBacking) -> Self {
1487        match backing {
1488            EditBacking::Bounded => Self::Bounded,
1489            EditBacking::Mirrored => Self::Mirrored,
1490        }
1491    }
1492}
1493
1494/// When a read-write session forces its writes to durable storage — who owns the
1495/// `fsync` cadence, this crate or the application.
1496///
1497/// This is *not* about whether a write reaches the file. Every commit and every
1498/// append has gone to the operating system by the time it returns, whatever this
1499/// policy says, so a committed edit is visible to any other process on the same
1500/// machine and survives this process crashing. What it governs is the `fsync` on
1501/// top of that, which is what makes those bytes survive the *machine* losing
1502/// power.
1503///
1504/// A session does gather the many small writes *inside* one such operation and
1505/// issue them a page at a time (issue #288), and an explicit
1506/// [page buffer](crate::FileAccessProperties::with_page_buffer_size) extends that
1507/// across operations — trading exactly the guarantee in the paragraph above,
1508/// which is why it is opt-in and this is not.
1509///
1510/// The reference C library does not `fsync` on its normal path either: the
1511/// default `sec2` driver installs no flush callback at all, so `H5Fflush` drains
1512/// libhdf5's own caches with `write` and stops there, leaving power-loss
1513/// durability to the application. [`OnClose`](Self::OnClose) is that behavior;
1514/// [`Always`](Self::Always), the default here, is the stronger one.
1515///
1516/// The whole-file writer ([`FileBuilder`](crate::FileBuilder)) and
1517/// [`repack`](crate::repack) are outside this: they never `fsync` under either
1518/// policy, since each writes a file and hands it over rather than holding an
1519/// editing session.
1520///
1521/// Sealed: a future policy (syncing on a timer, or once per N bytes) must not be
1522/// a breaking change.
1523#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1524#[non_exhaustive]
1525pub enum SyncPolicy {
1526    /// Force durability at every point the write paths define one: after each
1527    /// immediate [`Dataset::append`](crate::Dataset::append) batch, at each
1528    /// ordering barrier inside a [`File::commit`](crate::File::commit), and when
1529    /// the file is closed or dropped.
1530    ///
1531    /// This is what makes an append crash-atomic and a commit all-or-nothing
1532    /// against power loss: the barriers order the appended data before the
1533    /// superblock repoint that publishes it, so an interrupted write leaves the
1534    /// previous state rather than a torn one.
1535    #[default]
1536    Always,
1537    /// No `fsync` during the session; one when the file is finished. Nothing an
1538    /// append or a commit does is forced to durable storage — writes still reach
1539    /// the operating system immediately, so another process on the same machine
1540    /// sees them and a crash of *this* process loses nothing — and
1541    /// [`File::close`](crate::File::close), or dropping the last handle, issues
1542    /// a single barrier at the end. [`File::sync`](crate::File::sync) adds a
1543    /// checkpoint wherever the application wants one.
1544    ///
1545    /// One setting suspends the "reach the operating system immediately" half,
1546    /// and it is the one that requires this policy:
1547    /// [`with_page_buffer_size`](crate::FileAccessProperties::with_page_buffer_size)
1548    /// holds dirty pages in this process until the budget is spent, a
1549    /// [`File::sync`](crate::File::sync) is issued, or the session closes. Off by
1550    /// default, and it marks the file on disk for its lifetime so a session that
1551    /// dies holding pages leaves one every reader refuses.
1552    ///
1553    /// The terminal barrier is not an exception grafted onto "never sync": it is
1554    /// the point past which the application *cannot* act. `close` and `drop`
1555    /// both write — they apply staged edits, re-home the free-space managers of
1556    /// a file that persists them, and clear a SWMR writer's flag — and they
1557    /// destroy the handle that would have ordered those writes. A policy that
1558    /// skipped the barrier there would not hand the caller a cadence; it would
1559    /// take one away. The cost is one `fsync` per session against the five per
1560    /// append batch and two or three per commit that this policy removes.
1561    ///
1562    /// Three things are given up in exchange. **Power-loss ordering** during the
1563    /// session: with the barriers gone, a machine that loses power mid-commit
1564    /// can have the superblock repoint on disk without the data it points at.
1565    /// **Deferred write errors**: on a filesystem that allocates late, a write
1566    /// that will fail at writeback still returns success, and `fsync` is where
1567    /// the `ENOSPC`/`EIO` surfaces, so a commit the filesystem cannot complete
1568    /// returns `Ok` until the terminal barrier reports it. **Cross-host
1569    /// visibility**: "another process sees it" is the page cache, so under NFS's
1570    /// close-to-open semantics a reader on another host — a SWMR reader
1571    /// included — may not see writes a client is holding.
1572    OnClose,
1573}
1574
1575/// Why the bounded engine cannot edit a file, when the whole-file mirror can.
1576///
1577/// Kept separate from the refusals that apply to *both* engines: a fallback is
1578/// only ever worth taking for a limitation the mirror does not share. A paged
1579/// file with no persisted free-space managers, for instance, is refused by the
1580/// staged commit as well, so mirroring it would trade a clear error at open for
1581/// the same error later with work already staged.
1582fn bounded_only_limitation(session: &WriteEngine) -> Option<&'static str> {
1583    if session.superblock.version < 2 {
1584        return Some(
1585            "bounded read-write access requires a latest-format file (v2/v3 superblock); \
1586             leave MemoryStrategy unset, or pass MemoryStrategy::Auto, to fall back to \
1587             the whole-file mirror here",
1588        );
1589    }
1590    if !session.superblock.base_address.is_zero() {
1591        return Some(
1592            "bounded read-write access does not support a file with a userblock \
1593             (non-zero base address); leave MemoryStrategy unset, or pass \
1594             MemoryStrategy::Auto, to fall back to the whole-file mirror here",
1595        );
1596    }
1597    None
1598}
1599
1600/// Whether a file built from `create` could not then be opened read-write under
1601/// `access`, and why.
1602///
1603/// [`File::create_with_options`](crate::File::create_with_options) writes a file
1604/// and hands back an open read-write handle, so a creation/access pair that
1605/// cannot survive that second half must be caught *before* the write — otherwise
1606/// the call leaves a file on disk and returns `Err`, which reads like a failed
1607/// create but is not one.
1608///
1609/// This mirrors the open-time refusals above and must be kept in step with them:
1610/// [`bounded_only_limitation`] for the userblock, and the shared paged check in
1611/// [`open_rw_with_strategy`](WriteEngine::open_rw_with_strategy) for a paged file
1612/// with no persisted free space. Both are stated here in terms of the properties
1613/// that *cause* them, because the open-time wording tells the caller to recreate
1614/// the file — advice that is circular when the caller is creating it.
1615pub(crate) fn create_would_refuse_reopen(
1616    create: &FileCreateProperties,
1617    access: &FileAccessProperties,
1618) -> Option<&'static str> {
1619    if let Some((FileSpaceStrategy::Page, false, _)) = create.file_space_strategy() {
1620        return Some(
1621            "a paged file (FileSpaceStrategy::Page) with persist = false cannot be reopened \
1622             read-write, so creating one this way would write the file and then fail to open \
1623             it; pass persist = true to with_file_space_strategy, or build the file with \
1624             FileBuilder if it is only ever going to be read",
1625        );
1626    }
1627    if create.userblock() != 0 && access.memory_strategy() == Some(MemoryStrategy::Bounded) {
1628        return Some(
1629            "a userblock cannot be combined with MemoryStrategy::Bounded: the bounded engine \
1630             cannot edit a file with a non-zero base address, so creating one this way would \
1631             write the file and then refuse to open it; drop the userblock, or leave \
1632             MemoryStrategy unset to mirror this file",
1633        );
1634    }
1635    // A userblock on a *paged* file reaches the same open-time refusal as
1636    // `persist = false` above, by a different route: persisted free space is
1637    // declined for a non-zero base address, which leaves a paged file with no
1638    // seeded managers however its creation properties were written.
1639    if create.userblock() != 0
1640        && matches!(
1641            create.file_space_strategy(),
1642            Some((FileSpaceStrategy::Page, _, _))
1643        )
1644    {
1645        return Some(
1646            "a userblock cannot be combined with FileSpaceStrategy::Page: free space is not \
1647             persisted for a file with a non-zero base address, and a paged file without it \
1648             cannot be opened read-write — so creating one this way would write the file and \
1649             then fail to open it; drop the userblock, or choose another file-space strategy",
1650        );
1651    }
1652    // The page-buffer refusals `set_page_buffer_size` makes at open, restated as
1653    // the creation pair that would walk into them. They are phrased as something
1654    // to *add* to the properties in hand rather than as something to recreate the
1655    // file with, since the file does not exist yet (issues #288 and #308).
1656    if access.page_buffer_size() != 0 {
1657        if access.sync_policy() == SyncPolicy::Always {
1658            return Some(
1659                "a page buffer does nothing under the default SyncPolicy::Always, whose every \
1660                 barrier is an fsync that flushes it, so creating one this way would write the \
1661                 file and then fail to open it; add \
1662                 with_sync_policy(SyncPolicy::OnClose) to the access properties, or drop the \
1663                 page buffer",
1664            );
1665        }
1666        // The version-3 counterpart. It used to need none: a page buffer required
1667        // a paged file, and `FileSpaceStrategy::Page` below the 1.10 format is
1668        // refused by the builder itself before anything is written, so that pair
1669        // never reached an open. Issue #357 lifted the paged requirement, and an
1670        // *unpaged* file at `LibVer::V18` is an ordinary, buildable file with a
1671        // version-2 superblock — one whose status-flags byte no library reads
1672        // back, so the mark could not be raised on it. Without this the pair
1673        // would write the file and fail the open it promised.
1674        //
1675        // Unsatisfiable bounds are left to the builder, which reports which
1676        // format it can write; this is not the place to restate that.
1677        if matches!(
1678            crate::libver::LibVer::resolve_writable(create.libver_bounds()),
1679            Ok(v) if v < crate::libver::LibVer::V110
1680        ) {
1681            return Some(
1682                "a page buffer marks the file in a version-3 superblock, and these bounds \
1683                 write the 1.8 format, so creating one this way would write the file and then \
1684                 fail to open it; raise with_libver_bounds to admit LibVer::V110, or drop the \
1685                 page buffer",
1686            );
1687        }
1688        // An unpaged file has no page size of its own and merges within
1689        // `DEFAULT_GATHER_PAGE`, which is the same figure the format defaults
1690        // `H5Pset_file_space_page_size` to. This arm decides on its own now that
1691        // a budget below the session's own gather budget is honored rather than
1692        // refused (issue #391): a 2 KiB buffer on an unpaged create clears every
1693        // other check and is caught only here. It is written out rather than
1694        // folded into the paged arm's `unwrap_or` because the two constants are
1695        // free to move apart, and because a reader comparing this function
1696        // against `set_page_buffer_size` should find the same rule in both.
1697        let page_size = match create.file_space_strategy() {
1698            Some((FileSpaceStrategy::Page, _, _)) => create
1699                .file_space_page_size()
1700                .unwrap_or(crate::file_space_info::DEFAULT_PAGE_SIZE),
1701            _ => DEFAULT_GATHER_PAGE,
1702        };
1703        if (access.page_buffer_size() as u64) < page_size {
1704            return Some(
1705                "a page buffer smaller than the file's file-space page size would be refused \
1706                 at open — so creating one this way would write the file and then fail to \
1707                 open it; raise with_page_buffer_size",
1708            );
1709        }
1710    }
1711    None
1712}
1713
1714/// What a commit knows about a region it is vacating, which is what decides
1715/// where the region may be recorded.
1716///
1717/// A flat file ignores the distinction — every vacated region is simply free —
1718/// and a paged one cannot, because a byte handed out for the wrong page type
1719/// mixes the page it lands in.
1720#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1721enum FreeClass {
1722    /// The region sits in pages of this type, so it becomes free space of that
1723    /// type and may serve the next allocation of it.
1724    Page(PageType),
1725    /// The region provably holds nothing live, but which page type surrounds it
1726    /// is not provable — a chunk index this crate cannot place
1727    /// ([`WriteEngine::index_is_provably_raw`]) is the case that arises.
1728    ///
1729    /// It may not be handed out as either type, so it is recorded as *dead*
1730    /// rather than free: bytes that are no longer in use and are not yet
1731    /// reusable. Once every other byte of the page around it is free or dead
1732    /// too, the page holds nothing of either type and
1733    /// [`PagedEdit::promote_whole_free_pages`] turns the whole page into free
1734    /// space, which is the same rule that lets one page type claim a wholly free
1735    /// page from the other ([`PagedEdit::alloc_typed`]).
1736    Dead,
1737}
1738
1739impl From<PageType> for FreeClass {
1740    fn from(ty: PageType) -> Self {
1741        FreeClass::Page(ty)
1742    }
1743}
1744
1745/// Paged-file bookkeeping for the whole-file editor (issue #198, step 1).
1746///
1747/// A paged file never mixes metadata and raw data within one page, so this tracks
1748/// free space per page *type* — the one distinction that governs where a byte may
1749/// be placed — and keeps the commit's appends homogeneous by padding a tail page
1750/// whenever the page type changes.
1751///
1752/// One list per type, not one per on-disk manager. A paged file records its free
1753/// space in three managers (metadata-small, raw-small, and a generic-large one for
1754/// whole free pages), but for space this session can place, that split is a size
1755/// classification of the same raw or metadata space, and [`plan_paged_managers`]
1756/// recomputes it from scratch at every commit. Tracking it here as well would only
1757/// stop a freed chunk-data run and the freed index that abuts it from coalescing —
1758/// leaving two neighboring holes neither of which fits the dataset that just
1759/// vacated both (issue #261).
1760///
1761/// The free lists are seeded only when the file *persists* its free space. A paged
1762/// non-persisting file has no on-disk record of which pages hold metadata and
1763/// which hold raw data, so there is nothing to seed and no way to stay segregated;
1764/// [`commit`](EditSession::commit) refuses it outright, exactly as the bounded
1765/// backend does.
1766struct PagedEdit {
1767    page_size: u64,
1768    /// Free space inside metadata pages, plus whole free pages this session last
1769    /// saw as metadata. A page holding nothing belongs to no type, so
1770    /// [`alloc_typed`](Self::alloc_typed) lets raw data claim one from here.
1771    meta: FreeList,
1772    /// Free space inside raw-data pages, plus whole free pages this session last
1773    /// saw as raw — including every one seeded from the generic-large manager,
1774    /// which records no type. Metadata claims from here on the same terms.
1775    raw: FreeList,
1776    /// Free space this session may record but must never hand out, because the
1777    /// page it sits in is of unknown type. Seeded at open — nothing this engine
1778    /// frees is ever of unknown type, so it only ever shrinks, and only where a
1779    /// commit released the end of the file over it (issue #418) — and written
1780    /// back to the generic-large manager verbatim, so the space stays available
1781    /// to the writer that recorded it.
1782    ///
1783    /// It exists because the reference C library's generic-large manager is
1784    /// exactly that: `H5F_MEM_PAGE_GENERIC` is *"large-sized generic: meta and
1785    /// raw"*, and under paged aggregation on a contiguous-address driver every
1786    /// allocation of a page or more lands there whatever its type. Its
1787    /// page-alignment tail (`H5MF__alloc_pagefs`) is then recorded as a free
1788    /// section in the same manager — a sub-page fragment sitting in a page whose
1789    /// earlier bytes are live. Handing such a fragment to raw data would put raw
1790    /// bytes in a metadata page, so a section that is not a whole run of aligned
1791    /// pages is kept here rather than guessed at.
1792    unclassified: FreeList,
1793    /// Space this session has vacated whose page type it could not prove
1794    /// ([`FreeClass::Dead`]): no longer in use, and not reusable as either type.
1795    ///
1796    /// It is held rather than discarded so that the page around it can still be
1797    /// reclaimed: a page every one of whose bytes is free or dead holds nothing
1798    /// of either type, and [`promote_whole_free_pages`](Self::promote_whole_free_pages)
1799    /// moves it into the typed lists whole. Without this list those bytes would
1800    /// simply be lost, and the page they sit in could never be shown to be empty
1801    /// — which is what made a paged file leak a chunk index per deleted empty
1802    /// resizable dataset (issue #388).
1803    ///
1804    /// Unlike the free lists this one is not written to disk: a dead sub-page
1805    /// fragment is not free space by the file's account, and recording it in the
1806    /// only manager that takes untyped space would offer it to the reference
1807    /// library for either type. A fragment that never completes a page is
1808    /// therefore forgotten at close, which wastes its bytes and nothing more.
1809    dead: FreeList,
1810    /// Page type of the current tail page. `None` until this session's first
1811    /// typed append; the file is page-aligned at open, so the first append never
1812    /// needs to pad regardless of this.
1813    last: Option<PageType>,
1814    /// Free tails left by padding a metadata page before a raw append.
1815    meta_pad: Vec<(u64, u64)>,
1816    /// Free tails left by padding a raw page before a metadata append.
1817    raw_pad: Vec<(u64, u64)>,
1818}
1819
1820impl PagedEdit {
1821    /// Ensure the next allocation on `image` begins in a page holding page type
1822    /// `ty`: when the tail page holds the *other* type and is only partially
1823    /// filled, pad it to a page boundary and record the padding as free space of
1824    /// the outgoing type.
1825    ///
1826    /// This is the whole of the paged-append rule, and it lives here so the two
1827    /// places that grow a paged file — the staged commit through
1828    /// [`WriteEngine::begin_page`](WriteEngine::begin_page), and the shared
1829    /// Extensible-Array append engine through [`EditStore`] — cannot drift. They
1830    /// used to keep separate copies of this state, one per engine, which is what
1831    /// made an in-place append to a paged file unsafe from the whole-file editor
1832    /// (issue #198).
1833    ///
1834    /// Call it **before** reading the image's end-of-file to compute an address
1835    /// that will be embedded in the bytes being built: several callers build
1836    /// content whose interior addresses assume it lands at the current
1837    /// end-of-file, and padding inserted after that read would shift the landing
1838    /// address out from under them.
1839    fn begin(&mut self, image: &mut dyn FileImage, ty: PageType) -> Result<(), Error> {
1840        let len = image.len();
1841        if len % self.page_size != 0 {
1842            let pad_len = self.page_size - len % self.page_size;
1843            // `prev` is the outgoing page type to record the padding under, or
1844            // `None` for a crash-recovery pad whose tail-page type is unknown.
1845            let pad = match self.last {
1846                // Normal case: the tail page holds a known type; pad only on a
1847                // type switch, recording the tail as free of the outgoing type.
1848                Some(prev) if prev != ty => Some(Some(prev)),
1849                Some(_) => None, // same type: keep packing the tail page
1850                // A previous session grew this paged file and was killed before
1851                // its tail was page-aligned, so the file opened non-page-aligned
1852                // with no known tail type. Pad it up (extending whatever the tail
1853                // page holds, so the page stays homogeneous) and leave the padding
1854                // untracked, since recording it under the wrong page type could
1855                // let a reader reuse it and mix the page.
1856                None => Some(None),
1857            };
1858            if let Some(prev) = pad {
1859                let pad_at = len;
1860                image.append(&vec![0u8; pad_len.to_usize()?])?;
1861                match prev {
1862                    Some(PageType::Meta) => self.meta_pad.push((pad_at, pad_len)),
1863                    Some(PageType::Raw) => self.raw_pad.push((pad_at, pad_len)),
1864                    None => {} // crash-recovery pad: untracked (tail type unknown)
1865                }
1866            }
1867        }
1868        self.last = Some(ty);
1869        Ok(())
1870    }
1871
1872    fn new(page_size: u64) -> Self {
1873        PagedEdit {
1874            page_size,
1875            meta: FreeList::new(),
1876            raw: FreeList::new(),
1877            unclassified: FreeList::new(),
1878            dead: FreeList::new(),
1879            last: None,
1880            meta_pad: Vec::new(),
1881            raw_pad: Vec::new(),
1882        }
1883    }
1884
1885    /// Which list a persisted section from File Space Info `slot` belongs in.
1886    ///
1887    /// Slot 0 (SUPER) is small metadata and slot 2 (DRAW) is small raw: both name
1888    /// a page type outright. Slot 6 is the generic-large manager, which holds
1889    /// space of *either* type (see [`unclassified`](Self::unclassified)), so a
1890    /// section from it is only safe to reuse when it covers whole aligned pages —
1891    /// and such a section belongs to no type at all, since nothing lives in those
1892    /// pages to be mixed with. It is filed under raw and reached from either side
1893    /// through [`alloc_typed`](Self::alloc_typed), rather than kept in a third
1894    /// list, so that a run of free pages still coalesces with the sub-page
1895    /// fragment beside it. Everything else, including the per-type large managers
1896    /// a multi/split driver would populate, is left unclassified rather than
1897    /// guessed at.
1898    fn slot_list(slot: usize, addr: u64, size: u64, page_size: u64) -> Option<PageType> {
1899        match slot {
1900            0 => Some(PageType::Meta),
1901            2 => Some(PageType::Raw),
1902            6 if addr % page_size == 0 && size % page_size == 0 => Some(PageType::Raw),
1903            _ => None,
1904        }
1905    }
1906
1907    /// Record `(addr, size)` in the list its class names: the free space of one
1908    /// page type, or the dead list for space whose page type is unproven.
1909    /// [`plan_paged_managers`] splits and classes the free lists into the on-disk
1910    /// managers at serialization time, so this only has to route.
1911    ///
1912    /// Takes the lists rather than `&mut self` because a commit routes into
1913    /// *copies* of them — nothing is free until the superblock repoint — and the
1914    /// rule must not be restated at that call site.
1915    fn route_free(
1916        meta: &mut FreeList,
1917        raw: &mut FreeList,
1918        dead: &mut FreeList,
1919        addr: u64,
1920        size: u64,
1921        class: FreeClass,
1922    ) {
1923        match class {
1924            FreeClass::Page(PageType::Meta) => meta.free(addr, size),
1925            FreeClass::Page(PageType::Raw) => raw.free(addr, size),
1926            FreeClass::Dead => dead.free(addr, size),
1927        }
1928    }
1929
1930    /// Move every page that is *wholly* free or dead out of the three lists and
1931    /// into the raw list as one whole free page.
1932    ///
1933    /// A paged file may only place a byte in a page of its own type, which is why
1934    /// free space is tracked per type at all. A page holding nothing belongs to no
1935    /// type, so it may be opened for whichever type asks — the rule
1936    /// [`alloc_typed`](Self::alloc_typed) already applies to a whole page inside
1937    /// the other type's list, and [`slot_list`](Self::slot_list) to a whole page
1938    /// read back from the untyped manager. This applies the same rule one level
1939    /// up, to a page whose emptiness only the three lists *together* establish:
1940    /// part free metadata, part free raw data, part space vacated by an object
1941    /// whose page type could not be proven ([`FreeClass::Dead`]).
1942    ///
1943    /// That last part is what makes it necessary rather than a tidy-up. Dead
1944    /// space is never handed out on its own, so without this step a deleted chunk
1945    /// index this crate cannot place is lost for good, and a paged file under
1946    /// delete-and-recreate churn spends a page every few cycles that it can never
1947    /// take back (issue #388). Promoting the page is not a guess about what once
1948    /// sat in it: nothing live is left there to be mixed with.
1949    ///
1950    /// Filed under raw, which is where a whole free page belongs by the same
1951    /// convention [`slot_list`](Self::slot_list) reads them back under, and
1952    /// reachable from either side through `alloc_typed`.
1953    fn promote_whole_free_pages(
1954        meta: &mut FreeList,
1955        raw: &mut FreeList,
1956        dead: &mut FreeList,
1957        page_size: u64,
1958    ) {
1959        // The lists are individually coalesced and pairwise disjoint (a byte is
1960        // vacated once), so their union is a plain merge: sort by address and join
1961        // runs that touch. A page is promotable exactly when it lies inside one of
1962        // those runs, which is what a page split across two of the lists needs.
1963        let mut all = meta.sections();
1964        all.extend(raw.sections());
1965        all.extend(dead.sections());
1966        all.sort_unstable_by_key(|&(addr, _)| addr);
1967        let mut runs: Vec<(u64, u64)> = Vec::with_capacity(all.len());
1968        for (addr, len) in all {
1969            match runs.last_mut() {
1970                Some(run) if run.0 + run.1 >= addr => {
1971                    let end = (run.0 + run.1).max(addr + len);
1972                    run.1 = end - run.0;
1973                }
1974                _ => runs.push((addr, len)),
1975            }
1976        }
1977        for (addr, len) in runs {
1978            // The whole pages inside the run: its aligned interior, exactly as
1979            // `FreeList::alloc_whole_units` takes one. The partial edges sit in
1980            // pages whose other bytes may be live, so they keep whatever they are.
1981            let first = addr.next_multiple_of(page_size);
1982            let last = (addr + len) / page_size * page_size;
1983            if last <= first {
1984                continue;
1985            }
1986            let span = last - first;
1987            meta.take_range(first, span);
1988            raw.take_range(first, span);
1989            dead.take_range(first, span);
1990            raw.free(first, span);
1991        }
1992    }
1993
1994    /// Draw `len` bytes of page type `ty` from this file's free space, or `None`
1995    /// when nothing can serve it.
1996    ///
1997    /// Its own list first, which is the only place a *partly* free page can serve
1998    /// it. Failing that, whole pages inside the other type's free space: a page
1999    /// with nothing in it belongs to neither type, so opening it for `ty` cannot
2000    /// mix it. Enough whole pages to cover `len` are claimed, and the remainder
2001    /// joins `ty`'s list, since those pages now hold `ty`.
2002    ///
2003    /// The cross-type claim is what lets space survive a close. A whole free page
2004    /// is written to the generic-large manager, which records no page type, so
2005    /// every one of them comes back from disk as raw ([`slot_list`](Self::slot_list));
2006    /// without this, metadata could never reuse a page again after a reopen, and
2007    /// each session's commits would append past all of them (issue #286).
2008    fn alloc_typed(&mut self, len: u64, ty: PageType) -> Option<u64> {
2009        let (own, other) = match ty {
2010            PageType::Meta => (&mut self.meta, &mut self.raw),
2011            PageType::Raw => (&mut self.raw, &mut self.meta),
2012        };
2013        if let Some(addr) = own.alloc(len) {
2014            return Some(addr);
2015        }
2016        let span = align_up(len, self.page_size);
2017        let addr = other.alloc_whole_units(span, self.page_size)?;
2018        if span > len {
2019            own.free(addr + len, span - len);
2020        }
2021        Some(addr)
2022    }
2023
2024    /// The longest contiguous run [`alloc_typed`](Self::alloc_typed) could serve
2025    /// `ty` from in one call, or `0` when nothing could: the larger of its own
2026    /// list's longest region and the longest run of whole free pages in the
2027    /// other's.
2028    ///
2029    /// The same two sources in the same terms, so a caller sizing a draw on this
2030    /// figure gets exactly the run `alloc_typed` then hands it.
2031    fn largest_typed(&self, ty: PageType) -> u64 {
2032        let (own, other) = match ty {
2033            PageType::Meta => (&self.meta, &self.raw),
2034            PageType::Raw => (&self.raw, &self.meta),
2035        };
2036        own.largest().max(other.largest_whole_units(self.page_size))
2037    }
2038
2039    /// Every free region this session could still hand out, ascending by address.
2040    /// Used for space accounting, where the caller wants one total rather than a
2041    /// per-page-type breakdown.
2042    ///
2043    /// [`unclassified`](Self::unclassified) is excluded: it is free space, and
2044    /// [`File::persisted_free_space`](crate::File::persisted_free_space) reports
2045    /// it as such, but this session will never place anything in it, which is
2046    /// what the accounting field it feeds is about.
2047    fn reusable_sections(&self) -> Vec<(u64, u64)> {
2048        let mut out = self.meta.sections();
2049        out.extend(self.raw.sections());
2050        out.sort_unstable_by_key(|&(addr, _)| addr);
2051        debug_assert!(
2052            out.windows(2)
2053                .all(|w| w[0].0.saturating_add(w[0].1) <= w[1].0),
2054            "a region is free in both the metadata and the raw list, so the two \
2055             page-type lists have stopped being disjoint"
2056        );
2057        out
2058    }
2059}
2060
2061/// The free space a paged commit is about to write to disk, as
2062/// [`WriteEngine::paged_post_free`] computes it: the session's lists with this
2063/// commit's frees folded in, held apart from the session until the repoint makes
2064/// them true.
2065struct PagedPostFree {
2066    meta: FreeList,
2067    raw: FreeList,
2068    /// The session's dead space plus this commit's, with every page the three
2069    /// lists together empty already promoted out of it
2070    /// ([`PagedEdit::promote_whole_free_pages`]).
2071    dead: FreeList,
2072    /// Already flattened: nothing in a commit adds to or draws from this one.
2073    unclassified: Vec<FreeSection>,
2074}
2075
2076impl PagedPostFree {
2077    /// Give the run of unreferenced space that reaches `eof` back to the
2078    /// filesystem: drop it from every list here and return the end-of-allocation
2079    /// the commit should publish, which the caller truncates the file to
2080    /// (issue #418). `eof` itself when nothing at the end of the file is free.
2081    ///
2082    /// The cut is rounded **up** to a page boundary, so a paged file's
2083    /// end-of-allocation stays a whole number of pages: a run that begins
2084    /// mid-page begins in a page whose earlier bytes are live, and only the whole
2085    /// pages above it can go. That is the same granularity the reference C
2086    /// library shrinks a paged file at — its `H5MF__sect_large_can_shrink` sees
2087    /// only the large (whole-page) sections, the small per-type managers having
2088    /// no shrink at all — so a partial page at the end is kept and recorded,
2089    /// exactly as it is there.
2090    ///
2091    /// Every list is cut, including the space this session may record but never
2092    /// place ([`PagedEdit::unclassified`]) and the space it has vacated but
2093    /// cannot type ([`PagedEdit::dead`]). Bytes past the end-of-allocation are
2094    /// not in the file at all, so a section describing them would point outside
2095    /// it — the one thing the persisted managers must never do.
2096    ///
2097    /// Whole pages holding [`TRAILING_RESERVE_TAILS`] tails stay in the file, and
2098    /// stay recorded, for the reason [`release_trailing_run`] gives on a flat
2099    /// file — with the same refusal to release a run that gives back less than it
2100    /// keeps.
2101    fn release_trailing(&mut self, eof: u64, page_size: u64, tail_len: u64) -> u64 {
2102        let mut unclassified = FreeList::new();
2103        for s in &self.unclassified {
2104            unclassified.free(s.addr, s.size);
2105        }
2106        let start = trailing_run_start([&self.meta, &self.raw, &self.dead, &unclassified], eof);
2107        let cut = align_up(start, page_size);
2108        if cut >= eof {
2109            return eof;
2110        }
2111        // Whole pages are this file's unit, so the reserve is one too, and the
2112        // same "give back more than you keep" condition applies.
2113        let keep = align_up(TRAILING_RESERVE_TAILS * tail_len, page_size);
2114        if eof - cut < 2 * keep {
2115            return eof;
2116        }
2117        let eoa = cut + keep;
2118        let span = eof - eoa;
2119        self.meta.take_range(eoa, span);
2120        self.raw.take_range(eoa, span);
2121        self.dead.take_range(eoa, span);
2122        // Clamped rather than dropped: a section straddling the cut keeps the part
2123        // that is still inside the file.
2124        for s in &mut self.unclassified {
2125            s.size = s.size.min(eoa.saturating_sub(s.addr));
2126        }
2127        self.unclassified.retain(|s| s.size > 0);
2128        eoa
2129    }
2130}
2131
2132/// How much free space a released trailing run leaves behind, as a multiple of
2133/// the manager tail a persisting commit writes.
2134///
2135/// A persisting file rewrites those blocks on *every* commit, and a rewrite can
2136/// never land in its own predecessor's extent — the on-disk superblock still
2137/// points at it until the repoint, so overwriting it would destroy the file a
2138/// crash there falls back on. The end of the file therefore has to keep room for
2139/// the tails that follow this one, and a tail is not a fixed size: every
2140/// abandoned one adds a section to the managers, which makes the *next* tail
2141/// longer again.
2142///
2143/// Measured rather than chosen, on the shipped rule. At one,
2144/// `a_released_file_holds_its_length_across_many_tail_rewrites` leaves the flat
2145/// file alternating between 5,992 and 6,303 bytes instead of holding one length,
2146/// because a tail a section longer than the one that sized the reserve does not
2147/// fit it. At two, `persisting_churn_reaches_a_steady_size` has not settled by
2148/// its sixteenth round — 15,095 bytes after a middle third that held 14,685.
2149/// Three is the smallest value both accept; four is one tail of margin, the tail
2150/// length being itself a function of a section count that varies with the file.
2151const TRAILING_RESERVE_TAILS: u64 = 4;
2152
2153/// Release the run of free space reaching `eof` from a flat file's post-commit
2154/// free list, and return the end-of-allocation the commit should publish
2155/// (issue #418).
2156///
2157/// Not the whole run: [`TRAILING_RESERVE_TAILS`] tails' worth of it stays in the
2158/// file, and stays recorded, so the commits after this one still have somewhere
2159/// to write their own manager blocks.
2160///
2161/// And nothing at all unless the run gives back at least as much as it keeps.
2162/// Trimming the top off a hole that is *about* to be reused is the worst of both:
2163/// it returns a few hundred bytes and leaves the hole too small for the object
2164/// that would have filled it, which then goes past end-of-file instead, so the
2165/// file grows by a whole object to give back a fraction of one.
2166///
2167/// So `eof` — the file's length, unchanged — when it ends in live bytes and when
2168/// the run is not worth releasing; the list is then left exactly as it came in.
2169fn release_trailing_run(post: &mut FreeList, eof: u64, tail_len: u64) -> u64 {
2170    let run_start = trailing_run_start([&*post], eof);
2171    let keep = TRAILING_RESERVE_TAILS * tail_len;
2172    if eof - run_start < 2 * keep {
2173        return eof;
2174    }
2175    post.take_range(run_start + keep, eof - run_start - keep);
2176    run_start + keep
2177}
2178
2179/// Whether a persisting file's tail rewrite may grow the file to place its
2180/// manager blocks.
2181///
2182/// The two callers want opposite things from a tail that will not fit in free
2183/// space, and the difference is not a preference: a commit publishing a tree
2184/// *must* record its free space somewhere, while the shrink pass exists only to
2185/// make the file smaller and would defeat itself by appending.
2186#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2187enum TailPlacement {
2188    /// Place the tail in free space where it fits and past end-of-file where it
2189    /// does not. Every commit that publishes a tree.
2190    Anywhere,
2191    /// Place the tail in free space or not at all: when nothing fits, the
2192    /// rewrite is abandoned before it writes a byte and the file is left exactly
2193    /// as the commit before it published.
2194    ReuseOnly,
2195}
2196
2197/// Where a commit has decided to put one allocation's bytes, handed out by
2198/// [`WriteEngine::reserve`] and consumed by [`WriteEngine::place`].
2199///
2200/// The two arms differ in how the bytes are written — over a dead interior region
2201/// or past the end of the file — and in nothing else the caller sees, so a caller
2202/// that needs the address before the bytes (a relocatable blob) never has to know
2203/// which it got.
2204#[derive(Clone, Copy, Debug)]
2205enum Placement {
2206    /// A region an earlier commit freed, already removed from its free list.
2207    Reused { addr: u64, len: u64 },
2208    /// The end-of-file, with the page for the requested type already open.
2209    Appended { addr: u64, len: u64 },
2210}
2211
2212impl Placement {
2213    /// The absolute file address the bytes will occupy.
2214    fn address(self) -> u64 {
2215        match self {
2216            Placement::Reused { addr, .. } | Placement::Appended { addr, .. } => addr,
2217        }
2218    }
2219
2220    /// The reserved byte count, which the placed bytes must match exactly.
2221    fn len(self) -> u64 {
2222        match self {
2223            Placement::Reused { len, .. } | Placement::Appended { len, .. } => len,
2224        }
2225    }
2226}
2227
2228/// What an in-place append names its target by.
2229///
2230/// A handle with a resolvable path uses it, which lets the session compare the
2231/// target against its own staged edits. A handle reached by object reference has
2232/// no path, so it names the dataset by the object-header address it was reached
2233/// through — the same key the geometry cache uses.
2234#[derive(Clone, Copy)]
2235pub(crate) enum AppendTarget<'a> {
2236    Path(&'a str),
2237    Header(u64),
2238}
2239
2240/// Byte budget for one append batch on a session that batches: a large append is
2241/// split into whole-chunk batches of at most this many raw bytes (always at least
2242/// one chunk), each applied as its own crash-atomic fsync-barriered sequence, so
2243/// peak append memory never scales with the caller's slice.
2244const APPEND_BATCH_BYTES: u64 = 1 << 20;
2245
2246/// The data-only durability barrier: an ordering point always, and an `fsync`
2247/// only when this session's [`SyncPolicy`] keeps the cadence.
2248///
2249/// One function rather than one per caller because the commit path
2250/// ([`WriteEngine::barrier_data`]) and the append engine's phase boundaries
2251/// ([`EditStore::sync`]) are the same point reached from two owners of the
2252/// image, and a barrier that orders at one and not the other is a
2253/// crash-consistency defect no test on the default policy can see (issue #288).
2254///
2255/// The match is exhaustive on purpose, as at [`WriteEngine::barrier`]:
2256/// `SyncPolicy` is sealed to the outside but not to this crate, so a policy
2257/// added later fails to compile here until someone decides what it means.
2258fn barrier_data(image: &mut dyn FileImage, sync_policy: SyncPolicy) -> Result<(), Error> {
2259    match sync_policy {
2260        SyncPolicy::Always => image.sync_data(),
2261        SyncPolicy::OnClose => image.ordering_barrier(),
2262    }
2263}
2264
2265/// Byte budget for the writes one operation may gather before they are issued
2266/// (see [`WriteBuffering::Operation`]): the default every exclusively locked
2267/// read-write session opens under.
2268///
2269/// A megabyte, the same figure as [`APPEND_BATCH_BYTES`] and for a related
2270/// reason: that is already what a bounded session spends holding one batch of
2271/// raw append data, so this is a familiar quantum for the write path rather than
2272/// a new one. The two are not derived from each other and either may be tuned
2273/// alone. An operation larger than this is flushed part-way, which costs it
2274/// little — its writes are long and contiguous by then.
2275///
2276/// # Choosing a page-buffer budget
2277///
2278/// An explicit [page buffer](crate::FileAccessProperties::with_page_buffer_size)
2279/// *replaces* this budget rather than adding to it, and holds what it gathers
2280/// across operations rather than releasing it at every ordering barrier. The two
2281/// figures are therefore not comparable as memory: this one is a per-operation
2282/// peak, a page buffer's is continuous residency. A budget below it asks for less
2283/// of that residency and pays in writes, wherever one long contiguous run has to
2284/// be flushed and restarted. Writes issued on a 4 KiB-paged file:
2285///
2286/// ```text
2287/// workload                          unset   4 KiB   64 KiB   1 MiB
2288/// 32 chunk appends into 8 + commit    188      25        4       4
2289/// one 4 MiB append                    131    1094       74      10
2290/// ```
2291///
2292/// Nothing here bypasses the gatherer the way `H5PB_write` bypasses the C page
2293/// buffer for any I/O of a page or more (issue #357), so the second row is what
2294/// a small budget actually costs; `a_smaller_budget_issues_more_writes` asserts
2295/// that ordering rather than these counts. Which side of it to want is the
2296/// caller's trade, not this crate's (issue #391).
2297const WRITE_GATHER_BYTES: usize = 1 << 20;
2298
2299/// Page the write gatherer merges within on a file that is not paged.
2300///
2301/// A non-paged file has no page size of its own, and this is the same figure
2302/// HDF5 defaults `H5Pset_file_space_page_size` to, so the merge quantum matches
2303/// what the file *would* have used had it been paged.
2304const DEFAULT_GATHER_PAGE: u64 = crate::file_space_info::DEFAULT_PAGE_SIZE;
2305
2306/// One dataset's append geometry, handed to the public append path so it can
2307/// slice a large call into aligned batches without materializing the whole
2308/// call's bytes first.
2309pub(crate) struct AppendGeometry {
2310    /// Elements per chunk along axis 0 (>= 1).
2311    pub(crate) chunk_elems: u64,
2312    /// Bytes per on-disk element, proven non-zero.
2313    pub(crate) element_size: NonZeroUsize,
2314    /// Current length along the unlimited dimension.
2315    pub(crate) current_dim: u64,
2316    /// Whether this dataset's filter pipeline is **lossy** — ZFP, or float
2317    /// D-scale scale-offset. Such a dataset cannot have a partial trailing chunk
2318    /// grown, because that decodes and re-encodes already-committed values (see
2319    /// [`pipeline_lossless`]), so an append onto one has to start chunk-aligned.
2320    pub(crate) lossy_filters: bool,
2321    /// Whole-chunk elements in one full batch (>= one chunk's worth), or
2322    /// [`u64::MAX`] when the session does not batch.
2323    pub(crate) full_batch_elems: u64,
2324}
2325
2326/// Superblock consistency-flag bits raised while a SWMR writer is active: bit 0
2327/// (write access) | bit 2 (SWMR write access). Cleared on a clean close. Matches
2328/// the reference C library, h5py, and [`crate::File::open_swmr_writer`]. These
2329/// are the bits every open path checks — see [`file_lock::check_status_flags`].
2330const SWMR_WRITE_FLAGS: u32 = file_lock::WRITE_ACCESS | file_lock::SWMR_WRITE_ACCESS;
2331
2332/// A dataset located once for [`Dataset::append`](crate::Dataset::append) (or the bounded
2333/// backend's immediate append), then maintained across appends. Mirrors the
2334/// append writer's per-dataset state.
2335pub(crate) struct LocatedState {
2336    pub(crate) loc: Located,
2337    /// The dataset's on-disk element datatype (for the append type check and the
2338    /// filter chunk context).
2339    pub(crate) datatype: Datatype,
2340    /// Spatial (rank-length) chunk dimensions in elements: `[chunk_elems]`.
2341    pub(crate) spatial: Vec<u64>,
2342    /// Bytes per element (datatype size), proven non-zero.
2343    pub(crate) element_size: NonZeroUsize,
2344    /// The re-encodable filter pipeline, when the dataset is filtered.
2345    pub(crate) pipeline: Option<FilterPipeline>,
2346    /// What the slots past the new dimension must hold when an append completes
2347    /// a partial chunk (issue #296).
2348    pub(crate) fill: crate::fill_value::PaddingFill,
2349}
2350
2351/// State for a file that persists its free space on disk. Carries the file's
2352/// fixed file-space parameters and the extents of the free-space-manager blocks
2353/// (and superblock extension) the *current* on-disk file uses, so the next
2354/// persisting commit can reclaim them when it writes fresh ones.
2355struct PersistState {
2356    strategy: FileSpaceStrategy,
2357    threshold: u64,
2358    page_size: u64,
2359    /// `(addr, len)` of the on-disk superblock-extension header and every
2360    /// free-space-manager `FSHD`/`FSSE` block currently in use. Superseded — and
2361    /// therefore freed — by the next persisting commit.
2362    old_blocks: Vec<(u64, u64)>,
2363}
2364
2365/// A snapshot of a writable file's live space usage (issue #150).
2366///
2367/// This is the mutating-session counterpart of the read-only accounting on
2368/// [`File`](crate::File) ([`file_size`](crate::File::file_size) and
2369/// [`persisted_free_space`](crate::File::persisted_free_space)): it describes the
2370/// file *as the session currently holds it*, taken atomically at the moment of
2371/// the [`space_accounting`](crate::File::space_accounting) call.
2372///
2373/// It reflects the committed file plus any immediate in-place appends
2374/// ([`append`](crate::Dataset::append)), but **not** edits still
2375/// staged for the next [`commit`](crate::File::commit) — `create_group`,
2376/// `create_dataset`, `write_dataset`, `append_dataset`, `delete`, `copy`,
2377/// `copy_from`, and attribute edits change these figures only when they are
2378/// applied at commit. Use [`has_staged_edits`](crate::File::has_staged_edits) to
2379/// tell whether such pending work exists.
2380#[derive(Debug, Clone, PartialEq, Eq)]
2381#[non_exhaustive]
2382pub struct SpaceAccounting {
2383    /// The session's current logical size in bytes: the byte length of the file
2384    /// as the session holds it. It equals what
2385    /// [`File::file_size`](crate::File::file_size) reports for the file on disk
2386    /// right now (the HDF5 `H5Fget_filesize` value), because the session keeps its
2387    /// in-memory mirror byte-for-byte identical to the file — every committed
2388    /// write and every immediate in-place append
2389    /// ([`append`](crate::Dataset::append)) updates both together.
2390    ///
2391    /// It is not monotonic: [`commit`](crate::File::commit) can reclaim trailing
2392    /// free space and *shrink* the file. It can also exceed the superblock's
2393    /// recorded end-of-file address when the file was opened carrying unaccounted
2394    /// trailing bytes (the same slack [`File::file_size`](crate::File::file_size)
2395    /// surfaces), since opening does not rewrite that address.
2396    pub logical_size: u64,
2397    /// Total reusable free bytes this session can draw from before the file has to
2398    /// grow — the summed length of
2399    /// [`reusable_free_space`](Self::reusable_free_space).
2400    ///
2401    /// Counts holes left inside [`logical_size`](Self::logical_size) by this
2402    /// session's earlier commits (superseded object headers, the blocks of
2403    /// deleted objects) and, for a file created with
2404    /// `H5Pset_file_space_strategy(persist = true)` and no userblock, the regions
2405    /// seeded from the on-disk free-space managers when the session was opened (so
2406    /// reuse spans sessions). A fresh non-persisting session reports `0` even if
2407    /// the file contains holes left by other tools — those are never tracked. It
2408    /// is neither a lower bound on the next write's growth nor a promise of
2409    /// shrinkage: a region counted here may be truncated away at commit — rather
2410    /// than reused — if adjacent space is later freed and the coalesced run
2411    /// reaches end-of-file.
2412    ///
2413    /// **Which write can spend it is not uniform on a persisting file.** Such a
2414    /// session may write into a hole only once it has taken that hole *out* of
2415    /// the on-disk managers, so an in-place [`append`](crate::Dataset::append)
2416    /// holds a reserve of up to a megabyte per draw that this figure counts and
2417    /// the next [`commit`](crate::File::commit) cannot place into: the commit
2418    /// gives the unspent part back in the same tail that rewrites the managers,
2419    /// which runs *after* it has placed everything. The commit after that one
2420    /// can. So on a
2421    /// persisting file read this as "space this session will reuse rather than
2422    /// grow for", not as "space the very next commit can fill".
2423    ///
2424    /// It is also not the on-disk view. That reserve is deliberately absent from
2425    /// the free-space managers for as long as the session holds it, so
2426    /// [`File::persisted_free_space`](crate::File::persisted_free_space) — which
2427    /// reads those managers — reports less than this by whatever is reserved,
2428    /// and is the figure to compare against what another tool would find.
2429    pub reusable_free_bytes: u64,
2430    /// The reusable free regions as `(offset, length)` pairs, sorted ascending by
2431    /// offset and fully coalesced (no two regions touch or overlap).
2432    ///
2433    /// The offsets are **absolute** file offsets (from byte 0, including any
2434    /// userblock prefix), matching [`logical_size`](Self::logical_size). This
2435    /// differs from [`File::persisted_free_space`](crate::File::persisted_free_space),
2436    /// whose pairs are relative to the superblock base address; the two coincide
2437    /// for a file with no userblock (base address 0), which is the only kind whose
2438    /// persisted free space a session seeds. Empty when nothing is reusable.
2439    pub reusable_free_space: Vec<(u64, u64)>,
2440}
2441
2442impl WriteEngine {
2443    /// Open an existing HDF5 file for in-place editing under an explicit
2444    /// file-locking policy.
2445    ///
2446    /// Reads the file into memory and retains a read/write handle. Under
2447    /// [`FileLocking::Enabled`] it takes an exclusive OS advisory lock so the file
2448    /// cannot be opened concurrently by another writer or reader; the lock is
2449    /// released automatically when the session is dropped or the process exits
2450    /// (including on a crash). Fails with [`Error::FileLocked`] if the file is
2451    /// already locked, or [`Error::EditUnsupported`] if the file is not a
2452    /// supported target; its documentation enumerates the exact requirements.
2453    /// `HDF5_USE_FILE_LOCKING` overrides the requested policy, as in the C
2454    /// library.
2455    pub fn open_with_locking<P: AsRef<Path>>(path: P, locking: FileLocking) -> Result<Self, Error> {
2456        Self::open_inner(path.as_ref(), Some(locking))
2457    }
2458
2459    /// Open exactly as [`open_with_locking`](Self::open_with_locking) does, but
2460    /// behind an image that withholds its whole-file slice, so every read takes
2461    /// the [`Source`] path rather than the slice fast path.
2462    ///
2463    /// Distinct from [`open_rw_with_strategy`](Self::open_rw_with_strategy), which withholds the
2464    /// slice *and* the residency: this one still mirrors the file, so a test can
2465    /// compare the two read forms on a file the bounded open would refuse.
2466    #[cfg(test)]
2467    pub(crate) fn open_source_only(path: &Path) -> Result<Self, Error> {
2468        Self::open_imaged(path, Some(FileLocking::Enabled), |handle, _len| {
2469            Ok(Box::new(crate::image::SourceOnlyImage::new(
2470                Self::read_mirror(handle)?,
2471            )))
2472        })
2473    }
2474
2475    /// Open exactly as [`open_with_locking`](Self::open_with_locking) does, but
2476    /// behind an image whose writes into `fails` behave like a dying device:
2477    /// the first alters the file and then errors, the rest are refused
2478    /// outright. See [`crate::image::TornWriteImage`] for why it takes both to
2479    /// reach the state a refused commit cannot repair.
2480    #[cfg(test)]
2481    pub(crate) fn open_torn_writes(
2482        path: &Path,
2483        fails: core::ops::Range<u64>,
2484    ) -> Result<Self, Error> {
2485        Self::open_imaged(path, Some(FileLocking::Enabled), |handle, _len| {
2486            Ok(Box::new(crate::image::TornWriteImage::new(
2487                Self::read_mirror(handle)?,
2488                fails,
2489            )))
2490        })
2491    }
2492
2493    /// Open a bounded session whose image counts the bytes read through it, so a
2494    /// test can assert that an operation touches only a small part of the file.
2495    /// The counter is shared with the caller.
2496    #[cfg(test)]
2497    pub(crate) fn open_bounded_counting(
2498        path: &Path,
2499        read_bytes: std::sync::Arc<std::sync::atomic::AtomicU64>,
2500    ) -> Result<Self, Error> {
2501        let mut session = Self::open_imaged(path, Some(FileLocking::Enabled), |handle, len| {
2502            Ok(Box::new(crate::image::CountingImage::new(
2503                Box::new(HandleImage::new(
2504                    handle,
2505                    len,
2506                    crate::source::MetadataCacheConfig::disabled(),
2507                )),
2508                read_bytes,
2509                std::sync::Arc::default(),
2510            )))
2511        })?;
2512        session.batched_appends = true;
2513        Ok(session)
2514    }
2515
2516    /// Open a session under `policy` whose image counts the `fsync`s issued
2517    /// through it, so a test can assert what a write path actually costs. The
2518    /// counter is shared with the caller.
2519    ///
2520    /// It takes the bounded backing and its flags, so the session matches what
2521    /// [`File::open_rw`](crate::File::open_rw) builds for a latest-format file;
2522    /// the barrier sites live on the engine, above the choice of image, so one
2523    /// backing exercises them all.
2524    #[cfg(test)]
2525    pub(crate) fn open_sync_counting(
2526        path: &Path,
2527        policy: SyncPolicy,
2528        syncs: std::sync::Arc<std::sync::atomic::AtomicU64>,
2529    ) -> Result<Self, Error> {
2530        let mut session = Self::open_imaged(path, Some(FileLocking::Enabled), |handle, len| {
2531            Ok(Box::new(crate::image::CountingImage::new(
2532                Box::new(HandleImage::new(
2533                    handle,
2534                    len,
2535                    crate::source::MetadataCacheConfig::disabled(),
2536                )),
2537                std::sync::Arc::default(),
2538                syncs,
2539            )))
2540        })?;
2541        session.batched_appends = true;
2542        session.bounded = true;
2543        session.set_sync_policy(policy);
2544        Ok(session)
2545    }
2546
2547    /// Open an existing file for read-write editing under `strategy`: the one
2548    /// place that picks between the bounded backing (a [`HandleImage`] keeping no
2549    /// whole-file mirror, so resident memory is the metadata-cache budget plus
2550    /// whatever is being parsed) and the whole-file mirror. Backs
2551    /// [`File::open_rw`](crate::File::open_rw), which selects between them by
2552    /// the strategy it is given.
2553    ///
2554    /// The eligibility rules are checked here rather than deferred, because a
2555    /// caller who asked for bounded memory cannot be silently given the mirror
2556    /// instead. Under [`MemoryStrategy::Bounded`] a file the bounded engine
2557    /// cannot edit is refused up front; [`MemoryStrategy::Auto`] opts in to
2558    /// falling back to the mirror instead (issue #198, steps 3 and 4).
2559    ///
2560    /// Only a *bounded-only* limitation is worth falling back for — see
2561    /// [`bounded_only_limitation`]. Non-8-byte offsets or lengths are refused by
2562    /// [`open_imaged`](Self::open_imaged) for both engines, and so is an
2563    /// unsupported superblock version; a paged file without persisted free space
2564    /// is refused below for both.
2565    pub(crate) fn open_rw_with_strategy(
2566        path: &Path,
2567        cache: MetadataCacheConfig,
2568        locking: FileLocking,
2569        strategy: MemoryStrategy,
2570    ) -> Result<Self, Error> {
2571        if strategy == MemoryStrategy::Mirrored {
2572            return Self::open_with_locking(path, locking);
2573        }
2574        let mut session = Self::open_imaged(path, Some(locking), |handle, len| {
2575            Ok(Box::new(HandleImage::new(handle, len, cache)))
2576        })?;
2577        session.batched_appends = true;
2578        session.bounded = true;
2579        // Refusals that apply to *both* backings come first, or falling back for a
2580        // bounded-only limitation would skip them and hand back a session that
2581        // cannot commit. A paged file with no persisted managers has no on-disk
2582        // record of which pages hold metadata and which hold raw data, so nothing
2583        // can keep the pages segregated; the staged commit refuses it too, so
2584        // deferring would only trade this error for the same one later, with work
2585        // already staged. A userblock is one way to reach this state without the
2586        // file saying `persist = false`: persisted free space is declined for a
2587        // non-zero base address, which leaves the managers unseeded all the same.
2588        if session.paged.is_some() && session.persist.is_none() {
2589            return Err(Error::EditUnsupported(
2590                "read-write access to a paged file (H5F_FSPACE_STRATEGY_PAGE) requires \
2591                 persisted free space; recreate the file with \
2592                 with_file_space_strategy(FileSpaceStrategy::Page, true, ..) to grow it in place",
2593            ));
2594        }
2595        if let Some(reason) = bounded_only_limitation(&session) {
2596            if strategy == MemoryStrategy::Bounded {
2597                return Err(Error::EditUnsupported(reason));
2598            }
2599            // Release the handle and its exclusive lock before reopening, or the
2600            // mirrored open would contend with the probe we are discarding —
2601            // fatally so on Windows, where the OS lock is mandatory. Dropping a
2602            // bare `WriteEngine` writes nothing: the free-space finalize that a
2603            // dropped writer owes lives on `FileInner`, which this is not yet.
2604            // Another writer can take the lock in that window; the reopen then
2605            // reports `Error::FileLocked`, which is the truthful answer.
2606            drop(session);
2607            return Self::open_with_locking(path, locking);
2608        }
2609        Ok(session)
2610    }
2611
2612    /// Open an existing file for SWMR (single-writer/multiple-reader) writing:
2613    /// take **no** OS lock at all and raise the superblock's SWMR-write
2614    /// consistency flag. Backs [`File::open_swmr_writer`](crate::File::open_swmr_writer).
2615    ///
2616    /// The no-lock is unconditional — `lock = None` never reaches
2617    /// `acquire_exclusive`, so `HDF5_USE_FILE_LOCKING` cannot reintroduce a lock
2618    /// that would block the concurrent readers SWMR exists to permit (fatally so
2619    /// on Windows, where OS locks are mandatory). Requires a latest-format
2620    /// (version-3 superblock) file with no userblock and no persisted
2621    /// free-space, so the superblock can be rewritten in place.
2622    ///
2623    /// The version-3 requirement is the C library's (`H5F__super_read`: "superblock
2624    /// version for SWMR is less than 3"), and it is what keeps the SWMR-write flag
2625    /// meaningful: neither library reads the status-flags byte back on an older
2626    /// superblock, so a flag raised there would announce a live writer to nobody.
2627    /// This crate's writer emits version 3, so no file it produces is affected.
2628    pub(crate) fn open_swmr_writer<P: AsRef<Path>>(
2629        path: P,
2630        sync_policy: SyncPolicy,
2631    ) -> Result<Self, Error> {
2632        let mut session = Self::open_inner(path.as_ref(), None)?;
2633        // Before the flag write below, which is a durability point like any
2634        // other: a caller who asked for no `fsync` gets none, and the flag still
2635        // reaches every other process, which reads it from the operating system.
2636        session.set_sync_policy(sync_policy);
2637        if session.superblock.version < 3
2638            || !session.superblock.base_address.is_zero()
2639            || session.persist.is_some()
2640        {
2641            return Err(Error::SwmrAppendUnsupported(
2642                "SWMR writing requires a latest-format file (v3 superblock) with no userblock \
2643                 and no persisted free-space",
2644            ));
2645        }
2646        session.swmr_mode = true;
2647        session.set_consistency_flags(SWMR_WRITE_FLAGS)?;
2648        Ok(session)
2649    }
2650
2651    /// Set the superblock's consistency flags in the mirror and on disk, then
2652    /// flush. The one place this crate writes that byte: the SWMR-write flag on
2653    /// open and off on close, and the page buffer's crash mark
2654    /// ([`raise_crash_mark`](Self::raise_crash_mark)).
2655    ///
2656    /// Requires a version-2/3 superblock, since [`Superblock::serialize`] emits
2657    /// that layout; `open_swmr_writer` and
2658    /// [`set_page_buffer_size`](Self::set_page_buffer_size) each check it.
2659    ///
2660    /// The root address is stored on disk *relative to the base address* while
2661    /// the session holds it absolute (see the normalization in `open_imaged`), so
2662    /// the serialized clone converts it back — as the commit tail does at the
2663    /// only other place this crate serializes a superblock. Without it, writing
2664    /// the absolute address here repoints the root past the end of the file while
2665    /// claiming only to have touched a flag, and the file stops reading.
2666    ///
2667    /// `open_swmr_writer` happens to hold a base of zero, since it turns a
2668    /// userblock away outright. Its sibling no longer does:
2669    /// [`set_page_buffer_size`](Self::set_page_buffer_size) once required
2670    /// persisted free space, which is declined for a non-zero base, and issue
2671    /// #357 scoped that refusal to paged files — so an unpaged userblock file
2672    /// now reaches here with a base to convert. It was never a property to build
2673    /// on, which is why `a_status_flag_write_leaves_a_userblock_files_root_alone`
2674    /// pins the conversion directly rather than through a caller.
2675    fn set_consistency_flags(&mut self, flags: u32) -> Result<(), Error> {
2676        self.superblock.consistency_flags = flags;
2677        self.held_status_flags = flags;
2678        let mut on_disk = self.superblock.clone();
2679        // `open_imaged` built the in-memory address by adding the base to the
2680        // stored one, so this cannot be below the base — but it is a subtraction
2681        // over two file-derived numbers, and `relative` reports rather than wraps.
2682        on_disk.root_group_address = on_disk.base_address.relative(on_disk.root_group_address)?;
2683        let bytes = on_disk.serialize();
2684        self.write_at(self.sb_sig_off, &bytes)?;
2685        self.barrier_data()?;
2686        Ok(())
2687    }
2688
2689    /// Raise the page buffer's crash mark: superblock status-flag bit 0
2690    /// (`H5F_SUPER_WRITE_ACCESS`), the byte the C library raises for any writer
2691    /// and this crate otherwise raises only for a SWMR one.
2692    ///
2693    /// Why a page buffer is not offered without one is
2694    /// [`with_page_buffer_size`](crate::FileAccessProperties::with_page_buffer_size)'s
2695    /// to explain; what matters here is that two properties of *this* function
2696    /// are load-bearing, and neither is the [`SyncPolicy`]'s to skip:
2697    ///
2698    /// - It runs **before** the buffering mode changes, so no buffered write can
2699    ///   reach the disk ahead of it. Its caller states why nothing can fail in
2700    ///   between.
2701    /// - It is **fsynced**, unconditionally — the half no test here can see.
2702    ///   Reaching the operating system already covers a process that dies, and
2703    ///   this suite reproduces no power loss, so deleting the `sync_all` fails
2704    ///   nothing. It is there because power loss is exactly the case where the
2705    ///   buffer's held writes may be partly on the platter while a mark still in
2706    ///   the page cache is not. One `fsync` per session buys it.
2707    ///
2708    /// A failure rolls the byte back, best-effort. The alternative is a file
2709    /// marked in use by a session that never opened, which nothing can read until
2710    /// [`File::clear_swmr_flag`](crate::File::clear_swmr_flag) — a steep price
2711    /// for an open that failed. Best-effort because the rollback writes through
2712    /// the same handle that just failed; it is a chance, not a guarantee.
2713    fn raise_crash_mark(&mut self) -> Result<(), Error> {
2714        let raised = self
2715            .set_consistency_flags(file_lock::WRITE_ACCESS)
2716            .and_then(|()| self.image.sync_all());
2717        if raised.is_err() {
2718            let _ = self.set_consistency_flags(0);
2719            let _ = self.image.sync_all();
2720        }
2721        raised
2722    }
2723
2724    /// Take down whatever status flags this session raised, once everything they
2725    /// stood for is durable — the SWMR pair, or a page buffer's crash mark. A
2726    /// no-op for a session that raised none, which is every ordinary
2727    /// [`File::open_rw`](crate::File::open_rw) session.
2728    ///
2729    /// The whole byte goes to zero, so this cannot leave the half-set state
2730    /// [`check_status_flags`](crate::file_lock::check_status_flags) reports as
2731    /// flags in disagreement; that is why the guard asks whether anything is held
2732    /// rather than which bits are.
2733    ///
2734    /// Called from `File::close` and `FileInner::drop`, **after** their
2735    /// `force_sync`. The order is the whole point: clearing first and flushing
2736    /// second would leave a window in which the file's writes are still in memory
2737    /// and nothing on the disk says so, which is the state the flags exist to
2738    /// make unreachable. Nothing in this suite can tell the two orders apart —
2739    /// it takes a crash inside the window — so the argument is the reason, not a
2740    /// test.
2741    pub(crate) fn release_status_flags(&mut self) -> Result<(), Error> {
2742        if self.held_status_flags == 0 {
2743            return Ok(());
2744        }
2745        self.set_consistency_flags(0)?;
2746        self.image.sync_all()
2747    }
2748
2749    /// Shared open path. `lock = Some(policy)` acquires an exclusive OS lock under
2750    /// that policy (the ordinary read-write session); `lock = None` takes no lock
2751    /// at all (the SWMR writer — see [`open_swmr_writer`](Self::open_swmr_writer)).
2752    fn open_inner(path: &Path, lock: Option<FileLocking>) -> Result<Self, Error> {
2753        Self::open_imaged(path, lock, |handle, _len| {
2754            Ok(Box::new(Self::read_mirror(handle)?))
2755        })
2756    }
2757
2758    /// Read `handle` whole into a [`MirrorImage`]. The one place the engine
2759    /// still assumes it can hold the file, kept behind a named constructor so
2760    /// the mirrorless opens visibly do not call it.
2761    ///
2762    /// `read_to_end` reads from the handle's *current* cursor, and the open path
2763    /// has already read the superblock through it, so this rewinds first. Reading
2764    /// from wherever the last read landed would mirror a truncated file — with no
2765    /// error to say so, since a short mirror is a valid `Vec<u8>`.
2766    fn read_mirror(mut handle: fs::File) -> Result<MirrorImage, Error> {
2767        handle.seek(SeekFrom::Start(0)).map_err(Error::Io)?;
2768        let mut data = Vec::new();
2769        handle.read_to_end(&mut data).map_err(Error::Io)?;
2770        Ok(MirrorImage::new(handle, data))
2771    }
2772
2773    /// Shared open path over any backing: acquire the handle (and, when asked,
2774    /// the exclusive lock), parse and validate the superblock through a borrowed
2775    /// view of the handle, and only then let `build` decide how the bytes are
2776    /// held.
2777    ///
2778    /// Every refusal comes before `build`, because `build` may read the whole
2779    /// file: reaching a refusal after it would spend `O(file size)` on a file
2780    /// that is then rejected — a 20 GB flagged file read into memory and thrown
2781    /// away. The superblock reads themselves are a few bounded windows either
2782    /// way, so nothing is read twice.
2783    ///
2784    /// `build` receives the file's length as well as the handle because a
2785    /// mirrorless image has to be told its end-of-file — it has no buffer whose
2786    /// length implies it.
2787    ///
2788    /// Nothing below this point reads the file as a slice, which is what lets
2789    /// one engine open a whole-file mirror, a mirrorless handle, and (in tests)
2790    /// a mirror that withholds its slice.
2791    fn open_imaged(
2792        path: &Path,
2793        lock: Option<FileLocking>,
2794        build: impl FnOnce(fs::File, u64) -> Result<Box<dyn FileImage>, Error>,
2795    ) -> Result<Self, Error> {
2796        let handle = fs::OpenOptions::new()
2797            .read(true)
2798            .write(true)
2799            .open(path)
2800            .map_err(Error::Io)?;
2801        // Acquire the exclusive lock before reading or mutating; the retained
2802        // `handle` holds it for the session's life. A `None` policy (SWMR) never
2803        // reaches `acquire_exclusive`, so no lock is ever taken.
2804        if let Some(policy) = lock {
2805            file_lock::acquire_exclusive(&handle, policy, path)?;
2806        }
2807        let len = handle.metadata().map_err(Error::Io)?.len();
2808        // Read the superblock through the handle itself, before any image owns
2809        // it. `probe` borrows, so it is gone by the time `build` takes the
2810        // handle; it leaves the handle's cursor wherever its last read ended,
2811        // which is why the mirror positions the handle before reading it whole.
2812        let probe = crate::image::BorrowedHandle::new(&handle, len);
2813        let sb_sig_off = signature::find_signature_in(&probe)?.to_usize()?;
2814        let mut superblock = Superblock::parse_from_source(&probe, sb_sig_off as u64)?;
2815
2816        if superblock.version > 3 {
2817            return Err(Error::EditUnsupported("unsupported superblock version"));
2818        }
2819        // Refuse a file a writer already holds, before anything is mutated. This
2820        // is the one exclusion the OS lock above cannot make: a SWMR writer
2821        // takes no lock, so its file is lock-free but flagged (issue #245).
2822        file_lock::check_status_flags(
2823            &superblock,
2824            file_lock::OpenIntent::Write,
2825            file_lock::OpenTarget::Path(path),
2826        )?;
2827        if superblock.offset_size != OFFSET_SIZE || superblock.length_size != LENGTH_SIZE {
2828            return Err(Error::EditUnsupported(
2829                "only 8-byte offsets and lengths are supported for in-place editing",
2830            ));
2831        }
2832        // A userblock shifts the whole HDF5 image forward by `base_address`: the
2833        // superblock sits at the base address and every stored address is relative
2834        // to it (the end-of-file address is the sole absolute field). The editor
2835        // supports this by reading at `stored + base` and writing back
2836        // `file_offset - base`. Only the canonical layout — superblock located
2837        // exactly at the base address (e.g. a MATLAB v7.3 `.mat` file's 512-byte
2838        // userblock) — is accepted; a base address that disagrees with the
2839        // superblock's location is a relocated or malformed file we will not rewrite.
2840        if superblock.base_address != BaseAddress::new(sb_sig_off as u64) {
2841            return Err(Error::EditUnsupported(
2842                "a file whose superblock is not located at its base address is not editable in place",
2843            ));
2844        }
2845        // Normalize the root group address to an absolute file offset, exactly as
2846        // the reader does (`reader::parse_superblock`), so `resolve_path_any` and
2847        // the link-graph walk index the image correctly. It is converted back to a
2848        // stored (base-relative) address only when the superblock is serialized on
2849        // commit.
2850        superblock.root_group_address = superblock
2851            .base_address
2852            .absolute(superblock.root_group_address)?;
2853
2854        // Everything that can refuse this file has run; only now is it worth
2855        // holding the bytes.
2856        let image = build(handle, len)?;
2857
2858        let mut session = Self {
2859            image,
2860            sb_sig_off,
2861            superblock,
2862            staged: StagedEdits::default(),
2863            appender_claims: Vec::new(),
2864            next_appender_token: 0,
2865            free: FreeList::new(),
2866            reserved: FreeList::new(),
2867            proved_free_of_references: false,
2868            persist: None,
2869            located: HashMap::new(),
2870            vl_overwrite_heaps: HashMap::new(),
2871            superseded_heaps: Vec::new(),
2872            inplace_undo: Vec::new(),
2873            swmr_mode: false,
2874            paged: None,
2875            committed: false,
2876            resolved: HashMap::new(),
2877            batched_appends: false,
2878            bounded: false,
2879            libver_ceiling: None,
2880            fsm_len: len,
2881            publish_attempted: false,
2882            staging_batch: false,
2883            staged_generation: 0,
2884            held_status_flags: 0,
2885            sync_policy: SyncPolicy::Always,
2886        };
2887        // If the file persists its free space, seed the free list from the
2888        // on-disk managers and arm persistence for future commits. Best-effort:
2889        // an unreadable or non-persisting extension simply leaves the session in
2890        // the default, non-persisting mode.
2891        session.load_persisted_free_space();
2892        // Gather this session's writes, now that the page size the file was laid
2893        // out on is known. Only an exclusively locked session: `lock = None` is
2894        // the SWMR writer, whose concurrent readers observe the order its ordered
2895        // phases become visible in, and so must see every write as it is made
2896        // (issue #288).
2897        //
2898        // `lock.is_some()` is a proxy for "not the SWMR writer", used because
2899        // `swmr_mode` is not set until after this. It is exact only while
2900        // `open_swmr_writer` is the sole lock-free entry point: a future one that
2901        // took a lock would silently *gain* gathering, which is the direction
2902        // that costs a reader. The other direction — a lock-free non-SWMR open
2903        // losing gathering — costs only writes. Whoever adds such an entry point
2904        // owns this condition.
2905        if lock.is_some() {
2906            let page_size = session.gather_page_size();
2907            session
2908                .image
2909                .set_write_buffering(WriteBuffering::Operation {
2910                    page_size,
2911                    max_bytes: WRITE_GATHER_BYTES,
2912                })?;
2913        }
2914        Ok(session)
2915    }
2916
2917    /// The page this session's writes are merged within: the file's own
2918    /// file-space page size when it is paged, and the format's default otherwise.
2919    fn gather_page_size(&self) -> u64 {
2920        self.paged
2921            .as_ref()
2922            .map_or(DEFAULT_GATHER_PAGE, |pg| pg.page_size)
2923    }
2924
2925    /// Let this session's writes span operations, up to `max_bytes` of them: the
2926    /// `H5Pset_page_buffer_size` analogue, requested through
2927    /// [`FileAccessProperties::with_page_buffer_size`](crate::FileAccessProperties::with_page_buffer_size).
2928    ///
2929    /// Refused for a budget below the page this session merges within — the
2930    /// file's own file-space page size when it is paged and
2931    /// [`DEFAULT_GATHER_PAGE`] when it is not, which is what
2932    /// [`gather_page_size`](Self::gather_page_size) answers. A buffer that
2933    /// cannot hold one page flushes on every page it touches. The C library
2934    /// reaches the same conclusion and acts on it *silently*, rounding the budget
2935    /// up at `H5Fopen`, which is the one part of its behavior not worth copying.
2936    ///
2937    /// Every larger budget is honored, including one below the
2938    /// [`WRITE_GATHER_BYTES`] this session was already gathering under. That was
2939    /// refused until issue #391; see the constant for what a small one costs.
2940    ///
2941    /// It does **not** require a paged file, where `H5Pset_page_buffer_size`
2942    /// does. The C page buffer is a page *cache*, and `H5PB_create` rejects an
2943    /// unpaged file because its `min_meta_perc`/`min_raw_perc` reservations are
2944    /// counted in pages that the paged allocator keeps segregated by kind. This
2945    /// is a write gatherer instead: it merges runs within a page-sized window and
2946    /// flushes whole, so the window is all it needs, and `gather_page_size`
2947    /// supplies one on an unpaged file already — it is what every read-write
2948    /// session's default [`Operation`](WriteBuffering::Operation) gathering runs
2949    /// under. Lifting the requirement leaves the file byte-identical, which is
2950    /// what the unpaged arm of `a_page_buffer_holds_dirty_pages_across_operations`
2951    /// asserts (issue #357).
2952    ///
2953    /// A zero budget leaves the session's default gathering alone, matching the
2954    /// property's own "unset" value.
2955    pub(crate) fn set_page_buffer_size(&mut self, max_bytes: usize) -> Result<(), Error> {
2956        if max_bytes == 0 {
2957            return Ok(());
2958        }
2959        // A lock-free session's readers observe the order its writes become
2960        // visible in. `File::open_swmr_writer` refuses this property before it
2961        // raises the on-disk flag, which is the refusal a caller sees; this one is
2962        // here because the guarantee belongs to the engine that would break it,
2963        // and a SWMR session can otherwise clear every check below — an unpaged
2964        // version-3 file with an ample budget is exactly what SWMR writing wants.
2965        if self.swmr_mode {
2966            return Err(Error::EditUnsupported(
2967                "a SWMR writer cannot buffer its writes: its readers observe the order they \
2968                 become visible in",
2969            ));
2970        }
2971        // One page is the whole of the size rule, and a buffer that cannot hold
2972        // one drains on every page it touches.
2973        //
2974        // A budget below `WRITE_GATHER_BYTES` was refused alongside it until
2975        // issue #391, on the grounds that a page buffer *replaces* the byte
2976        // budget the session was already gathering under rather than adding to
2977        // it, so a smaller one is also the point at which one long run is flushed
2978        // and restarted. That cost is real — nothing here bypasses the gatherer
2979        // the way `H5PB_write` bypasses the C page buffer for I/O of a page or
2980        // more (issue #357) — but it is not this crate's trade to make. The
2981        // budget it replaces is a per-operation peak released at every barrier,
2982        // while this one is held across operations, so a smaller number lowers
2983        // both the peak and the residency, and a writer inside a tight memory cap
2984        // is entitled to spend the writes for that. See `WRITE_GATHER_BYTES` for
2985        // the measurement.
2986        let page_size = self.gather_page_size();
2987        if (max_bytes as u64) < page_size {
2988            return Err(Error::EditUnsupported(
2989                "a page buffer must be at least the file's file-space page size",
2990            ));
2991        }
2992        // A page buffer holds dirty pages across ordering barriers, and under
2993        // `Always` every barrier is an `fsync` that flushes it — so it would hold
2994        // nothing, and the caller would have paid the mark below for nothing in
2995        // return. It lives here with its three siblings rather than at the fapl,
2996        // because a refusal kept apart from the others is one
2997        // `create_would_refuse_reopen` forgets to restate, which is how this one
2998        // came to write the file before refusing it.
2999        if self.sync_policy == SyncPolicy::Always {
3000            return Err(Error::EditUnsupported(
3001                "a page buffer does nothing under SyncPolicy::Always, whose every barrier is \
3002                 an fsync that flushes it; pair with_page_buffer_size with \
3003                 with_sync_policy(SyncPolicy::OnClose), or drop it",
3004            ));
3005        }
3006        // A paged file whose free space is not persisted can neither commit nor
3007        // append — both are refused — so a page buffer would have nothing to
3008        // hold, while its mark blocked every reader for the session's life.
3009        //
3010        // Scoped to a paged file: an *unpaged* one commits and appends whether it
3011        // persists its free space or not, so there is nothing here to refuse. The
3012        // base address the mark's superblock rewrite needs is not this check's to
3013        // settle — `set_consistency_flags` converts the root address itself, and
3014        // is tested for a userblock file directly rather than through a caller.
3015        if self.paged.is_some() && self.persist.is_none() {
3016            return Err(Error::EditUnsupported(
3017                "a page buffer on a paged file needs its free space persisted: without it \
3018                 this session can neither commit nor append, so the buffer would hold nothing \
3019                 while marking the file against every reader; recreate the file with \
3020                 with_file_space_strategy(FileSpaceStrategy::Page, true, ..), or drop the \
3021                 page buffer",
3022            ));
3023        }
3024        // The crash mark below is the whole reason this property is offered at
3025        // all, and only a version-3 superblock carries one that anybody reads:
3026        // `check_status_flags` is gated there, matching where the C library gates
3027        // it. A version-2 paged file is reachable — `LibVer::V18` writes a
3028        // version-2 superblock and nothing about paged file space rules it out —
3029        // so this is a refusal rather than an assertion (issue #308).
3030        if self.superblock.version < 3 {
3031            return Err(Error::EditUnsupported(
3032                "a page buffer marks the file for the life of the session, so that a session \
3033                 that crashes leaves one every reader refuses rather than one that reads \
3034                 clean; only a version-3 superblock carries a status-flags byte any library \
3035                 reads back, and this file's is older. Rewrite it at the 1.10 format \
3036                 (repack, or FileBuilder's default bounds), or drop the page buffer",
3037            ));
3038        }
3039        // Before the mode change, and durable: from here on a write may sit in
3040        // memory across a barrier, and the file has to say so on the disk first.
3041        //
3042        // Nothing can fail between the two and leave the file marked with no
3043        // session behind it. `raise_crash_mark` ends in a `sync_all`, which
3044        // flushes, so the gatherer is empty when `set_write_buffering` flushes it
3045        // again — the one fallible step it takes.
3046        self.raise_crash_mark()?;
3047        self.image.set_write_buffering(WriteBuffering::Session {
3048            page_size,
3049            max_bytes,
3050        })
3051    }
3052
3053    /// Read the superblock-extension File Space Info message; if it requests
3054    /// persistence, seed [`self.free`](Self::free) from the on-disk free-space
3055    /// managers and record the manager/extension block extents for reclamation on
3056    /// the next commit. Silent on any malformed or absent metadata — persistence
3057    /// is then simply off for this session.
3058    fn load_persisted_free_space(&mut self) {
3059        if self.superblock.version < 2 {
3060            return; // no superblock extension exists before v2
3061        }
3062        let Some(ext_rel) = self.superblock.superblock_extension_address else {
3063            return;
3064        };
3065        if ext_rel == UNDEF {
3066            return;
3067        }
3068        // The extension address is stored relative to the base address, so it is
3069        // shifted to an absolute file offset before the header is read. This is a
3070        // no-op on the base-0 file every path below the userblock check sees, but
3071        // that check itself needs the strategy of a *userblock* file.
3072        let Ok(ext_addr) = self
3073            .superblock
3074            .base_address
3075            .absolute(ext_rel)
3076            .map_err(|_| ())
3077            .and_then(|a| usize::try_from(a).map_err(|_| ()))
3078        else {
3079            return;
3080        };
3081        let Some(info) = self.extension_fsinfo(ext_addr) else {
3082            return;
3083        };
3084        // Free-space reuse and persistence are not yet base-address aware: the
3085        // persisted section addresses (and the extension/manager block walk below)
3086        // are read as absolute, so on a userblock file they would seed `self.free`
3087        // with wrong regions that a later allocation could hand out into live
3088        // data. Leave persistence off for such a file — the on-disk managers stay
3089        // untouched and valid, this session simply appends rather than reusing.
3090        //
3091        // A *paged* userblock file is a different matter: appending without page
3092        // awareness would mix metadata and raw data in its pages and leave its end
3093        // of allocation unaligned, quietly producing a file that still claims the
3094        // paged strategy but no longer satisfies it. Install the paged marker
3095        // without persistence so the commit refusal below catches it, which is the
3096        // same rule a paged non-persisting file already takes.
3097        if !self.superblock.base_address.is_zero() {
3098            if info.strategy == FileSpaceStrategy::Page && info.page_size > 0 {
3099                self.paged = Some(PagedEdit::new(info.page_size));
3100            }
3101            return;
3102        }
3103        // Record the paged strategy regardless of the persist flag: a paged commit
3104        // needs page-aware bookkeeping, and a paged file that does not persist its
3105        // free space is refused outright (see `PagedEdit` and the commit refusal).
3106        //
3107        // A zero page size is refused rather than installed: every page calculation
3108        // divides by it, so a corrupt or hostile file declaring `Page` with a page
3109        // size of 0 would panic the editor. Leaving `paged` unset makes the file
3110        // take the ordinary flat path, which needs no page geometry.
3111        let paged = info.strategy == FileSpaceStrategy::Page && info.page_size > 0;
3112        if paged {
3113            self.paged = Some(PagedEdit::new(info.page_size));
3114        }
3115        if !info.persist {
3116            return;
3117        }
3118        let os = self.superblock.offset_size;
3119        let file_len = self.image.len();
3120
3121        // Seed the free list(s) with every persisted section (addresses are stored
3122        // relative to the base address, which this editor requires to be 0).
3123        // Defensive against a malformed or corrupt manager: skip a section that is
3124        // empty, runs past end-of-file, or overlaps one already taken. A
3125        // well-formed file (this crate's or the C library's) has none of these;
3126        // tolerating them keeps a bad file from seeding a bogus or double-counted
3127        // free region that a later commit would hand out into live data.
3128        if paged {
3129            // A paged file's free space is segregated across per-page-type
3130            // managers, so read each slot on its own and keep the page type its
3131            // slot implies ([`PagedEdit::slot_list`]). Flattening them (as the
3132            // non-paged path below does) would lose exactly the distinction the
3133            // commit has to preserve. A section whose slot does not settle its page
3134            // type is recorded but never handed out.
3135            let page_size = info.page_size;
3136            let mut tagged: Vec<(FreeSection, Option<PageType>)> = Vec::new();
3137            for (slot, &m) in info.manager_addrs.iter().enumerate() {
3138                if m == UNDEF {
3139                    continue;
3140                }
3141                let Ok(sections) = free_space_manager::read_persisted_sections_source(
3142                    &self.image(),
3143                    &[m],
3144                    BaseAddress::ZERO,
3145                    os,
3146                )
3147                .map(|(sections, _)| sections) else {
3148                    continue;
3149                };
3150                for s in sections {
3151                    let ty = PagedEdit::slot_list(slot, s.addr, s.size, page_size);
3152                    tagged.push((s, ty));
3153                }
3154            }
3155            // Distinct sections have distinct addresses in any well-formed file,
3156            // so the tie-break never arises; only a malformed manager can
3157            // advertise one address twice, and the overlap guard below already
3158            // discards the second of any such pair. No `debug_assert` here: this
3159            // parses untrusted bytes, which must not panic a debug build.
3160            tagged.sort_unstable_by_key(|(s, _)| s.addr);
3161            let mut prev_end = 0u64;
3162            for (s, ty) in tagged {
3163                let Some(end) = s.addr.checked_add(s.size) else {
3164                    continue;
3165                };
3166                if s.size == 0 || end > file_len || s.addr < prev_end {
3167                    continue;
3168                }
3169                prev_end = end;
3170                let pg = self
3171                    .paged
3172                    .as_mut()
3173                    .expect("the paged state was just installed");
3174                match ty {
3175                    Some(ty) => PagedEdit::route_free(
3176                        &mut pg.meta,
3177                        &mut pg.raw,
3178                        &mut pg.dead,
3179                        s.addr,
3180                        s.size,
3181                        ty.into(),
3182                    ),
3183                    None => pg.unclassified.free(s.addr, s.size),
3184                }
3185            }
3186            // A page the seeded lists empty between them belongs to no type and
3187            // may serve either, so fold it in now rather than leaving it to the
3188            // first commit. Nothing this crate writes produces one — it files a
3189            // whole free page under a single manager — but a file another writer
3190            // laid out can, and the rule is the same wherever the page came from.
3191            let pg = self
3192                .paged
3193                .as_mut()
3194                .expect("the paged state was just installed");
3195            PagedEdit::promote_whole_free_pages(&mut pg.meta, &mut pg.raw, &mut pg.dead, page_size);
3196        } else if let Ok(mut sections) = free_space_manager::read_persisted_sections_source(
3197            &self.image(),
3198            &info.manager_addrs,
3199            BaseAddress::ZERO,
3200            os,
3201        )
3202        .map(|(sections, _)| sections)
3203        {
3204            // Unique addresses in any well-formed file; see the paged branch
3205            // above for why a duplicate is harmless and unasserted here.
3206            sections.sort_unstable_by_key(|s| s.addr);
3207            let mut prev_end = 0u64;
3208            for s in sections {
3209                let Some(end) = s.addr.checked_add(s.size) else {
3210                    continue;
3211                };
3212                if s.size == 0 || end > file_len || s.addr < prev_end {
3213                    continue;
3214                }
3215                prev_end = end;
3216                self.free.free(s.addr, s.size);
3217            }
3218        }
3219
3220        // Record the byte extents of the blocks the live file uses so the next
3221        // persisting commit frees them when it writes replacements: the
3222        // extension header, and each defined manager's FSHD + FSSE.
3223        let mut old_blocks = Vec::new();
3224        if let Ok(spans) = self.oh_chunk_spans(ext_addr) {
3225            old_blocks.extend(spans);
3226        }
3227        for &m in &info.manager_addrs {
3228            if m == UNDEF {
3229                continue;
3230            }
3231            let Ok(hdr_len) = fshd_len(os).to_usize() else {
3232                continue;
3233            };
3234            let Ok(fshd) = self.image().read_metadata_at(m, hdr_len) else {
3235                continue;
3236            };
3237            if let Ok(h) = FsmHeader::parse(&fshd, os) {
3238                // `FsmHeader::parse` succeeding guarantees the header's own bytes
3239                // are present, so the FSHD extent is in-bounds; validate the
3240                // section-info extent before recording it, so a malformed
3241                // `fsse_used` can't later free a region running past end-of-file.
3242                old_blocks.push((m, fshd_len(os)));
3243                if h.fsse_addr != UNDEF
3244                    && h.fsse_addr
3245                        .checked_add(h.fsse_used)
3246                        .is_some_and(|end| end <= file_len)
3247                {
3248                    old_blocks.push((h.fsse_addr, h.fsse_used));
3249                }
3250            }
3251        }
3252
3253        self.persist = Some(PersistState {
3254            strategy: info.strategy,
3255            threshold: info.threshold,
3256            page_size: info.page_size,
3257            old_blocks,
3258        });
3259    }
3260
3261    /// Parse the File Space Info message out of the superblock-extension object
3262    /// header at `ext_addr`, if present and readable.
3263    fn extension_fsinfo(&self, ext_addr: usize) -> Option<FileSpaceInfo> {
3264        let os = self.superblock.offset_size;
3265        let ls = self.superblock.length_size;
3266        let base = self.superblock.base_address;
3267        let oh =
3268            ObjectHeader::parse_from_source(&self.image(), ext_addr as u64, os, ls, base).ok()?;
3269        let msg = oh
3270            .messages
3271            .iter()
3272            .find(|m| m.msg_type == MessageType::FileSpaceInfo)?;
3273        FileSpaceInfo::parse(&msg.data, os, ls).ok()
3274    }
3275
3276    /// Stage a new dataset, added on the next [`commit`](Self::commit).
3277    ///
3278    /// `path` is the dataset's full path; everything before the last component
3279    /// names the parent group, which must exist (or be created in this session).
3280    /// `builder` is the same [`DatasetBuilder`] [`FileBuilder`](crate::FileBuilder)
3281    /// uses, configured by the caller; its name is taken from `path`, so the two
3282    /// cannot disagree.
3283    ///
3284    /// The builder is passed in finished rather than handed out as a `&mut` into
3285    /// this engine. That is deliberate: a borrow into the engine forces the
3286    /// caller to hold it — and, above this layer, its lock — for as long as the
3287    /// builder is being configured, which is what let a user closure deadlock
3288    /// against the same file it was reading (issue #200).
3289    ///
3290    /// The dataset may be contiguous or chunked, and chunked datasets may be
3291    /// filtered (`with_deflate`, `with_shuffle`, `with_fletcher32`,
3292    /// `with_scale_offset`, `with_zfp`) and/or extensible (`with_maxshape`). An
3293    /// empty (zero-element) dataset is supported under either storage, a
3294    /// provenance dataset (`with_provenance`) is supported, and a
3295    /// contiguous dataset may carry variable-length attributes, a
3296    /// variable-length-string payload (`with_vlen_strings`), or path-resolved
3297    /// object-reference elements (`with_path_references`; chunking any of
3298    /// these is not supported). An attribute set too large for the object header
3299    /// is written to a fractal heap, as the whole-file writer does.
3300    pub(crate) fn stage_created_dataset(
3301        &mut self,
3302        path: &str,
3303        mut builder: DatasetBuilder,
3304    ) -> Result<(), Error> {
3305        let mut comps = split_path(path);
3306        self.refuse_if_claimed(&comps)?;
3307        self.refuse_creation_collision(&comps, StagedKind::Dataset)?;
3308        builder.name = comps.pop().unwrap_or_default();
3309        self.staged.push_dataset(comps, flatten_dataset(builder)?);
3310        Ok(())
3311    }
3312
3313    /// Stage an in-place overwrite of an **existing** dataset's values (the HDF5
3314    /// `H5Dwrite` whole-dataset write), applied on the next
3315    /// [`commit`](Self::commit).
3316    ///
3317    /// `path` is the full path of a dataset that must already exist; `builder`
3318    /// supplies the replacement data and is named from `path`, as in
3319    /// [`stage_created_dataset`](Self::stage_created_dataset).
3320    ///
3321    /// This is a *value* overwrite, not a reshape or retype: the new data's
3322    /// datatype and shape must match the on-disk dataset's exactly (byte-for-byte
3323    /// after serialization, so endianness and compound layout must agree), or
3324    /// `commit` reports [`Error::EditUnsupported`]. Contiguous, compact, and
3325    /// chunked (including filtered) datasets are all supported; the dataset's
3326    /// existing chunk geometry, filter pipeline, and chunk index are taken from the
3327    /// on-disk header. A chunk index this engine cannot enumerate (a version-2
3328    /// B-tree) is refused. Partial / sub-region writes are out of scope — the
3329    /// whole dataset is replaced.
3330    ///
3331    /// What the builder alone can rule out —
3332    /// [`refuse_unsupported_overwrite`](Self::refuse_unsupported_overwrite) —
3333    /// is refused *here* rather than at `commit`.
3334    ///
3335    /// When the new data is the same length as the existing contiguous data block
3336    /// (the common case), the bytes are written straight into that block: no
3337    /// object header is rewritten and the superblock root is not flipped, so the
3338    /// commit's linearization point is the synced data write itself. A chunked
3339    /// dataset is handled the same way when every (re-encoded) chunk is the same
3340    /// byte length as the slot it replaces — an unfiltered overwrite (chunk sizes
3341    /// are fixed by the unchanged shape) or a filtered one whose re-encoded chunks
3342    /// match — so it too writes straight into the existing chunk slots. When the
3343    /// length differs (a resized contiguous block, or a filtered chunk that no
3344    /// longer fits), the dataset's storage is rebuilt at end-of-file (or in
3345    /// reusable freed space), the old extent is freed, the data-layout message is
3346    /// repointed, the object header is rewritten, and the parent group's link is
3347    /// patched — exactly like an addition relocates the path up to the root. A
3348    /// relocating overwrite moves the object header, so it is refused unless the
3349    /// dataset has a single hard link.
3350    pub(crate) fn stage_dataset_write(
3351        &mut self,
3352        path: &str,
3353        mut builder: DatasetBuilder,
3354    ) -> Result<(), Error> {
3355        self.refuse_if_claimed(&split_path(path))?;
3356        let comps = split_path(path);
3357        // Before the flatten below, which would otherwise report the root as a
3358        // dataset with an empty name: the leaf of an empty path is what names
3359        // the builder.
3360        let Some(leaf) = comps.last() else {
3361            return Err(Error::EditUnsupported("cannot overwrite the root group"));
3362        };
3363        builder.name = leaf.clone();
3364        let fd = flatten_dataset(builder)?;
3365        Self::refuse_unsupported_overwrite(&fd)?;
3366        self.staged.writes.push((comps, fd));
3367        Ok(())
3368    }
3369
3370    /// Stage an append of new elements to an **existing** chunked, unlimited
3371    /// dataset, applied on the next [`commit`](Self::commit).
3372    ///
3373    /// `path` names a dataset that must already exist; `builder` supplies the
3374    /// elements to add via its typed / generic / raw `append_*` methods.
3375    ///
3376    /// Unlike [`stage_dataset_write`](Self::stage_dataset_write) (a value
3377    /// overwrite that forbids any shape change) this **grows** the dataset along
3378    /// its first (axis-0) dimension. It works on **filtered** datasets: the
3379    /// appended chunks are compressed through the dataset's own on-disk filter
3380    /// pipeline (deflate / shuffle / fletcher32 / scale-offset / LZF, and ZFP
3381    /// with the `zfp` feature), and the pipeline, datatype, fill value, and attributes are
3382    /// preserved verbatim. Appends of any length are supported — when the
3383    /// dataset's current length is not a whole multiple of the chunk length, the
3384    /// single trailing partial chunk is read, extended, and re-encoded; every
3385    /// other existing chunk is carried by metadata alone, so the existing data is
3386    /// not rewritten and the file does not grow by the whole dataset per append.
3387    ///
3388    /// This does **not** use SWMR and sets no consistency flag. Like every other
3389    /// staged edit it commits by appending the new chunks and a rebuilt
3390    /// index at end-of-file and repointing the superblock last (under the
3391    /// session's exclusive lock), so a crash leaves either the original dataset or
3392    /// the fully-grown one, never a torn state.
3393    ///
3394    /// The first release supports the Extensible-Array chunk index (the index the
3395    /// reference C library and h5py select for a single unlimited dimension under
3396    /// the latest format, and the one this crate writes for every unlimited
3397    /// dataset), rank-1 datasets, and datasets with a single hard link. A dataset
3398    /// that is not chunked, not unlimited along axis 0, not Extensible-Array
3399    /// indexed, higher than rank 1, uses a filter this engine cannot re-encode,
3400    /// has a sparse chunk grid, or (for [`append_raw`](AppendBuilder::append_raw))
3401    /// has a big-endian element datatype is refused with
3402    /// [`Error::AppendUnsupported`]. Use [`Dataset::is_chunked`](crate::Dataset::is_chunked),
3403    /// [`maxshape`](crate::Dataset::maxshape), and [`filters`](crate::Dataset::filters)
3404    /// to check eligibility up front.
3405    pub(crate) fn stage_dataset_append(
3406        &mut self,
3407        path: &str,
3408        builder: AppendBuilder,
3409    ) -> Result<(), Error> {
3410        let comps = split_path(path);
3411        self.refuse_if_claimed(&comps)?;
3412        if self.staged.dataset_at(&comps).is_some() {
3413            // This call came through a handle onto the object *in the file*: a
3414            // handle onto the staged creation goes to
3415            // [`stage_dataset_append_pending`] instead. So the elements could
3416            // only be meant for the object the same commit replaces, which is
3417            // never what the caller wants and is what `commit` refuses two calls
3418            // later. Said here, where the handle that asked is still in hand.
3419            return Err(Error::EditUnsupported(
3420                "a dataset is staged at this path in the same commit, so an append through a \
3421                 handle onto the object the file holds there could only grow the object being \
3422                 replaced; append through the handle that staged the creation, or commit first",
3423            ));
3424        }
3425        self.refuse_lossy_partial_tail(path, &builder)?;
3426        self.staged.appends.push((comps, builder));
3427        Ok(())
3428    }
3429
3430    /// Refuse, at the call that stages an append, a dataset whose lossy pipeline
3431    /// sits on a partial trailing chunk — the refusal
3432    /// [`prepare_append`](Self::prepare_append) raises during the commit's
3433    /// preflight, brought forward to where the caller can act on it (issue #407).
3434    ///
3435    /// Waiting for the commit makes that refusal permanent for the session and
3436    /// costs every edit staged beside it: a preflight failure restores the staged
3437    /// set, so the offending append is still there at the next
3438    /// [`commit`](crate::File::commit), and [`File::close`](crate::File::close)
3439    /// runs one and returns its `Err` — discarding the whole staged set on the way
3440    /// out. `BufferedAppender::new` refuses the same dataset eagerly for the same
3441    /// reason; this puts the staged path on the same footing.
3442    ///
3443    /// **Positive proof only.** Every reason the geometry cannot be read here — a
3444    /// path that does not resolve, a header this engine does not parse, a layout
3445    /// that is not chunked, a rank this release does not append to — leaves the
3446    /// answer to the preflight, which names each of them. Nothing about the bytes
3447    /// being appended enters into it: the on-disk length is what decides whether a
3448    /// trailing chunk has to be re-encoded, and staged appends never change that
3449    /// before the commit reads it. The commit-time check stays as the backstop,
3450    /// and is the only one that runs for an append staged through some other
3451    /// entry point.
3452    fn refuse_lossy_partial_tail(&self, path: &str, builder: &AppendBuilder) -> Result<(), Error> {
3453        // A zero-length append is dropped by the preflight before it ever reaches
3454        // `prepare_append`, so it stays a no-op here rather than becoming the one
3455        // append this refusal would newly reject.
3456        if builder.raw().is_empty() {
3457            return Ok(());
3458        }
3459        if self.appends_onto_a_lossy_partial_tail(path) {
3460            return Err(Error::AppendUnsupported(LOSSY_TAIL_REFUSAL));
3461        }
3462        Ok(())
3463    }
3464
3465    /// The geometry question behind [`refuse_lossy_partial_tail`](Self::refuse_lossy_partial_tail):
3466    /// does the file hold, at `path`, a rank-1 chunked dataset under a lossy
3467    /// filter pipeline whose length is not a whole multiple of its chunk length?
3468    /// `false` whenever that cannot be established, for whatever reason.
3469    fn appends_onto_a_lossy_partial_tail(&self, path: &str) -> bool {
3470        let Ok(addr) =
3471            crate::group_v2::resolve_path_any_from_source(&self.image(), &self.superblock, path)
3472        else {
3473            return false;
3474        };
3475        let Ok(region) =
3476            Self::gather_oh_messages(&self.image(), addr, self.superblock.base_address)
3477        else {
3478            return false;
3479        };
3480        let mut datatype: Option<(usize, usize)> = None;
3481        let mut dataspace: Option<(usize, usize)> = None;
3482        let mut layout: Option<(usize, usize)> = None;
3483        let mut filter: Option<(usize, usize)> = None;
3484        let mut p = 0;
3485        while let Ok(Some((msg_type, body, body_end))) = region.next_message(p) {
3486            match msg_type {
3487                MessageType::Datatype => datatype = Some((body, body_end)),
3488                MessageType::Dataspace => dataspace = Some((body, body_end)),
3489                MessageType::DataLayout => layout = Some((body, body_end)),
3490                MessageType::FilterPipeline => filter = Some((body, body_end)),
3491                _ => {}
3492            }
3493            p = body_end;
3494        }
3495        // No pipeline at all is nothing to re-encode, and an unparseable one is
3496        // refused by the preflight on its own terms.
3497        let (Some((fb, fe)), Some((dt_b, dt_e)), Some((ds_b, ds_e)), Some((lb, le))) =
3498            (filter, datatype, dataspace, layout)
3499        else {
3500            return false;
3501        };
3502        let Ok(pipeline) = FilterPipeline::parse(&region[fb..fe]) else {
3503            return false;
3504        };
3505        if pipeline_lossless(&pipeline) {
3506            return false;
3507        }
3508        let Ok((disk_dt, _)) = Datatype::parse(&region[dt_b..dt_e]) else {
3509            return false;
3510        };
3511        let Ok(disk_ds) = Dataspace::parse(&region[ds_b..ds_e], LENGTH_SIZE) else {
3512            return false;
3513        };
3514        let Ok(dl) = DataLayout::parse(&region[lb..le], OFFSET_SIZE, LENGTH_SIZE) else {
3515            return false;
3516        };
3517        // Rank 1 is the shape the preflight's `current_dim0 % chunk_elems`
3518        // arithmetic is about; anything else it refuses before reaching that test.
3519        if disk_ds.dimensions.len() != 1 {
3520            return false;
3521        }
3522        let Ok(geometry) = chunked_geometry(&disk_dt, &disk_ds, &dl) else {
3523            return false;
3524        };
3525        match (geometry.spatial.first(), disk_ds.dimensions.first()) {
3526            (Some(&chunk_elems), Some(&dim0)) if chunk_elems != 0 => dim0 % chunk_elems != 0,
3527            _ => false,
3528        }
3529    }
3530
3531    /// Stage an append made through a handle onto a dataset **this session
3532    /// staged and has not committed**, folding the elements into the pending
3533    /// creation.
3534    ///
3535    /// The caller establishes that its handle is the pending one;
3536    /// [`stage_dataset_append`](Self::stage_dataset_append) is the entry point
3537    /// for a handle onto an object the file already holds, and refuses the same
3538    /// path. Between the caller's check and this lock another thread can commit,
3539    /// which leaves the dataset in the file and nothing staged at its path — so
3540    /// the append becomes an ordinary staged one rather than an error.
3541    pub(crate) fn stage_dataset_append_pending(
3542        &mut self,
3543        path: &str,
3544        builder: AppendBuilder,
3545    ) -> Result<(), Error> {
3546        let comps = split_path(path);
3547        self.refuse_if_claimed(&comps)?;
3548        if self.staged.dataset_at(&comps).is_none() {
3549            // Nothing staged here after all, so these elements grow the object the
3550            // file holds — including its trailing chunk, which the same eager
3551            // refusal applies to. The fold below reaches no on-disk chunk at all
3552            // and is untouched by it.
3553            self.refuse_lossy_partial_tail(path, &builder)?;
3554            self.staged.appends.push((comps, builder));
3555            return Ok(());
3556        }
3557        self.refuse_mid_batch()?;
3558        self.extend_staged_dataset(&comps, &builder)
3559    }
3560
3561    /// Refuse an edit that changes or withdraws something already staged while a
3562    /// [`stage_atomically`](Self::stage_atomically) batch is open.
3563    ///
3564    /// [`StagedEdits::rewind`] undoes a batch by truncating the staged vectors,
3565    /// which is exact only while staging appends to them. The two operations
3566    /// that do otherwise — the fold
3567    /// [`stage_dataset_append_pending`](Self::stage_dataset_append_pending)
3568    /// makes, and the withdrawal [`delete`](Self::delete) makes — would leave a
3569    /// refused batch holding half of one. No caller reaches this today: a batch
3570    /// replays [`StagedOp`](crate::StagedGroup)s, which only ever create. It is
3571    /// here so the claim `rewind` rests on is enforced rather than remembered.
3572    fn refuse_mid_batch(&self) -> Result<(), Error> {
3573        if self.staging_batch {
3574            return Err(Error::EditUnsupported(
3575                "an edit that changes or withdraws an already-staged object cannot be made \
3576                 inside an atomic staging batch, whose undo drops additions only",
3577            ));
3578        }
3579        Ok(())
3580    }
3581
3582    /// Fold an append onto a dataset this same session staged and has not
3583    /// committed into the pending creation: the elements are added to the
3584    /// bytes the commit will write and the leading dimension grows to match.
3585    ///
3586    /// There is no dataset in the file to grow, so this is the same edit as
3587    /// having handed the elements to the builder in the first place — which is
3588    /// why it asks for neither an unlimited dimension nor a chunked layout,
3589    /// where an append onto a *committed* dataset needs both. Anything the
3590    /// staged creation must resolve at commit time per element — a
3591    /// variable-length string payload, an object reference — is refused: those
3592    /// carry a side table indexed by element that this cannot extend.
3593    ///
3594    /// A provenance dataset (`with_provenance`) is grown rather than refused:
3595    /// its attributes are derived from the bytes, so they are rebuilt from the
3596    /// grown ones ([`FlatDataset::rebuild_provenance`]) and the digest the
3597    /// commit writes is the digest of what it writes.
3598    fn extend_staged_dataset(
3599        &mut self,
3600        comps: &[String],
3601        builder: &AppendBuilder,
3602    ) -> Result<(), Error> {
3603        if builder.dt_conflict() {
3604            return Err(Error::AppendUnsupported(
3605                "this append mixes element datatypes; use one element type per append",
3606            ));
3607        }
3608        let fd = self
3609            .staged
3610            .dataset_at_mut(comps)
3611            .expect("caller checked a dataset is staged at this path");
3612        if fd.vl_string_staging.is_some() || fd.reference_targets.is_some() {
3613            return Err(Error::AppendUnsupported(
3614                "a staged variable-length-string or object-reference dataset cannot be appended \
3615                 to before it is committed: its per-element side table is built with the \
3616                 dataset. Supply every element through the builder that creates it",
3617            ));
3618        }
3619        if let Some(dt) = builder.elem_dt() {
3620            if *dt != fd.dt {
3621                return Err(Error::AppendUnsupported(
3622                    "appended element datatype does not match the staged dataset's datatype",
3623                ));
3624            }
3625        }
3626        let Some((lead, inner)) = fd.ds.dimensions.split_first() else {
3627            return Err(Error::AppendUnsupported(
3628                "a scalar dataset has no dimension to append along",
3629            ));
3630        };
3631        // One row of the leading dimension, in bytes: the element size times
3632        // every trailing extent. A rank-1 dataset's row is one element.
3633        let mut row_bytes = u64::from(fd.dt.type_size());
3634        for &d in inner {
3635            row_bytes = row_bytes.checked_mul(d).ok_or(Error::AppendUnsupported(
3636                "staged dataset row size overflows",
3637            ))?;
3638        }
3639        if row_bytes == 0 {
3640            return Err(Error::AppendUnsupported(
3641                "a staged dataset whose rows hold no bytes cannot be appended to",
3642            ));
3643        }
3644        let bytes = builder.raw();
3645        if bytes.len() as u64 % row_bytes != 0 {
3646            return Err(Error::AppendUnsupported(
3647                "appended byte length is not a whole number of rows of the staged dataset",
3648            ));
3649        }
3650        let grown = lead
3651            .checked_add(bytes.len() as u64 / row_bytes)
3652            .ok_or(Error::AppendUnsupported("staged dataset length overflows"))?;
3653        // A finite maximum is a promise the commit would refuse to break.
3654        if let Some(max) = fd
3655            .ds
3656            .max_dimensions
3657            .as_ref()
3658            .and_then(|m| m.first().copied())
3659        {
3660            if max != u64::MAX && grown > max {
3661                return Err(Error::AppendUnsupported(
3662                    "appending would grow the staged dataset past its maximum shape",
3663                ));
3664            }
3665        }
3666        fd.raw.extend_from_slice(bytes);
3667        fd.ds.dimensions[0] = grown;
3668        // The digest covers the bytes, so it is recomputed over the grown ones
3669        // rather than left describing the shorter dataset that was staged. Doing
3670        // it here, on the record the commit writes, is what keeps
3671        // `verify_provenance` passing on the file this produces.
3672        #[cfg(feature = "provenance")]
3673        fd.rebuild_provenance();
3674        Ok(())
3675    }
3676
3677    /// Register a live [`BufferedAppender`](crate::BufferedAppender) on `path`
3678    /// (`None` for a handle reached by object reference), returning the token
3679    /// that releases it.
3680    ///
3681    /// Refused when the claim could not be honored: a second appender on the
3682    /// same dataset would interleave the two buffers a chunk at a time, and a
3683    /// staged edit already pending on that path is one the appender's own flush
3684    /// would later refuse.
3685    pub(crate) fn claim_for_appender(&mut self, path: Option<&str>) -> Result<u64, Error> {
3686        let path = path.map(split_path);
3687        if self
3688            .appender_claims
3689            .iter()
3690            .any(|c| claims_conflict(c.path.as_deref(), path.as_deref()))
3691        {
3692            return Err(Error::EditUnsupported(
3693                "this dataset already has a live buffered appender; two of them would interleave \
3694                 their buffers a chunk at a time",
3695            ));
3696        }
3697        let blocked = match path.as_deref() {
3698            Some(p) => self.append_conflicts_with_pending(p),
3699            None => self.has_staged_edits() || self.committed,
3700        };
3701        if blocked {
3702            return Err(Error::EditUnsupported(
3703                "this session holds staged edits that would stop a buffered appender from \
3704                 flushing; commit or discard them before opening one",
3705            ));
3706        }
3707        let token = self.next_appender_token;
3708        self.next_appender_token += 1;
3709        self.appender_claims.push(AppenderClaim { token, path });
3710        Ok(token)
3711    }
3712
3713    /// Drop a claim. Called from the appender's `Drop`, after its final flush.
3714    pub(crate) fn release_appender_claim(&mut self, token: u64) {
3715        self.appender_claims.retain(|c| c.token != token);
3716    }
3717
3718    /// Refuse a staged edit at `path` that a live appender could not survive.
3719    ///
3720    /// An appender holds elements a caller has already been told were accepted,
3721    /// and only its own flush can write them; that flush goes through the
3722    /// immediate append path, which refuses a dataset with a staged edit on it or
3723    /// an ancestor. Left unchecked, staging such an edit turns accepted data into
3724    /// data lost silently in `Drop`. Refusing here moves the failure to the call
3725    /// that creates the conflict, where there is someone to report it to.
3726    fn refuse_if_claimed(&self, path: &[String]) -> Result<(), Error> {
3727        let conflicts = self.appender_claims.iter().any(|c| match &c.path {
3728            Some(p) => paths_overlap(p, path),
3729            None => true,
3730        });
3731        if conflicts {
3732            return Err(Error::EditUnsupported(
3733                "this dataset has a live buffered appender holding elements only its own flush \
3734                 can write, and this edit would stop that flush; finish or discard the appender \
3735                 first",
3736            ));
3737        }
3738        Ok(())
3739    }
3740
3741    /// Whether this session is the SWMR writer, whose append rules are a strict
3742    /// subset of the ordinary ones (see `append_inplace_gathered`).
3743    pub(crate) fn is_swmr(&self) -> bool {
3744        self.swmr_mode
3745    }
3746
3747    /// Whether any staged tree edit is still uncommitted. In-place appends
3748    /// ([`append_inplace_gathered`](Self::append_inplace_gathered)) are applied immediately and are
3749    /// never staged, so they never affect this; it reflects only edits awaiting
3750    /// [`commit`](Self::commit) — `create_group`, `create_dataset`,
3751    /// `write_dataset`, `append_dataset`, group and dataset attribute edits,
3752    /// `delete`, `copy`, and `copy_from`. Dropping the session silently discards
3753    /// any staged edits.
3754    ///
3755    /// A [`commit`](Self::commit) that refuses leaves this answering `true`: the
3756    /// staged set it declined to apply is put back exactly as it was.
3757    pub fn has_staged_edits(&self) -> bool {
3758        !self.staged.is_empty()
3759    }
3760
3761    /// Run `f`, and if it fails, drop whatever it managed to stage.
3762    ///
3763    /// One caller-facing call can stage many edits — `create_group_with` stages
3764    /// a group, its attributes and its whole subtree — and each is validated as
3765    /// it is staged, so the fifth dataset can be refused after four have been
3766    /// recorded. Without this, such a call would return `Err` having changed the
3767    /// session anyway, which is the half of issue #316 that lives one level
3768    /// above `commit`: a refused operation must cost the session nothing.
3769    pub(crate) fn stage_atomically<R>(
3770        &mut self,
3771        f: impl FnOnce(&mut Self) -> Result<R, Error>,
3772    ) -> Result<R, Error> {
3773        let mark = self.staged.mark();
3774        // Saved and restored rather than simply cleared, so a nested batch does
3775        // not lift the outer one's guard on its way out.
3776        let outer = std::mem::replace(&mut self.staging_batch, true);
3777        let result = f(self);
3778        self.staging_batch = outer;
3779        if result.is_err() {
3780            self.staged.rewind(mark);
3781        }
3782        result
3783    }
3784
3785    /// This session's file image as one slice, when its backing holds the whole
3786    /// file in memory; `None` for a file-backed image.
3787    ///
3788    /// The slice reflects committed state plus immediate in-place appends, not
3789    /// edits still staged for `commit`. The owned read-write
3790    /// [`File`](crate::File) uses it to serve reads by borrowing rather than
3791    /// copying, and falls back to [`image`](Self::image) when it is absent.
3792    pub(crate) fn image_slice(&self) -> Option<&[u8]> {
3793        self.image.as_slice()
3794    }
3795
3796    /// A random-access [`Source`] view of this session's file image, for the
3797    /// parsers the edit engine drives.
3798    ///
3799    /// Every read the engine performs against the file goes through this, so the
3800    /// image needs to be no more than a source of bytes — whole-file mirror or
3801    /// not — without the parsers knowing which (issue #198).
3802    pub(crate) fn image(&self) -> &dyn Source {
3803        self.image.as_ref()
3804    }
3805
3806    /// This session's parsed superblock, with `root_group_address` normalized to
3807    /// an absolute file offset (the open-time convention) and `base_address` the
3808    /// userblock size. A relocating commit updates it, so a caller holding a
3809    /// clone from an earlier moment may be reading a stale root.
3810    pub(crate) fn superblock(&self) -> &Superblock {
3811        &self.superblock
3812    }
3813
3814    /// The file's shared-message (SOHM) master table, or `None` for a file that
3815    /// shares no messages — which is every file this crate writes and nearly
3816    /// every file it is handed.
3817    ///
3818    /// Costs one small object-header parse per commit on a file that has a
3819    /// superblock extension at all, and nothing on one that does not.
3820    fn shared_message_table(&self) -> Result<Option<crate::sohm::SohmTable>, Error> {
3821        let rel = match self.superblock.superblock_extension_address {
3822            Some(rel) if rel != UNDEF => rel,
3823            _ => return Ok(None),
3824        };
3825        let base = self.superblock.base_address;
3826        let os = self.superblock.offset_size;
3827        let ls = self.superblock.length_size;
3828        // Best-effort down to here, and strict past it. A superblock extension
3829        // this engine cannot parse is one nothing in this crate reads a shared
3830        // message through either, so it is treated as a file that shares none;
3831        // once the extension is readable and *says* the file shares messages,
3832        // failing to read the table is an error, because the screen below is
3833        // then the difference between a refusal and a corrupted index.
3834        let Ok(abs) = base.absolute(rel) else {
3835            return Ok(None);
3836        };
3837        let Ok(header) = ObjectHeader::parse_from_source(&self.image(), abs, os, ls, base) else {
3838            return Ok(None);
3839        };
3840        let Some(msg) = header
3841            .messages
3842            .iter()
3843            .find(|m| m.msg_type == MessageType::SharedMessageTable)
3844        else {
3845            return Ok(None);
3846        };
3847        let message = crate::sohm::SharedMessageTableMessage::parse(&msg.data, os)?;
3848        let framed = BaseOffsetSource {
3849            inner: self.image(),
3850            base,
3851        };
3852        Ok(Some(crate::sohm::SohmTable::read_from_source(
3853            &framed, &message, os,
3854        )?))
3855    }
3856
3857    /// Refuse a commit that would strand a shared-message index record naming an
3858    /// object header. See [`SHARED_MESSAGE_INDEX_NAMES_A_MOVED_OBJECT`].
3859    fn screen_shared_message_index(&self, invalidated: &InvalidatedAddresses) -> Result<(), Error> {
3860        if invalidated.is_empty() {
3861            return Ok(());
3862        }
3863        let Some(table) = self.shared_message_table()? else {
3864            return Ok(());
3865        };
3866        let base = self.superblock.base_address;
3867        let os = self.superblock.offset_size;
3868        let ls = self.superblock.length_size;
3869        let framed = BaseOffsetSource {
3870            inner: self.image(),
3871            base,
3872        };
3873        for index in &table.indexes {
3874            let records = crate::sohm::read_index_records_from_source(&framed, index, os, ls)?;
3875            screen_shared_message_records(&records, invalidated)?;
3876        }
3877        Ok(())
3878    }
3879
3880    /// The on-disk format this session writes, read from the file it opened
3881    /// rather than chosen fresh, since a commit preserves the superblock version
3882    /// it found.
3883    ///
3884    /// This decides the *contiguous* data-layout message version, where the
3885    /// older number costs nothing: versions 3 and 4 have identical bodies there,
3886    /// so a dataset added to a 1.8 file stays readable by a 1.8 library.
3887    ///
3888    /// It does not gate chunked storage. A chunked dataset needs the version 4
3889    /// layout and a 1.10 chunk index whatever the superblock says, so adding one
3890    /// to an older file does make that file need 1.10 — deliberately, since the
3891    /// alternative is a version 1 B-tree index this crate does not write, and
3892    /// refusing instead would take away in-place editing of every file the C
3893    /// library wrote with its own default bounds. `crates/crosscheck/tests/edit.rs`
3894    /// covers exactly that case (issue #101).
3895    ///
3896    /// That is a default, not a verdict: a caller who needs the file to stay
3897    /// loadable by an old reader asks for it with
3898    /// [`FileAccessProperties::with_libver_bounds`], which sets
3899    /// [`libver_ceiling`](Self::libver_ceiling) and turns the addition into a
3900    /// refusal at commit rather than a silent format bump.
3901    ///
3902    /// [`FileAccessProperties::with_libver_bounds`]: crate::FileAccessProperties::with_libver_bounds
3903    pub(crate) fn libver(&self) -> LibVer {
3904        LibVer::from_superblock_version(self.superblock.version)
3905    }
3906
3907    /// Constrain what this session may add, from the fapl's library-version
3908    /// bounds. See [`libver_ceiling`](Self::libver_ceiling).
3909    ///
3910    /// Resolved through [`LibVer::resolve_writable`], the same rule the
3911    /// whole-file writer applies, so bounds admitting no format this crate
3912    /// writes are refused here as they are there rather than silently ignored on
3913    /// the editing path.
3914    pub(crate) fn set_libver_bounds(
3915        &mut self,
3916        bounds: Option<(LibVer, LibVer)>,
3917    ) -> Result<(), Error> {
3918        self.libver_ceiling = match bounds {
3919            Some(_) => Some(LibVer::resolve_writable(bounds).map_err(Error::Format)?),
3920            None => None,
3921        };
3922        Ok(())
3923    }
3924
3925    /// Refuse staged content the session's [`libver_ceiling`](Self::libver_ceiling)
3926    /// cannot express, before the commit writes anything.
3927    ///
3928    /// Only chunked storage is at stake: `build_chunked_dataset_oh` writes a
3929    /// version 4 data-layout message and a 1.10 chunk index unconditionally,
3930    /// while everything else this session adds is expressible in both formats
3931    /// (`libver` already picks the contiguous layout version). A filter or an
3932    /// unlimited dimension arrives as chunked storage, so they are covered here
3933    /// too.
3934    fn check_libver_admits<'a>(
3935        &self,
3936        datasets: impl IntoIterator<Item = &'a FlatDataset>,
3937    ) -> Result<(), Error> {
3938        let Some(ceiling) = self.libver_ceiling else {
3939            return Ok(());
3940        };
3941        if ceiling >= LibVer::V110 {
3942            return Ok(());
3943        }
3944        let chunked = datasets
3945            .into_iter()
3946            .any(|fd| fd.chunk_options.is_chunked() || fd.maxshape.is_some());
3947        if chunked {
3948            return Err(Error::Format(FormatError::LibverTooOldForContent {
3949                content: "a chunked, filtered, or resizable dataset",
3950                needs: LibVer::V110.name(),
3951                writing: ceiling.name(),
3952            }));
3953        }
3954        Ok(())
3955    }
3956
3957    /// Which backend this session resolved to: [`Bounded`] when it reads through
3958    /// a handle, [`Mirrored`] when it holds a whole-file image.
3959    ///
3960    /// [`Bounded`]: EditBacking::Bounded
3961    /// [`Mirrored`]: EditBacking::Mirrored
3962    pub(crate) fn edit_backing(&self) -> EditBacking {
3963        if self.bounded {
3964            EditBacking::Bounded
3965        } else {
3966            EditBacking::Mirrored
3967        }
3968    }
3969
3970    /// A snapshot of this session's live space usage — the current file size and
3971    /// the free space it can reuse — as a [`SpaceAccounting`].
3972    ///
3973    /// This is the mutating-session analogue of the read-only accounting on
3974    /// [`File`](crate::File): it answers "how big is the file right now, and how
3975    /// much space can be reused before it must grow?" from the session's own live
3976    /// state. The snapshot reflects the committed file plus any immediate in-place
3977    /// appends ([`append_inplace_gathered`](Self::append_inplace_gathered)) but excludes edits still
3978    /// staged for the next [`commit`](Self::commit); see [`SpaceAccounting`] for
3979    /// the field-by-field semantics and [`has_staged_edits`](Self::has_staged_edits)
3980    /// for detecting pending work.
3981    ///
3982    /// On a paged file (`H5F_FSPACE_STRATEGY_PAGE`) the reported regions are the
3983    /// union of the per-page-type managers, which a commit draws on under the rule
3984    /// paging exists for: an allocation takes a hole of the page type it is placing,
3985    /// or whole pages that are free of both, so reuse never re-mixes a page
3986    /// (issue #261). Space whose page type this file does not settle is recorded
3987    /// and handed back to the reference library but never spent, and is excluded
3988    /// from the reusable figure.
3989    ///
3990    /// ```no_run
3991    /// use hdf5_pure::File;
3992    ///
3993    /// let file = File::open_rw("existing.h5")?;
3994    /// let acct = file.space_accounting()?;
3995    /// println!(
3996    ///     "{} bytes on disk, {} reusable in {} free region(s)",
3997    ///     acct.logical_size,
3998    ///     acct.reusable_free_bytes,
3999    ///     acct.reusable_free_space.len(),
4000    /// );
4001    /// # Ok::<(), hdf5_pure::Error>(())
4002    /// ```
4003    #[must_use]
4004    pub fn space_accounting(&self) -> SpaceAccounting {
4005        // The append reserve is free space this session can still spend — it is
4006        // out of the on-disk managers, not out of the file — so leaving it out
4007        // would make an in-place append look like it had consumed bytes it has
4008        // not yet placed anything in (issue #387).
4009        //
4010        // It is folded back through `FreeList::free` rather than appended and
4011        // sorted, because a reserve span very often *abuts* what is left of the
4012        // hole it was drawn from — and the spent end of the reserve walks toward
4013        // that remainder until the two touch exactly. Reported as two regions
4014        // they would break the field's documented contract in the one way that
4015        // misleads: a caller sizing an allocation against the largest region
4016        // would be told nothing that big fits when it does.
4017        //
4018        // It goes back into the list it came from and no other. On a paged file
4019        // that is the raw list, which is what `take_raw_span` drew it out of;
4020        // the two page-type lists stay reported side by side as they always
4021        // were, because a caller cannot allocate across a page-type boundary and
4022        // merging them would claim it could. `FreeList::free`'s debug assertion
4023        // comes along and is welcome: the reserve is drawn out of exactly this
4024        // list, so an overlap here would mean that invariant had already broken.
4025        //
4026        // A paged file tracks its free space per page type; report the union of
4027        // the two, since the caller wants one total rather than a per-manager
4028        // breakdown.
4029        let reusable_free_space = match (&self.paged, self.reserved.is_empty()) {
4030            // The common case, and every non-persisting session: no reserve to
4031            // fold, so nothing is cloned.
4032            (Some(pg), true) => pg.reusable_sections(),
4033            (None, true) => self.free.sections(),
4034            (Some(pg), false) => {
4035                let mut raw = pg.raw.clone();
4036                for (addr, len) in self.reserved.sections() {
4037                    raw.free(addr, len);
4038                }
4039                let mut out = pg.meta.sections();
4040                out.extend(raw.sections());
4041                out.sort_unstable_by_key(|&(addr, _)| addr);
4042                debug_assert!(
4043                    out.windows(2).all(|w| w[0].0 < w[1].0),
4044                    "the same address is free in both the metadata and the raw \
4045                     list, so the two page-type lists have stopped being disjoint"
4046                );
4047                out
4048            }
4049            (None, false) => {
4050                let mut free = self.free.clone();
4051                for (addr, len) in self.reserved.sections() {
4052                    free.free(addr, len);
4053                }
4054                free.sections()
4055            }
4056        };
4057        let reusable_free_bytes = reusable_free_space.iter().map(|(_, len)| len).sum();
4058        SpaceAccounting {
4059            logical_size: self.image.len(),
4060            reusable_free_bytes,
4061            reusable_free_space,
4062        }
4063    }
4064
4065    /// The shared Extensible-Array append engine's view of this session: the
4066    /// image, the superblock, and the paged-file state, paired as [`EditStore`].
4067    ///
4068    /// Takes `&mut self`, so a caller that also needs [`located`](Self::located)
4069    /// borrowed at the same time must destructure the fields itself rather than
4070    /// call this.
4071    fn store(&mut self) -> EditStore<'_> {
4072        EditStore {
4073            image: self.image.as_mut(),
4074            superblock: &mut self.superblock,
4075            sb_sig_off: self.sb_sig_off,
4076            paged: self.paged.as_mut(),
4077            // This adapter's one caller only reads; nothing is allocated here.
4078            free: None,
4079            sync_policy: self.sync_policy,
4080        }
4081    }
4082
4083    /// Whether an immediate in-place append may allocate out of already-free
4084    /// space instead of growing the file (issue #349, issue #387).
4085    ///
4086    /// The staged [`commit`](Self::commit) always may: it publishes its
4087    /// allocations with the superblock repoint, so a crash before that leaves a
4088    /// file whose root never named them. An immediate append has no repoint, so
4089    /// what has to hold instead is that the bytes it overwrites are dead *on the
4090    /// disk as it stands*. Which list satisfies that depends on the session:
4091    ///
4092    /// - An ordinary [`File::open_rw`](crate::File::open_rw) session on a
4093    ///   default-strategy file holds its free space in memory alone. A region in
4094    ///   [`free`](Self::free) was freed by a commit this session already
4095    ///   published, so nothing on the disk points at it and nothing on the disk
4096    ///   claims it is free; a crash at any point of the append leaves it as dead
4097    ///   as it was. It is spent directly.
4098    /// - A file that **persists** its free-space managers records its holes on
4099    ///   disk, so a hole in `free` is one a durable manager still advertises and
4100    ///   spending it would leave that manager describing bytes a live chunk
4101    ///   occupies — through a clean close as much as a crash, since only a commit
4102    ///   or [`finalize_persist`](Self::finalize_persist) rewrites the record.
4103    ///   Such a session therefore spends [`reserved`](Self::reserved) instead:
4104    ///   space [`reserve_for_immediate_append`](Self::reserve_for_immediate_append)
4105    ///   has already taken *out* of the published managers.
4106    /// - A **paged** file keeps its free space per page type in [`PagedEdit`]
4107    ///   rather than in [`free`](Self::free), so drawing from that list would be
4108    ///   drawing from the wrong one and would put a byte of one kind in a page of
4109    ///   the other. It draws its reserve through [`PagedEdit::alloc_typed`] with
4110    ///   [`PageType::Raw`] instead, which is what keeps a page holding one kind.
4111    ///   Named as its own term at the call site rather than left to the argument
4112    ///   that the persist term already covers it (`append_prepare` refuses a
4113    ///   paged file that does not persist): a page-type invariant proved from
4114    ///   another function's refusal is exactly the shape that has gone wrong here
4115    ///   before (issue #261). It costs nothing to name, because `reserved` is
4116    ///   empty on a session that never reserved — the term declines reuse rather
4117    ///   than misdirecting it.
4118    ///
4119    /// The **SWMR** writer is the one absolute bar, and it is not about
4120    /// publication at all: its concurrent readers may still be inside a region
4121    /// this session freed, and the format forbids reusing one while they are,
4122    /// which is why the C library's own SWMR writer allocates append-only. No
4123    /// test can reach this term today, because a SWMR session refuses every
4124    /// staged edit ([`Error::SwmrStagedUnsupported`]) and so never frees
4125    /// anything. It is here so that lifting that refusal cannot silently enable
4126    /// reuse.
4127    ///
4128    /// One window is inherited rather than introduced. A commit whose superblock
4129    /// write itself fails leaves [`publish_attempted`](Self::publish_attempted)
4130    /// set, which withholds the free-list rollback, so the regions that commit
4131    /// freed stay in the list although whether it published is unknowable
4132    /// (issue #344). An append can now spend them where only a later commit could
4133    /// before. That is the same trade, on a larger surface: doing nothing is
4134    /// still the only answer that is never actively wrong.
4135    fn immediate_reuse_allowed(&self) -> bool {
4136        !self.swmr_mode
4137    }
4138
4139    /// Take a batch of free space out of the on-disk free-space managers so an
4140    /// immediate in-place append may spend it, for a session that persists them
4141    /// (issue #387). Afterwards [`reserved`](Self::reserved) holds whatever could
4142    /// be drawn; the allocator that spends it is what says whether that serves
4143    /// the append, since the rewrite's own tail may have taken part of it.
4144    ///
4145    /// The draw and the publication are one step and in that order: the bytes
4146    /// leave [`free`](Self::free) (or [`PagedEdit`]'s raw list) first, and the
4147    /// managers are then rewritten through the ordinary persisting commit tail
4148    /// with nothing to free and the root unchanged — the same call
4149    /// [`finalize_persist`](Self::finalize_persist) makes, whose superblock
4150    /// repoint is the crash-atomic linearization point. Before that repoint the
4151    /// old managers stand and still list the batch, which is correct because
4152    /// nothing has been written into it; after it, no durable record calls those
4153    /// bytes free and the append may have them.
4154    ///
4155    /// Nothing here moves a live object: `to_free` is empty and the new root is
4156    /// the old one, so the tail places only itself, in a hole or past the live
4157    /// data. The [`located`](Self::located) append-geometry cache is therefore
4158    /// still valid across this call, which is what lets it run between an
4159    /// append's plan and its apply.
4160    ///
4161    /// Draws nothing — not an error — when the session persists nothing to draw
4162    /// from, or when no hole can hold the allocation in hand; the caller simply
4163    /// grows the file, as it always did. An error means the manager rewrite
4164    /// itself failed, and the lists are put back so they agree with whatever the
4165    /// disk now holds, on the rule [`commit`](Self::commit) applies: a rewrite that
4166    /// never reached its superblock write left the managers as they were, so the
4167    /// lists go back to exactly what they were, tail placement included; one that
4168    /// did may have published, so the draw is handed back — those bytes are
4169    /// genuinely free, nothing having been written into them, and the next commit
4170    /// records them again — while the span its tail took stays out, since that
4171    /// tail may now be the live one.
4172    fn reserve_for_immediate_append(&mut self, want: u64) -> Result<(), Error> {
4173        if want == 0 || self.persist.is_none() || self.swmr_mode || self.reserved.largest() >= want
4174        {
4175            return Ok(());
4176        }
4177        let snapshot = self.snapshot_free();
4178        if !self.take_raw_spans(want, APPEND_RESERVE_BYTES) {
4179            return Ok(());
4180        }
4181        self.publish_attempted = false;
4182        match self.commit_persisting(
4183            self.superblock.root_group_address,
4184            Vec::new(),
4185            TailPlacement::Anywhere,
4186        ) {
4187            Ok(()) => Ok(()),
4188            Err(e) => {
4189                if self.publish_attempted {
4190                    self.release_reserve();
4191                } else {
4192                    self.restore_free(snapshot);
4193                }
4194                Err(e)
4195            }
4196        }
4197    }
4198
4199    /// Move raw-appendable free space into [`reserved`](Self::reserved) for one
4200    /// manager rewrite to publish: runs that can each hold `want` bytes, until
4201    /// `cap` bytes are held or no hole that large is left. Reports whether
4202    /// anything was drawn, which is exactly whether the first hole could hold
4203    /// `want`.
4204    ///
4205    /// The draw is sized on what the file has rather than on a fixed batch, and
4206    /// that is the whole of the fix for issue #413 — [`APPEND_RESERVE_BYTES`]
4207    /// says why the cap is a cap and not a floor. The rewrite that publishes a
4208    /// draw is the expensive part, so one draw gathers as many holes as the cap
4209    /// allows and the rewrite is paid once for all of them: a file fragmented
4210    /// into chunk-sized holes costs one rewrite per cap's worth of them, not one
4211    /// per chunk.
4212    ///
4213    /// Each run is sized on the largest hole and taken best fit, so it is a whole
4214    /// hole or a `cap`-bounded prefix of one, and the reserve never holds a
4215    /// fragment too small for the allocation that drew it. `want` above `cap`
4216    /// draws one run of exactly `want`: the cap bounds the amortization, not
4217    /// what a single allocation may need.
4218    fn take_raw_spans(&mut self, want: u64, cap: u64) -> bool {
4219        debug_assert!(want > 0, "a draw is sized on a real allocation");
4220        // The cap is the budget, except that a single allocation larger than it
4221        // still draws one run for itself.
4222        let budget = cap.max(want);
4223        let mut drawn = 0u64;
4224        while budget - drawn >= want {
4225            let available = self.largest_raw_run();
4226            if available < want {
4227                break;
4228            }
4229            let len = available.min(budget - drawn);
4230            if !self.take_raw_span(len) {
4231                // Sized on the allocator's own figure, so it serves; a refusal
4232                // here means the two disagree, and drawing nothing further is
4233                // the answer that leaves the file merely larger.
4234                debug_assert!(false, "a run of {len} bytes was reported and not served");
4235                break;
4236            }
4237            drawn += len;
4238        }
4239        drawn > 0
4240    }
4241
4242    /// The longest contiguous run [`take_raw_span`](Self::take_raw_span) could
4243    /// move into the reserve right now, or `0` when nothing could.
4244    fn largest_raw_run(&self) -> u64 {
4245        match self.paged.as_ref() {
4246            Some(pg) => pg.largest_typed(PageType::Raw),
4247            None => self.free.largest(),
4248        }
4249    }
4250
4251    /// Move `len` bytes of raw-appendable free space into
4252    /// [`reserved`](Self::reserved), reporting whether a contiguous run that
4253    /// large was available.
4254    ///
4255    /// A paged file draws through [`PagedEdit::alloc_typed`] with
4256    /// [`PageType::Raw`], the same call the staged commit makes for a dataset's
4257    /// data, so the reserve is raw-typed space and an append spending it cannot
4258    /// mix a page. A flat file has one list and draws from it.
4259    fn take_raw_span(&mut self, len: u64) -> bool {
4260        let addr = match self.paged.as_mut() {
4261            Some(pg) => pg.alloc_typed(len, PageType::Raw),
4262            None => self.free.alloc(len),
4263        };
4264        match addr {
4265            Some(addr) => {
4266                self.reserved.free(addr, len);
4267                true
4268            }
4269            None => false,
4270        }
4271    }
4272
4273    /// Give the unspent append reserve back to the lists the managers are written
4274    /// from, so the next rewrite records it as the free space it is.
4275    ///
4276    /// Called immediately before every rewrite of the on-disk managers — the
4277    /// persisting commit tail and [`finalize_persist`](Self::finalize_persist) —
4278    /// and nowhere else, so a commit that does *not* rewrite them (the
4279    /// same-length-overwrite fast path) leaves the reserve intact for the appends
4280    /// that follow it.
4281    ///
4282    /// The reserve is raw-typed on a paged file, so it goes back to the raw list.
4283    /// A run that was claimed whole from the metadata list is raw space now, and
4284    /// a page of it that is wholly free can be claimed back by either type
4285    /// through [`PagedEdit::alloc_typed`], exactly as any other free page can.
4286    fn release_reserve(&mut self) {
4287        if self.reserved.is_empty() {
4288            return;
4289        }
4290        let spans = std::mem::replace(&mut self.reserved, FreeList::new()).sections();
4291        match self.paged.as_mut() {
4292            Some(pg) => {
4293                for (addr, len) in spans {
4294                    pg.raw.free(addr, len);
4295                }
4296            }
4297            None => {
4298                for (addr, len) in spans {
4299                    self.free.free(addr, len);
4300                }
4301            }
4302        }
4303    }
4304
4305    /// Rewrite the on-disk free-space managers for a persisting commit, giving
4306    /// the unspent append reserve back first so the managers record it, and then
4307    /// give any trailing free space the rewrite exposed back to the filesystem.
4308    ///
4309    /// Every caller that publishes a *tree* takes this rather than
4310    /// [`commit_persisting`](Self::commit_persisting) directly. The one caller
4311    /// that does not is [`reserve_for_immediate_append`](Self::reserve_for_immediate_append),
4312    /// whose whole purpose is to publish managers that leave the reserve out.
4313    fn commit_persisting_releasing_reserve(
4314        &mut self,
4315        new_root: u64,
4316        to_free: Vec<(u64, u64, FreeClass)>,
4317    ) -> Result<(), Error> {
4318        self.release_reserve();
4319        self.commit_persisting(new_root, to_free, TailPlacement::Anywhere)?;
4320        self.shrink_to_the_trailing_free_run(new_root)
4321    }
4322
4323    /// Move a commit's manager blocks down into the space that commit freed, and
4324    /// truncate the file to them (issue #418).
4325    ///
4326    /// A commit that frees a run reaching end-of-file cannot land its own tail in
4327    /// that run: the regions it is vacating are still referenced by the *on-disk*
4328    /// root until the superblock repoint, so writing over them would destroy the
4329    /// pre-commit file if the process died in between. The tail therefore goes
4330    /// past end-of-file, above the very space that should have been given back,
4331    /// and the file cannot shrink — the delete-then-commit case of issue #418.
4332    ///
4333    /// Once that repoint is durable those regions are genuinely free, so a second
4334    /// tail-only rewrite can place the blocks inside them. It publishes through
4335    /// the same crash-atomic tail as any other commit, with nothing to free and
4336    /// the root unchanged, and the truncation follows its superblock write.
4337    ///
4338    /// It runs under [`TailPlacement::ReuseOnly`], so it places the tail in free
4339    /// space or writes nothing at all. That is the safety property: a pass taken
4340    /// for the sake of a smaller file can never leave a larger one.
4341    ///
4342    /// Skipped unless the last commit's tail is the highest thing in the file and
4343    /// the free run reaching it is long enough for [`release_trailing_run`] to
4344    /// release any of — the shape a delete leaves, and the condition that bounds
4345    /// this to one extra rewrite on the commits that can actually use it. A
4346    /// non-persisting file never gets here (its commit truncates directly), and
4347    /// neither does the SWMR writer, which
4348    /// [`open_swmr_writer`](crate::File::open_swmr_writer) refuses to open on a
4349    /// file that persists its free space at all.
4350    fn shrink_to_the_trailing_free_run(&mut self, new_root: u64) -> Result<(), Error> {
4351        let Some(persist) = self.persist.as_ref() else {
4352            return Ok(());
4353        };
4354        let (Some(tail_start), Some(tail_end)) = (
4355            persist.old_blocks.iter().map(|&(a, _)| a).min(),
4356            persist.old_blocks.iter().map(|&(a, l)| a + l).max(),
4357        ) else {
4358            return Ok(());
4359        };
4360        // Only a tail at the very end of the file is standing in the way of one.
4361        if tail_end != self.image.len() {
4362            return Ok(());
4363        }
4364        // The free run reaching up to it, plus the tail the rewrite frees, is what
4365        // the rewrite would have to release from. Held to the same threshold
4366        // `release_trailing_run` applies, since below it that call returns the
4367        // file's length unchanged and the pass would cost a rewrite to publish
4368        // exactly what the commit before it did.
4369        let run_start = match self.paged.as_ref() {
4370            Some(pg) => {
4371                trailing_run_start([&pg.meta, &pg.raw, &pg.dead, &pg.unclassified], tail_start)
4372            }
4373            None => trailing_run_start([&self.free], tail_start),
4374        };
4375        if tail_end - run_start < 2 * TRAILING_RESERVE_TAILS * (tail_end - tail_start) {
4376            return Ok(());
4377        }
4378        self.commit_persisting(new_root, Vec::new(), TailPlacement::ReuseOnly)
4379    }
4380
4381    /// Adopt the fapl's `fsync` cadence. Called once, on the funnel every
4382    /// read-write open passes through, before the session is handed out.
4383    pub(crate) fn set_sync_policy(&mut self, policy: SyncPolicy) {
4384        self.sync_policy = policy;
4385    }
4386
4387    /// The cadence this session adopted. Nothing in the crate branches on this —
4388    /// [`barrier`](Self::barrier) does that — but the fapl reaching the engine is
4389    /// otherwise invisible from outside, and an entry point that forgot to pass
4390    /// it on would look exactly like one that did.
4391    #[cfg(test)]
4392    pub(crate) fn sync_policy(&self) -> SyncPolicy {
4393        self.sync_policy
4394    }
4395
4396    /// Override how long this session's writes may sit in memory.
4397    ///
4398    /// The image is private to this module, and the only caller outside it is
4399    /// [`crate::crash_replay`], which sweeps the *same* workload under gathering
4400    /// and without it. That comparison is the answer to the obvious worry about
4401    /// making writes larger, so it is worth an accessor; nothing outside a test
4402    /// build has one.
4403    #[cfg(test)]
4404    pub(crate) fn set_write_buffering(&mut self, mode: WriteBuffering) -> Result<(), Error> {
4405        self.image.set_write_buffering(mode)
4406    }
4407
4408    /// Every write this session issued, as `(offset, length)` in the order it
4409    /// went out. Same reasoning as [`set_write_buffering`](Self::set_write_buffering):
4410    /// the image is private, and a test that asks whether a publish left the
4411    /// engine as one write has nowhere else to look.
4412    #[cfg(test)]
4413    pub(crate) fn issued_write_order(&self) -> Vec<(u64, u64)> {
4414        self.image.issued_write_order()
4415    }
4416
4417    /// The durability barrier a write path calls for, data and metadata both —
4418    /// issued unless this session's [`SyncPolicy`] leaves the `fsync` cadence to
4419    /// the application.
4420    ///
4421    /// Every such point routes through here rather than touching the image, so
4422    /// the policy is honored by construction at sites yet to be written.
4423    ///
4424    /// A barrier is an **ordering** point first and a durability point second,
4425    /// and only the second half is the policy's to skip. The writes gathered
4426    /// before it are issued here whatever the policy says, because the image
4427    /// issues gathered writes in address order: leave them gathered across a
4428    /// barrier and a commit's superblock — address 0 — goes to the disk *before*
4429    /// the content it names, which is the one order the whole tail is built to
4430    /// avoid. A write that then fails, or a process that dies mid-flush, would
4431    /// leave a superblock naming bytes that are not in the file, where before it
4432    /// left the previous file intact (issue #288).
4433    ///
4434    /// [`WriteBuffering::Session`] — an explicit page buffer — is the deliberate
4435    /// exception, and [`ordering_barrier`](crate::image::FileImage::ordering_barrier)
4436    /// is what encodes that: it does nothing under that mode, which is precisely
4437    /// the guarantee such a caller is trading away. What that caller gets back is
4438    /// a file that *says* so — see [`raise_crash_mark`](Self::raise_crash_mark).
4439    ///
4440    /// The teardown barrier is deliberately *not* one of these points:
4441    /// [`File::close`](crate::File::close) and `FileInner::drop` write after the
4442    /// last barrier a caller could have asked for, so they take
4443    /// [`force_sync`](Self::force_sync) instead.
4444    fn barrier(&mut self) -> Result<(), Error> {
4445        // Exhaustive on purpose, here and at the two sites below: `SyncPolicy` is
4446        // sealed to the outside but not to this crate, so a policy added later
4447        // fails to compile at every durability point until someone decides what
4448        // it means there.
4449        match self.sync_policy {
4450            // `sync_all` issues the gathered writes on its way to the disk.
4451            SyncPolicy::Always => self.image.sync_all(),
4452            SyncPolicy::OnClose => self.image.ordering_barrier(),
4453        }
4454    }
4455
4456    /// The data-only counterpart to [`barrier`](Self::barrier), for a write that
4457    /// does not move end-of-file and so needs no metadata flush. It orders the
4458    /// gathered writes for the same reason.
4459    fn barrier_data(&mut self) -> Result<(), Error> {
4460        barrier_data(self.image.as_mut(), self.sync_policy)
4461    }
4462
4463    /// Force this session's writes to durable storage *whatever* the policy
4464    /// says. The counterpart to [`barrier`](Self::barrier), which the policy may
4465    /// skip.
4466    ///
4467    /// Two callers: [`File::sync`](crate::File::sync), the application naming
4468    /// its own cadence, and the teardown path — `File::close` and
4469    /// `FileInner::drop` — whose own writes no caller can order, because both
4470    /// destroy the handle that would have done it.
4471    pub(crate) fn force_sync(&mut self) -> Result<(), Error> {
4472        self.image.sync_all()
4473    }
4474
4475    /// Rewrite the on-disk free-space managers for a file that persists them, if
4476    /// this session left them stale.
4477    ///
4478    /// Immediate in-place appends grow the file past the managers, leaving them
4479    /// describing a length the file no longer has, or spend a reserve those
4480    /// managers no longer list. A staged
4481    /// [`commit`](Self::commit) rewrites them as part of its tail, but a session
4482    /// that only appends never runs one, so [`File::close`](crate::File::close)
4483    /// and `FileInner::drop` call this instead. It is the same tail the commit
4484    /// writes — placed in space an earlier commit freed, or appended past
4485    /// everything live when none fits, with the superblock repoint as the
4486    /// crash-atomic linearization point — with nothing to free and the root
4487    /// unchanged.
4488    ///
4489    /// Where those blocks end up is not something a reader has to care about, and
4490    /// after this change usually is not end-of-file: a file with free space to
4491    /// spend has its managers inside it, above and below live data alike. What has
4492    /// to hold is that they describe the file as it now stands, which is what makes
4493    /// this call's own writes the last the session issues.
4494    ///
4495    /// It is also where a session's last release of trailing free space happens,
4496    /// since it takes the same tail as a commit (issue #418).
4497    ///
4498    /// A no-op for a non-persisting file, and skipped when the file has not grown
4499    /// past the managers since they were last written *and* the session holds no
4500    /// unspent append reserve, so an unchanged session never grows the file. The
4501    /// reserve is the second condition because an append that spent a hole rather
4502    /// than the end of the file leaves the length alone, and what it did not
4503    /// spend is space the managers must be told about again (issue #387).
4504    ///
4505    /// If a session that grew the file ends without `close` or `drop` running (a
4506    /// true crash — `SIGKILL`, power loss), the managers are left describing the
4507    /// shorter file, and an unspent reserve is left out of them altogether. Every
4508    /// append was durable and crash-atomic, so no data is lost, and both this
4509    /// crate and the reference C library reopen the file and read it correctly;
4510    /// the managers simply under-report until a clean rewrite, which wastes those
4511    /// bytes and never hands them out twice.
4512    pub(crate) fn finalize_persist(&mut self) -> Result<(), Error> {
4513        if self.persist.is_none() || (self.image.len() == self.fsm_len && self.reserved.is_empty())
4514        {
4515            return Ok(());
4516        }
4517        self.commit_persisting_releasing_reserve(self.superblock.root_group_address, Vec::new())
4518    }
4519
4520    /// Resolve and locate an in-place append target, applying every rule that
4521    /// does not depend on the bytes being appended: the file-level eligibility
4522    /// guards, the staged-edit conflict check, and the geometry lookup that
4523    /// populates [`located`](Self::located). Returns the dataset's object-header
4524    /// address, which is that cache's key.
4525    ///
4526    /// Split out from the append itself so [`append_geometry`](Self::append_geometry)
4527    /// can report a dataset's batching geometry under exactly the same rules the
4528    /// append will apply — a caller slicing a large append into batches must be
4529    /// refused before the first batch, not part-way through.
4530    fn append_prepare(&mut self, target: AppendTarget<'_>) -> Result<u64, Error> {
4531        // The fast in-place append is only sound on a base-0 latest-format file:
4532        // the slot math assumes absolute addresses and the superblock is patched in
4533        // place per call. A userblock or pre-v2 file falls back to the staged
4534        // `append_dataset`, which rebuilds the index and repoints the superblock
4535        // last.
4536        if !self.superblock.base_address.is_zero() {
4537            return Err(Error::AppendInPlaceUnsupported(
4538                "in-place append does not support a file with a userblock (non-zero base \
4539                 address); use Dataset::append_staged",
4540            ));
4541        }
4542        if self.superblock.version < 2 {
4543            return Err(Error::AppendInPlaceUnsupported(
4544                "in-place append requires a latest-format file (v2/v3 superblock); use \
4545                 Dataset::append_staged",
4546            ));
4547        }
4548        // A paged file (`H5F_FSPACE_STRATEGY_PAGE`) that does not persist its free
4549        // space has no on-disk record of which pages hold metadata and which hold
4550        // raw data, so neither this immediate append nor the staged commit can keep
4551        // the two segregated; refuse it outright. A paged *persisting* file appends
4552        // through the page-aware `EditStore`, which pads a tail page whenever the
4553        // page type changes, and has its managers rewritten at the next commit or
4554        // at close (issue #198).
4555        if self.paged.is_some() && self.persist.is_none() {
4556            return Err(Error::AppendInPlaceUnsupported(
4557                "in-place append is not supported on a paged file \
4558                 (H5F_FSPACE_STRATEGY_PAGE) without persisted free space; recreate the \
4559                 file with with_file_space_strategy(FileSpaceStrategy::Page, true, ..)",
4560            ));
4561        }
4562
4563        // Refuse an append against a dataset (or a subtree) that a still-staged edit
4564        // in this same session will relocate, replace, or delete — which would
4565        // strand the durably-appended rows or plan against a header the commit
4566        // moves. The caller must commit those edits first.
4567        //
4568        // A target named by object-header address cannot be compared against the
4569        // staged paths, so any staged edit at all disqualifies it. That is a
4570        // superset of the path check, and the remedy is the same one.
4571        match target {
4572            AppendTarget::Path(dataset) => {
4573                if self.append_conflicts_with_pending(&split_path(dataset)) {
4574                    return Err(Error::AppendInPlaceUnsupported(
4575                        "the dataset or an ancestor has a staged edit pending in this session; \
4576                         commit the staged edits before appending in place, or use \
4577                         Dataset::append_staged",
4578                    ));
4579                }
4580            }
4581            AppendTarget::Header(_) if self.has_staged_edits() || self.committed => {
4582                return Err(Error::AppendInPlaceUnsupported(
4583                    "this append target was reached by object reference, so it names a dataset \
4584                     by object-header address, and this session has staged or committed edits \
4585                     that can move that header; re-open the dataset by path to append to it",
4586                ));
4587            }
4588            AppendTarget::Header(_) => {}
4589        }
4590
4591        // Resolve the dataset's object-header address — the geometry cache key.
4592        // base == 0 here, so a resolved address is absolute; two hard links to
4593        // one dataset share the one entry.
4594        let oh_addr = match target {
4595            AppendTarget::Path(dataset) => match self.resolved.get(dataset) {
4596                Some(&addr) => addr,
4597                None => {
4598                    let addr = crate::group_v2::resolve_path_any_from_source(
4599                        &self.image(),
4600                        &self.superblock,
4601                        dataset,
4602                    )
4603                    .map_err(|_| {
4604                        Error::AppendInPlaceUnsupported("nothing to append to at the given path")
4605                    })?;
4606                    self.resolved.insert(dataset.to_string(), addr);
4607                    addr
4608                }
4609            },
4610            AppendTarget::Header(addr) => addr,
4611        };
4612
4613        // Locate the dataset on the first append (cache miss) against the session's
4614        // own image — no second lock, no second view of the file, no re-read.
4615        if !self.located.contains_key(&oh_addr) {
4616            let store = self.store();
4617            let state = locate_dataset_state(&store, oh_addr)?;
4618            self.located.insert(oh_addr, state);
4619        }
4620        Ok(oh_addr)
4621    }
4622
4623    /// The append geometry of the dataset `target` names, so a caller can slice a
4624    /// large append into aligned batches *before* materializing each batch's
4625    /// bytes — which is what keeps a bounded session's peak memory at one batch
4626    /// rather than the whole call.
4627    pub(crate) fn append_geometry(
4628        &mut self,
4629        target: AppendTarget<'_>,
4630    ) -> Result<AppendGeometry, Error> {
4631        let oh_addr = self.append_prepare(target)?;
4632        let st = &self.located[&oh_addr];
4633        let chunk_elems = st.loc.chunk_elems.max(1);
4634        Ok(AppendGeometry {
4635            chunk_elems,
4636            element_size: st.element_size,
4637            current_dim: st.loc.current_dim,
4638            lossy_filters: st.pipeline.as_ref().is_some_and(|p| !pipeline_lossless(p)),
4639            full_batch_elems: self.batch_elems(st.loc.chunk_bytes, chunk_elems),
4640        })
4641    }
4642
4643    /// Whole-chunk elements in one append batch.
4644    ///
4645    /// A bounded session caps a batch at [`APPEND_BATCH_BYTES`] of raw data (at
4646    /// least one chunk) so peak memory is independent of the call size, at the
4647    /// cost of splitting one crash-atomic append into several: a crash between
4648    /// batches leaves a valid shorter dataset, exactly as if the caller had
4649    /// looped. A mirror session already holds the whole file, so bounding the
4650    /// call buys nothing there and would trade that atomicity away for free;
4651    /// it takes the whole append as one batch.
4652    fn batch_elems(&self, chunk_bytes: usize, chunk_elems: u64) -> u64 {
4653        if !self.batched_appends {
4654            return u64::MAX;
4655        }
4656        (APPEND_BATCH_BYTES / (chunk_bytes.max(1) as u64)).max(1) * chunk_elems
4657    }
4658
4659    /// Apply a gathered in-place append (typed / generic / raw bytes) to the
4660    /// dataset `target` names, immediately and crash-atomically, driving the
4661    /// shared Extensible-Array engine against the session's own image through an
4662    /// [`EditStore`] adapter. Runs only the first `max_phase` durability phases;
4663    /// production callers pass 4, the crash-consistency tests stop at a boundary
4664    /// to simulate a crash.
4665    ///
4666    /// A bounded session splits the call into whole-chunk batches (see
4667    /// [`batch_elems`](Self::batch_elems)), each its own crash-atomic apply.
4668    /// Every predictable refusal is raised before the first batch, so a rejected
4669    /// append leaves the file untouched rather than partly grown.
4670    ///
4671    /// `Dataset::append` slices its own call the same way before it reaches here,
4672    /// so through the public API this loop runs once per call; it batches for the
4673    /// benefit of a caller that hands the engine one large builder directly, whose
4674    /// bytes are already materialized but whose plan need not be.
4675    pub(crate) fn append_inplace_gathered(
4676        &mut self,
4677        target: AppendTarget<'_>,
4678        b: &AppendBuilder,
4679        max_phase: u8,
4680    ) -> Result<(), Error> {
4681        if b.dt_conflict() {
4682            return Err(Error::AppendInPlaceUnsupported(
4683                "append mixes element types in one call; use one element type per append",
4684            ));
4685        }
4686        let oh_addr = self.append_prepare(target)?;
4687
4688        // Validate the appended bytes against the on-disk datatype.
4689        let raw = b.raw();
4690        let new_elems = validate_gathered_append(&self.located[&oh_addr], b)?;
4691        if new_elems == 0 {
4692            return Ok(());
4693        }
4694
4695        // In SWMR mode, hold to the subset a concurrent reader can follow safely:
4696        // unfiltered and chunk-aligned, so an append only ever inserts new,
4697        // not-yet-visible elements and never rewrites a visible trailing chunk
4698        // out from under a reader. Outside SWMR the session's exclusive lock is
4699        // what makes that rewrite safe; see `plan_ea_append`.
4700        if self.swmr_mode {
4701            let st = &self.located[&oh_addr];
4702            if st.pipeline.is_some() {
4703                return Err(Error::SwmrAppendUnsupported(
4704                    "filtered datasets are not supported for SWMR append",
4705                ));
4706            }
4707            let chunk_elems = st.loc.chunk_elems;
4708            if chunk_elems == 0
4709                || st.loc.current_dim % chunk_elems != 0
4710                || new_elems % chunk_elems != 0
4711            {
4712                return Err(Error::SwmrAppendUnsupported(
4713                    "SWMR append must be chunk-aligned: the current length and the appended \
4714                     length must both be whole multiples of the chunk length",
4715                ));
4716            }
4717        }
4718
4719        let (chunk_elems, elem_bytes, full_batch_elems) = {
4720            let st = &self.located[&oh_addr];
4721            (
4722                st.loc.chunk_elems.max(1),
4723                st.element_size.get() as u64,
4724                self.batch_elems(st.loc.chunk_bytes, st.loc.chunk_elems.max(1)),
4725            )
4726        };
4727        // Outside SWMR the session holds an exclusive lock, so re-encoding a
4728        // filtered partial trailing chunk into a fresh allocation and repointing
4729        // its index element is a change no other reader is crossing; see
4730        // `plan_ea_append`, which owns that argument and keeps the refusal for
4731        // the SWMR writer.
4732        let grow_visible_tail = !self.swmr_mode;
4733
4734        let mut done = 0u64;
4735        while done < new_elems {
4736            // Fill the trailing partial chunk first (so later batches start
4737            // chunk-aligned and never rewrite it again), then whole-chunk batches.
4738            // That first batch is the only one that can re-encode a filtered
4739            // trailing chunk; every batch after it starts on a boundary.
4740            let current_dim = self.located[&oh_addr].loc.current_dim;
4741            let to_boundary = (chunk_elems - current_dim % chunk_elems) % chunk_elems;
4742            let take = (new_elems - done).min(to_boundary.saturating_add(full_batch_elems));
4743            let batch =
4744                &raw[(done * elem_bytes).to_usize()?..((done + take) * elem_bytes).to_usize()?];
4745
4746            // Read/plan phase (immutable borrows only, nothing published yet), then
4747            // the ordered, fsync-barriered write phase — both shared with
4748            // `Dataset::append` through the chunk-index engine. `EditStore` borrows
4749            // only the image-carrying fields, so `self.located` stays independently
4750            // borrowable.
4751            let plan_result = {
4752                let Self {
4753                    image,
4754                    superblock,
4755                    sb_sig_off,
4756                    paged,
4757                    located,
4758                    sync_policy,
4759                    ..
4760                } = self;
4761                let st = &located[&oh_addr];
4762                let store = EditStore {
4763                    image: image.as_mut(),
4764                    superblock,
4765                    sb_sig_off: *sb_sig_off,
4766                    paged: paged.as_mut(),
4767                    // Planning only reads; nothing is allocated here.
4768                    free: None,
4769                    sync_policy: *sync_policy,
4770                };
4771                plan_ea_append(
4772                    &store,
4773                    &st.loc,
4774                    &st.datatype,
4775                    &st.spatial,
4776                    st.element_size,
4777                    st.pipeline.as_ref(),
4778                    grow_visible_tail,
4779                    batch,
4780                    take,
4781                    st.fill.pattern(st.element_size),
4782                )
4783            };
4784            let plan = plan_result.map_err(as_inplace_error)?;
4785            // A persisting file's holes are advertised on disk, so this batch's
4786            // bytes have to be taken out of the published managers before any of
4787            // them is written into. Sized on the largest single blob, since the
4788            // allocator serves each one from a contiguous run: a reserve that
4789            // cannot hold the biggest is a reserve the biggest goes past. A
4790            // refusal here (nothing left to draw, or nothing to draw from) is not
4791            // an error — the append grows the file, as it always did. Placed
4792            // between the plan and the apply because the manager rewrite it makes
4793            // publishes a superblock, and nothing may be half-applied across that.
4794            let largest_blob = plan
4795                .new_chunk_bytes
4796                .iter()
4797                .map(|b| b.len() as u64)
4798                .max()
4799                .unwrap_or(0);
4800            self.reserve_for_immediate_append(largest_blob)?;
4801            {
4802                let reuse = self.immediate_reuse_allowed();
4803                // Two terms, not one. A **paged** session must never be handed
4804                // `free`: it keeps its space per page type in `PagedEdit`, so
4805                // that list is the wrong one to draw from and placing a byte out
4806                // of it would mix a page. Every paged session is a persisting one
4807                // (`append_prepare` refuses a paged file without persistence), so
4808                // the first term alone would pick `reserved` for it anyway — and
4809                // that is exactly the shape issue #261 was: a page-type invariant
4810                // proved from a refusal in another function. Named here so the
4811                // safety is by construction, and so deleting that refusal cannot
4812                // silently turn a paged append loose on the flat list. `reserved`
4813                // is empty on any session that never reserved, which makes the
4814                // extra term cost nothing but the reuse it correctly declines.
4815                let from_reserve = self.persist.is_some() || self.paged.is_some();
4816                let Self {
4817                    image,
4818                    superblock,
4819                    sb_sig_off,
4820                    paged,
4821                    located,
4822                    free,
4823                    reserved,
4824                    sync_policy,
4825                    ..
4826                } = self;
4827                let st = located.get_mut(&oh_addr).expect("dataset located above");
4828                let mut store = EditStore {
4829                    image: image.as_mut(),
4830                    superblock,
4831                    sb_sig_off: *sb_sig_off,
4832                    paged: paged.as_mut(),
4833                    // A persisting or paged session spends only what the reserve
4834                    // took out of the managers; every other session spends its
4835                    // in-memory list directly. See `immediate_reuse_allowed`.
4836                    free: reuse.then_some(if from_reserve { reserved } else { free }),
4837                    sync_policy: *sync_policy,
4838                };
4839                apply_ea_append(&mut store, &mut st.loc, &plan, max_phase)
4840                    .map_err(as_inplace_error)?;
4841            }
4842            if max_phase < 4 {
4843                // Crash-consistency hook: the caller asked to stop inside the
4844                // first batch's durability sequence, so there is no next batch.
4845                // Each phase's own barrier has already issued the writes up to it
4846                // under either policy, which is what leaves a phase boundary a
4847                // real one for the tests that stop at it.
4848                return Ok(());
4849            }
4850            done += take;
4851        }
4852        Ok(())
4853    }
4854
4855    /// Test-only phased in-place append (stops after `max_phase` durability phases)
4856    /// used by the crash-consistency tests, mirroring `Dataset::append`'s harness.
4857    #[cfg(test)]
4858    fn append_inplace_i32_phased(
4859        &mut self,
4860        dataset: &str,
4861        values: &[i32],
4862        max_phase: u8,
4863    ) -> Result<(), Error> {
4864        let mut b = AppendBuilder::new();
4865        b.append_i32(values);
4866        self.append_inplace_gathered(AppendTarget::Path(dataset), &b, max_phase)
4867    }
4868
4869    /// Whether `target` (an [`append_inplace_gathered`](Self::append_inplace_gathered) dataset path)
4870    /// or any of its ancestors is named by a staged edit that a later
4871    /// [`commit`](Self::commit) would relocate, replace, or delete. `create_group`
4872    /// and group-attribute edits are excluded: they rewrite a group header without
4873    /// moving a descendant dataset's header or freeing its storage, so they cannot
4874    /// stale the append geometry cache.
4875    fn append_conflicts_with_pending(&self, target: &[String]) -> bool {
4876        let hits = |p: &[String]| paths_overlap(target, p);
4877        self.staged.writes.iter().any(|(p, _)| hits(p))
4878            || self.staged.appends.iter().any(|(p, _)| hits(p))
4879            || self.staged.deletes.iter().any(|p| hits(p))
4880            || self.staged.copies.iter().any(|(_, dst)| hits(dst))
4881            || self.staged.cross_copies.iter().any(|(dst, _)| hits(dst))
4882            || self.staged.dataset_attrs.iter().any(|(p, _)| hits(p))
4883            || self.staged.datasets.iter().any(|(parent, fd)| {
4884                let mut full = parent.clone();
4885                full.push(fd.name.clone());
4886                paths_overlap(target, &full)
4887            })
4888    }
4889
4890    /// What this session has staged at `path`, for a handle that wants to
4891    /// address a not-yet-committed object by name.
4892    ///
4893    /// Only a *creation* answers, and only when it owns the path — when the file
4894    /// holds no link there, or when this same commit removes the one it does.
4895    /// Both halves matter:
4896    ///
4897    /// - A staged **deletion** on its own hides nothing. The object is in the
4898    ///   file and still readable, and a lookup that pretended otherwise would
4899    ///   refuse a read the file can serve.
4900    /// - A creation **colliding** with a link the commit does not remove is one
4901    ///   [`refuse_creation_collision`](Self::refuse_creation_collision) turns
4902    ///   away where it is staged, so this arm is the same rule asked a second
4903    ///   time rather than a state a caller can reach. Reporting such a creation
4904    ///   would shadow a live, readable object with a handle that never becomes
4905    ///   valid, so the file's own object stays the meaning of the name.
4906    /// - The two together are a **replacement** (issue #305), and the creation
4907    ///   is what the path names from the moment it is staged, before the commit
4908    ///   as after. "The two together" is
4909    ///   [`StagedEdits::deletes_hand_over`]'s rule: the deletion names this path,
4910    ///   or an ancestor this session builds again.
4911    ///
4912    /// Only a path the session **named** answers. A commit also creates the
4913    /// intermediate groups on the way to an addition — `create_dataset("a/b")`
4914    /// gets an `a` whether or not one was asked for — and those are *not*
4915    /// reported, because a session cannot tell from its staged set alone whether
4916    /// such a group is one it is adding or one already in the file. Reporting
4917    /// them would hand a caller a not-yet-committed handle onto a group they can
4918    /// read today, which is the worse of the two mistakes; stage the group by
4919    /// name to address it before the commit.
4920    pub(crate) fn staged_object(&self, path: &str) -> Option<StagedObject> {
4921        if self.stages_no_creations() {
4922            return None;
4923        }
4924        let comps = split_path(path);
4925        if comps.is_empty() {
4926            // The root always exists; nothing can stage it.
4927            return None;
4928        }
4929        let kind = if self.staged.dataset_at(&comps).is_some() {
4930            StagedKind::Dataset
4931        } else if self.staged.has_group_at(&comps) {
4932            StagedKind::Group
4933        } else {
4934            return None;
4935        };
4936        let replaces_link = self.staged.deletes_hand_over(&comps);
4937        // The file is asked only about a path a creation actually names, so an
4938        // ordinary open of an object nothing is staged at never pays for this.
4939        if !replaces_link && self.path_in_file(&comps) {
4940            return None;
4941        }
4942        Some(StagedObject {
4943            kind,
4944            replaces_link,
4945        })
4946    }
4947
4948    /// Refuse a creation at a path something already owns — a link the file
4949    /// holds and this session does not remove, or a creation this session
4950    /// already staged — at the call that stages it.
4951    ///
4952    /// The commit refuses the same batch (`a link with this name already
4953    /// exists`), and the file arm raises that refusal's own error so the two
4954    /// cannot disagree about what a collision is. Refusing here is what lets
4955    /// [`Group::create_dataset`](crate::Group::create_dataset) and its two
4956    /// siblings hand back a handle onto the object they stage: a creation that
4957    /// merely collides is not what its path names — the file's object still is
4958    /// — so a handle onto it would have addressed the *old* object while
4959    /// claiming to address the new one.
4960    ///
4961    /// A creation this session already stages at the path is refused for the
4962    /// same reason, one the commit's own refusal cannot stand in for. The staged
4963    /// set is indexed by path and the index keeps the *first* record at each one
4964    /// (`or_insert`), so a second creation is staged behind the first and every
4965    /// handle onto that path — the one this call would hand back included —
4966    /// answers `shape`, `dtype`, `filters` and `append_staged` from the record
4967    /// the caller did not just describe. No committed object is misaddressed,
4968    /// since the commit refuses the batch, but the window between the two is one
4969    /// where a handle reports another object's geometry as its own.
4970    ///
4971    /// Two *group* creations at one path are the exception and stay allowed:
4972    /// they name one node, the commit builds one group from them, and a handle
4973    /// onto either addresses that group. Re-staging a group is how a caller adds
4974    /// attributes or children to one it already staged.
4975    ///
4976    /// A [`copy`](Self::copy) destination is still the commit's collision to
4977    /// refuse: it hands back no handle, so nothing can be misaddressed by it.
4978    fn refuse_creation_collision(&self, comps: &[String], kind: StagedKind) -> Result<(), Error> {
4979        // An empty path names the root, which is not a link and cannot be
4980        // created. Both callers already refuse it by name — with a message that
4981        // says so — so this must not answer first with a collision.
4982        if comps.is_empty() {
4983            return Ok(());
4984        }
4985        // Asked before the file, so that staging twice over a link this session
4986        // deletes — a replacement, which the file arm below lets through — is
4987        // caught by the same rule as staging twice over nothing.
4988        if self.staged.dataset_at(comps).is_some()
4989            || (kind == StagedKind::Dataset && self.staged.has_group_at(comps))
4990        {
4991            return Err(Error::EditUnsupported(
4992                "this session already stages an object with this name in the target group; \
4993                 delete it to withdraw that staging before staging another in its place",
4994            ));
4995        }
4996        if self.staged.deletes_hand_over(comps) || !self.path_in_file(comps) {
4997            return Ok(());
4998        }
4999        Err(Error::EditUnsupported(
5000            "a link with this name already exists in the target group",
5001        ))
5002    }
5003
5004    /// Whether the file this session is editing holds a link at `path`, as of
5005    /// its committed state.
5006    ///
5007    /// Resolved against a borrowed slice where the backing can lend one, as the
5008    /// reader's own path resolution does: this is asked once per by-name lookup
5009    /// of a path a creation is staged at, so the copy a `Source` read would make
5010    /// per link block is worth avoiding.
5011    fn path_in_file(&self, path: &[String]) -> bool {
5012        let joined = path.join("/");
5013        match self.image.as_slice() {
5014            Some(data) => {
5015                crate::group_v2::resolve_path_any(data, &self.superblock, &joined).is_ok()
5016            }
5017            None => crate::group_v2::resolve_path_any_from_source(
5018                &self.image(),
5019                &self.superblock,
5020                &joined,
5021            )
5022            .is_ok(),
5023        }
5024    }
5025
5026    /// Whether this session has staged no creations at all, which is the common
5027    /// case on the by-name lookup path. Checked before splitting a path into
5028    /// components, so an ordinary open of an existing object does not allocate
5029    /// one per call to ask a question whose answer is already known.
5030    fn stages_no_creations(&self) -> bool {
5031        self.staged.groups.is_empty() && self.staged.datasets.is_empty()
5032    }
5033
5034    /// How many times a commit has consumed this session's staged set, rather
5035    /// than handing it back to it. See the
5036    /// [`staged_generation`](Self::staged_generation) field for the rule, and
5037    /// [`crate::Error::StagingWithdrawn`] for what a handle does with it.
5038    pub(crate) fn staged_generation(&self) -> u64 {
5039        self.staged_generation
5040    }
5041
5042    /// What a staged dataset at `path` says about itself, for the introspection
5043    /// a handle onto it can answer before the commit writes any bytes.
5044    ///
5045    /// Cloned out of the staged [`FlatDataset`], which is the whole record of
5046    /// what the commit will write, so these answers cannot drift from the
5047    /// dataset that lands. Answers only for a creation that owns its path, on
5048    /// the terms [`staged_object`](Self::staged_object) sets out.
5049    pub(crate) fn staged_dataset_meta(&self, path: &str) -> Option<StagedMeta> {
5050        if self.staged_object(path)?.kind != StagedKind::Dataset {
5051            return None;
5052        }
5053        let fd = self.staged.dataset_at(&split_path(path))?;
5054        Some(StagedMeta {
5055            datatype: fd.dt.clone(),
5056            dimensions: fd.ds.dimensions.clone(),
5057            // Reported the way `Dataset::maxshape` reports an on-disk one: a
5058            // maximum equal to the current shape is a fixed-shape dataset.
5059            maxshape: match &fd.ds.max_dimensions {
5060                Some(md) if *md != fd.ds.dimensions => Some(md.clone()),
5061                _ => None,
5062            },
5063            // The rule the commit applies: chunk options or an extensible shape
5064            // select chunked storage, anything else contiguous.
5065            chunked: fd.chunk_options.is_chunked() || fd.maxshape.is_some(),
5066            filters: fd
5067                .chunk_options
5068                .filters
5069                .iter()
5070                .map(|f| (f.kind.filter_id(), f.optional))
5071                .collect(),
5072        })
5073    }
5074
5075    /// The direct children `parent` gains from this session's staged creations:
5076    /// the groups first and then the datasets, each in the order it was staged.
5077    ///
5078    /// `parent` may itself be staged, which is what lets a listing on a
5079    /// not-yet-committed group report its members. Only children the session
5080    /// named answer, for the reason [`staged_object`](Self::staged_object)
5081    /// gives.
5082    ///
5083    /// Unlike [`staged_object`](Self::staged_object) this does not ask the file
5084    /// whether each name is already taken: the caller is enumerating a group and
5085    /// already holds its on-disk links, so `replaces_link` is all it needs to
5086    /// apply the same rule without a path resolution per child.
5087    pub(crate) fn staged_children(&self, parent: &str) -> Vec<StagedChild> {
5088        if self.stages_no_creations() {
5089            return Vec::new();
5090        }
5091        let base = split_path(parent);
5092        // Whether this group is itself a replacement staged here — its on-disk
5093        // links go with the object being removed, so every creation under it
5094        // owns its name — and which links directly under it are removed by
5095        // name: the two ways a creation here is a replacement rather than a
5096        // collision. A deletion of the group that this session does *not* build
5097        // again is neither: the commit refuses that batch, so the file's own
5098        // children still own their names (see [`StagedEdits::deletes_hand_over`]).
5099        let base_deleted = self.staged.deletes_hand_over(&base) && self.staged.has_group_at(&base);
5100        let deleted_here: HashSet<&str> = self
5101            .staged
5102            .deletes
5103            .iter()
5104            .filter(|d| d.len() == base.len() + 1 && d[..base.len()] == base[..])
5105            .map(|d| d[base.len()].as_str())
5106            .collect();
5107
5108        let mut seen: HashSet<&str> = HashSet::new();
5109        let mut out: Vec<StagedChild> = Vec::new();
5110        for path in &self.staged.groups {
5111            if path.len() == base.len() + 1 && path[..base.len()] == base[..] {
5112                let name = path[base.len()].as_str();
5113                if seen.insert(name) {
5114                    out.push(StagedChild {
5115                        name: name.to_string(),
5116                        kind: StagedKind::Group,
5117                        replaces_link: base_deleted || deleted_here.contains(name),
5118                    });
5119                }
5120            }
5121        }
5122        for (p, fd) in &self.staged.datasets {
5123            if p[..] == base[..] && seen.insert(fd.name.as_str()) {
5124                out.push(StagedChild {
5125                    name: fd.name.clone(),
5126                    kind: StagedKind::Dataset,
5127                    replaces_link: base_deleted || deleted_here.contains(fd.name.as_str()),
5128                });
5129            }
5130        }
5131        out
5132    }
5133
5134    /// Stage a new (empty) group at `path`, created on the next
5135    /// [`commit`](Self::commit). The parent must already exist or be created in
5136    /// the same session; populate the group with datasets via
5137    /// [`create_dataset`](Self::create_dataset) using a path under it.
5138    pub fn create_group(&mut self, path: &str) -> Result<(), Error> {
5139        let comps = split_path(path);
5140        self.refuse_if_claimed(&comps)?;
5141        self.refuse_creation_collision(&comps, StagedKind::Group)?;
5142        self.staged.push_group(comps);
5143        Ok(())
5144    }
5145
5146    /// Stage an attribute add or replacement on a group, applied on the next
5147    /// [`commit`](Self::commit).
5148    ///
5149    /// `path` names the group to edit; `""` or `"/"` names the root group. The
5150    /// group may already exist or may be created earlier in the same session
5151    /// with [`create_group`](Self::create_group). Attributes — fixed-size or
5152    /// variable-length — are stored compactly in the rebuilt group header while
5153    /// they fit it, and in a fractal heap past that, on the terms
5154    /// [`plan_attr_ops`] sets out.
5155    pub fn set_group_attr(
5156        &mut self,
5157        path: &str,
5158        name: &str,
5159        value: AttrValue,
5160    ) -> Result<(), Error> {
5161        let comps = split_path(path);
5162        self.refuse_if_claimed(&comps)?;
5163        self.staged.group_attrs.push((
5164            comps,
5165            AttrOp::Set {
5166                name: name.to_string(),
5167                value,
5168            },
5169        ));
5170        Ok(())
5171    }
5172
5173    /// Stage removal of a compact attribute from a group, applied on the next
5174    /// [`commit`](Self::commit).
5175    ///
5176    /// `path` names the group to edit; `""` or `"/"` names the root group. The
5177    /// named attribute must exist in the committed group state after any earlier
5178    /// staged attribute operations for the same group have been applied.
5179    pub fn remove_group_attr(&mut self, path: &str, name: &str) -> Result<(), Error> {
5180        let comps = split_path(path);
5181        self.refuse_if_claimed(&comps)?;
5182        self.staged.group_attrs.push((
5183            comps,
5184            AttrOp::Remove {
5185                name: name.to_string(),
5186            },
5187        ));
5188        Ok(())
5189    }
5190
5191    /// Stage an attribute add or replacement on an **existing dataset**, applied on
5192    /// the next [`commit`](Self::commit).
5193    ///
5194    /// `path` names the dataset to edit. Attributes — fixed-size or
5195    /// variable-length — are stored in the rebuilt dataset header
5196    /// while they fit it, and in a fractal heap past that, on the terms
5197    /// [`plan_attr_ops`] sets out. Applying it relocates the dataset's object
5198    /// header (the header is rewritten and its single naming link repointed; the
5199    /// dataset's data and chunk index stay in place), so it is supported only when
5200    /// the dataset has a **single hard link**. To set attributes on a dataset being *created* in this
5201    /// session, use the builder's [`set_attr`](crate::DatasetBuilder::set_attr)
5202    /// instead.
5203    pub fn set_dataset_attr(
5204        &mut self,
5205        path: &str,
5206        name: &str,
5207        value: AttrValue,
5208    ) -> Result<(), Error> {
5209        let comps = split_path(path);
5210        self.refuse_if_claimed(&comps)?;
5211        self.staged.dataset_attrs.push((
5212            comps,
5213            AttrOp::Set {
5214                name: name.to_string(),
5215                value,
5216            },
5217        ));
5218        Ok(())
5219    }
5220
5221    /// Stage removal of a compact attribute from an **existing dataset**, applied on
5222    /// the next [`commit`](Self::commit).
5223    ///
5224    /// `path` names the dataset to edit; the named attribute must exist in the
5225    /// committed dataset state after any earlier staged attribute operations for the
5226    /// same dataset have been applied. Like [`set_dataset_attr`](Self::set_dataset_attr)
5227    /// it relocates the dataset header and requires a single hard link.
5228    pub fn remove_dataset_attr(&mut self, path: &str, name: &str) -> Result<(), Error> {
5229        let comps = split_path(path);
5230        self.refuse_if_claimed(&comps)?;
5231        self.staged.dataset_attrs.push((
5232            comps,
5233            AttrOp::Remove {
5234                name: name.to_string(),
5235            },
5236        ));
5237        Ok(())
5238    }
5239
5240    /// Stage removal of the link at `path` (the HDF5 `H5Ldelete`), applied on the
5241    /// next [`commit`](Self::commit). The link's object — and, for a group, its
5242    /// whole subtree — becomes unreachable. The bytes it occupied are returned to
5243    /// this session's free list (issue #21): a later commit reuses them for new
5244    /// objects instead of growing the file, and if a freed run reaches
5245    /// end-of-file the file is truncated. Contiguous and chunked datasets (their
5246    /// chunk index and chunk data blocks) and whole group subtrees are all
5247    /// reclaimed. Reclaim is best-effort — an object whose blocks this engine
5248    /// cannot enumerate exhaustively (variable-length global-heap storage, dense
5249    /// attribute/link heaps, a version 2 B-tree chunk index) is left as dead
5250    /// bytes rather than risk freeing a region that is still in use. Freed space is
5251    /// reused within the open session; for a file created with
5252    /// `H5Pset_file_space_strategy(persist = true)` it is also recorded on disk so
5253    /// it survives reopen, otherwise it is forgotten
5254    /// on close. After reuse, an object reference to a deleted object may resolve
5255    /// to an unrelated object (deleting a referenced object is undefined in HDF5).
5256    ///
5257    /// The path must exist. The link's parent group must itself be editable in
5258    /// place (compact links, single-chunk header); the target being removed has
5259    /// no such restriction.
5260    ///
5261    /// # Replacing an object
5262    ///
5263    /// Deleting a path and creating a new object at that same path in one commit
5264    /// is a *replacement*, and is accepted (issue #305): the removal is applied
5265    /// before the addition, and the commit's single superblock write publishes
5266    /// both, so the path is occupied at every instant a crash could interrupt.
5267    /// The new object need not resemble the old one — a dataset may replace a
5268    /// group, or the reverse — and replacing a group discards its whole subtree
5269    /// rather than inheriting it.
5270    ///
5271    /// Everything this commit adds *below* a replaced path lands in the
5272    /// replacement, whatever order the calls were made in: staging
5273    /// `create_dataset("g/x")` and then replacing `g` puts `x` in the new group,
5274    /// not the one being removed. A staged edit that could only mean the
5275    /// *original* is refused instead — a group under the replaced path that this
5276    /// commit does not itself create, an attribute set on the object being
5277    /// removed, a value overwrite inside it, or a [`copy`](crate::File::copy)
5278    /// reading from it.
5279    ///
5280    /// See [`Group::delete`](crate::Group::delete) for the public form and a
5281    /// worked example.
5282    ///
5283    /// A deletion may not overlap a staged change at a *different* path
5284    /// (deleting `/a` while adding `/a/b`, unless `/a` is itself replaced in the
5285    /// same commit), nor an edit to the object being removed (an attribute set
5286    /// on it, or a value overwrite of something inside it); split those into
5287    /// separate commits.
5288    /// Deleting something this session **staged** and has not committed
5289    /// withdraws that staging instead: there is no link in the file to unlink,
5290    /// so the session is left as though the creation had never been made — its
5291    /// attributes, appends and staged children go with it. When the file *also*
5292    /// holds a link at the path (the creation was replacing it), the withdrawal
5293    /// leaves the plain deletion of the file's own object behind.
5294    ///
5295    /// The root itself (`""` or `"/"`) is refused with [`Error::EditUnsupported`]:
5296    /// nothing links to it, so there is no link to remove.
5297    pub fn delete(&mut self, path: &str) -> Result<(), Error> {
5298        let comps = split_path(path);
5299        // The root is not linked from anywhere, so there is no link to remove —
5300        // and an empty path is a prefix of every other, so a deletion staged
5301        // here would make every staged creation in the session look like a
5302        // replacement of a file object until the commit refused the batch.
5303        // Refused by name, as creating the root is.
5304        if comps.is_empty() {
5305            return Err(Error::EditUnsupported(
5306                "cannot delete the root group; delete its members instead",
5307            ));
5308        }
5309        self.refuse_if_claimed(&comps)?;
5310        if self.staged.dataset_at(&comps).is_some() || self.staged.has_group_at(&comps) {
5311            self.refuse_mid_batch()?;
5312            self.staged.withdraw_at(&comps);
5313            // Withdrawing is the whole deletion unless the file holds a link
5314            // here too — and if it does, a deletion of it may already be staged,
5315            // which is what made this a replacement in the first place. A second
5316            // one would be an overlapping deletion the commit refuses.
5317            if self.staged.deletes_cover(&comps) || !self.path_in_file(&comps) {
5318                return Ok(());
5319            }
5320        }
5321        self.staged.deletes.push(comps);
5322        Ok(())
5323    }
5324
5325    /// Stage a deep copy of the object at `src` to a new link at `dst` (the HDF5
5326    /// `H5Ocopy`), applied on the next [`commit`](Self::commit). The source — a
5327    /// dataset or a whole group subtree — is duplicated: fresh copies of every
5328    /// object's data and header are written, internal links and the contiguous
5329    /// data address are repointed to the copies, and a link named by `dst`'s last
5330    /// component is added to `dst`'s parent group. The original is untouched.
5331    ///
5332    /// The copy reflects the file's on-disk state at commit time. `src` must
5333    /// exist and `dst` must not (and may not lie inside `src`). A chunked (and
5334    /// filtered) dataset is copied with its chunk payloads and filter pipeline
5335    /// preserved byte-for-byte (the index is rebuilt at the new location, so a
5336    /// source using a B-tree-v1 or implicit index is reproduced with an equivalent
5337    /// v4 index). The source subtree must otherwise be copyable in place: compact
5338    /// links and attributes, single-chunk headers, and a chunk index this engine
5339    /// can enumerate (a version-2 B-tree, or a sparse/unallocated chunk grid, is
5340    /// refused) — otherwise `commit` reports [`Error::EditUnsupported`].
5341    ///
5342    /// A *contiguous* dataset whose storage was never allocated is copied as the
5343    /// storage it has — none — rather than as the fill value a read answers with,
5344    /// which is what keeps a schema-only source from arriving materialized at the
5345    /// size its shape declares. A dataset storing its elements in external files
5346    /// (`H5Pset_external`) carries that same empty storage over data this crate
5347    /// does not read, and is refused by name so the two do not share an answer
5348    /// (issue #336).
5349    pub fn copy(&mut self, src: &str, dst: &str) -> Result<(), Error> {
5350        let (s, d) = (split_path(src), split_path(dst));
5351        // Both ends matter: the source is read and the destination is written,
5352        // and a commit relocates headers along either path.
5353        self.refuse_if_claimed(&s)?;
5354        self.refuse_if_claimed(&d)?;
5355        self.staged.copies.push((s, d));
5356        Ok(())
5357    }
5358
5359    /// Stage a deep copy of the object at `src` in another open file `source` to a
5360    /// new link at `dst` in this file — a *cross-file* HDF5 `H5Ocopy` — applied on
5361    /// the next [`commit`](Self::commit). Like [`copy`](Self::copy) but the source
5362    /// lives in a separate, independently-opened [`File`](crate::File) reader
5363    /// rather than the file being edited.
5364    ///
5365    /// The source — a dataset or a whole group subtree — is duplicated faithfully:
5366    /// fresh, byte-identical copies of every object's header and data are appended
5367    /// to this file, internal links repointed, and a link named by `dst`'s last
5368    /// component added to `dst`'s parent group (which must already exist or be
5369    /// created earlier in this session). Both files are left otherwise untouched;
5370    /// the destination only changes on `commit`.
5371    ///
5372    /// Unlike the same-file [`copy`](Self::copy), the source is read **eagerly**
5373    /// here (the `source` borrow need not outlive the call), so this returns
5374    /// `Result`: the source subtree is resolved, validated, and read out before
5375    /// returning, and only an already-validated copy is queued for `commit`.
5376    ///
5377    /// # Errors
5378    ///
5379    /// Returns [`Error::EditUnsupported`] if the copy cannot be reproduced exactly
5380    /// in another file. Because the copy is byte-for-byte verbatim, anything that
5381    /// embeds a *source-file* absolute address is refused (it would dangle here):
5382    /// **variable-length** or **reference** datasets and attributes (including a
5383    /// chunked dataset whose elements are variable-length or references, whose
5384    /// chunk payloads embed such addresses), and any **shared header message** (a
5385    /// committed datatype, or an SOHM-shared dataspace, fill value, or filter
5386    /// pipeline). As with [`copy`](Self::copy) a chunked/filtered source is copied
5387    /// with its chunk payloads and pipeline preserved (index rebuilt at the new
5388    /// location); the source must use compact links and attributes, single-chunk
5389    /// version-2 headers, and a chunk index this engine can enumerate (a
5390    /// version-2 B-tree, or a sparse chunk grid, is refused). The
5391    /// `source` must be a buffered file ([`File::open`](crate::File::open) or
5392    /// [`File::from_bytes`](crate::File::from_bytes), not
5393    /// [`open_streaming`](crate::File::open_streaming)) using 8-byte offsets and no
5394    /// userblock, and `src` must exist in it and not be the root group.
5395    pub fn copy_from(
5396        &mut self,
5397        source: &crate::reader::File,
5398        src: &str,
5399        dst: &str,
5400    ) -> Result<(), Error> {
5401        // The source bytes must be addressable: a streaming file is refused.
5402        let src_data = source.in_memory_image().ok_or(Error::EditUnsupported(
5403            "cross-file copy requires a buffered source file (File::open or File::from_bytes), not a streaming one",
5404        ))?;
5405        let src_sb = source.superblock();
5406        if src_sb.offset_size != OFFSET_SIZE || src_sb.length_size != LENGTH_SIZE {
5407            return Err(Error::EditUnsupported(
5408                "cross-file copy requires the source file to use 8-byte offsets and lengths",
5409            ));
5410        }
5411        if !source.base_address().is_zero() {
5412            return Err(Error::EditUnsupported(
5413                "cross-file copy requires the source file to have no userblock (base address 0)",
5414            ));
5415        }
5416
5417        let src = split_path(src);
5418        if src.is_empty() {
5419            return Err(Error::EditUnsupported("cannot copy the root group"));
5420        }
5421        let dst = split_path(dst);
5422        if dst.is_empty() {
5423            return Err(Error::EditUnsupported("copy destination path is empty"));
5424        }
5425
5426        let src_addr = crate::group_v2::resolve_path_any(src_data, src_sb, &src.join("/"))
5427            .map_err(|_| Error::EditUnsupported("copy source does not exist in the source file"))?;
5428        // Read (and foreign-address-screen) the whole subtree now, while `source`
5429        // is borrowed; the owned tree carries every byte the commit will write. The
5430        // source is gated to base 0 above, so its stored addresses are absolute.
5431        let tree = Self::read_copy_subtree(
5432            &BytesSource::new(src_data),
5433            src_addr,
5434            0,
5435            true,
5436            BaseAddress::ZERO,
5437        )?;
5438        self.refuse_if_claimed(&dst)?;
5439        self.staged.cross_copies.push((dst, tree));
5440        Ok(())
5441    }
5442
5443    /// Apply all staged additions and deletions to the file in place and flush.
5444    ///
5445    /// Appends each new dataset (its data — a contiguous blob, or the chunk data
5446    /// and index for a chunked/filtered dataset — plus its object header) and
5447    /// each new group, then appends rewritten object headers for every touched
5448    /// group and its ancestors up to the root (omitting any deleted links), then
5449    /// repoints the superblock at the new root. On success the staged set is
5450    /// cleared and the session can be reused. On any [`Error::EditUnsupported`]
5451    /// the file on disk is left untouched: the checks that raise it — including
5452    /// each dataset's filter-pipeline and chunk-geometry validation — all run
5453    /// before the first byte is written. Should a later step fail mid-apply (an
5454    /// I/O error, or a residual build error), the superblock — repointed last —
5455    /// still names the prior root, so the file stays valid and the appended bytes
5456    /// are unreferenced slack.
5457    ///
5458    /// **A refused commit costs the session nothing it had staged.** The
5459    /// staged set is whole afterwards — so the batch can be committed again,
5460    /// and refuses again identically rather than applying the part of itself
5461    /// the refusal was not about — and the free regions the attempt drew from
5462    /// are given back, so an attempt that never became visible costs the
5463    /// session neither staged work nor reusable space (issue #316; see
5464    /// [`StagedEdits`] and [`FreeSnapshot`]).
5465    ///
5466    /// A failure *past* the first write is the other case, and it clears the
5467    /// staged set rather than restoring it: the file is valid but some of the
5468    /// batch is in it as slack, and a later `commit` must not re-issue the rest
5469    /// as if nothing had happened.
5470    ///
5471    /// **What the file holds is a separate promise: a commit that fails before
5472    /// its repoint leaves every dataset reading what it read before.** "Slack"
5473    /// describes all but one of the edits such a commit applies. The exception
5474    /// is a same-length value overwrite, which writes straight over the
5475    /// dataset's existing data block and so is live the moment it lands, with
5476    /// no repoint to withhold; the prior bytes are written back over every one
5477    /// of them on the way out (issue #344).
5478    ///
5479    /// Past the repoint the promise is the other one, and it has to be: the
5480    /// commit has published, so its overwrites are the file's values and are
5481    /// left standing. A failure there — `repoint_stored_references` is the step
5482    /// that can produce one — returns an error over a file that holds the whole
5483    /// batch, which is what
5484    /// `a_commit_that_fails_past_its_repoint_keeps_the_value_it_published`
5485    /// pins.
5486    ///
5487    /// # Errors
5488    ///
5489    /// Returns [`Error::CommitPartiallyApplied`] — carrying the write failure,
5490    /// not the refusal — when that restore itself failed, which is the one
5491    /// refusal after which a dataset the batch named may hold either value.
5492    /// Every other refusal leaves them all as they were.
5493    pub fn commit(&mut self) -> Result<(), Error> {
5494        let snapshot = self.snapshot_free();
5495        self.publish_attempted = false;
5496        // Cleared at entry, like `superseded_heaps` and for the same reason: a
5497        // previous attempt that unwound past this function — a panic caught by
5498        // the session lock, which recovers from poisoning — would otherwise
5499        // leave entries behind, and this commit's rollback would replay bytes
5500        // captured before it over whatever lives at those addresses now. The
5501        // clear on the way out is a separate duty: it stops a journal being
5502        // held across the idle time between commits.
5503        self.inplace_undo.clear();
5504        // The staged set comes out for the duration of the attempt and goes back
5505        // if the attempt refuses, so a refusal costs the session no staged work
5506        // (issue #316). What goes back is whatever the attempt did not take: its
5507        // preflight only reads, and its apply phase takes the whole set in one
5508        // move at the point of no return, so a preflight refusal restores every
5509        // edit and a failure past that point restores nothing.
5510        let mut staged = std::mem::take(&mut self.staged);
5511        let mut result = self.commit_inner(&mut staged);
5512        if result.is_err() && !self.publish_attempted {
5513            // The three legs of the rollback: the file's values, the free
5514            // lists, and the staged set. Nothing observes the order — the first
5515            // touches the file and the others only in-memory state, inside one
5516            // synchronous call — so it is written the way it reads: put the
5517            // file back, then re-offer what the attempt drew from (issue #344).
5518            let restored = self.undo_inplace_writes();
5519            self.restore_free(snapshot);
5520            self.staged = staged;
5521            // The set is back, so nothing this session staged has been consumed
5522            // and every handle onto one of those creations is still pending.
5523            if let Err(restore) = restored {
5524                // Both are worth carrying: the refusal is what the caller has to
5525                // fix, and the write failure is what proves the file changed.
5526                let refusal = result.expect_err("only a failed attempt is rolled back");
5527                result = Err(Error::CommitPartiallyApplied {
5528                    refusal: Box::new(refusal),
5529                    restore: Box::new(restore),
5530                });
5531            }
5532        } else {
5533            // The set is gone — published, or in the file as slack a later
5534            // commit must not re-issue — so a handle onto one of its creations
5535            // stops being pending and addresses the file from here on.
5536            self.staged_generation += 1;
5537        }
5538        // For the paths that took no rollback — a commit that succeeded, whose
5539        // overwrites are now the file's values, and one that failed with the
5540        // publish already issued, where undoing anything is unsound. A rollback
5541        // drained the journal on its way through, so it owes nothing here.
5542        self.inplace_undo.clear();
5543        result
5544    }
5545
5546    /// The free lists as they stand now, for [`commit`](Self::commit) to restore
5547    /// if its apply loop draws from them and then fails.
5548    fn snapshot_free(&self) -> FreeSnapshot {
5549        FreeSnapshot {
5550            free: self.free.clone(),
5551            reserved: self.reserved.clone(),
5552            paged: self
5553                .paged
5554                .as_ref()
5555                .map(|pg| (pg.meta.clone(), pg.raw.clone())),
5556            vl_overwrite_heaps: self.vl_overwrite_heaps.clone(),
5557        }
5558    }
5559
5560    /// Put the free lists back as [`snapshot_free`](Self::snapshot_free) found
5561    /// them. Called only for a commit that failed before publishing anything, so
5562    /// every region it restores is dead again.
5563    ///
5564    /// That rests on nothing the live tree reaches naming a span the attempt
5565    /// allocated, which takes two rules rather than one. Every object a commit
5566    /// *places* is unreachable until the superblock repoint; and the one write
5567    /// that does not wait for the repoint — a same-length value overwrite — is
5568    /// planned only over bytes that carry no allocated address, because
5569    /// [`prepare_write`](Self::prepare_write) relocates a staged
5570    /// variable-length overwrite instead of writing it in place, which is
5571    /// exactly the counterexample (issue #321).
5572    ///
5573    /// [`undo_inplace_writes`](Self::undo_inplace_writes) has nevertheless run
5574    /// by the time this does, so the file's values are back before its regions
5575    /// are offered again (issue #344).
5576    fn restore_free(&mut self, snapshot: FreeSnapshot) {
5577        self.vl_overwrite_heaps = snapshot.vl_overwrite_heaps;
5578        self.free = snapshot.free;
5579        self.reserved = snapshot.reserved;
5580        if let (Some(pg), Some((meta, raw))) = (self.paged.as_mut(), snapshot.paged) {
5581            pg.meta = meta;
5582            pg.raw = raw;
5583        }
5584    }
5585
5586    /// Overwrite `raw` at `at`, keeping the bytes it replaces so a commit that
5587    /// fails before its repoint can put them back.
5588    ///
5589    /// The read covers exactly the range about to be written, which the write
5590    /// preflight sized and bounds-checked against the on-disk dataset — a
5591    /// same-length overwrite by construction.
5592    fn write_inplace_journaled(&mut self, at: usize, raw: &[u8]) -> Result<(), Error> {
5593        let prior = self.image().read_exact_at(at as u64, raw.len())?;
5594        self.inplace_undo.push((at, prior));
5595        self.write_at(at, raw)
5596    }
5597
5598    /// Put back the values the refused commit's same-length in-place overwrites
5599    /// replaced, and drop the journal.
5600    ///
5601    /// The third leg of a refused commit's rollback, beside
5602    /// [`restore_free`](Self::restore_free) and the staged set: those two give
5603    /// back what the attempt *consumed*, this gives back what it *changed*. It
5604    /// is the only one of the three that has to touch the file, because a
5605    /// same-length overwrite is the only edit a commit writes over bytes the
5606    /// live root already reaches (issue #344).
5607    ///
5608    /// # Errors
5609    ///
5610    /// Every entry is attempted even after one fails, so the file is restored as
5611    /// far as it can be, and the first failure met is what comes back. That is the
5612    /// one outcome leaving values from a batch that was refused, which is why
5613    /// [`commit`](Self::commit) reports it as
5614    /// [`Error::CommitPartiallyApplied`] rather than as the refusal.
5615    fn undo_inplace_writes(&mut self) -> Result<(), Error> {
5616        let journal = core::mem::take(&mut self.inplace_undo);
5617        if journal.is_empty() {
5618            return Ok(());
5619        }
5620        let mut failed = None;
5621        // Newest first, and load-bearing rather than conventional. The commit
5622        // refuses to overwrite the same *path* twice, which is not the same as
5623        // the same dataset: an object reachable through two hard links has two
5624        // paths and one data block, and a same-length overwrite through either
5625        // is allowed precisely because it rewrites the block every link sees.
5626        // Two such writes in one commit therefore journal the same address
5627        // twice, the second entry holding what the first write put there.
5628        // Replaying oldest-first would finish on that value — one from the very
5629        // batch being rolled back.
5630        for (at, prior) in journal.into_iter().rev() {
5631            if let Err(e) = self.write_at(at, &prior) {
5632                failed.get_or_insert(e);
5633            }
5634        }
5635        match failed {
5636            Some(e) => Err(e),
5637            // A restore that is not a barrier can be gathered and reordered
5638            // past a later write, which is the whole hazard write gathering
5639            // introduced (issue #288); order it here instead. The data-only
5640            // barrier is the one that fits, because putting bytes back into
5641            // blocks that already existed moves no end-of-file — it is a
5642            // narrower barrier than the `barrier()` the commit itself uses
5643            // after these writes, deliberately.
5644            None => self.barrier_data(),
5645        }
5646    }
5647
5648    fn commit_inner(&mut self, staged: &mut StagedEdits) -> Result<(), Error> {
5649        if staged.is_empty() {
5650            return Ok(());
5651        }
5652
5653        // An attempt that failed partway may have recorded superseded collections
5654        // it never freed; this one must not free them on its behalf, since it is
5655        // not the commit that replaced what they hold.
5656        self.superseded_heaps.clear();
5657        // Drop every heap-collection provenance record this batch could falsify,
5658        // before any of them is read below.
5659        self.invalidate_heap_provenance(staged);
5660
5661        // A paged file (`H5F_FSPACE_STRATEGY_PAGE`) that does not persist its free
5662        // space has no on-disk record of which pages hold metadata and which hold
5663        // raw data, so this commit could not keep the two segregated and would
5664        // silently degrade the paging. Refuse up front, before any writes, exactly
5665        // as the bounded backend does. A paged *persisting* file is committed
5666        // through the page-aware tail below (issue #198).
5667        if self.paged.is_some() && self.persist.is_none() {
5668            return Err(Error::EditUnsupported(
5669                "committing an edit to a paged file (H5F_FSPACE_STRATEGY_PAGE) requires \
5670                 persisted free space; recreate the file with \
5671                 with_file_space_strategy(FileSpaceStrategy::Page, true, ..) to edit it in place",
5672            ));
5673        }
5674
5675        // Invalidate the in-place-append geometry cache before doing any work. A
5676        // commit that reaches here rewrites and relocates object headers, frees
5677        // vacated regions into `self.free`, and may truncate the file — any of
5678        // which can leave a cached `Located` pointing at a moved header or into a
5679        // now-free-eligible region. Clearing at *entry* (rather than the success
5680        // tail) means a later failure — including one after the durable root flip,
5681        // which leaves the session reusable — never strands a stale cache. The
5682        // no-op fast return above does no such work, so it keeps the cache. The
5683        // The next in-place append re-locates against the fresh file.
5684        self.located.clear();
5685        self.resolved.clear();
5686        // Past this point the commit may relocate object headers, so every address
5687        // a caller captured earlier is suspect; see `committed`.
5688        self.committed = true;
5689
5690        // On a file with a userblock, stored addresses are relative to this base
5691        // and the editor converts at every disk boundary (read `stored + base`,
5692        // write `file_offset - base`). Userblock support covers value overwrites,
5693        // additions of contiguous and chunked/filtered datasets, in-place and
5694        // relocating overwrites of every layout (chunked, contiguous, compact) with
5695        // reclaim, object deletion (with base-aware subtree reclaim), object copy
5696        // (in-file, and cross-file into a userblock destination), group creation,
5697        // and compact group attributes. Cross-file copy still requires a base-0
5698        // *source* (see [`copy_from`](Self::copy_from)).
5699        let base = self.superblock.base_address;
5700
5701        // --- Preflight value overwrites (`write_dataset`) before any write, under
5702        // the same all-or-nothing contract as additions. Each is resolved,
5703        // validated (datatype and shape must match the on-disk dataset exactly),
5704        // and classified: a same-length contiguous overwrite is applied straight
5705        // in place (no header rewrite, no superblock flip), while a resize or
5706        // compact rewrite relocates the header and is staged against its parent
5707        // group so the commit below rebuilds it and patches the link. ---
5708        let mut inplace_writes: Vec<(usize, OverwriteBytes)> = Vec::new();
5709        let mut moving_writes: Vec<(PathKey, String, u64, MovingWrite)> = Vec::new();
5710        let mut write_targets: Vec<PathKey> = Vec::new();
5711        // The file-wide hard-link count, computed lazily the first time a commit
5712        // relocates a header: such a write moves the object's header and patches
5713        // only the one parent link that names it, so an object reachable through
5714        // more than one hard link would have its other links left pointing at the
5715        // stale header. Refuse that rather than silently diverge the aliases (a
5716        // same-length in-place overwrite is unaffected — it rewrites the shared
5717        // data block, which every link sees).
5718        //
5719        // Read by four places, which is every way a header moves: a relocating
5720        // overwrite, a staged append, a dataset attribute edit, and — since
5721        // issue #327 — a rebuilt group, which had the same defect and none of
5722        // the guard.
5723        let mut incoming_links: Option<Option<HashMap<u64, u32>>> = None;
5724        for (full, fd) in &staged.writes {
5725            // A path named twice in one commit would write it twice (and double-
5726            // free a resized extent); require separate commits.
5727            if write_targets.contains(full) {
5728                return Err(Error::EditUnsupported(
5729                    "the same dataset is overwritten twice in one commit; use separate commits",
5730                ));
5731            }
5732            let path_str = full.join("/");
5733            let addr = crate::group_v2::resolve_path_any_from_source(
5734                &self.image(),
5735                &self.superblock,
5736                &path_str,
5737            )
5738            .map_err(|_| Error::EditUnsupported("nothing to overwrite at the given path"))?;
5739            let addr = usize::try_from(addr)
5740                .map_err(|_| Error::EditUnsupported("dataset address exceeds this platform"))?;
5741            match Self::prepare_write(&self.image(), addr as u64, fd, base, full)? {
5742                WritePlan::InPlace { data_addr, bytes } => {
5743                    inplace_writes.push((data_addr, bytes));
5744                }
5745                // A chunked in-place overwrite never carries staging — a staged
5746                // one is a `ChunkPayload::Deferred`, which relocates.
5747                WritePlan::InPlaceChunks { writes } => inplace_writes.extend(
5748                    writes
5749                        .into_iter()
5750                        .map(|(at, raw)| (at, OverwriteBytes::ready(raw))),
5751                ),
5752                WritePlan::Moving(mw) => {
5753                    // A relocating overwrite rewrites the dataset's header and data
5754                    // address. Every variant is base-aware on a userblock file: the
5755                    // chunked one rebuilds the chunk blob with stored addresses and
5756                    // reclaims the old storage base-relative, the contiguous one
5757                    // stores the relocated data address base-relative (and frees the
5758                    // old extent at its absolute offset), and the compact one carries
5759                    // its data inline. The parent link to the rewritten header is
5760                    // patched base-relative below.
5761                    //
5762                    // A relocating overwrite is safe only when this is the
5763                    // dataset's sole hard link. Compute the link graph once.
5764                    let counts = incoming_links
5765                        .get_or_insert_with(|| self.count_incoming_hard_links())
5766                        .as_ref();
5767                    match counts.and_then(|c| c.get(&(addr as u64))) {
5768                        Some(&1) => {}
5769                        _ => {
5770                            return Err(Error::EditUnsupported(
5771                                "overwriting a dataset that resizes or relocates its header is \
5772                                 only supported when it has a single hard link",
5773                            ));
5774                        }
5775                    }
5776                    let leaf = full.last().unwrap().clone();
5777                    let parent = full[..full.len() - 1].to_vec();
5778                    moving_writes.push((parent, leaf, addr as u64, mw));
5779                }
5780            }
5781            write_targets.push(full.clone());
5782        }
5783
5784        // --- Preflight appends (`append_dataset`) under the same all-or-nothing,
5785        // single-hard-link contract. Each plans a relocating append — existing
5786        // chunk data stays in place; the appended (and any rewritten trailing)
5787        // chunks and a rebuilt Extensible-Array index are staged, and the whole is
5788        // treated like a relocating overwrite of the dataset's header (staged
5789        // against its parent group so the commit patches the link). A zero-length
5790        // append is a no-op and is dropped here. ---
5791        for (full, ab) in &staged.appends {
5792            if full.is_empty() {
5793                return Err(Error::AppendUnsupported("cannot append to the root group"));
5794            }
5795            if ab.raw.is_empty() {
5796                continue; // nothing to append
5797            }
5798            // A dataset overwritten or appended earlier in this commit would be
5799            // planned against a stale header and its old storage double-freed;
5800            // require separate commits.
5801            if write_targets.contains(full) {
5802                return Err(Error::AppendUnsupported(
5803                    "the same dataset is edited more than once in one commit; use separate commits",
5804                ));
5805            }
5806            let path_str = full.join("/");
5807            let addr = crate::group_v2::resolve_path_any_from_source(
5808                &self.image(),
5809                &self.superblock,
5810                &path_str,
5811            )
5812            .map_err(|_| Error::AppendUnsupported("nothing to append to at the given path"))?;
5813            let addr = usize::try_from(addr)
5814                .map_err(|_| Error::AppendUnsupported("dataset address exceeds this platform"))?;
5815            let mw = Self::prepare_append(&self.image(), addr as u64, ab, base)?;
5816            // A relocating append moves the dataset's object header and patches only
5817            // the one parent link that names it, so it is safe only when this is the
5818            // dataset's sole hard link (same rule as a relocating overwrite).
5819            let counts = incoming_links
5820                .get_or_insert_with(|| self.count_incoming_hard_links())
5821                .as_ref();
5822            match counts.and_then(|c| c.get(&(addr as u64))) {
5823                Some(&1) => {}
5824                _ => {
5825                    return Err(Error::AppendUnsupported(
5826                        "appending relocates the dataset header; only supported when it \
5827                         has a single hard link",
5828                    ));
5829                }
5830            }
5831            let leaf = full.last().unwrap().clone();
5832            let parent = full[..full.len() - 1].to_vec();
5833            moving_writes.push((parent, leaf, addr as u64, mw));
5834            write_targets.push(full.clone());
5835        }
5836
5837        // --- Preflight dataset attribute edits (`set_dataset_attr` /
5838        // `remove_dataset_attr`) under the same all-or-nothing, single-hard-link
5839        // contract. Each gathers the dataset's verbatim object-header region,
5840        // applies the compact attribute ops to it, and stages a relocating
5841        // `AttrEdit` header rewrite against the parent group — like a value
5842        // overwrite, but the data-layout message (and thus the chunk data and index)
5843        // is preserved verbatim, so only the header moves. ---
5844        if !staged.dataset_attrs.is_empty() {
5845            // Collect the ops per dataset in first-seen path order, so multiple edits
5846            // to one dataset produce a single relocating header rewrite.
5847            let mut order: Vec<PathKey> = Vec::new();
5848            let mut ops_by_path: HashMap<&PathKey, Vec<&AttrOp>> = HashMap::new();
5849            for (path, op) in &staged.dataset_attrs {
5850                if !ops_by_path.contains_key(path) {
5851                    order.push(path.clone());
5852                }
5853                ops_by_path.entry(path).or_default().push(op);
5854            }
5855            for full in order {
5856                let ops = ops_by_path.remove(&full).unwrap();
5857                if full.is_empty() {
5858                    return Err(Error::EditUnsupported(
5859                        "cannot set a dataset attribute on the root group; use set_group_attr",
5860                    ));
5861                }
5862                // A dataset already overwritten or appended in this commit would be
5863                // planned against a stale header; require separate commits.
5864                if write_targets.contains(&full) {
5865                    return Err(Error::EditUnsupported(
5866                        "the same dataset is edited more than once in one commit (an attribute \
5867                         edit plus another edit); use separate commits",
5868                    ));
5869                }
5870                let path_str = full.join("/");
5871                let addr = crate::group_v2::resolve_path_any_from_source(
5872                    &self.image(),
5873                    &self.superblock,
5874                    &path_str,
5875                )
5876                .map_err(|_| {
5877                    Error::EditUnsupported("nothing to set an attribute on at the given path")
5878                })?;
5879                let addr = usize::try_from(addr)
5880                    .map_err(|_| Error::EditUnsupported("dataset address exceeds this platform"))?;
5881                // An attribute edit relocates the dataset's object header and patches
5882                // only the one naming link, so it is safe only when this is the
5883                // dataset's sole hard link (same rule as a relocating overwrite).
5884                let counts = incoming_links
5885                    .get_or_insert_with(|| self.count_incoming_hard_links())
5886                    .as_ref();
5887                match counts.and_then(|c| c.get(&(addr as u64))) {
5888                    Some(&1) => {}
5889                    _ => {
5890                        return Err(Error::EditUnsupported(
5891                            "editing a dataset attribute relocates its header; only supported \
5892                             when it has a single hard link",
5893                        ));
5894                    }
5895                }
5896                let region = Self::gather_oh_messages(&self.image(), addr as u64, base)?;
5897                let edits = plan_attr_ops(&self.image(), base, Some(addr as u64), &region, &ops)?;
5898                let leaf = full.last().unwrap().clone();
5899                let parent = full[..full.len() - 1].to_vec();
5900                moving_writes.push((
5901                    parent,
5902                    leaf,
5903                    addr as u64,
5904                    MovingWrite::AttrEdit {
5905                        region: edits.region,
5906                        attrs: edits.attrs,
5907                    },
5908                ));
5909                write_targets.push(full);
5910            }
5911        }
5912
5913        // Pre-commit object-header addresses this commit rewrites elsewhere, for
5914        // `InvalidatedAddresses` below: a reference naming one of these keeps an
5915        // address the commit is vacating (issue #317). Read off the one list every
5916        // relocating write is staged on, so a write added later cannot be left out
5917        // of the screen without failing to compile.
5918        let mut moved_headers: Vec<u64> =
5919            moving_writes.iter().map(|&(_, _, addr, _)| addr).collect();
5920
5921        // Fast path: when the only staged edits are same-length in-place
5922        // overwrites, apply them straight to their data blocks and return without
5923        // rebuilding any header or flipping the superblock root. The commit's
5924        // linearization point is the synced data write — there is no tree to
5925        // repoint, so each overwrite stands alone. (A persisting file takes the
5926        // same path: no free-space change occurs.)
5927        //
5928        // Because this path never rewrites the superblock, it deliberately leaves
5929        // it untouched — including a pre-existing stale consistency flag (e.g. one
5930        // left by a crashed SWMR writer). A lone same-length value overwrite does
5931        // not introduce any inconsistency, so it does not clear one either; an edit
5932        // that takes the full path below (any header/root change) clears the flag
5933        // as usual.
5934        //
5935        // A staged variable-length overwrite cannot reach here at all: it
5936        // relocates, so `moving_writes` is non-empty for it. That is enforced
5937        // where the plan is chosen (`prepare_write`) rather than re-tested here,
5938        // and it has to hold for a second reason besides the one that made it
5939        // relocate — such an overwrite places a global heap collection, and an
5940        // append moves end-of-file, which only the superblock this path leaves
5941        // untouched records (issue #321).
5942        debug_assert!(
5943            inplace_writes.iter().all(|(_, b)| b.vlen.is_none()),
5944            "a staged variable-length overwrite must relocate, not write in place"
5945        );
5946        if moving_writes.is_empty()
5947            && staged.datasets.is_empty()
5948            && staged.groups.is_empty()
5949            && staged.group_attrs.is_empty()
5950            && staged.deletes.is_empty()
5951            && staged.copies.is_empty()
5952            && staged.cross_copies.is_empty()
5953        {
5954            // This path's own point of no return, and it takes the staged set
5955            // for the same reason the main one below does (issue #316): every
5956            // refusal that applies to these overwrites has already run, and a
5957            // batch some of which has been written is not one a later `commit`
5958            // may re-issue. It is also what this path already did, back when the
5959            // write preflight drained the staged set on its way here.
5960            //
5961            // Leaving the set in place would make a half-written batch
5962            // retryable — these overwrites are same-length writes to fixed
5963            // addresses, so repeating them is idempotent — but that reads the
5964            // rule the other way round for one path, on an argument nothing
5965            // enforces. What the file holds is settled separately and the same
5966            // way on both paths: a `write_at` that fails partway is undone
5967            // through the journal below, so the batch is dropped but the
5968            // dataset's values are the ones it had (issue #344).
5969            drop(std::mem::take(staged));
5970            for (data_addr, bytes) in &inplace_writes {
5971                // Every entry here stages nothing (the guard above), so this
5972                // hands back the staged bytes unchanged and unallocated.
5973                let raw = self.resolve_overwrite_bytes(bytes)?;
5974                self.write_inplace_journaled(*data_addr, &raw)?;
5975            }
5976            self.barrier()?;
5977            return Ok(());
5978        }
5979
5980        // --- Plan: build the tree of "dirty" groups (root plus every group on a
5981        // path to an addition or deletion), validating every target before any
5982        // write. `add_targets` records the full paths created this commit, used
5983        // to reject a deletion that overlaps an addition. ---
5984        let mut nodes: BTreeMap<PathKey, Node> = BTreeMap::new();
5985        nodes.entry(PathKey::new()).or_default(); // root is always dirty
5986        let mut add_targets: Vec<PathKey> = Vec::new();
5987        // Where each in-file `copy` reads from. A copy takes its bytes from the
5988        // *pre-commit* file, so a source this same commit replaces would copy the
5989        // object being removed while the replacement lands at the same path — see
5990        // the delete-staging loop, which refuses that. Cross-file copies are not
5991        // tracked: their source is in another file, so no path here can name it.
5992        let mut copy_sources: Vec<PathKey> = Vec::new();
5993        let mut attr_targets: Vec<PathKey> = Vec::new();
5994
5995        // Mark explicitly-created new groups, ensuring their ancestor chain.
5996        for path in &staged.groups {
5997            if path.is_empty() {
5998                return Err(Error::EditUnsupported("cannot create the root group"));
5999            }
6000            ensure_ancestors(&mut nodes, path);
6001            nodes.entry(path.clone()).or_default().is_new = true;
6002            add_targets.push(path.clone());
6003        }
6004
6005        // Make each staged dataset's parent group a node (with its ancestor
6006        // chain). The datasets themselves stay in the staged set, which the
6007        // preflight reads but must not empty (issue #316); `datasets_by_group`
6008        // below is the grouped view both the preflight and the apply loop use.
6009        for (parent, fd) in &staged.datasets {
6010            let mut full = parent.clone();
6011            full.push(fd.name.clone());
6012            add_targets.push(full);
6013            ensure_ancestors(&mut nodes, parent);
6014        }
6015
6016        // Attach relocating value overwrites (resized contiguous or compact) to
6017        // their parent group nodes: the new header is written below and the
6018        // parent's existing link patched to it, like an existing child group.
6019        for (parent, leaf, old_oh, mw) in moving_writes {
6020            ensure_ancestors(&mut nodes, &parent);
6021            nodes
6022                .entry(parent)
6023                .or_default()
6024                .writes
6025                .push((leaf, old_oh, mw));
6026        }
6027
6028        // Stage group attribute edits against their target groups. A target may
6029        // be a newly-created group from this same commit, but not a copied
6030        // destination or a dataset being added in the same commit. The ops
6031        // themselves stay in the staged set and are looked up by path where they
6032        // are applied, so a refusal below still gives them back (issue #316).
6033        for (path, _) in &staged.group_attrs {
6034            ensure_ancestors(&mut nodes, path);
6035            attr_targets.push(path.clone());
6036        }
6037
6038        // Stage copies: validate the source subtree is copyable (read-only),
6039        // then treat the destination like an addition to its parent group.
6040        for (src, dst) in &staged.copies {
6041            if src.is_empty() {
6042                return Err(Error::EditUnsupported("cannot copy the root group"));
6043            }
6044            if dst.is_empty() {
6045                return Err(Error::EditUnsupported("copy destination path is empty"));
6046            }
6047            if is_prefix(src, dst) {
6048                return Err(Error::EditUnsupported(
6049                    "cannot copy an object into itself or its own subtree",
6050                ));
6051            }
6052            let src_str = src.join("/");
6053            let src_addr = crate::group_v2::resolve_path_any_from_source(
6054                &self.image(),
6055                &self.superblock,
6056                &src_str,
6057            )
6058            .map_err(|_| Error::EditUnsupported("copy source does not exist"))?;
6059            let src_addr = usize::try_from(src_addr)
6060                .map_err(|_| Error::EditUnsupported("source address exceeds this platform"))?;
6061            // Read the source subtree from this file's own mirror (`cross_file`
6062            // false: same address space, so verbatim addresses stay valid). On a
6063            // userblock file the stored addresses are base-relative, so pass this
6064            // session's base for the read to absolutize them.
6065            let tree = Self::read_copy_subtree(&self.image(), src_addr as u64, 0, false, base)?;
6066            copy_sources.push(src.clone());
6067            add_targets.push(dst.clone());
6068            let leaf = dst.last().unwrap().clone();
6069            let parent = dst[..dst.len() - 1].to_vec();
6070            ensure_ancestors(&mut nodes, &parent);
6071            nodes.entry(parent).or_default().copies.push((leaf, tree));
6072        }
6073
6074        // Stage cross-file copies: their subtrees were already read out of the
6075        // source file (with foreign-address screening) when `copy_from` was
6076        // called, so here they are simply linked into the destination parent like
6077        // any other addition.
6078        for (dst, _) in &staged.cross_copies {
6079            if dst.is_empty() {
6080                return Err(Error::EditUnsupported("copy destination path is empty"));
6081            }
6082            add_targets.push(dst.clone());
6083            let leaf = dst.last().unwrap().clone();
6084            let parent = dst[..dst.len() - 1].to_vec();
6085            ensure_ancestors(&mut nodes, &parent);
6086            nodes.entry(parent).or_default().cross_copies.push(leaf);
6087        }
6088
6089        // Stage deletions: each must exist, must not overlap any other staged
6090        // change *unless* this commit replaces what it removes, and is recorded
6091        // against its parent group (which becomes dirty). `deleted_addrs` keeps
6092        // each removed object's header address so its owned blocks can be
6093        // reclaimed after the commit lands (issue #21).
6094        let delete_targets = &staged.deletes;
6095        let mut deleted_addrs: Vec<usize> = Vec::new();
6096        for (i, d) in delete_targets.iter().enumerate() {
6097            if d.is_empty() {
6098                return Err(Error::EditUnsupported("cannot delete the root group"));
6099            }
6100            let path_str = d.join("/");
6101            let del_addr = crate::group_v2::resolve_path_any_from_source(
6102                &self.image(),
6103                &self.superblock,
6104                &path_str,
6105            )
6106            .map_err(|_| Error::EditUnsupported("nothing to delete at the given path"))?;
6107            if let Ok(a) = usize::try_from(del_addr) {
6108                deleted_addrs.push(a);
6109            }
6110            // A deletion may overlap other staged work when this commit
6111            // *replaces* what it removes: an addition names exactly `d`, so the
6112            // removal and the new object at the same path are one rotation
6113            // rather than an edit of something being deleted (issue #305).
6114            let recreated = add_targets.iter().any(|t| t == d);
6115            // A replacement also requires that every group node at or below `d`
6116            // is one this commit builds fresh. A node that is *not* new is
6117            // rebuilt from the old object's on-disk header — the object being
6118            // replaced — and both ways that lands are wrong, in the two shapes
6119            // this guard was measured against:
6120            //
6121            // * At `d` itself (a group attribute set on a path this commit
6122            //   replaces with a *dataset*), the node and the replacement share a
6123            //   `path_addr` key. The replacement's address overwrites the group's,
6124            //   the parent's link is then patched to the address it already had,
6125            //   and the rebuilt group header is left orphaned — a commit that
6126            //   returns `Ok` having **silently discarded** the staged attribute.
6127            // * Strictly below `d`, the parent is a freshly built region with no
6128            //   existing link to patch, so `patch_link_target` reports a missing
6129            //   child link partway through the apply. That leaves a valid file
6130            //   (the superblock is never repointed) but appends dead bytes and
6131            //   reports the wrong thing.
6132            //
6133            // Refusing here keeps both in the preflight, where the commit is
6134            // all-or-nothing and the message can name the actual conflict.
6135            if recreated
6136                && !nodes
6137                    .iter()
6138                    .all(|(key, node)| !is_prefix(d, key) || node.is_new)
6139            {
6140                return Err(Error::EditUnsupported(
6141                    "a staged edit names a group at or under a replaced path that this \
6142                     commit does not itself create; create it in the same commit, or use \
6143                     separate commits",
6144                ));
6145            }
6146            // A copy reading from a path this commit replaces is the same conflict
6147            // seen from the other side: `read_copy_subtree` already took its bytes
6148            // from the pre-commit file, so the commit would place the *original*
6149            // at the copy's destination while placing something else at `d` — two
6150            // different objects from one path, in one commit, with no error. A
6151            // source that is merely deleted and not replaced is unambiguous (it is
6152            // a move) and stays allowed.
6153            if recreated {
6154                for t in &copy_sources {
6155                    if is_prefix(d, t) {
6156                        return Err(Error::EditUnsupported(
6157                            "a copy in this commit reads from a path the same commit \
6158                             replaces; use separate commits",
6159                        ));
6160                    }
6161                }
6162            }
6163            // Past that return `recreated` means the whole touched subtree is
6164            // fresh, so an addition at or below `d` lands in the replacement. The
6165            // apply loop already removes a group's deleted links before appending
6166            // any new one (`remove_link_from_region` runs first), so a
6167            // replacement needs no further sequencing here.
6168            for t in &add_targets {
6169                if recreated && is_prefix(d, t) {
6170                    continue;
6171                }
6172                if is_prefix(d, t) || is_prefix(t, d) {
6173                    return Err(Error::EditUnsupported(
6174                        "a deletion overlaps an addition in the same commit; \
6175                         replace the path instead, or use separate commits",
6176                    ));
6177                }
6178            }
6179            for t in &attr_targets {
6180                if recreated && is_prefix(d, t) {
6181                    continue;
6182                }
6183                if is_prefix(d, t) {
6184                    return Err(Error::EditUnsupported(
6185                        "a deletion overlaps a group-attribute edit in the same commit; use separate commits",
6186                    ));
6187                }
6188            }
6189            for t in &write_targets {
6190                if is_prefix(d, t) {
6191                    // `write_targets` holds three kinds — a value overwrite, a
6192                    // dataset-attribute edit, and a staged append — so the
6193                    // message names what they have in common rather than only
6194                    // the first of them.
6195                    return Err(Error::EditUnsupported(
6196                        "a deletion overlaps a staged edit to a dataset in the same \
6197                         commit; use separate commits",
6198                    ));
6199                }
6200            }
6201            for (j, d2) in delete_targets.iter().enumerate() {
6202                if i != j && is_prefix(d, d2) {
6203                    return Err(Error::EditUnsupported(
6204                        "overlapping deletions in one commit; delete the common parent only",
6205                    ));
6206                }
6207            }
6208            let parent = d[..d.len() - 1].to_vec();
6209            ensure_ancestors(&mut nodes, &parent);
6210            nodes
6211                .entry(parent)
6212                .or_default()
6213                .deletes
6214                .push(d.last().unwrap().clone());
6215        }
6216
6217        // Resolve / validate each node's base object-header region up front.
6218        // Every existing dirty group is rewritten to a freshly-appended header,
6219        // so its old header becomes dead bytes once the superblock is repointed;
6220        // `superseded_addrs` records those old headers for reclamation (#21),
6221        // paired with the path whose rebuilt header replaces each — the two
6222        // halves of one relocation, which is what an object reference stored
6223        // elsewhere in the file has to be repointed across (issue #324).
6224        let keys: Vec<PathKey> = nodes.keys().cloned().collect();
6225        let mut superseded_addrs: Vec<(PathKey, usize)> = Vec::new();
6226        for key in &keys {
6227            let is_new = nodes[key].is_new;
6228            if is_new {
6229                nodes.get_mut(key).unwrap().base_region = fresh_group_region();
6230            } else {
6231                let path_str = key.join("/");
6232                let addr = crate::group_v2::resolve_path_any_from_source(
6233                    &self.image(),
6234                    &self.superblock,
6235                    &path_str,
6236                )
6237                .map_err(|_| {
6238                    Error::EditUnsupported(
6239                        "a target group does not exist; create it first in this session",
6240                    )
6241                })?;
6242                let addr = usize::try_from(addr)
6243                    .map_err(|_| Error::EditUnsupported("group address exceeds this platform"))?;
6244                // Rebuilding this group moves its header and patches only the
6245                // link this commit resolved it through, so every other hard link
6246                // to it would be left naming the old header — which this commit
6247                // then frees. The aliases show the pre-commit group until
6248                // something reuses the span, and are unreadable after (issue
6249                // #327). Same rule, and the same lazily-computed count, as the
6250                // three relocating dataset writes above.
6251                //
6252                // The root group is exempt because it is named by the superblock
6253                // rather than by a link, so it has no entry in the count and
6254                // nothing to strand. Every other group was resolved by path and
6255                // therefore has at least one.
6256                if !key.is_empty() {
6257                    let counts = incoming_links
6258                        .get_or_insert_with(|| self.count_incoming_hard_links())
6259                        .as_ref();
6260                    match counts.and_then(|c| c.get(&(addr as u64)).copied()) {
6261                        Some(1) => {}
6262                        // Known, and more than one: the aliases are the problem.
6263                        Some(_) => {
6264                            return Err(Error::EditUnsupported(
6265                                "editing a group relocates its object header; only supported \
6266                                 when it has a single hard link",
6267                            ));
6268                        }
6269                        // Not known: the file-wide walk that counts links gave up
6270                        // — an object header it could not parse, a group it could
6271                        // not enumerate, or a link graph past its bound. A
6272                        // different refusal because it is a different problem, and
6273                        // "it has more than one hard link" would send the reader
6274                        // looking for a second link that may not exist.
6275                        None => {
6276                            return Err(Error::EditUnsupported(
6277                                "editing a group relocates its object header, and this file's \
6278                                 links could not be walked to establish that nothing else \
6279                                 names it",
6280                            ));
6281                        }
6282                    }
6283                }
6284                let info = self.inspect_group(addr)?;
6285                superseded_addrs.push((key.clone(), addr));
6286                let node = nodes.get_mut(key).unwrap();
6287                node.base_region = info.region;
6288                node.existing_links = info.link_names;
6289            }
6290        }
6291
6292        // A rebuilt group's old header is vacated just as a relocated dataset's is,
6293        // so the two join one list for the screen below.
6294        moved_headers.extend(superseded_addrs.iter().map(|&(_, a)| a as u64));
6295
6296        // Apply and validate group attribute edits before any writes. This keeps
6297        // unsupported attribute edits under the same all-or-nothing preflight
6298        // contract as unsupported dataset additions. A variable-length attribute
6299        // is not fully resolved here — its global heap collection is built (it
6300        // is self-contained, no address needed yet) but placed and patched into
6301        // `base_region` only in the apply loop below, once its address is known.
6302        let attrs_by_group = group_by_parent(staged.group_attrs.iter().map(|(p, op)| (p, op)));
6303        // The pre-commit header address of each group this commit rewrites. An
6304        // edit that sends a group's attributes to a fractal heap reads the set it
6305        // rebuilds back out of the file, which needs the address; a group this
6306        // commit *creates* has neither an address nor any stored attribute, and
6307        // its `None` says so.
6308        let existing_group_addrs: HashMap<PathKey, u64> = superseded_addrs
6309            .iter()
6310            .map(|(key, addr)| (key.clone(), *addr as u64))
6311            .collect();
6312        for key in &keys {
6313            if let Some(ops) = attrs_by_group.get(key) {
6314                let region = std::mem::take(&mut nodes.get_mut(key).unwrap().base_region);
6315                let edits = plan_attr_ops(
6316                    &self.image(),
6317                    base,
6318                    existing_group_addrs.get(key).copied(),
6319                    &region,
6320                    ops,
6321                )?;
6322                let node = nodes.get_mut(key).unwrap();
6323                node.base_region = edits.region;
6324                node.attrs = edits.attrs;
6325            }
6326        }
6327
6328        // Map each node to its direct child group nodes (for link wiring).
6329        let mut children: BTreeMap<PathKey, Vec<PathKey>> = BTreeMap::new();
6330        for key in &keys {
6331            if !key.is_empty() {
6332                let parent = key[..key.len() - 1].to_vec();
6333                children.entry(parent).or_default().push(key.clone());
6334            }
6335        }
6336
6337        // Each group's added datasets, in the order the apply loop will place
6338        // them, for the guards below to read. Borrowed from the staged set:
6339        // every one of those guards may still refuse, and a refusal gives the
6340        // caller back every edit it was holding (issue #316). The apply loop
6341        // rebuilds the same grouping, by the same rule, once it owns the set.
6342        let datasets_by_group = group_by_parent(staged.datasets.iter().map(|(p, fd)| (p, fd)));
6343
6344        // A group that tracks *link* creation order takes an addition — the
6345        // apply loop numbers each added link from the running maximum its Link
6346        // Info message records — up to the point the addition would send its
6347        // links dense, which is refused before anything is written. The count
6348        // this screens is the one the group would hold afterwards: the links it
6349        // has now, less the ones this commit deletes, plus the ones it adds.
6350        for key in &keys {
6351            let node = &nodes[key];
6352            let added = node.copies.len()
6353                + node.cross_copies.len()
6354                + datasets_by_group.get(key).map_or(0, Vec::len)
6355                + children.get(key).map_or(0, |kids| {
6356                    kids.iter().filter(|child| nodes[*child].is_new).count()
6357                });
6358            if added > 0 {
6359                let kept = node
6360                    .existing_links
6361                    .iter()
6362                    .filter(|name| !node.deletes.contains(name))
6363                    .count();
6364                reject_dense_link_creation_order(&node.base_region, kept + added)?;
6365            }
6366        }
6367
6368        // Validate names: no addition may collide with an existing link or with
6369        // another addition under the same parent. A link this same commit
6370        // deletes is not one of the existing ones — the apply loop removes it
6371        // from the region before any addition is appended, so replacing an
6372        // object at its own path is a rotation, not a collision (issue #305).
6373        for key in &keys {
6374            let node = &nodes[key];
6375            let mut adding: Vec<&str> = Vec::new();
6376            for fd in datasets_by_group.get(key).into_iter().flatten() {
6377                adding.push(&fd.name);
6378            }
6379            for child in children.get(key).into_iter().flatten() {
6380                if nodes[child].is_new {
6381                    adding.push(child.last().unwrap());
6382                }
6383            }
6384            for (leaf, _) in &node.copies {
6385                adding.push(leaf);
6386            }
6387            for leaf in &node.cross_copies {
6388                adding.push(leaf);
6389            }
6390            for (i, name) in adding.iter().enumerate() {
6391                let survives = node.existing_links.iter().any(|n| n == name)
6392                    && !node.deletes.iter().any(|n| n == name);
6393                if survives || adding[..i].contains(name) {
6394                    return Err(Error::EditUnsupported(
6395                        "a link with this name already exists in the target group",
6396                    ));
6397                }
6398            }
6399        }
6400
6401        // Content the caller's library-version bound cannot carry is refused
6402        // here, before any write, so a rejected addition leaves the commit
6403        // unapplied.
6404        self.check_libver_admits(staged.datasets.iter().map(|(_, fd)| fd))?;
6405
6406        // Enumerate what this commit's deletions reclaim, from the current
6407        // on-disk layout and before any byte moves. It is read here, ahead of
6408        // every remaining refusal, because two of them screen against it: an
6409        // object reference this commit writes must not name space the same
6410        // commit frees (issue #317). The spans are carried to `to_free` below
6411        // rather than walked a second time, so the screen and the allocator
6412        // always mean the same thing by "removed".
6413        //
6414        // An object's storage is reclaimed only when the link being removed is
6415        // its LAST hard link: HDF5 objects can have several hard links, and one
6416        // reachable through a surviving link is still live (freeing it would
6417        // corrupt the survivor). Count every hard link in the pre-commit file
6418        // and reclaim a deleted object only when its count is exactly 1.
6419        // `deleted_addrs` is de-duplicated first so two delete paths that are
6420        // hard links to the same object are not visited (and freed) twice. If
6421        // the link graph cannot be walked in full, no deleted object is
6422        // reclaimed (a safe leak) — and none is screened either, which is sound
6423        // in the same direction: nothing is freed, so no reference dangles.
6424        // Superseded group headers and relocated dataset headers are vacated too,
6425        // and are screened separately: `moved_headers` holds them, and they are
6426        // addresses rather than spans.
6427        let mut deleted_free: Vec<(u64, u64, FreeClass)> = Vec::new();
6428        deleted_addrs.sort_unstable();
6429        deleted_addrs.dedup();
6430        if !deleted_addrs.is_empty() {
6431            if let Some(incoming) = self.count_incoming_hard_links() {
6432                for &a in &deleted_addrs {
6433                    self.collect_free_spans(a, 0, &incoming, &mut deleted_free);
6434                }
6435            }
6436        }
6437        // An in-file copy re-emits its source's element bytes verbatim, so a
6438        // copied object reference keeps naming whatever it named in the source —
6439        // including an object this same commit is removing (issue #317). The page
6440        // types the walk records are for the allocator, not for this.
6441        //
6442        // Removals only, and this is the one place the two halves part company. A
6443        // *supplied* address is a fresh claim about the file, and a commit that
6444        // moves the object falsifies it. A *copied* one repeats a claim the file
6445        // already made, and since issue #324 a move no longer falsifies either
6446        // one: the commit's last act walks the tree it published and repoints
6447        // every reachable stored address, the copy's among them
6448        // ([`crate::reference_patch`], and `a_copy_made_in_the_same_commit_that_
6449        // moves_its_target_is_repointed`). A removal is the case that stays,
6450        // because there is no new address to point at.
6451        //
6452        // Refusing on `moved` here would also cost what it cannot buy: every
6453        // commit that reaches here rebuilds its root group, so `moved` is never
6454        // empty, and a chunked object-reference dataset is refused on a non-empty
6455        // screen alone rather than on a matching address — so every such copy
6456        // would be refused, in every commit.
6457        let for_copied = InvalidatedAddresses {
6458            removed: deleted_free
6459                .iter()
6460                .map(|&(off, len, _)| (off, len))
6461                .collect(),
6462            moved: Vec::new(),
6463            base: self.superblock.base_address,
6464        };
6465        {
6466            // Framed at the base address: a shared-message address is stored
6467            // relative to it, and `SourceResolver` reads its references as
6468            // absolute within the view it is given.
6469            let image = self.image();
6470            let framed = BaseOffsetSource {
6471                inner: image,
6472                base: self.superblock.base_address,
6473            };
6474            for key in &keys {
6475                for (_, tree) in &nodes[key].copies {
6476                    screen_copied_references(tree, &for_copied, &framed)?;
6477                }
6478            }
6479        }
6480
6481        // The same screen the copies just got, plus the half a supplied address
6482        // earns and a copied one does not.
6483        let for_supplied = InvalidatedAddresses {
6484            moved: moved_headers,
6485            ..for_copied
6486        };
6487
6488        // A shared-message index record can hold an object-header address too,
6489        // and it is the one stored address the commit's closing repoint walk
6490        // cannot reach. Screen it here, against the same two lists.
6491        self.screen_shared_message_index(&for_supplied)?;
6492
6493        // References a builder already resolved to addresses never reach
6494        // `resolve_reference_target`, so they are screened out of the element
6495        // bytes instead — additions and value overwrites alike, since neither
6496        // form's bytes are touched again before they are written. A slot still
6497        // holding a placeholder screens as the zero it is, and is screened for
6498        // real by `resolve_reference_target` when it resolves.
6499        //
6500        // Cross-file copies need no screen for the opposite reason:
6501        // `reject_foreign_addresses` refuses a reference datatype outright on
6502        // that path.
6503        for (_, fd) in staged.datasets.iter().chain(&staged.writes) {
6504            screen_resolved_references(&fd.dt, &fd.raw, &for_supplied)?;
6505        }
6506
6507        // Anything this commit adds could be a reference container the last walk
6508        // did not see, so it retires that walk's "no references in this file"
6509        // finding (issue #324). See `proved_free_of_references` for which of
6510        // these four clauses is load-bearing and which are belt-and-braces, and
6511        // for why the other five staged collections need no clause at all.
6512        //
6513        // A staged dataset's *attributes* are screened beside its element
6514        // datatype. Neither can carry a reference today — a committed one is
6515        // refused by `flatten_dataset`, and `AttrValue` has no reference variant
6516        // — but that is a refusal in another function rather than a property of
6517        // this collection, and the check that means what it says is the one that
6518        // looks.
6519        if staged.datasets.iter().chain(&staged.writes).any(|(_, fd)| {
6520            datatype_holds_object_address(&fd.dt)
6521                || fd
6522                    .attrs
6523                    .iter()
6524                    .any(|a| datatype_holds_object_address(&a.datatype))
6525        }) || !staged.copies.is_empty()
6526            || !staged.cross_copies.is_empty()
6527        {
6528            self.proved_free_of_references = false;
6529        }
6530
6531        // Prove every object-reference target resolves before any write (see
6532        // `preflight_reference_targets`'s doc comment): otherwise a reference
6533        // resolution failure discovered mid-apply-loop would leave every
6534        // earlier-processed group's real writes (headers, data, copied
6535        // subtrees) orphaned in the file despite `commit()` returning `Err`.
6536        Self::preflight_reference_targets(
6537            &keys,
6538            &datasets_by_group,
6539            &nodes,
6540            &add_targets,
6541            &write_targets,
6542            delete_targets,
6543            &for_supplied,
6544            &self.image(),
6545            &self.superblock,
6546        )?;
6547
6548        // --- The point of no return. Every refusal is behind us, so the staged
6549        // set is taken here rather than at the top of this function: a refusal
6550        // above restores *every* edit the caller staged, and a failure below —
6551        // an I/O error mid-apply, which leaves the file valid because the
6552        // superblock is repointed last — restores none of them, so no later
6553        // `commit` can finish a batch this one abandoned (issue #316). Nothing
6554        // above this line may consume from `staged`. ---
6555        let mut taken = std::mem::take(staged);
6556        // The same paths as the borrow above, owned now that the set has moved.
6557        let delete_targets = std::mem::take(&mut taken.deletes);
6558        // The same grouping as `datasets_by_group` above, by the same rule and
6559        // so in the same order — which is what keeps the apply loop's placement
6560        // order the one `preflight_reference_targets` proved.
6561        let mut flat = group_by_parent(taken.datasets.drain(..));
6562        // A cross-file copy's subtree can move now, so it joins its destination
6563        // group's in-file copies. The parent node exists: the preflight made one
6564        // for every destination.
6565        for (dst, tree) in taken.cross_copies.drain(..) {
6566            let leaf = dst.last().unwrap().clone();
6567            let parent = dst[..dst.len() - 1].to_vec();
6568            nodes.get_mut(&parent).unwrap().copies.push((leaf, tree));
6569        }
6570
6571        // Gather the regions this commit will vacate, read from the current
6572        // on-disk layout before any byte moves: every deleted object's owned
6573        // blocks plus every superseded group header. They stay out of the free
6574        // list until every append this commit makes is behind it, so none of
6575        // them is reused while it is still live. (On the non-persisting tail
6576        // that point is a few lines *before* the superblock write rather than
6577        // after it — the apply loop is where the appends happen, and it is long
6578        // done by then.) Enumeration is
6579        // best-effort — `collect_free_spans` simply omits anything it cannot
6580        // account for exhaustively, so the worst case is unreclaimed dead bytes,
6581        // never a freed-but-live region.
6582        // It starts as the deleted objects' blocks, enumerated above the preflight
6583        // because a refusal there screens against them.
6584        let mut to_free: Vec<(u64, u64, FreeClass)> = deleted_free;
6585
6586        // A superseded group header is dead once the root is repointed. Its chunk
6587        // spans are enumerated base-aware (`oh_chunk_spans` shifts continuation
6588        // addresses by the userblock base and returns absolute file offsets), as is
6589        // the delete path (`collect_free_spans`), so all of this reclamation works
6590        // on userblock files too.
6591        for &(_, a) in &superseded_addrs {
6592            if let Ok(spans) = self.oh_chunk_spans(a) {
6593                to_free.extend(meta_spans(spans));
6594            }
6595        }
6596
6597        // A relocating overwrite (`write_dataset` resize, or any compact rewrite)
6598        // vacates the dataset's old object header, and a resized contiguous one
6599        // also vacates its old data block: both become dead once the parent's
6600        // relinked header lands. `superseded_addrs` covers only the rebuilt group
6601        // headers, not the relocated dataset's own header, so record that here too.
6602        // The pre-commit dataset-header address rides on the write plan (`old_oh`),
6603        // recorded where the plan was made; its chunks and old data extent are
6604        // freed only after the superblock repoint.
6605
6606        // The single-hard-link guard in the write preflight makes freeing the old
6607        // header safe (no surviving link still points at it).
6608        for key in &keys {
6609            for (_leaf, old_oh, mw) in &nodes[key].writes {
6610                match mw {
6611                    MovingWrite::Contiguous {
6612                        old_extent: Some(extent),
6613                        ..
6614                    } => to_free.push((extent.0, extent.1, FreeClass::Page(PageType::Raw))),
6615                    // A relocated chunked dataset vacates its old chunk index and
6616                    // chunk data blocks. `chunked_storage_spans` returns `None` for
6617                    // anything it cannot enumerate exhaustively (leaving dead bytes
6618                    // rather than freeing a region still in use); the old header
6619                    // chunks are freed generically below.
6620                    MovingWrite::Chunked { old_addr, .. } => {
6621                        if let Ok(a) = usize::try_from(*old_addr) {
6622                            if let Some(spans) = self.chunked_storage_spans(a) {
6623                                to_free.extend(spans);
6624                            }
6625                        }
6626                    }
6627                    // A relocating append keeps the existing chunk *data* in place
6628                    // (shared by both indexes during the commit), so only the old
6629                    // index structure and the relocated old trailing chunk are dead.
6630                    // The old header chunks are freed by the generic path below.
6631                    MovingWrite::AppendedChunks {
6632                        old_addr,
6633                        old_tail_extent,
6634                        kept_chunks,
6635                        ..
6636                    } => {
6637                        if let Ok(a) = usize::try_from(*old_addr) {
6638                            if let Some(spans) = self.chunked_index_spans(a) {
6639                                // The old index is reclaimed as raw only where it
6640                                // provably sits in a raw page, and recorded as dead
6641                                // otherwise; see `index_is_provably_raw`. The
6642                                // dataset's old chunk data is the kept chunks
6643                                // (base-relative) plus the trailing partial chunk
6644                                // this append relocated — both already in hand, so
6645                                // the proof needs no second walk of the index.
6646                                let data: Vec<(u64, u64)> = kept_chunks
6647                                    .iter()
6648                                    .filter_map(|c| {
6649                                        Some((base.absolute(c.address).ok()?, c.compressed_size))
6650                                    })
6651                                    .chain(*old_tail_extent)
6652                                    .collect();
6653                                let class = if self.index_is_provably_raw(&data, &spans) {
6654                                    FreeClass::Page(PageType::Raw)
6655                                } else {
6656                                    FreeClass::Dead
6657                                };
6658                                to_free.extend(spans.into_iter().map(|(a, l)| (a, l, class)));
6659                            }
6660                        }
6661                        if let Some(ext) = old_tail_extent {
6662                            // The relocated old trailing chunk is raw data.
6663                            to_free.push((ext.0, ext.1, FreeClass::Page(PageType::Raw)));
6664                        }
6665                    }
6666                    _ => {}
6667                }
6668                // The relocated dataset's old header chunks are dead too.
6669                if let Ok(a) = usize::try_from(*old_oh) {
6670                    if let Ok(spans) = self.oh_chunk_spans(a) {
6671                        to_free.extend(meta_spans(spans));
6672                    }
6673                }
6674            }
6675        }
6676
6677        // Defense in depth: never hand the free list an out-of-bounds or
6678        // overlapping span. The last-link guard plus the per-object checks
6679        // should already make the accumulated spans disjoint; this enforces it
6680        // as a whole-commit invariant against the pre-commit end-of-file. Any
6681        // dropped span (which should not occur for a well-formed file) only
6682        // leaks, never corrupts.
6683        retain_disjoint_in_bounds(&mut to_free, self.image.len());
6684
6685        // --- Apply: process deepest groups first so each parent sees its
6686        // children's new addresses, then repoint the superblock last.
6687        // `path_addr` accumulates every group's and dataset's address as it is
6688        // placed — read by `resolve_reference_target` to resolve a same-commit
6689        // object-reference target (see the dataset-placement loop below for the
6690        // group/dataset key convention: a group's own path, or a dataset's
6691        // full parent+name path). ---
6692        let mut path_addr: BTreeMap<PathKey, u64> = BTreeMap::new();
6693        // Where each object-header address this commit vacates has been rewritten
6694        // to, for repointing the object references the rest of the file already
6695        // stores (issue #324). Filled from the two places a header moves: a
6696        // relocating value overwrite, recorded in the loop below as its plan is
6697        // applied, and a rebuilt group, joined from `superseded_addrs` once every
6698        // group's new address is known.
6699        let mut relocations: BTreeMap<u64, u64> = BTreeMap::new();
6700        let mut by_depth = keys.clone();
6701        // Stable on purpose: `keys` comes from a `BTreeMap`, so equal-depth
6702        // groups arrive in path order, and this order is the order they are
6703        // placed in — which fixes every address the commit writes. An unstable
6704        // sort may permute a depth group, and the file's layout with it.
6705        by_depth.sort_by_key(|k| std::cmp::Reverse(k.len())); // deepest first
6706        for key in &by_depth {
6707            let (mut region, deletes, copies, writes, attrs) = {
6708                let node = nodes.get_mut(key).unwrap();
6709                (
6710                    std::mem::take(&mut node.base_region),
6711                    std::mem::take(&mut node.deletes),
6712                    std::mem::take(&mut node.copies),
6713                    std::mem::take(&mut node.writes),
6714                    std::mem::take(&mut node.attrs),
6715                )
6716            };
6717
6718            // Remove deleted links first (verbatim-preserving the rest).
6719            //
6720            // First is a correctness requirement rather than a convention, and
6721            // has been since a replacement became one commit (issue #305):
6722            // `remove_link_from_region` matches by *name*, so a removal running
6723            // after the additions below would take the replacement's link with
6724            // the original's and leave the path gone. Every link this loop could
6725            // collide with — a copy's, a dataset's, a new child group's — is
6726            // appended after it, which is what keeps that unreachable. (A
6727            // relocating write and an existing child group *patch* a link rather
6728            // than appending one, so neither can be a replacement's.)
6729            for name in &deletes {
6730                region = remove_link_from_region(&region, name)?;
6731            }
6732
6733            // Every link appended below takes the next creation index from this
6734            // group's running maximum, on the groups that track link creation
6735            // order and on no others; `link_order.record` writes the bumped
6736            // maximum back once the last of them is placed. Read after the
6737            // deletions above because a deletion leaves the maximum alone — the
6738            // gap it opens is never handed out again.
6739            let mut link_order = LinkCreationOrder::for_region(&region)?;
6740
6741            // Write each staged source subtree and link its root into this group.
6742            // `write_copy_subtree` returns an absolute header address; the parent
6743            // link stores it relative to the userblock base.
6744            for (leaf, tree) in copies {
6745                let root = self.write_copy_subtree(&tree)?;
6746                region.push_link(&leaf, base.relative(root)?, link_order.take()?);
6747            }
6748
6749            // Datasets directly under this group. Appended addresses are absolute
6750            // file offsets; the contiguous data-layout address and the parent link
6751            // target are stored relative to the base address (`- base`). Placed
6752            // non-reference datasets first (recording each into `path_addr`), then
6753            // reference datasets — a reference to a *non-reference* sibling added
6754            // in the same group's batch resolves regardless of `staged.datasets`
6755            // call order (`Vec::sort_by_key` is stable, so within each of the two
6756            // groups the original order is preserved). Two reference datasets that
6757            // target each other in the same batch are still call-order-dependent —
6758            // whichever is placed first resolves the other, and the reverse
6759            // direction is safely refused as "still writing" (never corrupted),
6760            // caught up front by `preflight_reference_targets`.
6761            let mut group_datasets: Vec<FlatDataset> =
6762                flat.remove(key).into_iter().flatten().collect();
6763            // Stable on purpose: this is a partition, and the relative order
6764            // within each half is what the paragraph above promises.
6765            group_datasets.sort_by_key(|fd| fd.reference_targets.is_some());
6766            for mut fd in group_datasets {
6767                // Place each variable-length attribute's global heap collection
6768                // and patch its placeholder heap address. Unlike VL-string
6769                // *data* (`vl_string_staging`, refused when chunked below), a
6770                // chunked/extensible dataset can carry a VL *attribute* just
6771                // fine — attributes live in the object header, not inside a
6772                // chunk, so patching them here before either apply branch runs
6773                // covers both.
6774                for (idx, collections) in std::mem::take(&mut fd.vl_attrs) {
6775                    let addrs = self.place_vl_collections(&collections)?;
6776                    patch_vl_refs(&mut fd.attrs[idx].raw_data, &addrs);
6777                }
6778                // Resolve an object-reference dataset's per-element targets now
6779                // that every earlier-placed object in this commit is in
6780                // `path_addr` (chunked datasets never carry these —
6781                // `flatten_dataset` refuses that combination).
6782                if let Some(patches) = fd.reference_targets.take() {
6783                    for patch in &patches {
6784                        let addr = Self::resolve_reference_target(
6785                            &patch.target,
6786                            &path_addr,
6787                            &nodes,
6788                            &add_targets,
6789                            &write_targets,
6790                            &delete_targets,
6791                            &for_supplied,
6792                            &self.image(),
6793                            &self.superblock,
6794                        )?;
6795                        write_reference_address(&mut fd.raw, patch.byte_offset, addr);
6796                    }
6797                }
6798                let oh = if fd.chunk_options.is_chunked() || fd.maxshape.is_some() {
6799                    self.build_chunked_dataset(&fd)?
6800                } else {
6801                    // A staged variable-length-string dataset's element
6802                    // references still carry a placeholder heap address; place
6803                    // its collection and patch them before `raw` is appended
6804                    // (chunked datasets never carry staging — refused above).
6805                    if let Some(staging) = fd.vl_string_staging.take() {
6806                        if !staging.collections.is_empty() {
6807                            let addrs = self.place_vl_collections(&staging.collections)?;
6808                            patch_vl_refs_masked(&mut fd.raw, &staging.patch_offsets, &addrs);
6809                        }
6810                    }
6811                    // A zero-element dataset has no data block to allocate; its
6812                    // layout address is the undefined-address sentinel (never
6813                    // base-relative — see `build_dataset_oh`'s empty-data callers
6814                    // in the whole-file writer), matching every reader's and the
6815                    // reference C library's convention for "no storage allocated".
6816                    let data_addr = if fd.raw.is_empty() {
6817                        u64::MAX
6818                    } else {
6819                        base.relative(self.alloc_or_append_typed(&fd.raw, PageType::Raw)?)?
6820                    };
6821                    // Attributes this dataset keeps in a fractal heap are placed
6822                    // now — after the variable-length patching above, so the heap
6823                    // holds resolved references — and the header names the heap
6824                    // instead of carrying them inline.
6825                    let attr_info = self.place_dense_attrs_if_needed(&fd)?;
6826                    build_dataset_oh(
6827                        &fd.dt,
6828                        // Committed datatypes are refused when a dataset is
6829                        // staged (`flatten_dataset`), so every type here is
6830                        // written into the dataset's own header.
6831                        &DatatypeLocation::Inline,
6832                        &fd.ds,
6833                        data_addr,
6834                        fd.raw.len() as u64,
6835                        &fd.attrs,
6836                        attr_info.as_deref(),
6837                        fd.fill.as_deref(),
6838                        self.libver(),
6839                    )?
6840                };
6841                let oh_addr = self.alloc_or_append_typed(&oh, PageType::Meta)?;
6842                region.push_link(&fd.name, base.relative(oh_addr)?, link_order.take()?);
6843                let mut full = key.clone();
6844                full.push(fd.name.clone());
6845                path_addr.insert(full, oh_addr);
6846            }
6847
6848            // Relocating value overwrites under this group: write the new data and
6849            // rewritten header, then patch this group's existing link to it. The
6850            // link target is stored relative to the base address (`- base`); on a
6851            // userblock file only the chunked variant reaches here (contiguous and
6852            // compact resizes are refused in the write preflight).
6853            for (leaf, old_oh, mw) in &writes {
6854                let new_oh = self.write_moving(mw)?;
6855                patch_link_target(&mut region, leaf, base.relative(new_oh)?)?;
6856                relocations.insert(*old_oh, new_oh);
6857            }
6858
6859            // Wire links to dirty child groups (new → add a link; existing →
6860            // patch the existing link to the child's new address). Link targets are
6861            // stored relative to the base address.
6862            for child in children.get(key).into_iter().flatten() {
6863                let child_name = child.last().unwrap();
6864                let child_addr = base.relative(path_addr[child])?;
6865                if nodes[child].is_new {
6866                    region.push_link(child_name, child_addr, link_order.take()?);
6867                } else {
6868                    patch_link_target(&mut region, child_name, child_addr)?;
6869                }
6870            }
6871
6872            // Every addition to this group is placed, so its Link Info message
6873            // can record the maximum creation index it has now assigned. A group
6874            // that gained no link, or that does not track the order, is left
6875            // byte-identical.
6876            link_order.record(&mut region)?;
6877
6878            // Whatever this group's attribute edits left to place: a
6879            // variable-length attribute's heap collection, or a whole dense set.
6880            self.place_edited_attrs(&mut region, attrs)?;
6881
6882            let oh = build_v2_object_header(&region)?;
6883            let addr = self.alloc_or_append_typed(&oh, PageType::Meta)?;
6884            path_addr.insert(key.clone(), addr);
6885        }
6886
6887        // Same-length in-place overwrites (`write_dataset`) write straight into
6888        // their existing, already-referenced data blocks. Those blocks are
6889        // reachable from both the old and the new root (the dataset's header is
6890        // unchanged), so the write is independent of the superblock flip; it is
6891        // ordered before the barrier sync below so the new bytes are durable
6892        // alongside everything else this commit appended.
6893        // Nothing resolved here allocates: `prepare_write` plans a staged
6894        // variable-length overwrite as a relocating write rather than an
6895        // in-place one (issue #321), so every entry hands its staged bytes back
6896        // untouched — which the `vlen.is_none()` assertion guarding the fast
6897        // path states for the whole list, this one included. The journal below
6898        // is what costs here, and it is not free: see `inplace_undo`.
6899        for (data_addr, bytes) in &inplace_writes {
6900            let raw = self.resolve_overwrite_bytes(bytes)?;
6901            self.write_inplace_journaled(*data_addr, &raw)?;
6902        }
6903
6904        // Every group's new header address is known now that the apply loop has
6905        // run, so the rebuilt groups can join the relocation map. A group whose
6906        // key is missing would be one the loop did not place, which cannot happen:
6907        // `superseded_addrs` is filled from `keys`, and the loop visits all of it.
6908        for (key, old) in &superseded_addrs {
6909            if let Some(&new) = path_addr.get(key) {
6910                relocations.insert(*old as u64, new);
6911            }
6912        }
6913
6914        // Repoint the superblock at the new root last: this is the commit's
6915        // linearization point. Until it lands, the file on disk still points at
6916        // the old root (the appended objects are merely unreferenced trailing
6917        // bytes), so a failure here leaves a valid file.
6918        //
6919        // That ordering is only crash-safe if the appended objects are durable
6920        // before the root pointer is flipped; otherwise a power loss could
6921        // persist the flip ahead of the data it references, leaving the root
6922        // pointing at bytes that never reached disk. `flush` on a plain `File`
6923        // does not force a write-back, so sync the appended bytes to disk first
6924        // (the barrier), then flip the pointer, then sync the flip.
6925        let new_root = path_addr[&PathKey::new()];
6926
6927        // The heap collections this commit's value overwrites superseded. They
6928        // join `to_free` rather than being handed to the free list here, which
6929        // is what keeps them out of reach of this commit's own allocations:
6930        // nothing draws on `to_free` until the commit has finished placing, so
6931        // the elements naming them are still readable from the prior root for
6932        // as long as that root is the live one.
6933        // Freed as metadata because that is the page type they were placed as
6934        // (`place_vl_collections`), which is the only claim about them this
6935        // session can make from its own record.
6936        to_free.extend(
6937            self.superseded_heaps
6938                .drain(..)
6939                .map(|(addr, len)| (addr, len, FreeClass::Page(PageType::Meta))),
6940        );
6941        // Re-run the whole-commit invariant over the set these just joined, since
6942        // the pass above ran before the in-place writes resolved and so never saw
6943        // them. This is defensive and nothing reaches it: no test fails when it
6944        // is deleted, and no sequence has been found that puts a superseded
6945        // collection out of bounds or across another freed span. It is kept
6946        // because a heap collection is freed on the strength of a *record*
6947        // rather than of a structure read back out of the file, which is the one
6948        // span where a stale answer would not be caught by anything else.
6949        retain_disjoint_in_bounds(&mut to_free, self.image.len());
6950
6951        // A persisting file keeps its freed space recorded on disk rather than
6952        // truncating it away, so its commit takes a different tail: one that
6953        // rewrites the on-disk managers rather than only repointing the superblock.
6954        if self.persist.is_some() {
6955            self.commit_persisting_releasing_reserve(new_root, to_free)?;
6956            return self.repoint_stored_references(&relocations);
6957        }
6958
6959        // The new tree is fully written, so the regions this commit vacated are
6960        // now dead: hand them to the session free list. If the resulting free
6961        // space forms a run reaching end-of-file, the file can be physically
6962        // truncated to where that run starts; otherwise the end-of-file is
6963        // unchanged. `take_trailing` removes the trimmed run so it is not also
6964        // counted as reusable interior space.
6965        // The class is not consulted: a non-persisting commit is only reached on a
6966        // flat file (a paged one is refused without persistence), which has no page
6967        // types to keep apart and so vacates nothing it cannot place.
6968        for (a, l, _) in to_free.drain(..) {
6969            self.free.free(a, l);
6970        }
6971        let cur_eof = self.image.len();
6972        let trunc_to = self.free.take_trailing(cur_eof);
6973        let new_eof = trunc_to.unwrap_or(cur_eof);
6974
6975        self.barrier()?;
6976        // The root address is stored relative to the base address; the end-of-file
6977        // address is absolute. After writing the relative root to disk, keep the
6978        // in-memory `root_group_address` absolute (the open-time convention).
6979        if self.superblock.version >= 2 {
6980            // Build the new superblock off a clone and adopt it only once the
6981            // write succeeds, so a failed write does not desync the in-memory
6982            // state. The v2/v3 superblock carries its own checksum.
6983            let mut new_sb = self.superblock.clone();
6984            new_sb.root_group_address = base.relative(new_root)?;
6985            new_sb.eof_address = new_eof;
6986            // Publish the flags this session *raised*, so a flag the source file
6987            // arrived carrying (left set by a crashed SWMR writer, say) is
6988            // scrubbed and this clean commit leaves the file properly closed for
6989            // the C library (issue #73). serialize() recomputes the v2/v3
6990            // checksum.
6991            //
6992            // Provably zero here: the only session that holds flags across a
6993            // commit is a page-buffered one, which requires a paged file, and a
6994            // paged file takes the tail below instead. Written as the field
6995            // rather than as `0` because "publish what this session holds" is the
6996            // rule, and a site that states it as a literal is one refactor away
6997            // from being wrong silently (issue #308).
6998            new_sb.consistency_flags = self.held_status_flags;
6999            let sb_bytes = new_sb.serialize();
7000            self.publish_attempted = true;
7001            self.write_at(self.sb_sig_off, &sb_bytes)?;
7002            self.barrier()?;
7003            new_sb.root_group_address = new_root;
7004            self.superblock = new_sb;
7005        } else {
7006            self.publish_attempted = true;
7007            self.repoint_v0v1_root(base.relative(new_root)?, new_eof)?;
7008            self.barrier()?;
7009            self.superblock.root_group_address = new_root;
7010            self.superblock.eof_address = new_eof;
7011        }
7012
7013        // Physically shrink the file only after the superblock — now carrying the
7014        // smaller end-of-file — is durable. A crash between the two leaves a file
7015        // whose superblock end-of-file is correct and whose trailing bytes are
7016        // mere unreferenced slack, which the next open ignores; the reverse order
7017        // could advertise an end-of-file past the actual file length.
7018        if let Some(cut) = trunc_to {
7019            self.image.truncate(cut)?;
7020            self.barrier()?;
7021        }
7022        self.repoint_stored_references(&relocations)
7023    }
7024
7025    /// Repoint every object reference the file already stores at a header this
7026    /// commit moved (issue #324).
7027    ///
7028    /// Called at the end of each commit tail, *after* the superblock repoint,
7029    /// which is where it belongs on both counts. The commit is atomic at the
7030    /// repoint and this is a fixup derived from it, so it must not run earlier:
7031    /// a crash before the repoint has to leave the pre-commit file, its stored
7032    /// references included. And the tree it walks has to be the committed one —
7033    /// a rebuilt group's header is a fresh copy of the pre-commit one and
7034    /// carries the old address of anything it referenced, so patching the
7035    /// superseded header instead would correct bytes that are already dead.
7036    ///
7037    /// The writes are followed by a [`barrier`](Self::barrier), so a commit that
7038    /// returns under [`SyncPolicy::Always`] has put its references on disk and
7039    /// not merely its tree. That rests on `barrier`'s own contract rather than
7040    /// on a crash test — nothing here sweeps the window — and it costs nothing
7041    /// on the files that do not need it: a plan with nothing in it returns
7042    /// first, and on a file holding no object reference there is never anything
7043    /// in it.
7044    ///
7045    /// A crash between the repoint and that barrier leaves the commit standing
7046    /// with some references still stale, which is exactly the state every commit
7047    /// left before this existed.
7048    ///
7049    /// Bounded by [`MAX_LINK_GRAPH_NODES`], the same budget
7050    /// [`count_incoming_hard_links`](Self::count_incoming_hard_links) walks the
7051    /// link graph under.
7052    fn repoint_stored_references(&mut self, relocations: &BTreeMap<u64, u64>) -> Result<(), Error> {
7053        if self.proved_free_of_references {
7054            return Ok(());
7055        }
7056        let plan = crate::reference_patch::plan(
7057            &self.image(),
7058            &self.superblock,
7059            relocations,
7060            MAX_LINK_GRAPH_NODES,
7061        )?;
7062        // Record the walk's verdict before applying it, and only ever the
7063        // positive proof: a walk that found a reference, or could not read some
7064        // object, leaves the question open rather than answering "yes there are
7065        // references", because the next commit's answer could differ.
7066        if plan.proved_free_of_references() {
7067            self.proved_free_of_references = true;
7068        }
7069        if plan.is_empty() {
7070            return Ok(());
7071        }
7072        plan.apply(self)?;
7073        self.barrier()
7074    }
7075
7076    /// Commit tail for a file that persists its free space (issue #21). Unlike
7077    /// the non-persisting path, freed space is *retained* and recorded on disk —
7078    /// matching the reference library's persistent free-space strategy — so a
7079    /// later reopen (by this crate or the C library) recovers it.
7080    ///
7081    /// The post-commit free list (this commit's vacated regions plus the now-dead
7082    /// old free-space-manager and extension blocks) is serialized into a fresh
7083    /// `FSHD`/`FSSE` pair and a rewritten superblock-extension File Space Info
7084    /// message. The three are written as one contiguous tail, placed in space an
7085    /// *earlier* commit freed where it fits ([`flat_tail_layout`](Self::flat_tail_layout))
7086    /// and appended at end-of-file where it does not. Nothing live or
7087    /// still-referenced is overwritten either way, and the superblock — repointed
7088    /// last — is the linearization point, so a crash before it leaves the prior
7089    /// file (root, extension, and managers) wholly intact.
7090    ///
7091    /// The tail used to always append. Every commit then grew the file by one
7092    /// tail and freed its predecessor's, so a delete-and-recreate workload grew
7093    /// without bound while reporting all of that as reusable — the same defect
7094    /// [`commit_persisting_paged`](Self::commit_persisting_paged) had for its own
7095    /// tail (issue #286), on the strategy the paged one was measured against
7096    /// (issue #358).
7097    fn commit_persisting(
7098        &mut self,
7099        new_root: u64,
7100        to_free: Vec<(u64, u64, FreeClass)>,
7101        placement: TailPlacement,
7102    ) -> Result<(), Error> {
7103        // A paged file records its free space in per-page-type managers and keeps
7104        // its allocation page-aligned, so it takes its own tail (issue #198).
7105        if self.paged.is_some() {
7106            return self.commit_persisting_paged(new_root, to_free, placement);
7107        }
7108        let os = self.superblock.offset_size;
7109        let (strategy, threshold, page_size, old_blocks) = {
7110            // Copy what we need so no borrow of `self.persist` is held across the
7111            // `&mut self` writes below; the old state stays in place so a failure
7112            // leaves the session reusable.
7113            let ps = self
7114                .persist
7115                .as_ref()
7116                .expect("commit_persisting is only called when persistence is armed");
7117            (
7118                ps.strategy,
7119                ps.threshold,
7120                ps.page_size,
7121                ps.old_blocks.clone(),
7122            )
7123        };
7124
7125        let old_ext_rel = self
7126            .superblock
7127            .superblock_extension_address
7128            .filter(|&a| a != UNDEF)
7129            .ok_or(Error::EditUnsupported(
7130                "a persisting file has no superblock extension to update",
7131            ))?;
7132        let old_ext_addr = usize::try_from(old_ext_rel)
7133            .map_err(|_| Error::EditUnsupported("extension address exceeds this platform"))?;
7134
7135        // The persist File Space Info message is fixed-size, so the rewritten
7136        // extension's length is independent of the addresses it will carry: size
7137        // it with a placeholder to place the FSM blocks that follow it.
7138        let placeholder =
7139            FileSpaceInfo::persistent_single_manager(strategy, threshold, page_size, 0, 0);
7140        let ext_len =
7141            build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &placeholder)?)?
7142                .len() as u64;
7143
7144        // Place the tail — the rewritten extension and the manager blocks — in a
7145        // hole an *earlier* commit freed where one fits, and at end-of-file where
7146        // none does. `self.free` holds only durable free space (this commit's own
7147        // frees stay in `to_free` until the repoint), so a reused span holds bytes
7148        // already unreachable from the on-disk root, which is the guarantee
7149        // [`reserve`](Self::reserve) rests on too.
7150        let (post, placed_at, tail_len, eoa) =
7151            self.flat_tail_layout(&to_free, &old_blocks, ext_len, os);
7152        if placed_at.is_none() && placement == TailPlacement::ReuseOnly {
7153            // The shrink pass appends nothing: growing the file by a tail is the
7154            // opposite of what it was called for. Nothing has been written and the
7155            // layout handed its reservation back, so the session is exactly as the
7156            // commit before it left it.
7157            return Ok(());
7158        }
7159        let reused = placed_at.is_some();
7160        let ext_addr = placed_at.unwrap_or_else(|| self.image.len());
7161        let sections = free_sections(&post);
7162        let fshd_addr = ext_addr + ext_len;
7163        let fsse_addr = fshd_addr + fshd_len(os);
7164        // A reused tail sits inside the file, which ends it at the end-of-allocation
7165        // the layout settled on — the current end-of-file, less any run of free
7166        // space reaching it, which `post` no longer records and the truncation
7167        // below gives back to the filesystem (issue #418). An appended tail ends
7168        // the file itself.
7169        let final_eof = if reused { eoa } else { ext_addr + tail_len };
7170        debug_assert!(
7171            ext_addr + tail_len <= final_eof,
7172            "the tail must end at or below the end-of-allocation it was placed under"
7173        );
7174
7175        // Build the real extension and the FSM blocks. With no free space to
7176        // record we still refresh the extension (persist on, managers undefined).
7177        let (ext_oh, fsm_blocks) = if sections.is_empty() {
7178            let info = FileSpaceInfo::persistent_empty(strategy, threshold, page_size);
7179            let ext_oh =
7180                build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &info)?)?;
7181            (ext_oh, None)
7182        } else {
7183            // `eoa_pre_fsm` is the end-of-allocation before the free-space-manager
7184            // section blocks (`FSHD`/`FSSE`) were allocated: a consumer may shrink
7185            // back to here and rebuild them. For an appended tail that is the FSHD,
7186            // not the extension — the extension sits below it and persists, so
7187            // shrinking leaves the superblock and its extension pointer valid (only
7188            // the manager blocks, which are rewritten every commit, are discarded).
7189            // A tail placed *inside* the file gave the allocation back nothing to
7190            // shrink, so it records the end-of-allocation itself. Either way it is
7191            // defined, which a persisting file's message must be or an
7192            // assertion-enabled libhdf5 aborts on open (issue #178), and is the
7193            // value `H5Fget_freespace` accounts for correctly (verified in the
7194            // crosscheck).
7195            let eoa_pre_fsm = if reused { final_eof } else { fshd_addr };
7196            let info = FileSpaceInfo::persistent_single_manager(
7197                strategy,
7198                threshold,
7199                page_size,
7200                fshd_addr,
7201                eoa_pre_fsm,
7202            );
7203            let ext_oh =
7204                build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &info)?)?;
7205            let (fshd, fsse) =
7206                serialize_file_fsm(&sections, fshd_addr, fsse_addr, os, SECT_CLASS_SIMPLE);
7207            (ext_oh, Some((fshd, fsse)))
7208        };
7209        // Both forms of the message carry the same twelve manager slots, so either
7210        // one measures what the placeholder did. The tail was reserved for that
7211        // length, and the manager blocks are placed after it.
7212        debug_assert_eq!(
7213            ext_oh.len() as u64,
7214            ext_len,
7215            "extension length must be stable across the placeholder and real messages"
7216        );
7217
7218        // Write the extension, then the FSM blocks. Both forms are safe against a
7219        // crash here: an appended tail is past everything live, a reused one sits
7220        // in space an earlier commit freed, and neither is referenced until the
7221        // repoint below.
7222        let region = (ext_addr, tail_len);
7223        self.write_tail_block(region, ext_addr, &ext_oh)?;
7224        if let Some((fshd, fsse)) = fsm_blocks {
7225            self.write_tail_block(region, fshd_addr, &fshd)?;
7226            self.write_tail_block(region, fsse_addr, &fsse)?;
7227        }
7228        // Exactly the bytes written, contiguous from the extension, which is what
7229        // the next commit supersedes.
7230        let new_old_blocks = vec![(ext_addr, tail_len)];
7231
7232        // Barrier, then repoint the superblock (root, eof, and the new extension)
7233        // — the linearization point — and sync it.
7234        self.barrier()?;
7235        let mut new_sb = self.superblock.clone();
7236        new_sb.root_group_address = new_root;
7237        new_sb.eof_address = final_eof;
7238        new_sb.superblock_extension_address = Some(ext_addr);
7239        // As above, and zero for the same reason: this is the *unpaged* persisting
7240        // tail, which no page-buffered session reaches (issue #73, issue #308).
7241        new_sb.consistency_flags = self.held_status_flags;
7242        let sb_bytes = new_sb.serialize();
7243        self.publish_attempted = true;
7244        self.write_at(self.sb_sig_off, &sb_bytes)?;
7245        self.barrier()?;
7246        self.superblock = new_sb;
7247
7248        // Physically shrink the file only after the superblock — now carrying the
7249        // smaller end-of-file — is durable, exactly as the non-persisting tail
7250        // does: a crash between the two leaves trailing bytes the next open
7251        // ignores, where the reverse order would advertise an end-of-file past
7252        // the file's actual length. The managers just written describe `post`,
7253        // which no longer names anything above this cut.
7254        if final_eof < self.image.len() {
7255            self.image.truncate(final_eof)?;
7256            self.barrier()?;
7257        }
7258
7259        // The repoint is durable: the prior free list plus this commit's vacated
7260        // regions are now genuinely free, and the freshly written blocks become
7261        // the ones a future commit will supersede.
7262        self.free = post;
7263        self.persist = Some(PersistState {
7264            strategy,
7265            threshold,
7266            page_size,
7267            old_blocks: new_old_blocks,
7268        });
7269        // The managers describe the file at this length, so nothing is owed until
7270        // it grows past them again.
7271        self.fsm_len = self.image.len();
7272        Ok(())
7273    }
7274
7275    /// The free space a flat persisting commit is about to record: the session's
7276    /// durable list plus the regions this commit vacated and the superseded
7277    /// extension and manager blocks, which are dead once the superblock is
7278    /// repointed.
7279    ///
7280    /// Returned as a temporary rather than folded into the session, for the reason
7281    /// [`paged_post_free`](Self::paged_post_free) does the same: every region
7282    /// gathered here is still *live* until that repoint, and everything in between
7283    /// can fail, so a session that believed them free after a failed commit would
7284    /// hand them out over the objects still occupying them.
7285    ///
7286    /// Called once to *size* the tail and again for each round its placement takes
7287    /// to settle, since what remains free depends on the space the tail has drawn
7288    /// for itself ([`flat_tail_layout`](Self::flat_tail_layout)).
7289    fn flat_post_free(
7290        &self,
7291        to_free: &[(u64, u64, FreeClass)],
7292        old_blocks: &[(u64, u64)],
7293    ) -> FreeList {
7294        let mut post = self.free.clone();
7295        // A flat file keeps one list for the whole of it, so the class each freed
7296        // region carries for the paged path is not consulted here — and a flat
7297        // file produces no [`FreeClass::Dead`] region to begin with, since the
7298        // proof that classes one short-circuits where there are no page types to
7299        // keep apart.
7300        for &(a, l, _) in to_free {
7301            post.free(a, l);
7302        }
7303        for &(a, l) in old_blocks {
7304            post.free(a, l);
7305        }
7306        post
7307    }
7308
7309    /// Reserve free space for a flat persisting commit's tail, returning the free
7310    /// list the tail leaves behind, the address it got — `None` when nothing
7311    /// reusable fits, which is the caller's cue to append — its length, which the
7312    /// caller needs either way, and the end-of-allocation the commit should
7313    /// publish, which is the current end-of-file less any run of free space that
7314    /// reaches it (issue #418).
7315    ///
7316    /// The reservation and the length define each other, exactly as they do for the
7317    /// paged tail ([`tail_layout`](Self::tail_layout)): drawing bytes out of the
7318    /// free list changes the very sections the manager blocks record, and a
7319    /// different section set is a different length. This proposes a length, sizes
7320    /// the blocks against the list as it stands once that length is taken, and
7321    /// accepts any plan that *fits*. A longer plan is refused — it would write past
7322    /// the reservation into live bytes — and the proposal becomes the length that
7323    /// plan asked for, which is the natural next candidate.
7324    ///
7325    /// A plan **shorter** than its reservation is accepted, and the reservation
7326    /// rather than the plan is what the tail extent covers, so those spare bytes
7327    /// are freed with the rest of the tail when the next commit supersedes it.
7328    /// Rejecting them instead is what the paged tail does, and it can afford to:
7329    /// its fallback is a fresh page, recorded in full. Here the fallback is
7330    /// end-of-file, and the two lengths genuinely need not meet — best fit picks a
7331    /// different hole as the proposal crosses a hole's size, and the pair can
7332    /// oscillate by one section record indefinitely. Measured on the workload
7333    /// `persisting_churn_reaches_a_steady_size` runs, insisting on an exact fit sent
7334    /// about one commit in eight to end-of-file, which is the growth this exists to
7335    /// stop.
7336    ///
7337    /// What that costs is bounded and small: a commit that takes spare bytes and is
7338    /// then the last of its session leaves them behind, since a reopened session
7339    /// reconstructs the tail extent from the blocks themselves
7340    /// ([`load_persisted_free_space`](Self::load_persisted_free_space)) and cannot
7341    /// see a reservation that outlived the process. The spare is a section record
7342    /// and, where the reservation emptied a size group, that group's own header
7343    /// too — tens of bytes, once per session, against a whole tail on one commit in
7344    /// eight.
7345    ///
7346    /// Iteration is capped rather than trusted to converge: the length is a step
7347    /// function of the section set, so a proposal can in principle keep growing.
7348    /// Appending is always available and always correct, so giving up costs space
7349    /// rather than a guarantee — and the cap bounds the work, since each round
7350    /// clones the free list and sizes the section list for real.
7351    ///
7352    /// Draws from `self.free` before anything is written, which
7353    /// [`commit`](Self::commit) puts back if the attempt then fails. The one caller
7354    /// that does not snapshot is [`finalize_persist`](Self::finalize_persist), whose
7355    /// failure leaves this session's list short by the reservation — in memory only,
7356    /// on a session already being torn down, with the on-disk managers unchanged.
7357    ///
7358    /// When `self.free` has no hole for it, the tail may take one out of
7359    /// [`reserved`](Self::reserved) instead. Only the rewrite that publishes an
7360    /// append's draw ([`reserve_for_immediate_append`](Self::reserve_for_immediate_append))
7361    /// reaches this with anything there — every other caller releases the reserve
7362    /// first — and for that rewrite the fallback is what keeps the draw from
7363    /// growing the file: a draw takes every hole the append fits in, and on a
7364    /// flat file that is the one list the tail is placed from, so without it the
7365    /// rewrite appended its tail at end-of-file every time and the file grew by a
7366    /// tail per draw (issue #413). Space in the reserve is exactly what this
7367    /// rewrite publishes as *not* free, so a tail placed there is live in bytes
7368    /// the managers do not advertise, which is the same standing a tail placed
7369    /// from `self.free` has once the repoint lands. `self.free` first, so that an
7370    /// ordinary hole is spent before the append's own space is; best fit within
7371    /// the reserve then takes its smallest run, leaving the largest for the
7372    /// append that drew it. The paged tail ([`tail_layout`](Self::tail_layout))
7373    /// has no such fallback: it opens a page when nothing fits, and
7374    /// `paged_group_churn_with_populated_datasets_reaches_a_steady_size` holds it
7375    /// to not doing so once per draw.
7376    fn flat_tail_layout(
7377        &mut self,
7378        to_free: &[(u64, u64, FreeClass)],
7379        old_blocks: &[(u64, u64)],
7380        ext_len: u64,
7381        os: u8,
7382    ) -> (FreeList, Option<u64>, u64, u64) {
7383        /// Enough rounds for the section set to settle after a reservation shrinks
7384        /// it, without letting a proposal that keeps growing spin.
7385        const ROUNDS: usize = 4;
7386
7387        let eof = self.image.len();
7388        // The first proposal: what the tail would measure with nothing reserved.
7389        // A manager block's length depends only on the sections it records, never
7390        // on where it sits. It is also the answer for a tail that ends up appended,
7391        // since every round hands its reservation back before this returns.
7392        let probe = self.flat_post_free(to_free, old_blocks);
7393        let appended_len = ext_len + file_fsm_blocks_len(&free_sections(&probe), os);
7394        let mut proposed = appended_len;
7395
7396        for _ in 0..ROUNDS {
7397            let (at, from_reserve) = match self.free.alloc(proposed) {
7398                Some(at) => (at, false),
7399                None => match self.reserved.alloc(proposed) {
7400                    Some(at) => (at, true),
7401                    None => break,
7402                },
7403            };
7404            let mut post = self.flat_post_free(to_free, old_blocks);
7405            // Free space that reaches end-of-file is released rather than
7406            // recorded: the file is truncated to where the run starts, so the
7407            // sections these blocks are sized from must already leave it out
7408            // (issue #418). The reservation was taken before this list was built,
7409            // so the run can only begin at or above the tail's own end.
7410            let eoa = release_trailing_run(&mut post, eof, proposed);
7411            let len = ext_len + file_fsm_blocks_len(&free_sections(&post), os);
7412            if len <= proposed {
7413                debug_assert!(
7414                    at + proposed <= eoa,
7415                    "a reused tail at {at} of {proposed} bytes runs past the end-of-allocation \
7416                     {eoa} it was placed under"
7417                );
7418                return (post, Some(at), proposed, eoa);
7419            }
7420            if from_reserve {
7421                self.reserved.free(at, proposed);
7422            } else {
7423                self.free.free(at, proposed);
7424            }
7425            proposed = len;
7426        }
7427        // Nothing reusable fits, so the tail is appended past end-of-file and no
7428        // trailing run is released: whatever free space reaches end-of-file was
7429        // too small for the tail, and stays recorded below it.
7430        (probe, None, appended_len, eof)
7431    }
7432
7433    /// Commit tail for a genuine paged file (`H5F_FSPACE_STRATEGY_PAGE`, issue
7434    /// #198). The paged counterpart of [`commit_persisting`](Self::commit_persisting).
7435    ///
7436    /// Two things differ from the flat tail. Free space is recorded in *per-page-type*
7437    /// managers — SUPER (slot 0) for metadata, DRAW (slot 2) for small raw, and the
7438    /// generic-large manager (slot 6) for whole free pages and large-raw fragments —
7439    /// rather than one generic manager, so a paged file reopened by the reference
7440    /// library still finds its free space segregated. And the file is padded to a
7441    /// page before the tail is laid down, so the end-of-allocation stays a whole
7442    /// number of pages, matching the paged file the from-scratch writer produces.
7443    ///
7444    /// The tail itself is placed like any other run of metadata, in free space
7445    /// where some fits ([`tail_layout`](Self::tail_layout)), and only opens a page
7446    /// at end-of-file when none does. It used to always append into a page of its
7447    /// own and pad the remainder out untracked, which cost a page per commit and
7448    /// recorded none of it — a paged file under delete-and-recreate churn grew
7449    /// without bound while reporting a fraction of that as reusable (issue #286).
7450    /// The reference library does not page-align these blocks either: its manager
7451    /// headers sit at whatever offset within a metadata page they are allocated at.
7452    ///
7453    /// Crash atomicity is identical to the flat path: the tail is either past the
7454    /// live file or inside space an earlier commit freed, and is unreferenced
7455    /// either way until the superblock repoint, which is the linearization point.
7456    fn commit_persisting_paged(
7457        &mut self,
7458        new_root: u64,
7459        to_free: Vec<(u64, u64, FreeClass)>,
7460        placement: TailPlacement,
7461    ) -> Result<(), Error> {
7462        let os = self.superblock.offset_size;
7463        let (strategy, threshold, page_size, old_blocks) = {
7464            let ps = self
7465                .persist
7466                .as_ref()
7467                .expect("commit_persisting is only called when persistence is armed");
7468            (
7469                ps.strategy,
7470                ps.threshold,
7471                ps.page_size,
7472                ps.old_blocks.clone(),
7473            )
7474        };
7475
7476        // Page-align the file before anything else, so the rewritten extension and
7477        // the manager blocks begin on a page boundary and stay in metadata pages.
7478        // The padded tail becomes free space of whatever type that page held.
7479        self.pad_to_page()?;
7480
7481        let old_ext_rel = self
7482            .superblock
7483            .superblock_extension_address
7484            .filter(|&a| a != UNDEF)
7485            .ok_or(Error::EditUnsupported(
7486                "a persisting file has no superblock extension to update",
7487            ))?;
7488        let old_ext_addr = usize::try_from(old_ext_rel)
7489            .map_err(|_| Error::EditUnsupported("extension address exceeds this platform"))?;
7490
7491        // The 12-slot persist message is fixed-size, so a placeholder sizes the
7492        // rewritten extension before its manager addresses are known — and before
7493        // its address is, which is what lets the tail be sized before it is placed.
7494        let placeholder = FileSpaceInfo::persistent_managers(
7495            strategy,
7496            threshold,
7497            page_size,
7498            [UNDEF; NUM_FILE_FSM_MANAGERS],
7499            0,
7500        );
7501        let ext_len =
7502            build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &placeholder)?)?
7503                .len() as u64;
7504
7505        // Place the tail — the rewritten extension and the manager blocks — as an
7506        // ordinary run of metadata, in a hole an *earlier* commit freed where one
7507        // fits. That is what stops a file under delete-and-recreate churn from
7508        // growing: every commit frees its predecessor's tail exactly, so in the
7509        // steady state a tail lands in a hole an earlier one vacated — the one two
7510        // commits back, since a tail is freed into `pg`'s lists only at the repoint
7511        // that follows its successor — and the file never has to open a page for it
7512        // (issue #286). `pg`'s lists hold only
7513        // durable free space — this commit's own frees stay in `to_free` until the
7514        // repoint — so the bytes overwritten are already unreachable from the
7515        // on-disk root, the guarantee [`reserve`](Self::reserve) relies on.
7516        //
7517        // The tail is sized from the very lists it draws on, so its length and its
7518        // placement define each other: reserving space removes sections, and fewer
7519        // sections need fewer bytes to record. `tail_layout` settles that by
7520        // proposing a length, planning against it, and accepting the proposal only
7521        // when the plan comes out exactly that long — no shorter, which would leave
7522        // untracked bytes inside the reservation, and no longer, which would run
7523        // past it into whatever lives next. A few rounds settle it; a proposal that
7524        // will not converge falls through to the append below, which has no length
7525        // to satisfy.
7526        let placed = self.tail_layout(&to_free, &old_blocks, ext_len, page_size, os);
7527        if placed.is_none() && placement == TailPlacement::ReuseOnly {
7528            // As on the flat path: the shrink pass opens no page of its own.
7529            return Ok(());
7530        }
7531        let reused = placed.is_some();
7532        let (post, plan, ext_addr, blocks_len, eoa) = match placed {
7533            Some(layout) => layout,
7534            None => {
7535                // Nothing fits: open a metadata page at end-of-file. `pad_to_page`
7536                // above already left the file page-aligned, so this only records the
7537                // tail page's type. No trailing run is released here — whatever
7538                // free space reaches end-of-file was too small for the tail, and
7539                // stays recorded below it.
7540                self.begin_page(PageType::Meta)?;
7541                let at = self.image.len();
7542                let post = self.paged_post_free(&to_free, &old_blocks);
7543                let plan = plan_paged_managers(
7544                    &free_sections(&post.meta),
7545                    &free_sections(&post.raw),
7546                    &post.unclassified,
7547                    page_size,
7548                    at + ext_len,
7549                    os,
7550                );
7551                let blocks_len = plan.end_of_managers.max(at + ext_len) - at;
7552                (post, plan, at, blocks_len, at)
7553            }
7554        };
7555        let final_eof = if reused {
7556            // The tail landed inside the file; the end-of-allocation is where this
7557            // commit's appends left it — page-aligned by `pad_to_page`, less the
7558            // whole pages of free space reaching it, which `post` no longer
7559            // records and the truncation below gives back (issue #418).
7560            eoa
7561        } else {
7562            align_up(ext_addr + blocks_len, page_size)
7563        };
7564        debug_assert!(
7565            ext_addr + blocks_len <= final_eof,
7566            "the tail must end at or below the end-of-allocation it was placed under"
7567        );
7568
7569        let ext_oh = if plan.is_empty() {
7570            // No free space to track: an empty persist message, page-aligned.
7571            let info = FileSpaceInfo::persistent_empty(strategy, threshold, page_size);
7572            build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &info)?)?
7573        } else {
7574            // Paged convention (matching the from-scratch writer): the managers are
7575            // ordinary metadata below a page-aligned end-of-allocation.
7576            let info = FileSpaceInfo::persistent_managers(
7577                strategy, threshold, page_size, plan.slots, final_eof,
7578            );
7579            build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &info)?)?
7580        };
7581        debug_assert_eq!(
7582            ext_oh.len() as u64,
7583            ext_len,
7584            "extension length must be stable across the placeholder and real messages"
7585        );
7586
7587        // Write the extension, then every manager block. Both forms are safe against
7588        // a crash here: an appended tail is past everything live, and a reused one
7589        // sits in space an earlier commit freed. Neither is referenced until the
7590        // repoint below.
7591        let region = (ext_addr, blocks_len);
7592        self.write_tail_block(region, ext_addr, &ext_oh)?;
7593        for b in &plan.blocks {
7594            let (fshd, fsse) =
7595                serialize_file_fsm(&b.sections, b.fshd_addr, b.fsse_addr, os, b.class);
7596            self.write_tail_block(region, b.fshd_addr, &fshd)?;
7597            self.write_tail_block(region, b.fsse_addr, &fsse)?;
7598        }
7599        // An appended tail ends mid-page; pad it out, so the end-of-allocation stays
7600        // a whole number of pages. A reused tail is already inside the file, and
7601        // matched its reservation exactly, so there is nothing to pad — and where
7602        // it released a trailing run its end-of-allocation is *below* the current
7603        // end-of-file, which the truncation after the repoint gives back.
7604        if !reused {
7605            self.pad_zeros_to(final_eof)?;
7606        }
7607        // Exactly the bytes written, contiguous from the extension. A session that
7608        // reopens this file records the same extents from the message and the
7609        // manager headers, so nothing depends on remembering this across a close.
7610        let new_old_blocks = vec![(ext_addr, blocks_len)];
7611
7612        // Barrier, then repoint the superblock (root, eof, and the new extension)
7613        // — the linearization point — and sync it.
7614        self.barrier()?;
7615        let mut new_sb = self.superblock.clone();
7616        new_sb.root_group_address = new_root;
7617        new_sb.eof_address = final_eof;
7618        new_sb.superblock_extension_address = Some(ext_addr);
7619        // The one publish site a page-buffered session actually reaches, and so
7620        // the one where this must not be a literal zero: a crash mark has to
7621        // outlive every commit the session makes (issue #308).
7622        new_sb.consistency_flags = self.held_status_flags;
7623        let sb_bytes = new_sb.serialize();
7624        self.publish_attempted = true;
7625        self.write_at(self.sb_sig_off, &sb_bytes)?;
7626        self.barrier()?;
7627        self.superblock = new_sb;
7628
7629        // Physically shrink the file only after the superblock — now carrying the
7630        // smaller end-of-file — is durable, for the reason the flat tail gives:
7631        // a crash between the two leaves trailing bytes the next open ignores,
7632        // where the reverse order would advertise an end-of-file past the file's
7633        // actual length.
7634        if final_eof < self.image.len() {
7635            self.image.truncate(final_eof)?;
7636            self.barrier()?;
7637        }
7638
7639        // The repoint is durable. Only now are this commit's vacated regions
7640        // genuinely free, so adopt the lists built above and drop the padding tails
7641        // they already account for. The blocks just written become the ones the next
7642        // commit supersedes.
7643        if let Some(pg) = self.paged.as_mut() {
7644            pg.meta = post.meta;
7645            pg.raw = post.raw;
7646            pg.dead = post.dead;
7647            // Rebuilt rather than left alone: this list is otherwise constant for
7648            // the session, but a released trailing run may have cut into it, and a
7649            // section naming bytes the file no longer has would be written back to
7650            // the managers by the next commit.
7651            let mut unclassified = FreeList::new();
7652            for s in &post.unclassified {
7653                unclassified.free(s.addr, s.size);
7654            }
7655            pg.unclassified = unclassified;
7656            pg.meta_pad.clear();
7657            pg.raw_pad.clear();
7658            if !reused {
7659                // The tail page is fresh metadata: the managers sit in it, so a
7660                // following append of metadata may keep packing that page, and the
7661                // rest of it is free metadata this session can spend. The managers
7662                // just written cannot say so — they would have to describe space
7663                // whose size their own length decides — so the next commit records
7664                // it, from here. A reused tail leaves end-of-file where the data put
7665                // it, so the outgoing page type stands and there is no padding.
7666                pg.last = Some(PageType::Meta);
7667                pg.meta
7668                    .free(ext_addr + blocks_len, final_eof - (ext_addr + blocks_len));
7669            }
7670        }
7671        self.persist = Some(PersistState {
7672            strategy,
7673            threshold,
7674            page_size,
7675            old_blocks: new_old_blocks,
7676        });
7677        // The managers describe the file at this length, so nothing is owed until
7678        // it grows past them again.
7679        self.fsm_len = self.image.len();
7680        Ok(())
7681    }
7682
7683    /// Pad a paged file to a page boundary if its tail page is partially filled,
7684    /// recording the padding as free space of the tail page's type. A no-op on a
7685    /// non-paged file or an already-aligned one.
7686    fn pad_to_page(&mut self) -> Result<(), Error> {
7687        let len = self.image.len();
7688        let pad = match &self.paged {
7689            Some(pg) if len % pg.page_size != 0 => {
7690                Some((pg.last, pg.page_size - len % pg.page_size))
7691            }
7692            _ => None,
7693        };
7694        if let Some((last, pad_len)) = pad {
7695            let pad_at = len;
7696            self.append(&vec![0u8; pad_len.to_usize()?])?;
7697            if let Some(pg) = self.paged.as_mut() {
7698                match last {
7699                    // A partially-filled tail page at commit time is a raw page:
7700                    // the last thing the apply loop writes for a dataset is its
7701                    // header, but a commit that only wrote raw data ends on one.
7702                    Some(PageType::Meta) => pg.meta_pad.push((pad_at, pad_len)),
7703                    Some(PageType::Raw) => pg.raw_pad.push((pad_at, pad_len)),
7704                    // No typed append this commit, so the tail page is one a
7705                    // previous session left non-aligned — a crash, since a clean
7706                    // close pads. Its type is unknown, and recording the padding
7707                    // under a guess would advertise it for reuse of that type and
7708                    // mix the page, so leave it untracked (see `PagedEdit::begin`,
7709                    // which makes the same call for the same reason). Reuse made
7710                    // this reachable: before it, every commit appended at least the
7711                    // root group header, so `last` was always known here.
7712                    None => {}
7713                }
7714            }
7715        }
7716        Ok(())
7717    }
7718
7719    /// The free lists a paged commit is about to persist: the session's durable
7720    /// lists plus the page-padding tails this commit's appends left behind, the
7721    /// regions it vacated, and the superseded extension and manager blocks (all
7722    /// metadata, dead once the superblock is repointed).
7723    ///
7724    /// Every page the result leaves wholly empty is promoted to a free page before
7725    /// it is returned ([`PagedEdit::promote_whole_free_pages`]), so the managers
7726    /// this commit writes already describe it. Deferring the promotion to the
7727    /// repoint would leave the session holding free space its own on-disk record
7728    /// does not name.
7729    ///
7730    /// Returned as temporaries rather than folded into the session, exactly as the
7731    /// flat path builds `post`: every region gathered here is still *live* until
7732    /// that repoint, so the session's own lists must not learn about it until the
7733    /// repoint succeeds. Everything in between can fail (the extension rewrite,
7734    /// each write, each barrier), and a session that survived a failed commit
7735    /// while believing live extents were free would hand them out on the next
7736    /// commit — silently destroying the objects still occupying them.
7737    ///
7738    /// Called once to *size* the tail and again for each round the tail's
7739    /// placement takes to settle, since what remains depends on the space the tail
7740    /// has drawn for itself ([`tail_layout`](Self::tail_layout)). Each call clones
7741    /// both lists; they hold one region per hole, not per byte, so the copies are
7742    /// small.
7743    fn paged_post_free(
7744        &self,
7745        to_free: &[(u64, u64, FreeClass)],
7746        old_blocks: &[(u64, u64)],
7747    ) -> PagedPostFree {
7748        let pg = self
7749            .paged
7750            .as_ref()
7751            .expect("commit_persisting_paged is only called on a paged file");
7752        let (mut meta, mut raw, mut dead) = (pg.meta.clone(), pg.raw.clone(), pg.dead.clone());
7753        // Carried through untouched: this engine never frees into that list, and
7754        // nothing it places comes out of it.
7755        let unclassified = free_sections(&pg.unclassified);
7756        let mut free = |a: u64, l: u64, class: FreeClass| {
7757            PagedEdit::route_free(&mut meta, &mut raw, &mut dead, a, l, class);
7758        };
7759        for &(a, l) in &pg.meta_pad {
7760            free(a, l, PageType::Meta.into());
7761        }
7762        for &(a, l) in &pg.raw_pad {
7763            free(a, l, PageType::Raw.into());
7764        }
7765        for &(a, l, class) in to_free {
7766            free(a, l, class);
7767        }
7768        // The superseded extension and manager blocks, which are metadata wherever
7769        // they sat: the tail is placed as metadata, and a page it had to open for
7770        // itself was opened as metadata too.
7771        for &(a, l) in old_blocks {
7772            free(a, l, PageType::Meta.into());
7773        }
7774        PagedEdit::promote_whole_free_pages(&mut meta, &mut raw, &mut dead, pg.page_size);
7775        PagedPostFree {
7776            meta,
7777            raw,
7778            dead,
7779            unclassified,
7780        }
7781    }
7782
7783    /// Reserve free metadata space for a paged commit's tail and plan the manager
7784    /// blocks for the address it got, or `None` when nothing reusable fits — the
7785    /// caller then appends instead.
7786    ///
7787    /// The reservation and the plan are mutually dependent: drawing `len` bytes out
7788    /// of the free lists changes the sections those very blocks record, and a
7789    /// different section set is a different length. This proposes a length, plans
7790    /// against the lists as they stand once that length is taken, and accepts only
7791    /// an exact agreement. Anything else hands the reservation back and proposes
7792    /// the length the plan just came out at, which is the natural next candidate.
7793    ///
7794    /// Both kinds of disagreement have to be rejected, not just the obvious one. A
7795    /// plan **longer** than its reservation would write past it into whatever lives
7796    /// after. A plan **shorter** would leave bytes inside the reservation that no
7797    /// manager records and the next commit does not free — the leak this whole
7798    /// change exists to remove, reintroduced a few bytes at a time.
7799    ///
7800    /// Iteration is capped rather than trusted to converge: the length is a
7801    /// step function of the section set, so a proposal can in principle oscillate
7802    /// between two values that each imply the other. Appending is always available
7803    /// and always correct, so giving up costs a page rather than a guarantee.
7804    fn tail_layout(
7805        &mut self,
7806        to_free: &[(u64, u64, FreeClass)],
7807        old_blocks: &[(u64, u64)],
7808        ext_len: u64,
7809        page_size: u64,
7810        os: u8,
7811    ) -> Option<(PagedPostFree, PagedManagerPlan, u64, u64, u64)> {
7812        /// Enough rounds for the section set to settle after a reservation shrinks
7813        /// it, without letting an oscillating proposal spin.
7814        const ROUNDS: usize = 4;
7815
7816        let eof = self.image.len();
7817
7818        // The first proposal: what the blocks would measure with nothing reserved.
7819        // A manager block's length depends only on the sections it records, never
7820        // on its address, so a start of 0 measures the blocks alone.
7821        let probe = self.paged_post_free(to_free, old_blocks);
7822        let mut proposed = ext_len
7823            + plan_paged_managers(
7824                &free_sections(&probe.meta),
7825                &free_sections(&probe.raw),
7826                &probe.unclassified,
7827                page_size,
7828                0,
7829                os,
7830            )
7831            .end_of_managers;
7832
7833        for _ in 0..ROUNDS {
7834            let pg = self
7835                .paged
7836                .as_mut()
7837                .expect("commit_persisting_paged is only called on a paged file");
7838            let at = pg.alloc_typed(proposed, PageType::Meta)?;
7839            let mut post = self.paged_post_free(to_free, old_blocks);
7840            // Whole free pages at the end of the file are released rather than
7841            // recorded, so the sections these blocks are sized from must already
7842            // leave them out (issue #418). The reservation was taken before this
7843            // list was built, so the run can only begin at or above the tail's own
7844            // end.
7845            let eoa = post.release_trailing(eof, page_size, proposed);
7846            // Class the free space into its managers and place their blocks after
7847            // the extension. Shared with the bounded backend so both lay out
7848            // identically.
7849            let plan = plan_paged_managers(
7850                &free_sections(&post.meta),
7851                &free_sections(&post.raw),
7852                &post.unclassified,
7853                page_size,
7854                at + ext_len,
7855                os,
7856            );
7857            // An empty plan writes no blocks at all, leaving the tail the extension
7858            // alone; `end_of_managers` is then its own start.
7859            let blocks_len = plan.end_of_managers.max(at + ext_len) - at;
7860            if blocks_len == proposed {
7861                debug_assert!(
7862                    at + blocks_len <= eoa,
7863                    "a reused tail at {at} of {blocks_len} bytes runs past the end-of-allocation \
7864                     {eoa} it was placed under"
7865                );
7866                return Some((post, plan, at, blocks_len, eoa));
7867            }
7868            let pg = self
7869                .paged
7870                .as_mut()
7871                .expect("the paged state outlives this loop");
7872            PagedEdit::route_free(
7873                &mut pg.meta,
7874                &mut pg.raw,
7875                &mut pg.dead,
7876                at,
7877                proposed,
7878                PageType::Meta.into(),
7879            );
7880            proposed = blocks_len;
7881        }
7882        None
7883    }
7884
7885    /// Write one block of a persisting commit's tail — paged or flat — at the
7886    /// address its layout gave it, which is either the current end-of-file (the
7887    /// tail is being appended) or inside `region`, the span reserved from the free
7888    /// lists, when the tail is being reused into space an earlier commit left.
7889    ///
7890    /// The region is checked rather than asserted, for the reason
7891    /// [`place`](Self::place) checks its own reservation: a reused span is followed
7892    /// by live bytes, so a block running past it would silently destroy a
7893    /// neighboring object, and this is the one write in a commit that lands in the
7894    /// middle of a live file. The region's length is derived from the same layout
7895    /// that placed these blocks, so the two cannot disagree today; the comparison
7896    /// is what keeps that true if a layout ever learns a length this sizing does
7897    /// not model.
7898    fn write_tail_block(
7899        &mut self,
7900        region: (u64, u64),
7901        addr: u64,
7902        bytes: &[u8],
7903    ) -> Result<(), Error> {
7904        let (start, len) = region;
7905        if addr < start || addr + bytes.len() as u64 > start + len {
7906            return Err(Error::Format(FormatError::SerializationError(format!(
7907                "a commit's tail reserved [{start}, {}) but placed {} bytes at {addr}",
7908                start + len,
7909                bytes.len()
7910            ))));
7911        }
7912        if addr == self.image.len() {
7913            let written = self.append(bytes)?;
7914            debug_assert_eq!(written, addr, "an appended block must land at end-of-file");
7915            return Ok(());
7916        }
7917        self.write_at(
7918            usize::try_from(addr)
7919                .map_err(|_| Error::EditUnsupported("tail address exceeds this platform"))?,
7920            bytes,
7921        )
7922    }
7923
7924    /// Extend the file with zeros up to `target` (>= the current length), used by
7925    /// the paged tail to pad the final metadata page to its boundary.
7926    fn pad_zeros_to(&mut self, target: u64) -> Result<(), Error> {
7927        let len = self.image.len();
7928        if target > len {
7929            let pad = (target - len).to_usize()?;
7930            self.append(&vec![0u8; pad])?;
7931        }
7932        debug_assert_eq!(self.image.len(), target);
7933        Ok(())
7934    }
7935
7936    /// Rebuild the superblock-extension object header's message region with its
7937    /// File Space Info message replaced by `info` (every other message preserved
7938    /// verbatim), ready to wrap with [`build_v2_object_header`]. The persisting
7939    /// message is fixed-size, so this never changes the region's length.
7940    fn rewrite_extension_region(
7941        &self,
7942        ext_addr: usize,
7943        info: &FileSpaceInfo,
7944    ) -> Result<OhRegion, Error> {
7945        let region =
7946            Self::gather_oh_messages(&self.image(), ext_addr as u64, self.superblock.base_address)?;
7947        rewrite_extension_region_bytes(&region, info)
7948    }
7949
7950    /// Repoint a version 0/1 superblock at the rebuilt (now v2) root group and
7951    /// update its end-of-file field, patching the raw bytes in place — these
7952    /// superblocks carry no checksum. The root symbol-table entry is switched to
7953    /// cache type 0 (its scratch-pad B-tree / local-heap addresses, which
7954    /// describe the old symbol-table group, no longer apply). The
7955    /// object-header-address write is done last so it is the linearization point.
7956    fn repoint_v0v1_root(&mut self, new_root: u64, new_eof: u64) -> Result<(), Error> {
7957        let os = self.superblock.offset_size as usize;
7958        // Field layout after the fixed prefix: base / free-space / EOF / driver
7959        // addresses, then the root symbol-table entry (link-name offset, object
7960        // header address, cache type(4), reserved(4), scratch(16)). The prefix is
7961        // 24 bytes for v0 and 28 for v1 (the latter adds indexed-storage-K).
7962        let var_start = if self.superblock.version == 0 { 24 } else { 28 };
7963        let base = self.sb_sig_off + var_start;
7964        let eof_off = base + 2 * os;
7965        let ste = base + 4 * os;
7966        let oh_addr_off = ste + os;
7967        let cache_off = ste + 2 * os;
7968        self.write_at(eof_off, &new_eof.to_le_bytes()[..os])?;
7969        self.write_at(cache_off, &[0u8; 4])?; // cache type = none
7970        self.write_at(cache_off + 8, &[0u8; 16])?; // clear scratch-pad
7971        self.write_at(oh_addr_off, &new_root.to_le_bytes()[..os])?;
7972        Ok(())
7973    }
7974
7975    /// Collect every message of the object header at `addr` into one contiguous
7976    /// region, following continuation blocks across chunks and dropping the
7977    /// `Continuation` messages themselves. Re-emitting the result through
7978    /// [`build_v2_object_header`] collapses a multi-chunk header (as the
7979    /// reference C library often writes) into a single chunk, which is how this
7980    /// editor rebuilds headers. The chunk-0 prefix is validated by
7981    /// [`oh_region_at`]; each continuation block must be a well-formed `OCHK`
7982    /// block within the file.
7983    ///
7984    /// Reads each header chunk out of `src` as one bounded buffer rather than
7985    /// indexing a whole-file image, so this serves a session whose file is not
7986    /// mirrored in memory (issue #198).
7987    fn gather_oh_messages<S: Source + ?Sized>(
7988        src: &S,
7989        addr: u64,
7990        base: BaseAddress,
7991    ) -> Result<OhRegion, Error> {
7992        let chunks = read_oh_chunks(src, addr, base)?;
7993        // Every chunk of one header shares what chunk 0's prefix declared, which
7994        // is what makes concatenating their records into one region well defined,
7995        // and what carries the prefix's optional blocks through to the rebuild.
7996        let mut out = OhRegion::empty(chunks[0].props());
7997        for chunk in &chunks {
7998            let layout = chunk.layout();
7999            let (region, mut p) = chunk.message_region();
8000            while let Some((msg_type, _body, body_end)) = layout.next_message(region, p)? {
8001                if msg_type != MessageType::ObjectHeaderContinuation {
8002                    out.push_bytes(&region[p..body_end]);
8003                }
8004                p = body_end;
8005            }
8006        }
8007        Ok(out)
8008    }
8009
8010    /// Reconstruct a version-1 (symbol-table) group as a fresh v2 compact-link
8011    /// message region: a LinkInfo message, one Link message per existing child,
8012    /// and the group's existing attributes (re-wrapped as v2 messages). The
8013    /// symbol-table message and other non-link/non-attribute messages
8014    /// (modification time, comment, …) are dropped — editing a v0/v1 group
8015    /// converts it to the latest format. Refuses an attribute it cannot
8016    /// reproduce (shared, or larger than a v2 message can hold).
8017    fn reconstruct_v1_group(&self, addr: usize) -> Result<GroupInfo, Error> {
8018        let os = self.superblock.offset_size;
8019        let ls = self.superblock.length_size;
8020        let base = self.superblock.base_address;
8021        let oh = ObjectHeader::parse_from_source(&self.image(), addr as u64, os, ls, base)?;
8022        if oh
8023            .messages
8024            .iter()
8025            .any(|m| m.msg_type == MessageType::DataLayout)
8026        {
8027            return Err(Error::EditUnsupported(
8028                "a target path names a dataset, not a group",
8029            ));
8030        }
8031        let entries = resolve_group_entries_from_source(&self.image(), &oh, os, ls, base)?;
8032
8033        let mut region = fresh_group_region();
8034        let mut link_names = Vec::with_capacity(entries.len());
8035        for e in &entries {
8036            // Group-entry addresses are already stored relative to the base address,
8037            // matching how `encode_link_message` stores link targets — so they are
8038            // re-emitted verbatim, no base conversion needed. A version 1 group
8039            // records no link creation order — the mechanism arrived with the
8040            // Link Info message — so these links carry no creation index, and
8041            // `fresh_group_region` declares none.
8042            region.push_link(&e.name, e.object_header_address, None);
8043            link_names.push(e.name.clone());
8044        }
8045        for m in &oh.messages {
8046            if m.msg_type == MessageType::Attribute {
8047                if m.data.len() > OBJECT_HEADER_MESSAGE_MAX {
8048                    return Err(Error::EditUnsupported(
8049                        "a v0/v1 group attribute is too large to convert in place",
8050                    ));
8051                }
8052                if m.flags & !MSG_FLAG_SHARED != 0 {
8053                    // Every other flag bit says something about the message this
8054                    // rewrite would have to reproduce and does not — that it is
8055                    // constant, that it must not be shared, what a reader should
8056                    // do if it cannot decode it. The shared bit is the one this
8057                    // conversion knows how to carry.
8058                    return Err(Error::EditUnsupported(
8059                        "a v0/v1 group attribute message carries an object-header flag this \
8060                         conversion cannot reproduce",
8061                    ));
8062                }
8063                if m.flags & MSG_FLAG_SHARED != 0 {
8064                    // The body is a reference, not an attribute; the reference is
8065                    // what gets rewrapped, in the encoding a version 2 header
8066                    // uses. Reachable only from a file whose version 1 objects
8067                    // share messages, which the reference C library does not
8068                    // write — it moves an object to a version 2 header before
8069                    // sharing anything on it.
8070                    region.push_shared(
8071                        MessageType::Attribute,
8072                        &modernize_shared_reference(&m.data, os, ls)?,
8073                    );
8074                    continue;
8075                }
8076                // Re-wrap the attribute message body (it is self-describing) in a
8077                // v2 message record. The rebuilt header does not track creation
8078                // order — a version 1 header cannot have carried any — so the
8079                // attribute needs no creation index.
8080                region.push(MessageType::Attribute, &m.data);
8081            }
8082        }
8083        Ok(GroupInfo { region, link_names })
8084    }
8085
8086    /// Parse and validate a group's object header, returning its message region
8087    /// — the bytes to copy when rewriting the header — and the names of its
8088    /// existing links. A version 2 header is rebuilt from its own message bytes
8089    /// (collapsing continuation chunks, preserving every message); a version 1
8090    /// symbol-table group is converted to v2 via [`reconstruct_v1_group`].
8091    fn inspect_group(&self, addr: usize) -> Result<GroupInfo, Error> {
8092        let sig = self.image().read_metadata_at(addr as u64, 4);
8093        if sig.as_deref() != Ok(&b"OHDR"[..]) {
8094            return self.reconstruct_v1_group(addr);
8095        }
8096        let mut region =
8097            Self::gather_oh_messages(&self.image(), addr as u64, self.superblock.base_address)?;
8098        let mut p = 0;
8099        let mut has_link_info = false;
8100        let mut link_names = Vec::new();
8101        while let Some((msg_type, body, body_end)) = region.next_message(p)? {
8102            match msg_type {
8103                MessageType::LinkInfo => {
8104                    has_link_info = true;
8105                    // LinkInfo: version(1) flags(1) [max_creation_index(8) if
8106                    // flags&0x01] fractal_heap_addr(8) … — dense storage has a
8107                    // defined fractal-heap address. Bound the read by this
8108                    // message's own body, not just the region, so a short or
8109                    // malformed LinkInfo can't make us read the next message.
8110                    let mut q = body + 2;
8111                    if body_end - body >= 2 && region[body + 1] & 0x01 != 0 {
8112                        q += 8;
8113                    }
8114                    if q + 8 <= body_end {
8115                        let heap_addr = u64::from_le_bytes(region[q..q + 8].try_into().unwrap());
8116                        if heap_addr != u64::MAX {
8117                            return Err(Error::EditUnsupported(
8118                                "a target group uses dense (fractal-heap) link storage (not supported in place yet)",
8119                            ));
8120                        }
8121                    }
8122                }
8123                MessageType::Link => {
8124                    if let Ok(link) = LinkMessage::parse(&region[body..body_end], OFFSET_SIZE) {
8125                        link_names.push(link.name);
8126                    }
8127                }
8128                MessageType::DataLayout => {
8129                    return Err(Error::EditUnsupported(
8130                        "a target path names a dataset, not a group",
8131                    ));
8132                }
8133                _ => {}
8134            }
8135            p = body_end;
8136        }
8137        if !has_link_info {
8138            return Err(Error::EditUnsupported(
8139                "a target group's object header has no link-info message",
8140            ));
8141        }
8142        // Heal headers written by older hdf5-pure releases that omitted the
8143        // Group Info message, so the rewritten group stays writable by the C
8144        // library.
8145        ensure_group_info(&mut region)?;
8146        Ok(GroupInfo { region, link_names })
8147    }
8148
8149    /// The refusals a staged value overwrite (`write_dataset`) makes from the
8150    /// staged builder alone, with no on-disk header involved.
8151    ///
8152    /// [`stage_dataset_write`](Self::stage_dataset_write) applies these as the
8153    /// write is staged, so the call that configured the builder is the one that
8154    /// reports the mistake. A refusal that reads only `fd` belongs here; one that
8155    /// needs the target's header belongs in
8156    /// [`prepare_write`](Self::prepare_write).
8157    fn refuse_unsupported_overwrite(fd: &FlatDataset) -> Result<(), Error> {
8158        // A value overwrite never introduces chunking, filters, or an extensible
8159        // shape: those would change the storage layout, not just the bytes.
8160        if fd.chunk_options.is_chunked() || fd.maxshape.is_some() {
8161            return Err(Error::EditUnsupported(
8162                "write_dataset overwrites values only; it cannot make a dataset \
8163                 chunked, filtered, or extensible",
8164            ));
8165        }
8166
8167        // `write_dataset` overwrites element bytes only; it does not touch the
8168        // object header's attribute messages. Attributes staged on the returned
8169        // builder would otherwise be silently dropped (the in-place path rewrites
8170        // only the data block, and the moving path reuses the verbatim on-disk
8171        // header), so refuse rather than degrade — set them in a separate edit.
8172        if !fd.attrs.is_empty() {
8173            return Err(Error::EditUnsupported(
8174                "write_dataset overwrites values only; it cannot set attributes \
8175                 (set them with a separate edit)",
8176            ));
8177        }
8178
8179        // `write_dataset` overwrites element bytes only; it reuses the dataset's
8180        // existing Fill Value message (the in-place path rewrites only the data
8181        // block, and the moving path keeps every header message but the layout
8182        // verbatim). A fill value staged on the returned builder would otherwise
8183        // be silently ignored, so refuse rather than degrade — set the fill value
8184        // when the dataset is first created.
8185        if fd.fill.is_some() {
8186            return Err(Error::EditUnsupported(
8187                "write_dataset overwrites values only; it cannot change the fill \
8188                 value (set it when the dataset is created)",
8189            ));
8190        }
8191
8192        // `with_vlen_strings` stages placeholder element references, resolved by
8193        // placing their global heap collections and patching the addresses in
8194        // (issue #321). That happens where each plan is written, not here: see
8195        // `OverwriteBytes`.
8196
8197        // `reference_targets` holds elements only the add path resolves, in
8198        // `preflight_reference_targets` and the apply loop after it; the
8199        // overwrite path never reads the field. Checked on the field rather than
8200        // on `with_path_references`, since every producer stages elements that
8201        // are equally unresolved. Left unrefused, a staged overwrite writes
8202        // address-zero placeholders over a working reference dataset and
8203        // `commit` reports `Ok` (issue #318). `with_reference_data` supplies
8204        // resolved addresses and overwrites like any other value.
8205        if fd.reference_targets.is_some() {
8206            return Err(Error::EditUnsupported(
8207                "write_dataset cannot overwrite an object-reference dataset's \
8208                 data in place yet",
8209            ));
8210        }
8211
8212        Ok(())
8213    }
8214
8215    /// Preflight a staged value overwrite (`write_dataset`): resolve the dataset
8216    /// at `addr`, validate that the staged `fd` matches it byte-exactly in
8217    /// datatype and shape, and classify how the bytes will be applied. No file
8218    /// bytes are written here — this is part of the all-or-nothing preflight, so a
8219    /// rejected write leaves the commit unapplied.
8220    ///
8221    /// Contiguous, compact, and chunked (including filtered) datasets are all
8222    /// supported; the chunk geometry, filter pipeline, and chunk index come from
8223    /// the on-disk header (a chunk index this engine cannot enumerate — a
8224    /// version-2 B-tree — is refused). A datatype or shape that differs from the
8225    /// on-disk dataset's is likewise refused — this is a value overwrite, not a
8226    /// reshape or retype.
8227    ///
8228    /// Every refusal `fd` can decide on its own has been made by then, where the
8229    /// write is staged.
8230    fn prepare_write<S: Source + ?Sized>(
8231        src: &S,
8232        addr: u64,
8233        fd: &FlatDataset,
8234        base: BaseAddress,
8235        path: &PathKey,
8236    ) -> Result<WritePlan, Error> {
8237        // Enforced by construction: `staged.writes` has one producer, and it
8238        // refuses there. Asserted rather than re-refused because a second caller
8239        // that skipped it would not fail here — a chunked builder would fall
8240        // through and be written as a plain contiguous overwrite, with
8241        // `chunk_options` silently dropped.
8242        debug_assert!(
8243            Self::refuse_unsupported_overwrite(fd).is_ok(),
8244            "a staged write reached prepare_write without refuse_unsupported_overwrite"
8245        );
8246
8247        let region = Self::gather_oh_messages(src, addr, base)?;
8248
8249        // Locate the datatype, dataspace, and data-layout messages, and detect a
8250        // filter pipeline (filtered storage is always chunked, never contiguous).
8251        let mut datatype: Option<(usize, usize)> = None;
8252        let mut dataspace: Option<(usize, usize)> = None;
8253        let mut layout: Option<(usize, usize)> = None;
8254        let mut filter: Option<(usize, usize)> = None;
8255        // The dataset's own fill value, which the edge overhang of a partial
8256        // chunk must hold (issue #296). Both message forms, newest wins.
8257        let mut fill_msg: Option<(MessageType, usize, usize)> = None;
8258        let mut has_link = false;
8259        let mut p = 0;
8260        while let Some((msg_type, body, body_end)) = region.next_message(p)? {
8261            match msg_type {
8262                MessageType::Datatype => datatype = Some((body, body_end)),
8263                MessageType::Dataspace => dataspace = Some((body, body_end)),
8264                MessageType::DataLayout => layout = Some((body, body_end)),
8265                MessageType::FilterPipeline => filter = Some((body, body_end)),
8266                // Versioned beats legacy, and within a type the first wins —
8267                // the same rule `Dataset::fill_bytes` and `Located::from_walk`
8268                // apply, so all three agree on a header carrying more than one.
8269                MessageType::FillValue
8270                    if !matches!(fill_msg, Some((MessageType::FillValue, ..))) =>
8271                {
8272                    fill_msg = Some((msg_type, body, body_end));
8273                }
8274                MessageType::FillValueOld if fill_msg.is_none() => {
8275                    fill_msg = Some((msg_type, body, body_end));
8276                }
8277                MessageType::Link | MessageType::LinkInfo | MessageType::SymbolTable => {
8278                    has_link = true;
8279                }
8280                _ => {}
8281            }
8282            p = body_end;
8283        }
8284
8285        if has_link {
8286            return Err(Error::EditUnsupported(
8287                "write_dataset target is a group, not a dataset",
8288            ));
8289        }
8290        let (dt_b, dt_e) =
8291            datatype.ok_or(Error::EditUnsupported("dataset header has no datatype"))?;
8292        let (ds_b, ds_e) =
8293            dataspace.ok_or(Error::EditUnsupported("dataset header has no dataspace"))?;
8294        let (lb, le) = layout.ok_or(Error::EditUnsupported("dataset header has no data layout"))?;
8295
8296        // Compare datatype and shape structurally against the staged data. A
8297        // value overwrite must keep both exactly: the datatype (including its
8298        // class, size, endianness, and any compound/array/enumeration layout) so
8299        // the bytes are interpreted the same, and the *current* dimensions so the
8300        // byte count is unchanged. Parsing both sides and comparing the decoded
8301        // values — rather than the raw message bytes — tolerates the harmless
8302        // encoding differences between this crate's writer and the reference C
8303        // library (e.g. the C library records a maximum-dimensions array equal to
8304        // the current dimensions, which this crate omits) while still refusing any
8305        // real retype or reshape.
8306        let (disk_dt, _) = crate::datatype::Datatype::parse(&region[dt_b..dt_e])
8307            .map_err(|_| Error::EditUnsupported("dataset header datatype could not be parsed"))?;
8308        if disk_dt != fd.dt {
8309            return Err(Error::EditUnsupported(
8310                "write_dataset datatype does not match the on-disk dataset (overwrite, not retype)",
8311            ));
8312        }
8313        let disk_ds = Dataspace::parse(&region[ds_b..ds_e], LENGTH_SIZE)
8314            .map_err(|_| Error::EditUnsupported("dataset header dataspace could not be parsed"))?;
8315        if disk_ds.space_type != fd.ds.space_type
8316            || disk_ds.rank != fd.ds.rank
8317            || disk_ds.dimensions != fd.ds.dimensions
8318        {
8319            return Err(Error::EditUnsupported(
8320                "write_dataset shape does not match the on-disk dataset (overwrite, not reshape)",
8321            ));
8322        }
8323
8324        // Classify the layout. Version 3/4 compact (class 0), contiguous (class
8325        // 1), and chunked (class 2) are supported; an old-version layout or a
8326        // virtual layout (class 3) is refused.
8327        if le - lb < 2 {
8328            return Err(Error::EditUnsupported("malformed data-layout message"));
8329        }
8330        let version = region[lb];
8331        if version != 3 && version != 4 {
8332            return Err(Error::EditUnsupported(
8333                "an unsupported data-layout version cannot be overwritten in place yet",
8334            ));
8335        }
8336        match region[lb + 1] {
8337            // Compact: the data is inline in the header. Rebuild the header with
8338            // the new inline bytes (relocating it), patching the parent link.
8339            0 => Ok(WritePlan::Moving(MovingWrite::Compact {
8340                region,
8341                bytes: staged_bytes(fd, path),
8342            })),
8343            1 => {
8344                if le - lb < 18 {
8345                    return Err(Error::EditUnsupported("malformed contiguous data layout"));
8346                }
8347                let addr_off = lb + 2;
8348                let data_addr =
8349                    u64::from_le_bytes(region[addr_off..addr_off + 8].try_into().unwrap());
8350                let data_size = u64::from_le_bytes(region[lb + 10..lb + 18].try_into().unwrap());
8351
8352                // Same length and a defined, in-bounds data block: overwrite the
8353                // bytes straight in place. No header rewrite, no relink. The stored
8354                // address is base-relative; the in-place write targets the absolute
8355                // file offset `data_addr + base`.
8356                //
8357                // Never for a staged variable-length overwrite, which relocates
8358                // instead. Its resolution *allocates* — a global heap collection,
8359                // drawn from a freed region where one fits — and an in-place write
8360                // is a live mutation of a block the current root already reaches.
8361                // Together those put a reference to a just-allocated span inside
8362                // bytes the live tree names, so a commit failing after the write
8363                // and before its repoint hands the span back to the free list
8364                // (`restore_free`) while the image still points into it. The next
8365                // commit is then free to place something else there, and the
8366                // dataset reads that object's bytes with every checksum intact.
8367                //
8368                // A relocating write has no such window: its new data block is
8369                // reachable from nothing until the superblock repoint, so a
8370                // failed attempt leaves the region genuinely dead and re-offering
8371                // it is sound — which is the invariant `restore_free` states.
8372                if fd.vl_string_staging.is_none()
8373                    && data_addr != UNDEF
8374                    && data_size == fd.raw.len() as u64
8375                {
8376                    if let Some(start) = base
8377                        .absolute(data_addr)
8378                        .ok()
8379                        .and_then(|a| usize::try_from(a).ok())
8380                    {
8381                        if start
8382                            .checked_add(fd.raw.len())
8383                            .is_some_and(|e| e as u64 <= src.len())
8384                        {
8385                            return Ok(WritePlan::InPlace {
8386                                data_addr: start,
8387                                bytes: staged_bytes(fd, path),
8388                            });
8389                        }
8390                    }
8391                }
8392
8393                // Length differs or the block was undefined/out of bounds: the new
8394                // data goes elsewhere and the old extent (if any) is freed. The
8395                // freed extent is recorded as an absolute file offset (`+ base`) to
8396                // match the session free list.
8397                let old_extent = if data_addr != UNDEF && data_size > 0 {
8398                    Some((base.absolute(data_addr)?, data_size))
8399                } else {
8400                    None
8401                };
8402                Ok(WritePlan::Moving(MovingWrite::Contiguous {
8403                    region,
8404                    addr_off,
8405                    bytes: staged_bytes(fd, path),
8406                    old_extent,
8407                }))
8408            }
8409            // Chunked: overwrite each chunk in place when every new (re-encoded)
8410            // chunk is the same byte length as its slot, else rebuild and relocate
8411            // the whole chunk storage. The chunk geometry, filter pipeline, and
8412            // index type all come from the existing on-disk header (the staged
8413            // builder carries none — chunked/filtered/extensible builders are
8414            // refused at the top of this function as "not a value overwrite").
8415            2 => {
8416                // Chunked overwrite (in-place or relocating). On a userblock file
8417                // every stored chunk-index and chunk address is relative to `base`:
8418                // the in-place path below walks the index on a base-relative view of
8419                // the file and shifts the resulting write offsets back by `base`,
8420                // and the relocating path rebuilds the chunk blob with stored
8421                // addresses (see `write_chunked_relocatable`).
8422                let dl =
8423                    DataLayout::parse(&region[lb..le], OFFSET_SIZE, LENGTH_SIZE).map_err(|_| {
8424                        Error::EditUnsupported("dataset header data layout could not be parsed")
8425                    })?;
8426                let DataLayout::Chunked {
8427                    version: lversion,
8428                    chunk_index_type,
8429                    ..
8430                } = dl
8431                else {
8432                    return Err(Error::EditUnsupported("dataset is not chunked"));
8433                };
8434                if !chunk_index_enumerable(lversion, chunk_index_type) {
8435                    return Err(Error::EditUnsupported(
8436                        "a chunked dataset with a version-2 B-tree or unknown chunk index \
8437                         cannot be overwritten in place yet",
8438                    ));
8439                }
8440
8441                let ChunkedGeometry {
8442                    spatial,
8443                    element_size,
8444                    raw_size,
8445                    maxshape,
8446                } = chunked_geometry(&fd.dt, &disk_ds, &dl)?;
8447
8448                // Split the new value into full-size chunk buffers in dense
8449                // row-major grid order, then re-encode through the on-disk
8450                // pipeline when the dataset is filtered.
8451                // The overhang past the dataset's edge holds the dataset's own
8452                // fill value, not zeros: an allocated chunk is expected to carry
8453                // it wherever nothing was written, and those slots are what a
8454                // reader returns once the dataset is extended into them (#296).
8455                let padding = fill_msg
8456                    .map_or(crate::fill_value::PaddingFill::Zero, |(mt, b, e)| {
8457                        crate::fill_value::PaddingFill::from_message(mt, &region[b..e])
8458                    });
8459                let pipeline_message: Option<Vec<u8>> =
8460                    filter.map(|(fb, fe)| region[fb..fe].to_vec());
8461
8462                // Refuse a pipeline this engine cannot re-encode before anything
8463                // is split or encoded, so the refusal reaches the preflight on
8464                // both branches below — including the staged one, which does its
8465                // splitting in the apply phase and so has no other chance to make
8466                // it.
8467                if let Some(pm) = &pipeline_message {
8468                    let pipeline = FilterPipeline::parse(pm).map_err(|_| {
8469                        Error::EditUnsupported("dataset filter pipeline could not be parsed")
8470                    })?;
8471                    if !pipeline_reencodable(&pipeline) {
8472                        return Err(Error::EditUnsupported(
8473                            "a chunked dataset using a filter this engine cannot re-encode \
8474                             cannot be overwritten in place yet",
8475                        ));
8476                    }
8477                }
8478
8479                // Element bytes still carrying unresolved variable-length
8480                // references cannot be split here: a filtered chunk's compressed
8481                // length depends on the heap addresses patched into it, so the
8482                // split has to follow the patch, which follows a placement the
8483                // preflight may not make. See `ChunkPayload::Deferred`.
8484                if fd.vl_string_staging.is_some() {
8485                    return Ok(WritePlan::Moving(MovingWrite::Chunked {
8486                        region,
8487                        shape: disk_ds.dimensions.clone(),
8488                        chunk_dims: spatial,
8489                        element_size,
8490                        maxshape,
8491                        pipeline_message,
8492                        payload: ChunkPayload::Deferred {
8493                            bytes: staged_bytes(fd, path),
8494                            padding,
8495                            dt: fd.dt.clone(),
8496                        },
8497                        old_addr: addr,
8498                    }));
8499                }
8500
8501                let new_chunk_bytes = split_and_encode_chunks(
8502                    &fd.raw,
8503                    &disk_ds.dimensions,
8504                    &spatial,
8505                    element_size,
8506                    &padding,
8507                    pipeline_message.as_deref(),
8508                    &fd.dt,
8509                )?;
8510
8511                // Fast path: overwrite each chunk straight in its slot when every
8512                // new chunk fits. No header rewrite and no superblock flip — the
8513                // chunk (and index) blocks are reachable from both roots. The index
8514                // is left untouched when chunks keep their size and rebuilt in place
8515                // when they shrink. The index walk runs on a base-relative view of
8516                // the file (so the layout's stored addresses index correctly), and
8517                // the returned write offsets are shifted back to absolute file
8518                // offsets by adding `base` (a no-op on a base-0 file).
8519                let base_off = usize::try_from(base.get()).map_err(|_| {
8520                    Error::EditUnsupported("userblock base address exceeds this platform")
8521                })?;
8522                if let Some(writes) = try_inplace_chunk_writes(
8523                    &BaseOffsetSource { inner: src, base },
8524                    &dl,
8525                    &disk_ds,
8526                    &spatial,
8527                    raw_size,
8528                    &new_chunk_bytes,
8529                ) {
8530                    let writes = writes
8531                        .into_iter()
8532                        .map(|(off, b)| (off + base_off, b))
8533                        .collect();
8534                    return Ok(WritePlan::InPlaceChunks { writes });
8535                }
8536
8537                // Otherwise relocate: rebuild a fresh chunk blob + index at
8538                // end-of-file (carrying the re-encoded chunk bytes and the source
8539                // pipeline verbatim), swap the data-layout message in the verbatim
8540                // header, and free the old chunk storage after the commit lands.
8541                Ok(WritePlan::Moving(MovingWrite::Chunked {
8542                    region,
8543                    shape: disk_ds.dimensions.clone(),
8544                    chunk_dims: spatial,
8545                    element_size,
8546                    maxshape,
8547                    pipeline_message,
8548                    payload: ChunkPayload::Encoded(new_chunk_bytes),
8549                    old_addr: addr,
8550                }))
8551            }
8552            _ => Err(Error::EditUnsupported(
8553                "an unsupported data-layout class cannot be overwritten in place yet",
8554            )),
8555        }
8556    }
8557
8558    /// Plan a relocating append to an existing chunked, unlimited,
8559    /// Extensible-Array-indexed dataset at `addr`. Validates the target, splits
8560    /// the appended elements into new (and one rewritten trailing) chunks —
8561    /// compressed through the on-disk pipeline when filtered — and gathers the
8562    /// existing complete chunks by metadata alone. Returns the
8563    /// [`MovingWrite::AppendedChunks`] plan; the commit machinery appends the new
8564    /// chunks and a rebuilt index and repoints the header (see
8565    /// [`write_appended_chunks`](Self::write_appended_chunks)).
8566    ///
8567    /// Reads only; no bytes are written here. `src` is the file image and `base`
8568    /// its userblock base; the dataset's stored (base-relative) structures are read
8569    /// through a `base`-shifted view.
8570    fn prepare_append<S: Source + ?Sized>(
8571        src: &S,
8572        addr: u64,
8573        ab: &AppendBuilder,
8574        base: BaseAddress,
8575    ) -> Result<MovingWrite, Error> {
8576        if ab.dt_conflict {
8577            return Err(Error::AppendUnsupported(
8578                "append mixes element types in one builder; use one element type per \
8579                 append_dataset call",
8580            ));
8581        }
8582
8583        let region = Self::gather_oh_messages(src, addr, base)?;
8584
8585        // Locate the datatype, dataspace, data-layout, and filter-pipeline
8586        // messages, and detect a group (link) header.
8587        let mut datatype: Option<(usize, usize)> = None;
8588        let mut dataspace: Option<(usize, usize)> = None;
8589        let mut layout: Option<(usize, usize)> = None;
8590        let mut filter: Option<(usize, usize)> = None;
8591        // The dataset's own fill value, which the edge overhang of a partial
8592        // chunk must hold (issue #296). Both message forms, newest wins.
8593        let mut fill_msg: Option<(MessageType, usize, usize)> = None;
8594        let mut has_link = false;
8595        let mut p = 0;
8596        while let Some((msg_type, body, body_end)) = region.next_message(p)? {
8597            match msg_type {
8598                MessageType::Datatype => datatype = Some((body, body_end)),
8599                MessageType::Dataspace => dataspace = Some((body, body_end)),
8600                MessageType::DataLayout => layout = Some((body, body_end)),
8601                MessageType::FilterPipeline => filter = Some((body, body_end)),
8602                // Versioned beats legacy, and within a type the first wins —
8603                // the same rule `Dataset::fill_bytes` and `Located::from_walk`
8604                // apply, so all three agree on a header carrying more than one.
8605                MessageType::FillValue
8606                    if !matches!(fill_msg, Some((MessageType::FillValue, ..))) =>
8607                {
8608                    fill_msg = Some((msg_type, body, body_end));
8609                }
8610                MessageType::FillValueOld if fill_msg.is_none() => {
8611                    fill_msg = Some((msg_type, body, body_end));
8612                }
8613                MessageType::Link | MessageType::LinkInfo | MessageType::SymbolTable => {
8614                    has_link = true;
8615                }
8616                _ => {}
8617            }
8618            p = body_end;
8619        }
8620        if has_link {
8621            return Err(Error::AppendUnsupported(
8622                "append target is a group, not a dataset",
8623            ));
8624        }
8625        let (dt_b, dt_e) =
8626            datatype.ok_or(Error::AppendUnsupported("dataset header has no datatype"))?;
8627        let (ds_b, ds_e) =
8628            dataspace.ok_or(Error::AppendUnsupported("dataset header has no dataspace"))?;
8629        let (lb, le) = layout.ok_or(Error::AppendUnsupported(
8630            "dataset header has no data layout",
8631        ))?;
8632
8633        let (disk_dt, _) = Datatype::parse(&region[dt_b..dt_e])
8634            .map_err(|_| Error::AppendUnsupported("dataset header datatype could not be parsed"))?;
8635        let disk_ds = Dataspace::parse(&region[ds_b..ds_e], LENGTH_SIZE).map_err(|_| {
8636            Error::AppendUnsupported("dataset header dataspace could not be parsed")
8637        })?;
8638        let dl = DataLayout::parse(&region[lb..le], OFFSET_SIZE, LENGTH_SIZE).map_err(|_| {
8639            Error::AppendUnsupported("dataset header data layout could not be parsed")
8640        })?;
8641
8642        // Require chunked, data-layout version 4, Extensible-Array index (type 4).
8643        let DataLayout::Chunked {
8644            version: lversion,
8645            chunk_index_type,
8646            btree_address,
8647            ..
8648        } = &dl
8649        else {
8650            return Err(Error::AppendUnsupported(
8651                "append requires a chunked dataset",
8652            ));
8653        };
8654        if *lversion != 4 || *chunk_index_type != Some(4) {
8655            return Err(Error::AppendUnsupported(
8656                "append requires an Extensible-Array-indexed chunked dataset (a single \
8657                 unlimited dimension under the latest format)",
8658            ));
8659        }
8660
8661        // Require rank 1, unlimited along axis 0.
8662        if disk_ds.space_type != DataspaceType::Simple || disk_ds.dimensions.len() != 1 {
8663            return Err(Error::AppendUnsupported(
8664                "append requires a rank-1 dataset in this release",
8665            ));
8666        }
8667        match &disk_ds.max_dimensions {
8668            Some(md) if md.first() == Some(&u64::MAX) => {}
8669            _ => {
8670                return Err(Error::AppendUnsupported(
8671                    "append requires a dataset that is unlimited along its first dimension",
8672                ));
8673            }
8674        }
8675
8676        let ChunkedGeometry {
8677            spatial,
8678            element_size,
8679            ..
8680        } = chunked_geometry(&disk_dt, &disk_ds, &dl)?;
8681        let chunk_elems = spatial[0];
8682        if chunk_elems == 0 {
8683            return Err(Error::AppendUnsupported(
8684                "append requires a nonzero chunk length",
8685            ));
8686        }
8687
8688        // Validate the appended bytes against the on-disk element type.
8689        if ab.raw.len() % element_size != 0 {
8690            return Err(Error::AppendUnsupported(
8691                "appended byte length is not a whole number of elements",
8692            ));
8693        }
8694        match &ab.elem_dt {
8695            // A typed append must match the on-disk datatype exactly (class, size,
8696            // and byte order) — this is a value append, not a retype.
8697            Some(expected) if *expected != disk_dt => {
8698                return Err(Error::AppendUnsupported(
8699                    "append datatype does not match the on-disk dataset (wrong element \
8700                     type or byte order)",
8701                ));
8702            }
8703            Some(_) => {}
8704            // A raw append trusts the caller's bytes but still refuses any datatype
8705            // whose flat little-endian bytes cannot be written verbatim: a
8706            // big-endian numeric leaf would silently misencode, and a
8707            // variable-length or reference leaf embeds heap/object addresses a byte
8708            // append cannot reproduce. A typed append is byte-order- and
8709            // class-checked by the datatype-equality arm above.
8710            None => {
8711                if !datatype_is_raw_appendable(&disk_dt) {
8712                    return Err(Error::AppendUnsupported(
8713                        "append_raw onto this dataset's datatype (non-little-endian, \
8714                         variable-length, or reference) could misencode the bytes; use a \
8715                         typed append",
8716                    ));
8717                }
8718            }
8719        }
8720
8721        let new_elems = (ab.raw.len() / element_size) as u64;
8722        let current_dim0 = disk_ds.dimensions[0];
8723        let new_dim0 = current_dim0
8724            .checked_add(new_elems)
8725            .ok_or(Error::AppendUnsupported(
8726                "append would overflow the dataset dimension",
8727            ))?;
8728
8729        // The filter pipeline is preserved verbatim in the rebuilt header; parse it
8730        // to re-encode the new chunks. An engine-unencodable filter is refused.
8731        let pipeline_message: Option<Vec<u8>> = filter.map(|(fb, fe)| region[fb..fe].to_vec());
8732        let has_filters = pipeline_message.is_some();
8733        let pipeline = match &pipeline_message {
8734            Some(pm) => {
8735                let parsed = FilterPipeline::parse(pm).map_err(|_| {
8736                    Error::AppendUnsupported("dataset filter pipeline could not be parsed")
8737                })?;
8738                if !pipeline_reencodable(&parsed) {
8739                    return Err(Error::AppendUnsupported(
8740                        "dataset uses a filter this engine cannot re-encode",
8741                    ));
8742                }
8743                Some(parsed)
8744            }
8745            None => None,
8746        };
8747
8748        if base.get() > src.len() {
8749            return Err(Error::AppendUnsupported(
8750                "userblock base address past end-of-file",
8751            ));
8752        }
8753        let view = BaseOffsetSource { inner: src, base };
8754
8755        // The rebuilt index's element format (bare address vs address+size+mask) is
8756        // chosen by `has_filters`; it must agree with the source index's client id,
8757        // or the kept chunks — carried by metadata into the new index — would be
8758        // re-encoded in the wrong element width.
8759        if let Some(idx_addr) = *btree_address {
8760            let hdr =
8761                ExtensibleArrayHeader::parse_from_source(&view, idx_addr, OFFSET_SIZE, LENGTH_SIZE)
8762                    .map_err(|_| {
8763                        Error::AppendUnsupported(
8764                            "dataset extensible-array header could not be parsed",
8765                        )
8766                    })?;
8767            if (hdr.client_id == 1) != has_filters {
8768                return Err(Error::AppendUnsupported(
8769                    "dataset filter metadata is inconsistent (chunk-index client id \
8770                     disagrees with the filter pipeline)",
8771                ));
8772            }
8773        }
8774
8775        // Enumerate the existing chunks (base-relative addresses) and require a
8776        // dense grid: `plan_dense_grid` returns the chunks in index order and
8777        // `None` on any hole, duplicate, or count mismatch against the dimension.
8778        let infos = enumerate_chunks_from_source(&view, &dl, &disk_ds, OFFSET_SIZE, LENGTH_SIZE)
8779            .map_err(|_| Error::AppendUnsupported("dataset chunk index could not be enumerated"))?;
8780        let grid = plan_dense_grid(infos, &disk_ds.dimensions, &spatial).ok_or(
8781            Error::AppendUnsupported(
8782                "dataset has a sparse or inconsistent chunk grid; cannot append",
8783            ),
8784        )?;
8785        let grid_order = grid.grid_order;
8786
8787        // Complete chunks are kept by metadata; a trailing partial chunk (when the
8788        // current length is not chunk-aligned) is rewritten.
8789        let n_full = usize::try_from(current_dim0 / chunk_elems)
8790            .map_err(|_| Error::AppendUnsupported("chunk count exceeds this platform"))?;
8791        let has_partial = current_dim0 % chunk_elems != 0;
8792
8793        // Rewriting that chunk decodes and re-encodes committed values, which a
8794        // *lossy* pipeline does not reproduce: a ZFP block re-quantizes to fit
8795        // its new contents, and float D-scale packs offsets from a per-chunk
8796        // minimum that a smaller appended value moves (measured: 1.45 became 1.5
8797        // at one decimal). Refused here as on the immediate path (#407).
8798        if has_partial
8799            && let Some(pl) = &pipeline
8800            && !pipeline_lossless(pl)
8801        {
8802            return Err(Error::AppendUnsupported(LOSSY_TAIL_REFUSAL));
8803        }
8804
8805        let mut kept_chunks: Vec<WrittenChunk> = Vec::with_capacity(n_full);
8806        for ci in grid_order.iter().take(n_full) {
8807            kept_chunks.push(WrittenChunk {
8808                address: ci.address,
8809                compressed_size: u64::from(ci.chunk_size),
8810                // Preserve the source mask verbatim: a C/h5py file records a nonzero
8811                // mask for a chunk whose filter was skipped (e.g. deflate on
8812                // incompressible data), and forcing it to 0 would corrupt that chunk.
8813                filter_mask: ci.filter_mask,
8814            });
8815        }
8816
8817        // Build the raw byte region for the tail (from the last chunk boundary to
8818        // the new end): the live prefix of any rewritten partial chunk, then the
8819        // appended bytes.
8820        let mut tail_raw: Vec<u8> = Vec::new();
8821        let mut old_tail_extent: Option<(u64, u64)> = None;
8822        if has_partial {
8823            let partial = &grid_order[n_full];
8824            let len = partial.chunk_size as usize;
8825            partial
8826                .address
8827                .checked_add(len as u64)
8828                .filter(|&e| e <= view.len())
8829                .ok_or(Error::AppendUnsupported(
8830                    "trailing chunk extends past end-of-file",
8831                ))?;
8832            let stored = view
8833                .read_exact_at(partial.address, len)
8834                .map_err(|_| Error::AppendUnsupported("trailing chunk could not be read"))?;
8835            let full = if let Some(pl) = &pipeline {
8836                let ctx = ChunkContext::from_datatype(&spatial, &disk_dt)?;
8837                decompress_chunk(&stored, pl, ctx, partial.filter_mask).map_err(Error::Format)?
8838            } else {
8839                stored
8840            };
8841            let live_elems = usize::try_from(current_dim0 % chunk_elems)
8842                .map_err(|_| Error::AppendUnsupported("chunk length exceeds this platform"))?;
8843            let live_bytes = live_elems * element_size.get();
8844            if full.len() < live_bytes {
8845                return Err(Error::AppendUnsupported(
8846                    "trailing chunk decoded shorter than its live element count",
8847                ));
8848            }
8849            tail_raw.extend_from_slice(&full[..live_bytes]);
8850            // The old partial chunk's data block is dead once the new index lands.
8851            old_tail_extent = Some((
8852                base.absolute(partial.address)?,
8853                u64::from(partial.chunk_size),
8854            ));
8855        }
8856        tail_raw.extend_from_slice(&ab.raw);
8857
8858        // Split the tail into full chunk buffers and compress each through the
8859        // pipeline when filtered. The final chunk's overhang takes the dataset's
8860        // fill value, which is what a reader returns from those slots once a
8861        // later append or resize reaches them (#296).
8862        let tail_len_elems = new_dim0 - (n_full as u64) * chunk_elems;
8863        let padding = fill_msg.map_or(crate::fill_value::PaddingFill::Zero, |(mt, b, e)| {
8864            crate::fill_value::PaddingFill::from_message(mt, &region[b..e])
8865        });
8866        let split = split_into_chunks(
8867            &tail_raw,
8868            &[tail_len_elems],
8869            &spatial,
8870            element_size,
8871            padding.pattern(element_size),
8872        )
8873        .map_err(Error::Format)?;
8874        let new_chunk_bytes: Vec<Vec<u8>> = if let Some(pl) = &pipeline {
8875            let ctx = ChunkContext::from_datatype(&spatial, &disk_dt)?;
8876            let mut out = Vec::with_capacity(split.len());
8877            // One encoder across the appended tail; see `FilterScratch`.
8878            let mut scratch = FilterScratch::new();
8879            for buf in &split {
8880                out.push(compress_chunk_with(&mut scratch, buf, pl, ctx).map_err(Error::Format)?);
8881            }
8882            out
8883        } else {
8884            split
8885        };
8886
8887        // Grow the dataspace along axis 0, preserving the (unlimited) max-dims.
8888        let mut grown = disk_ds.clone();
8889        grown.dimensions[0] = new_dim0;
8890        let new_dataspace_body = grown.serialize(LENGTH_SIZE);
8891
8892        #[expect(
8893            clippy::cast_possible_truncation,
8894            reason = "spatial chunk dims come from the on-disk u32 chunk_dimensions, so they fit u32"
8895        )]
8896        let chunk_dims_u32: Vec<u32> = spatial.iter().map(|&dm| dm as u32).collect();
8897
8898        Ok(MovingWrite::AppendedChunks {
8899            region,
8900            new_dataspace_body,
8901            chunk_dims_u32,
8902            element_size,
8903            has_filters,
8904            kept_chunks,
8905            new_chunk_bytes,
8906            old_addr: addr,
8907            old_tail_extent,
8908        })
8909    }
8910
8911    /// Parse the object header at `addr` into a copyable model, validating that
8912    /// every message can be reproduced faithfully (verbatim message bytes, with
8913    /// only the contiguous data address and child link targets repointed).
8914    /// Dense (fractal-heap) attribute storage is read out of the source heap into
8915    /// a parsed attribute set carried on the model (`dense_attrs`) and re-emitted
8916    /// into a fresh heap on write, within the bounds that heap declares (see
8917    /// `file_writer::dense_attrs_check`); an attribute too large to hold as a
8918    /// managed object is re-emitted as a *huge* object. Rejects multi-chunk
8919    /// headers, dense or soft/external links, old-version data layouts, external
8920    /// data storage ([`reject_external_storage`]), and headers that are neither a
8921    /// dataset nor a group. (A *chunked* layout is modelled, not rejected —
8922    /// [`ObjModel::DatasetChunked`].)
8923    fn read_object<S: Source + ?Sized>(
8924        src: &S,
8925        addr: u64,
8926        base: BaseAddress,
8927    ) -> Result<ObjModel, Error> {
8928        let region = Self::gather_oh_messages(src, addr, base)?;
8929
8930        // Ahead of the layout classification below, where external storage and
8931        // never-allocated storage would otherwise share one undefined address
8932        // and one answer — and ahead of the dense-attribute read, so a header
8933        // this cannot copy says why rather than failing as something else.
8934        reject_external_storage(&region)?;
8935
8936        // First pass: detect whether attributes are stored densely (a defined
8937        // fractal-heap address in the Attribute Info message). A dense object is
8938        // copied by reading its attributes out of the source heap and rebuilding
8939        // a fresh heap on write, so its Attribute Info message and any inline
8940        // Attribute messages are dropped from the verbatim region — the rebuilt
8941        // region carries neither, and `dense_attrs` carries the parsed set.
8942        let mut dense = false;
8943        let mut p = 0;
8944        while let Some((msg_type, body, body_end)) = region.next_message(p)? {
8945            if msg_type == MessageType::AttributeInfo {
8946                // An Attribute Info message does not by itself mean dense
8947                // storage: the reference C library and h5py emit one (with an
8948                // *undefined* fractal-heap address) even for compact, inline
8949                // attributes in the latest format, to carry attribute
8950                // creation-order metadata. Only a *defined* heap address is real
8951                // dense (fractal-heap) storage. A message that cannot be parsed
8952                // is refused conservatively.
8953                let ai = crate::attribute_info::AttributeInfoMessage::parse(
8954                    &region[body..body_end],
8955                    OFFSET_SIZE,
8956                )
8957                .map_err(|_| {
8958                    Error::EditUnsupported(
8959                        "a source attribute-info message could not be parsed for copying",
8960                    )
8961                })?;
8962                if ai.fractal_heap_address.is_some() {
8963                    dense = true;
8964                }
8965            }
8966            p = body_end;
8967        }
8968
8969        // If dense, read the attribute set out of the source fractal heap now (so
8970        // the source buffer need not outlive the read) and validate it can be
8971        // re-emitted into a fresh heap on write. The read is [`read_object_attrs`],
8972        // shared with the attribute editor: both need the source framed past its
8973        // userblock before the heap walk, and one home for that framing is what
8974        // keeps the two from coming to disagree about it.
8975        let dense_attrs = if dense {
8976            let stored = read_object_attrs(src, addr, base)?;
8977            let set = dense_attr_set(&region, stored)?;
8978            // The typed error names the offending attribute, which the previous
8979            // blanket `EditUnsupported` message could not.
8980            crate::file_writer::dense_attrs_check(&set.attrs).map_err(Error::Format)?;
8981            set
8982        } else {
8983            DenseAttrSet::default()
8984        };
8985
8986        let mut layout: Option<(usize, usize)> = None; // (body offset in kept, size)
8987        let mut has_link_info = false;
8988        // (name, creation index, target address) per hard link. The creation
8989        // index is `None` unless the source group tracks link creation order,
8990        // and is carried so a copy of one that does reproduces its order.
8991        let mut children: Vec<(String, Option<u64>, u64)> = Vec::new();
8992        // The rebuilt chunk-0 region: every message kept verbatim except hard
8993        // Link messages (carried as `children`) and, when dense, the Attribute
8994        // Info message and inline Attribute messages (carried as `dense_attrs`).
8995        let mut kept = OhRegion::empty(region.props());
8996
8997        let mut p = 0;
8998        while let Some((msg_type, body, body_end)) = region.next_message(p)? {
8999            let mut keep = true;
9000            match msg_type {
9001                MessageType::AttributeInfo => {
9002                    // Already parsed in the first pass; drop the dense Attribute
9003                    // Info message so the rebuilt header references the fresh heap
9004                    // (spliced in on write) rather than the source one. A compact
9005                    // (undefined-heap) Attribute Info message is kept verbatim.
9006                    if dense {
9007                        keep = false;
9008                    }
9009                }
9010                MessageType::Attribute => {
9011                    // A dense object should carry no inline Attribute messages,
9012                    // but drop any defensively so the rebuilt header's only
9013                    // attribute storage is the fresh heap.
9014                    if dense {
9015                        keep = false;
9016                    }
9017                }
9018                MessageType::LinkInfo => {
9019                    has_link_info = true;
9020                    let mut q = body + 2;
9021                    if body_end - body >= 2 && region[body + 1] & 0x01 != 0 {
9022                        q += 8;
9023                    }
9024                    if q + 8 <= body_end {
9025                        let heap_addr = u64::from_le_bytes(region[q..q + 8].try_into().unwrap());
9026                        if heap_addr != u64::MAX {
9027                            return Err(Error::EditUnsupported(
9028                                "a group uses dense (fractal-heap) link storage (not supported in place yet)",
9029                            ));
9030                        }
9031                    }
9032                }
9033                MessageType::Link => {
9034                    keep = false;
9035                    match LinkMessage::parse(&region[body..body_end], OFFSET_SIZE) {
9036                        Ok(LinkMessage {
9037                            name,
9038                            link_target:
9039                                LinkTarget::Hard {
9040                                    object_header_address,
9041                                },
9042                            creation_order,
9043                            ..
9044                        }) => children.push((name, creation_order, object_header_address)),
9045                        _ => {
9046                            return Err(Error::EditUnsupported(
9047                                "a group contains a soft/external link (not copyable in place yet)",
9048                            ));
9049                        }
9050                    }
9051                }
9052                MessageType::DataLayout => {
9053                    // Record the layout body offset within the *kept* region so a
9054                    // contiguous dataset's data-address field can be repointed
9055                    // even after earlier messages were dropped.
9056                    layout = Some((kept.len() + (body - p), body_end - body));
9057                }
9058                _ => {}
9059            }
9060            if keep {
9061                kept.push_bytes(&region[p..body_end]);
9062            }
9063            p = body_end;
9064        }
9065
9066        if let Some((lbody, lsize)) = layout {
9067            let version = kept[lbody];
9068            if !(version == 3 || version == 4) || lsize < 2 {
9069                return Err(Error::EditUnsupported(
9070                    "an unsupported data-layout version cannot be copied in place yet",
9071                ));
9072            }
9073            let class = kept[lbody + 1];
9074            match class {
9075                0 => Ok(ObjModel::DatasetVerbatim {
9076                    region: kept,
9077                    dense_attrs,
9078                }),
9079                1 => {
9080                    if lbody + 18 > kept.len() {
9081                        return Err(Error::EditUnsupported("malformed contiguous data layout"));
9082                    }
9083                    let data_addr =
9084                        u64::from_le_bytes(kept[lbody + 2..lbody + 10].try_into().unwrap());
9085                    let data_size =
9086                        u64::from_le_bytes(kept[lbody + 10..lbody + 18].try_into().unwrap());
9087                    Ok(ObjModel::DatasetContiguous {
9088                        region: kept,
9089                        addr_off: lbody + 2,
9090                        data_addr,
9091                        data_size,
9092                        dense_attrs,
9093                    })
9094                }
9095                // Chunked: the verbatim header carries the data-layout and filter-
9096                // pipeline messages; `read_copy_subtree` (which holds the source
9097                // buffer) enumerates and captures the chunk bytes and rebuilds the
9098                // index on write.
9099                2 => Ok(ObjModel::DatasetChunked {
9100                    region: kept,
9101                    dense_attrs,
9102                }),
9103                _ => Err(Error::EditUnsupported(
9104                    "an unsupported data-layout class cannot be copied in place yet",
9105                )),
9106            }
9107        } else if has_link_info {
9108            // A copied group must carry a Group Info message so the copy stays
9109            // writable by the C library, even when the source omitted it.
9110            ensure_group_info(&mut kept)?;
9111            Ok(ObjModel::Group {
9112                non_link_region: kept,
9113                children,
9114                dense_attrs,
9115            })
9116        } else {
9117            Err(Error::EditUnsupported(
9118                "an object is neither a contiguous/compact dataset nor a group",
9119            ))
9120        }
9121    }
9122
9123    /// Read the object at `addr` in the source buffer `d` — and, for a group, its
9124    /// whole subtree — into an owned [`CopyTree`], the read half of an object copy.
9125    /// No bytes are written; this both validates that the subtree is copyable and
9126    /// captures the bytes the write half ([`write_copy_subtree`](Self::write_copy_subtree))
9127    /// later appends, so the source buffer need not outlive the read.
9128    ///
9129    /// `src` is the image the source object lives in: this session's own file image
9130    /// for an in-file [`copy`](Self::copy), or another file's image for a cross-file
9131    /// [`copy_from`](Self::copy_from). `base` is that image's userblock base (the
9132    /// session's own base for an in-file copy, always 0 for a cross-file copy, whose
9133    /// source is gated to base 0): the stored, base-relative addresses read out of
9134    /// the source headers are shifted by it to index `src`. When `cross_file` is set,
9135    /// every copied object header is additionally screened by
9136    /// [`reject_foreign_addresses`] — verbatim bytes that embed a *source-file*
9137    /// absolute address (variable-length or reference data, a committed datatype)
9138    /// would dangle in another file and are refused, whereas an in-file copy keeps
9139    /// them valid by sharing the source file's heaps and objects.
9140    fn read_copy_subtree<S: Source + ?Sized>(
9141        src: &S,
9142        addr: u64,
9143        depth: u32,
9144        cross_file: bool,
9145        base: BaseAddress,
9146    ) -> Result<CopyTree, Error> {
9147        if depth >= MAX_COPY_DEPTH {
9148            return Err(Error::EditUnsupported(
9149                "copy source nests too deeply (possible hard-link cycle)",
9150            ));
9151        }
9152        // `base` is the userblock base of the image `src`: this session's own base
9153        // for an in-file copy, and always 0 for a cross-file copy (the source is
9154        // gated to base 0 in `copy_from`). `addr` is an absolute offset into `src`;
9155        // the stored (base-relative) addresses `read_object` returns for contiguous
9156        // data, chunk storage, and child links are converted to absolute offsets by
9157        // adding `base` before `src` is read or a child is descended into.
9158        match Self::read_object(src, addr, base)? {
9159            ObjModel::DatasetVerbatim {
9160                region,
9161                dense_attrs,
9162            } => {
9163                if cross_file {
9164                    reject_foreign_addresses(&region)?;
9165                    reject_foreign_dense_attrs(&dense_attrs.attrs)?;
9166                }
9167                Ok(CopyTree::DatasetVerbatim {
9168                    region,
9169                    dense_attrs,
9170                })
9171            }
9172            ObjModel::DatasetContiguous {
9173                region,
9174                addr_off,
9175                data_addr,
9176                data_size,
9177                dense_attrs,
9178            } => {
9179                if cross_file {
9180                    reject_foreign_addresses(&region)?;
9181                    reject_foreign_dense_attrs(&dense_attrs.attrs)?;
9182                }
9183                // Storage the source never allocated is copied as storage, not as
9184                // the values reading it answers with. The reference library does
9185                // not allocate a contiguous dataset's data until something is
9186                // written to it, leaving the layout message's address undefined,
9187                // and reading one answers the fill value for every element (#292)
9188                // — so a copy that materialized what it read would turn a
9189                // schema-only dataset into a fully written one of the size its
9190                // shape declares. `repack` carries the same shape through as
9191                // unallocated (#293); this is the copy path's half of it (#336).
9192                //
9193                // Only reached once external storage is out of the way: it uses
9194                // this same undefined address for a dataset that *does* hold
9195                // data, and `read_object` refuses it by name above.
9196                let data = if data_addr == UNDEF {
9197                    None
9198                } else {
9199                    // The stored data address is base-relative; shift it to an absolute
9200                    // offset into `src` before reading the data block out.
9201                    let start = base.absolute(data_addr).map_err(|_| {
9202                        Error::EditUnsupported("data address exceeds this platform")
9203                    })?;
9204                    let len = usize::try_from(data_size)
9205                        .map_err(|_| Error::EditUnsupported("data size exceeds this platform"))?;
9206                    start
9207                        .checked_add(len as u64)
9208                        .filter(|&e| e <= src.len())
9209                        .ok_or(Error::EditUnsupported("dataset data is out of bounds"))?;
9210                    Some(
9211                        src.read_exact_at(start, len)
9212                            .map_err(|_| Error::EditUnsupported("dataset data is out of bounds"))?,
9213                    )
9214                };
9215                Ok(CopyTree::DatasetContiguous {
9216                    region,
9217                    addr_off,
9218                    data,
9219                    dense_attrs,
9220                })
9221            }
9222            ObjModel::DatasetChunked {
9223                region,
9224                dense_attrs,
9225            } => {
9226                // Screen the verbatim header on the cross-file path. This refuses a
9227                // variable-length or reference datatype (whose chunk payload embeds
9228                // source-file global-heap / object addresses that would dangle in
9229                // another file) and any shared message — exactly the forms repack
9230                // also refuses for a cross-file verbatim chunk copy. An in-file copy
9231                // keeps them valid by sharing the source file's heaps.
9232                if cross_file {
9233                    reject_foreign_addresses(&region)?;
9234                    reject_foreign_dense_attrs(&dense_attrs.attrs)?;
9235                }
9236                let ChunkedHeaderParts {
9237                    dt,
9238                    ds,
9239                    layout,
9240                    pipeline_message,
9241                } = parse_chunked_header(&region)?;
9242                let DataLayout::Chunked {
9243                    version: lversion,
9244                    chunk_index_type,
9245                    ..
9246                } = layout
9247                else {
9248                    return Err(Error::EditUnsupported("dataset is not chunked"));
9249                };
9250                if !chunk_index_enumerable(lversion, chunk_index_type) {
9251                    return Err(Error::EditUnsupported(
9252                        "a chunked dataset with a version-2 B-tree or unknown chunk index \
9253                         cannot be copied in place yet",
9254                    ));
9255                }
9256                let ChunkedGeometry {
9257                    spatial: chunk_dims,
9258                    element_size,
9259                    raw_size: _,
9260                    maxshape,
9261                } = chunked_geometry(&dt, &ds, &layout)?;
9262
9263                // The layout's chunk-index address and every chunk address it leads
9264                // to are stored base-relative, so enumerate and read on a
9265                // base-relative view of the source image (the identity on a base-0
9266                // file). The returned addresses are then offsets into `dview`.
9267                let dview = BaseOffsetSource { inner: src, base };
9268
9269                // Enumerate the source chunks and map them onto a dense grid; a
9270                // sparse (holed/unallocated) dataset cannot be reproduced by the
9271                // verbatim layout path, which needs every grid slot filled.
9272                let infos =
9273                    enumerate_chunks_from_source(&dview, &layout, &ds, OFFSET_SIZE, LENGTH_SIZE)?;
9274                let grid = plan_dense_grid(infos, &ds.dimensions, &chunk_dims).ok_or(
9275                    Error::EditUnsupported(
9276                        "a chunked dataset with unallocated (sparse) chunks cannot be copied in place yet",
9277                    ),
9278                )?;
9279                if grid.grid_order.is_empty() {
9280                    return Err(Error::EditUnsupported(
9281                        "an empty chunked dataset cannot be copied in place yet",
9282                    ));
9283                }
9284
9285                // Capture each chunk's already-compressed bytes (no decode) into an
9286                // owned buffer, in dense row-major grid order, so the copy can be
9287                // written after the source buffer is gone (cross-file copy reads at
9288                // staging time). Sizes and masks are carried verbatim.
9289                let mut meta = Vec::with_capacity(grid.grid_order.len());
9290                let mut chunk_bytes = Vec::with_capacity(grid.grid_order.len());
9291                for ci in &grid.grid_order {
9292                    let len = ci.chunk_size as usize;
9293                    ci.address
9294                        .checked_add(len as u64)
9295                        .filter(|&e| e <= dview.len())
9296                        .ok_or(Error::EditUnsupported("chunk data is out of bounds"))?;
9297                    chunk_bytes.push(
9298                        dview
9299                            .read_exact_at(ci.address, len)
9300                            .map_err(|_| Error::EditUnsupported("chunk data is out of bounds"))?,
9301                    );
9302                    meta.push(ChunkMeta {
9303                        compressed_size: ci.chunk_size as u64,
9304                        filter_mask: ci.filter_mask,
9305                    });
9306                }
9307
9308                Ok(CopyTree::DatasetChunked {
9309                    region,
9310                    shape: ds.dimensions.clone(),
9311                    chunk_dims,
9312                    element_size,
9313                    maxshape,
9314                    pipeline_message,
9315                    meta,
9316                    chunk_bytes,
9317                    dense_attrs,
9318                })
9319            }
9320            ObjModel::Group {
9321                non_link_region,
9322                children,
9323                dense_attrs,
9324            } => {
9325                if cross_file {
9326                    reject_foreign_addresses(&non_link_region)?;
9327                    reject_foreign_dense_attrs(&dense_attrs.attrs)?;
9328                }
9329                let mut kids = Vec::with_capacity(children.len());
9330                for (name, creation_order, child) in children {
9331                    // Child link targets are stored base-relative; re-absolutize
9332                    // before descending so `addr` stays an absolute offset into `src`.
9333                    let child = base.absolute(child).map_err(|_| {
9334                        Error::EditUnsupported("child address exceeds this platform")
9335                    })?;
9336                    kids.push((
9337                        name,
9338                        creation_order,
9339                        Self::read_copy_subtree(src, child, depth + 1, cross_file, base)?,
9340                    ));
9341                }
9342                Ok(CopyTree::Group {
9343                    non_link_region,
9344                    children: kids,
9345                    dense_attrs,
9346                })
9347            }
9348        }
9349    }
9350
9351    /// Append the fresh copies described by `node` (data blobs and headers) into
9352    /// this session at end-of-file or into reusable freed regions, returning the
9353    /// new object-header address of the copied root. The write half of an object
9354    /// copy; children are written before their parent group so each parent links
9355    /// its children's new addresses, and a contiguous dataset's data-address field
9356    /// is repointed at the freshly-written copy. Every address the copy writes into
9357    /// a header (a contiguous data block, a child link) is stored relative to the
9358    /// userblock base (`- base`, a no-op on a base-0 file); the chunked storage and
9359    /// dense attribute heaps are laid out base-relative by their own builders.
9360    fn write_copy_subtree(&mut self, node: &CopyTree) -> Result<u64, Error> {
9361        let base = self.superblock.base_address;
9362        match node {
9363            CopyTree::DatasetVerbatim {
9364                region,
9365                dense_attrs,
9366            } => {
9367                let mut region = region.clone();
9368                self.append_dense_attrs(&mut region, dense_attrs.clone())?;
9369                let oh = build_v2_object_header(&region)?;
9370                self.alloc_or_append_typed(&oh, PageType::Meta)
9371            }
9372            CopyTree::DatasetContiguous {
9373                region,
9374                addr_off,
9375                data,
9376                dense_attrs,
9377            } => {
9378                let mut region = region.clone();
9379                // Storage the source never allocated is copied as storage: there
9380                // is no block to place, and the region already carries the
9381                // undefined address the source stored, so the copy declares the
9382                // same empty storage (issue #336).
9383                if let Some(data) = data {
9384                    let new_data_addr = self.alloc_or_append_typed(data, PageType::Raw)?;
9385                    // The placement is an absolute offset; the data-layout
9386                    // address field stores it relative to the userblock base.
9387                    let relative = base.relative(new_data_addr)?;
9388                    region.bytes_mut()[*addr_off..*addr_off + 8]
9389                        .copy_from_slice(&relative.to_le_bytes());
9390                }
9391                // The dense heap is placed independently of the data — it is
9392                // built for whatever address it gets (see `append_dense_attrs`),
9393                // so no ordering between the two is owed.
9394                self.append_dense_attrs(&mut region, dense_attrs.clone())?;
9395                let oh = build_v2_object_header(&region)?;
9396                self.alloc_or_append_typed(&oh, PageType::Meta)
9397            }
9398            CopyTree::DatasetChunked {
9399                region,
9400                shape,
9401                chunk_dims,
9402                element_size,
9403                maxshape,
9404                pipeline_message,
9405                meta,
9406                chunk_bytes,
9407                dense_attrs,
9408            } => self.write_chunked_relocatable(
9409                region,
9410                shape,
9411                chunk_dims,
9412                *element_size,
9413                maxshape.as_deref(),
9414                pipeline_message.as_deref(),
9415                meta,
9416                chunk_bytes,
9417                dense_attrs.clone(),
9418            ),
9419            CopyTree::Group {
9420                non_link_region,
9421                children,
9422                dense_attrs,
9423            } => {
9424                let mut region = non_link_region.clone();
9425                for (name, creation_order, child) in children {
9426                    let new_child = self.write_copy_subtree(child)?;
9427                    // The link target is stored relative to the userblock base.
9428                    // A copied link keeps the creation index the source recorded
9429                    // for it, so a copy of a group that tracks link creation
9430                    // order carries the same order as its source — the Link Info
9431                    // message naming that order is copied verbatim beside it.
9432                    region.push_link(name, base.relative(new_child)?, *creation_order);
9433                }
9434                // The dense heap is built for whatever address it is placed at
9435                // (see `append_dense_attrs`), so it needs no ordering against the
9436                // children's headers and data.
9437                self.append_dense_attrs(&mut region, dense_attrs.clone())?;
9438                let oh = build_v2_object_header(&region)?;
9439                self.alloc_or_append_typed(&oh, PageType::Meta)
9440            }
9441        }
9442    }
9443
9444    /// Write a chunked dataset's storage and return its new object-header address
9445    /// — the shared write half of a chunked copy ([`CopyTree::DatasetChunked`])
9446    /// and a relocating chunked overwrite ([`MovingWrite::Chunked`]).
9447    ///
9448    /// A fresh chunk-data blob and index are laid out relocatably via
9449    /// [`plan_chunked_data_verbatim`] / [`emit_chunked_data_verbatim`], pulling
9450    /// each chunk's already-compressed bytes from `chunk_bytes` (in dense
9451    /// row-major grid order) and carrying `meta`'s sizes and filter masks and the
9452    /// source `pipeline_message` verbatim — no recompression, no filter-parameter
9453    /// reconstruction. Like [`build_chunked_dataset`](Self::build_chunked_dataset)
9454    /// the blob is sized from its plan before it is placed, so it can go into a
9455    /// freed region that fits it as readily as at end-of-file. The verbatim header
9456    /// `region`'s data-layout message is then swapped for the one the planner
9457    /// produced (every other message preserved), any dense attribute heap is
9458    /// placed, and the header is written into reusable freed space or at
9459    /// end-of-file.
9460    #[expect(
9461        clippy::too_many_arguments,
9462        reason = "the chunked rebuild needs the full geometry, \
9463        pipeline, and chunk payloads; bundling them into a struct would only move the list"
9464    )]
9465    fn write_chunked_relocatable(
9466        &mut self,
9467        region: &OhRegion,
9468        shape: &[u64],
9469        chunk_dims: &[u64],
9470        element_size: NonZeroUsize,
9471        maxshape: Option<&[u64]>,
9472        pipeline_message: Option<&[u8]>,
9473        meta: &[ChunkMeta],
9474        chunk_bytes: &[Vec<u8>],
9475        dense_attrs: DenseAttrSet,
9476    ) -> Result<u64, Error> {
9477        // Plan once at a provisional base purely to size the data region: the plan
9478        // walks chunk *sizes* and sizes the index from its layout, so it touches
9479        // no bytes at all, and every address it embeds sits in a fixed-width
9480        // field, so its total length is the same wherever the blob lands. That is
9481        // what lets the address be chosen — a freed region or end-of-file — before
9482        // the bytes exist. This plan is discarded; the one built at the real base
9483        // below is what the emit works from.
9484        let sizing = plan_chunked_data_verbatim(
9485            meta,
9486            shape,
9487            chunk_dims,
9488            element_size,
9489            pipeline_message,
9490            0,
9491            maxshape,
9492        )?;
9493        let (_addr, layout_message) =
9494            self.place_relocatable(sizing.plan.total_len, PageType::Raw, |stored_base| {
9495                // Re-plan at the address the blob really occupies, so its embedded
9496                // addresses resolve to their real file offsets once the reader adds
9497                // the userblock base back (see `build_chunked_dataset`).
9498                let layout = plan_chunked_data_verbatim(
9499                    meta,
9500                    shape,
9501                    chunk_dims,
9502                    element_size,
9503                    pipeline_message,
9504                    stored_base,
9505                    maxshape,
9506                )?;
9507                let mut buf =
9508                    Vec::with_capacity(usize::try_from(layout.plan.total_len).unwrap_or(0));
9509                emit_chunked_data_verbatim(
9510                    &mut buf,
9511                    &layout.plan,
9512                    &SliceChunkProvider {
9513                        chunks: chunk_bytes,
9514                    },
9515                )?;
9516                Ok((buf, layout.layout_message))
9517            })?;
9518        // Swap the data-layout message for the rebuilt one; keep every other header
9519        // message (datatype, dataspace, fill value, filter pipeline, attributes)
9520        // verbatim.
9521        let mut new_region = replace_layout_message(region, &layout_message)?;
9522        self.append_dense_attrs(&mut new_region, dense_attrs)?;
9523        let oh = build_v2_object_header(&new_region)?;
9524        self.alloc_or_append_typed(&oh, PageType::Meta)
9525    }
9526
9527    /// When `attrs` is non-empty, build a fresh dense (fractal-heap) attribute
9528    /// blob for it, place it, and splice the matching Attribute Info message onto
9529    /// `region`. A no-op for an empty set.
9530    ///
9531    /// The blob produced by [`file_writer::DenseAttrPlan::build`] is fully
9532    /// relocatable: every address it embeds is `base + fixed offset`, and its
9533    /// length is the same for every base, so it can go into a freed metadata
9534    /// region as readily as at end-of-file — the base it is built for is whichever
9535    /// address it gets. The reservation comes from the plan the blob is then
9536    /// built from, so sizing it costs no bytes. The freshly built heap is always
9537    /// same-file, so it never aliases the source heap even for an in-file copy.
9538    /// The caller has already validated [`file_writer::dense_attrs_check`].
9539    fn append_dense_attrs(
9540        &mut self,
9541        region: &mut OhRegion,
9542        set: DenseAttrSet,
9543    ) -> Result<(), Error> {
9544        if set.is_empty() {
9545            return Ok(());
9546        }
9547        let attr_info_message = self.place_dense_attrs(&set.attrs, set.creation)?;
9548        region.push(MessageType::AttributeInfo, &attr_info_message);
9549        Ok(())
9550    }
9551
9552    /// Place dense attribute storage for a staged dataset whose set needs it,
9553    /// returning the Attribute Info message naming the heap — or `None` when the
9554    /// set belongs in the object header, where the header builders write it
9555    /// inline. Which it is was decided and validated when the dataset was staged
9556    /// ([`FlatDataset::attrs_are_dense`]).
9557    fn place_dense_attrs_if_needed(&mut self, fd: &FlatDataset) -> Result<Option<Vec<u8>>, Error> {
9558        if !fd.attrs_are_dense {
9559            return Ok(None);
9560        }
9561        self.place_dense_attrs(&fd.attrs, DenseAttrCreationOrder::Untracked)
9562            .map(Some)
9563    }
9564
9565    /// Build a fresh dense attribute blob for `attrs`, place it, and return the
9566    /// Attribute Info message naming it — the header's whole share of dense
9567    /// storage. See [`append_dense_attrs`](Self::append_dense_attrs) for why the
9568    /// blob may be placed anywhere.
9569    fn place_dense_attrs(
9570        &mut self,
9571        attrs: &[crate::attribute::AttributeMessage],
9572        creation: DenseAttrCreationOrder,
9573    ) -> Result<Vec<u8>, Error> {
9574        let plan = crate::file_writer::dense_attrs_plan(attrs, creation);
9575        let (_addr, attr_info_message) =
9576            self.place_relocatable(plan.blob_len(), PageType::Meta, |stored_base| {
9577                let blob = plan.build(stored_base);
9578                Ok((blob.blob, blob.attr_info_message))
9579            })?;
9580        Ok(attr_info_message)
9581    }
9582
9583    /// Resolve what an attribute edit left for this phase ([`plan_attr_ops`]) and
9584    /// append what names it to the object's message `region`.
9585    ///
9586    /// Both arms place a variable-length attribute's global heap collection and
9587    /// patch the placeholder references in its message; where they differ is what
9588    /// carries the result. A compact attribute becomes an inline Attribute
9589    /// message; a dense set is built into a fresh heap the header then names.
9590    ///
9591    /// For the dense arm the patching has to come first: the heap stores each
9592    /// attribute's *message bytes*, references and all, so a heap built before
9593    /// them would hold the placeholders — the same ordering the whole-file writer
9594    /// keeps for the same reason.
9595    ///
9596    /// A no-op for an edit that resolved entirely in the preflight, which is what
9597    /// makes it safe to call on every rebuilt header.
9598    fn place_edited_attrs(
9599        &mut self,
9600        region: &mut OhRegion,
9601        attrs: EditedAttrs,
9602    ) -> Result<(), Error> {
9603        match attrs {
9604            EditedAttrs::Compact(pending) => {
9605                for pending in pending {
9606                    let PendingVlAttr {
9607                        mut msg,
9608                        collections,
9609                        creation_index,
9610                    } = pending;
9611                    let addrs = self.place_vl_collections(&collections)?;
9612                    patch_vl_refs(&mut msg.raw_data, &addrs);
9613                    *region = put_attr_message(
9614                        region,
9615                        &msg.name,
9616                        &msg.serialize(LENGTH_SIZE),
9617                        creation_index,
9618                    )?;
9619                }
9620                Ok(())
9621            }
9622            EditedAttrs::Dense(mut dense) => {
9623                for (idx, collections) in std::mem::take(&mut dense.vl) {
9624                    let addrs = self.place_vl_collections(&collections)?;
9625                    patch_vl_refs(&mut dense.set.attrs[idx].raw_data, &addrs);
9626                }
9627                self.append_dense_attrs(region, dense.set)
9628            }
9629        }
9630    }
9631
9632    /// Apply a relocating value overwrite (`write_dataset` resize / compact
9633    /// rewrite): write the new data and a rewritten object header at end-of-file
9634    /// (or into reusable freed space) and return the new header address. The
9635    /// caller patches the parent group's link to this address. The old data
9636    /// extent (for a resized contiguous dataset) is freed separately, after the
9637    /// commit's superblock repoint, so it is never reused mid-commit.
9638    fn write_moving(&mut self, mw: &MovingWrite) -> Result<u64, Error> {
9639        let base = self.superblock.base_address;
9640        match mw {
9641            MovingWrite::Contiguous {
9642                region,
9643                addr_off,
9644                bytes,
9645                ..
9646            } => {
9647                let raw = self.resolve_overwrite_bytes(bytes)?;
9648                let new_data_addr = self.alloc_or_append_typed(&raw, PageType::Raw)?;
9649                let mut region = region.clone();
9650                // The placement is an absolute file offset; the contiguous
9651                // data-layout field stores it relative to the userblock base (`-
9652                // base`, a no-op on a base-0 file).
9653                let relative = base.relative(new_data_addr)?;
9654                region.bytes_mut()[*addr_off..*addr_off + 8]
9655                    .copy_from_slice(&relative.to_le_bytes());
9656                // The data size field follows the 8-byte address in the contiguous
9657                // layout body; keep it in sync with the new length.
9658                let size_off = *addr_off + 8;
9659                let raw_len = raw.len() as u64;
9660                region.bytes_mut()[size_off..size_off + 8].copy_from_slice(&raw_len.to_le_bytes());
9661                let oh = build_v2_object_header(&region)?;
9662                self.alloc_or_append_typed(&oh, PageType::Meta)
9663            }
9664            MovingWrite::Compact { region, bytes } => {
9665                let raw = self.resolve_overwrite_bytes(bytes)?;
9666                let region = rebuild_compact_layout_region(region, &raw)?;
9667                let oh = build_v2_object_header(&region)?;
9668                self.alloc_or_append_typed(&oh, PageType::Meta)
9669            }
9670            MovingWrite::Chunked {
9671                region,
9672                shape,
9673                chunk_dims,
9674                element_size,
9675                maxshape,
9676                pipeline_message,
9677                payload,
9678                ..
9679            } => {
9680                let deferred;
9681                let chunk_bytes = match payload {
9682                    ChunkPayload::Encoded(chunk_bytes) => chunk_bytes,
9683                    ChunkPayload::Deferred { bytes, padding, dt } => {
9684                        // Resolve before splitting: the heap collections have to
9685                        // be placed, and their addresses patched into the element
9686                        // references, before those references are cut into chunks
9687                        // and — on a filtered dataset — compressed over.
9688                        let raw = self.resolve_overwrite_bytes(bytes)?;
9689                        deferred = split_and_encode_chunks(
9690                            &raw,
9691                            shape,
9692                            chunk_dims,
9693                            *element_size,
9694                            padding,
9695                            pipeline_message.as_deref(),
9696                            dt,
9697                        )?;
9698                        &deferred
9699                    }
9700                };
9701                let meta: Vec<ChunkMeta> = chunk_bytes
9702                    .iter()
9703                    .map(|c| ChunkMeta {
9704                        compressed_size: c.len() as u64,
9705                        filter_mask: 0,
9706                    })
9707                    .collect();
9708                self.write_chunked_relocatable(
9709                    region,
9710                    shape,
9711                    chunk_dims,
9712                    *element_size,
9713                    maxshape.as_deref(),
9714                    pipeline_message.as_deref(),
9715                    &meta,
9716                    chunk_bytes,
9717                    DenseAttrSet::default(),
9718                )
9719            }
9720            MovingWrite::AppendedChunks {
9721                region,
9722                new_dataspace_body,
9723                chunk_dims_u32,
9724                element_size,
9725                has_filters,
9726                kept_chunks,
9727                new_chunk_bytes,
9728                ..
9729            } => self.write_appended_chunks(
9730                region,
9731                new_dataspace_body,
9732                chunk_dims_u32,
9733                *element_size,
9734                *has_filters,
9735                kept_chunks,
9736                new_chunk_bytes,
9737            ),
9738            MovingWrite::AttrEdit { region, attrs } => {
9739                // `region` already carries what the commit preflight could resolve;
9740                // `place_edited_attrs` places the rest, exactly as the
9741                // group-attribute apply loop does. Then build and place the
9742                // relocated dataset header: the data-layout message is untouched,
9743                // so the dataset's chunk data and index stay in place and only the
9744                // header moves.
9745                let mut region = region.clone();
9746                self.place_edited_attrs(&mut region, attrs.clone())?;
9747                let oh = build_v2_object_header(&region)?;
9748                self.alloc_or_append_typed(&oh, PageType::Meta)
9749            }
9750        }
9751    }
9752
9753    /// Apply a relocating append ([`MovingWrite::AppendedChunks`]): place the new
9754    /// (and any rewritten trailing) chunk bytes, rebuild a fresh
9755    /// Extensible Array over the kept plus appended chunks, grow the dataspace and
9756    /// repoint the data layout in the verbatim header `region`, and write the
9757    /// relocated header. Returns the new header address; the caller patches the
9758    /// parent link. The kept chunk data is untouched (referenced by both the old
9759    /// and new index during the commit); the old index/header/trailing chunk are
9760    /// freed only after the superblock repoint.
9761    ///
9762    /// The new chunks and the new index go down together where one freed region
9763    /// holds them and a paged file is what makes that worth asking, and one at a
9764    /// time otherwise; the body says why.
9765    #[expect(
9766        clippy::too_many_arguments,
9767        reason = "the append rebuild needs the header region, grown dataspace, chunk \
9768        geometry, and both chunk sets; bundling them into a struct would only move the list"
9769    )]
9770    fn write_appended_chunks(
9771        &mut self,
9772        region: &OhRegion,
9773        new_dataspace_body: &[u8],
9774        chunk_dims_u32: &[u32],
9775        element_size: NonZeroUsize,
9776        has_filters: bool,
9777        kept_chunks: &[WrittenChunk],
9778        new_chunk_bytes: &[Vec<u8>],
9779    ) -> Result<u64, Error> {
9780        let base = self.superblock.base_address;
9781        // Where the appended chunks and the rebuilt index go, and whether they go
9782        // together.
9783        //
9784        // Contiguity is worth something on a *paged* file. The index goes in a raw
9785        // page, not a metadata one, because that is where this crate puts every
9786        // chunk index — and the reclaim side can only tell a raw index from the
9787        // metadata one the reference library writes by the chunk data it abuts
9788        // ([`index_is_provably_raw`](Self::index_is_provably_raw)). An index that
9789        // abuts none of its chunks cannot be placed by the commit that supersedes
9790        // it, so its bytes are held as dead until the page around them empties.
9791        // Laying the new chunks and the new index down as one blob — the way the
9792        // from-scratch writer and `write_chunked_relocatable` lay out chunked
9793        // storage — keeps that proof available (issue #388).
9794        //
9795        // It is worth nothing on a file that is not paged, which has no page types
9796        // to keep apart, and it must never be bought with reuse: one blob needs one
9797        // freed region big enough for the lot, where the chunks placed one at a
9798        // time fill several smaller ones. So the blob form is taken only when a
9799        // single region already holds it, and otherwise each chunk is placed on its
9800        // own and the index after them. That fallback keeps the proof only where
9801        // the last chunk placed ends up immediately below the index — which is what
9802        // happens when both append at end-of-file, and not what happens when the
9803        // chunks find holes to sit in. An index left abutting nothing is recorded
9804        // as dead rather than free, which costs space and never correctness.
9805        //
9806        // Sizing runs at a provisional base, exactly as `write_chunked_relocatable`
9807        // sizes its blob: an Extensible Array's length follows the slot layout and
9808        // every address it embeds sits in a fixed-width field, so the total is the
9809        // same wherever the array lands, and `place` rejects any disagreement.
9810        //
9811        // The element width comes from the chunk geometry rather than from the
9812        // chunk list, so a rebuild declares the same width the original index did —
9813        // including when the dataset it grows was created empty.
9814        let chunk_bytes =
9815            full_chunk_bytes(chunk_dims_u32.iter().map(|&d| u64::from(d)), element_size);
9816        // Rank 1 and unlimited along axis 0 (`prepare_append` refuses anything
9817        // else), so every chunk's index slot is its position in the grid and the
9818        // array is dense from zero.
9819        //
9820        // This engine applies every filter to a new chunk (no per-chunk skipping),
9821        // so an appended chunk's mask is always 0. Kept chunks carry their own
9822        // (possibly nonzero) mask.
9823        let chunk_total: u64 = new_chunk_bytes.iter().map(|cb| cb.len() as u64).sum();
9824        let placed_chunks = |blob_stored: u64| -> Vec<WrittenChunk> {
9825            let mut combined: Vec<WrittenChunk> = kept_chunks.to_vec();
9826            let mut offset = blob_stored;
9827            for cb in new_chunk_bytes {
9828                combined.push(WrittenChunk {
9829                    address: offset,
9830                    compressed_size: cb.len() as u64,
9831                    filter_mask: 0,
9832                });
9833                offset += cb.len() as u64;
9834            }
9835            combined
9836        };
9837        let sizing = placed_chunks(0);
9838        let ea_len = extensible_array_len(
9839            &crate::chunked_write::IndexSlots::dense(&sizing),
9840            chunk_bytes,
9841            OFFSET_SIZE,
9842            LENGTH_SIZE,
9843            has_filters,
9844        );
9845        let ea =
9846            |slots: &crate::chunked_write::IndexSlots<'_>, at: u64| -> Result<Vec<u8>, Error> {
9847                build_extensible_array_at(
9848                    slots,
9849                    chunk_bytes,
9850                    OFFSET_SIZE,
9851                    LENGTH_SIZE,
9852                    has_filters,
9853                    at,
9854                )
9855                .map_err(Error::Format)
9856            };
9857        // Reserved by hand rather than through `reserve`, which would fall back to
9858        // end-of-file: this asks only whether a freed region holds the whole blob,
9859        // and takes the per-chunk path when none does.
9860        let blob = match self.paged {
9861            Some(_) => self.alloc_free(chunk_total + ea_len, PageType::Raw),
9862            None => None,
9863        };
9864        let ea_stored = match blob {
9865            Some(addr) => {
9866                // The region is out of the free lists from here, so every way the
9867                // build can fail has to put it back: a `?` straight out of this arm
9868                // would leave it neither free nor written for the rest of the
9869                // session. Collected into a `Result` and handed back on the way out.
9870                let placed = (|| -> Result<u64, Error> {
9871                    let blob_stored = base.relative(addr)?;
9872                    let combined = placed_chunks(blob_stored);
9873                    let mut buf =
9874                        Vec::with_capacity(usize::try_from(chunk_total + ea_len).unwrap_or(0));
9875                    for cb in new_chunk_bytes {
9876                        buf.extend_from_slice(cb);
9877                    }
9878                    buf.extend_from_slice(&ea(
9879                        &crate::chunked_write::IndexSlots::dense(&combined),
9880                        blob_stored + chunk_total,
9881                    )?);
9882                    self.place(
9883                        Placement::Reused {
9884                            addr,
9885                            len: chunk_total + ea_len,
9886                        },
9887                        &buf,
9888                    )?;
9889                    Ok(blob_stored + chunk_total)
9890                })();
9891                match placed {
9892                    Ok(ea_stored) => ea_stored,
9893                    Err(e) => {
9894                        self.release_raw_alloc(addr, chunk_total + ea_len);
9895                        return Err(e);
9896                    }
9897                }
9898            }
9899            None => {
9900                // A chunk embeds no addresses of its own, so it can go anywhere and
9901                // the index below simply records where it went.
9902                let mut combined: Vec<WrittenChunk> = kept_chunks.to_vec();
9903                for cb in new_chunk_bytes {
9904                    let abs = self.alloc_or_append_typed(cb, PageType::Raw)?;
9905                    combined.push(WrittenChunk {
9906                        address: base.relative(abs)?,
9907                        compressed_size: cb.len() as u64,
9908                        filter_mask: 0,
9909                    });
9910                }
9911                let (ea_addr, ()) = self.place_relocatable(ea_len, PageType::Raw, |at| {
9912                    Ok((
9913                        ea(&crate::chunked_write::IndexSlots::dense(&combined), at)?,
9914                        (),
9915                    ))
9916                })?;
9917                base.relative(ea_addr)?
9918            }
9919        };
9920
9921        // Swap the dataspace (grown) and data-layout (repointed at the new index)
9922        // messages; every other header message is preserved verbatim.
9923        #[expect(
9924            clippy::cast_possible_truncation,
9925            reason = "element size is a datatype byte width that fits u32"
9926        )]
9927        let layout_body = serialize_v4_extensible_array(
9928            chunk_dims_u32,
9929            ea_stored,
9930            OFFSET_SIZE,
9931            element_size.get() as u32,
9932        );
9933        let region = replace_dataspace_message(region, new_dataspace_body)?;
9934        let region = replace_layout_message(&region, &layout_body)?;
9935        let oh = build_v2_object_header(&region)?;
9936        self.alloc_or_append_typed(&oh, PageType::Meta)
9937    }
9938
9939    /// Append `bytes` at end-of-file, returning the absolute address they were
9940    /// written at.
9941    fn append(&mut self, bytes: &[u8]) -> Result<u64, Error> {
9942        self.image.append(bytes)
9943    }
9944
9945    /// Overwrite bytes in place at `offset`. The caller guarantees the range
9946    /// already exists.
9947    fn write_at(&mut self, offset: usize, bytes: &[u8]) -> Result<(), Error> {
9948        self.image.write_at(offset as u64, bytes)
9949    }
9950
9951    /// Ensure the next allocation begins in a page holding page type `ty`, on a
9952    /// paged file. A no-op on the common non-paged file.
9953    ///
9954    /// A paged file never mixes metadata and raw data within one page, so when the
9955    /// tail page holds the *other* type and is only partially filled it is first
9956    /// padded to a page boundary, the padding being recorded as free space of the
9957    /// outgoing type.
9958    ///
9959    /// Call this **before** reading the image's end-of-file ([`Source::len`]) to compute an
9960    /// address that will be embedded in the bytes being built: several callers
9961    /// (the chunk blob, the extensible-array index, the dense-attribute blob)
9962    /// build content whose interior addresses assume it lands at the current
9963    /// end-of-file, and padding inserted after that read would shift the landing
9964    /// address out from under them.
9965    fn begin_page(&mut self, ty: PageType) -> Result<(), Error> {
9966        // Destructure so the page state and the image are borrowed as the
9967        // separate fields they are.
9968        let Self { image, paged, .. } = self;
9969        match paged.as_mut() {
9970            Some(pg) => pg.begin(image.as_mut(), ty),
9971            None => Ok(()),
9972        }
9973    }
9974
9975    /// Place `bytes` as page type `ty`, reusing a free region where one fits.
9976    /// Equivalent to [`reserve`](Self::reserve) followed by [`place`](Self::place),
9977    /// for the callers whose bytes are already built.
9978    fn alloc_or_append_typed(&mut self, bytes: &[u8], ty: PageType) -> Result<u64, Error> {
9979        let at = self.reserve(bytes.len() as u64, ty)?;
9980        self.place(at, bytes)
9981    }
9982
9983    /// Choose where `len` bytes of page type `ty` will go: a reusable free region
9984    /// left by a prior commit, or the current end-of-file. The returned
9985    /// [`Placement`] must be handed to [`place`](Self::place) with exactly `len`
9986    /// bytes.
9987    ///
9988    /// Splitting the choice from the write is what lets a *relocatable* blob — one
9989    /// whose interior addresses are all `base + fixed offset`, like a chunked
9990    /// dataset's data region or a dense attribute heap — be built for a freed
9991    /// region rather than only for end-of-file: its size is known from its plan
9992    /// before its bytes exist, so the address can be settled first
9993    /// ([`place_relocatable`](Self::place_relocatable) is that sequence).
9994    ///
9995    /// Reuse only ever draws from free space vacated by *earlier* commits in this
9996    /// session (or seeded from the on-disk managers at open) — never space the
9997    /// current commit is about to free, which stays in its `to_free` list until
9998    /// after the superblock repoint. The bytes it overwrites are therefore already
9999    /// unreachable from the on-disk root, so a mid-commit crash cannot corrupt the
10000    /// live tree: the superblock still points at the prior, intact root.
10001    ///
10002    /// A paged file reuses within the matching page type, or out of a page holding
10003    /// nothing at all, which belongs to no type ([`PagedEdit::alloc_typed`]); every
10004    /// page stays homogeneous either way. It never opens a page for a reused region
10005    /// — the tail page is untouched by a write into the middle of the file.
10006    fn reserve(&mut self, len: u64, ty: PageType) -> Result<Placement, Error> {
10007        if let Some(addr) = self.alloc_free(len, ty) {
10008            return Ok(Placement::Reused { addr, len });
10009        }
10010        self.begin_page(ty)?;
10011        Ok(Placement::Appended {
10012            addr: self.image.len(),
10013            len,
10014        })
10015    }
10016
10017    /// Draw `len` bytes from the free space of page type `ty`, or `None` when no
10018    /// single region is large enough.
10019    ///
10020    /// A non-paged file keeps one list for the whole file. A paged file keeps one
10021    /// per page type and must be served from the list matching `ty`: handing a
10022    /// metadata hole to raw data (or the reverse) would mix the two within a page,
10023    /// which is the single invariant the paged strategy exists to hold. A page
10024    /// holding nothing at all is the exception, and
10025    /// [`alloc_typed`](PagedEdit::alloc_typed) is where it is spent.
10026    fn alloc_free(&mut self, len: u64, ty: PageType) -> Option<u64> {
10027        let Some(pg) = self.paged.as_mut() else {
10028            return self.free.alloc(len);
10029        };
10030        pg.alloc_typed(len, ty)
10031    }
10032
10033    /// Hand `[addr, addr + len)`, drawn from
10034    /// [`alloc_free`](Self::alloc_free) with [`PageType::Raw`], back to the list
10035    /// it came from — for a placement refused *after* the draw, which would
10036    /// otherwise hold the region for the rest of the session without writing
10037    /// anything into it.
10038    ///
10039    /// A paged file takes it back into the raw list whichever list served it: a
10040    /// whole page [`PagedEdit::alloc_typed`] claimed from the metadata side for
10041    /// raw data is raw from the moment it is claimed, and that call already
10042    /// returns its alignment tail there. A page that ends up wholly free is
10043    /// promoted out of either list by
10044    /// [`promote_whole_free_pages`](PagedEdit::promote_whole_free_pages) as usual.
10045    fn release_raw_alloc(&mut self, addr: u64, len: u64) {
10046        match self.paged.as_mut() {
10047            Some(pg) => pg.raw.free(addr, len),
10048            None => self.free.free(addr, len),
10049        }
10050    }
10051
10052    /// Write `bytes` at the address [`reserve`](Self::reserve) handed out,
10053    /// returning that address.
10054    ///
10055    /// The length is checked rather than asserted: a reused region is sized to the
10056    /// reservation and is followed by live bytes, so writing more than was reserved
10057    /// would silently destroy a neighboring object. That makes it the one internal
10058    /// miscount worth paying a comparison to catch in every build.
10059    fn place(&mut self, at: Placement, bytes: &[u8]) -> Result<u64, Error> {
10060        if bytes.len() as u64 != at.len() {
10061            return Err(Error::Format(FormatError::SerializationError(format!(
10062                "a placement reserved {} bytes but built {}",
10063                at.len(),
10064                bytes.len()
10065            ))));
10066        }
10067        match at {
10068            Placement::Reused { addr, .. } => {
10069                self.write_at(
10070                    usize::try_from(addr).map_err(|_| {
10071                        Error::EditUnsupported("free-region address exceeds this platform")
10072                    })?,
10073                    bytes,
10074                )?;
10075                Ok(addr)
10076            }
10077            Placement::Appended { addr, .. } => {
10078                let written = self.append(bytes)?;
10079                debug_assert_eq!(
10080                    written, addr,
10081                    "an appended placement must land at end-of-file"
10082                );
10083                Ok(written)
10084            }
10085        }
10086    }
10087
10088    /// Place a *relocatable* blob of `len` bytes as page type `ty`: pick its
10089    /// address first, then build it for that address with `build`, then write it
10090    /// there. Returns the address and whatever else `build` produced (typically the
10091    /// object-header message naming the blob, which embeds the same address).
10092    ///
10093    /// `build` receives the *stored* (base-relative) address the blob will occupy,
10094    /// since every address a blob embeds is stored base-relative and the reader
10095    /// recovers it as `stored + base_address`. On a file without a userblock the
10096    /// two are equal.
10097    ///
10098    /// `len` must be the length `build` will produce; [`place`](Self::place)
10099    /// rejects a mismatch. For every blob placed this way the length is a function
10100    /// of the content alone — the addresses sit in fixed-width fields — so every
10101    /// caller derives it, and none builds the blob to measure it:
10102    /// [`DenseAttrPlan::blob_len`](crate::file_writer::DenseAttrPlan::blob_len)
10103    /// for a dense attribute heap, [`extensible_array_len`] for an appended
10104    /// chunk index, and [`chunked_data_len`] / [`plan_chunked_data_verbatim`]
10105    /// for a whole chunked data region, both of which size their index through
10106    /// [`chunk_index_len`](crate::chunked_write::chunk_index_len).
10107    fn place_relocatable<T>(
10108        &mut self,
10109        len: u64,
10110        ty: PageType,
10111        build: impl FnOnce(u64) -> Result<(Vec<u8>, T), Error>,
10112    ) -> Result<(u64, T), Error> {
10113        let at = self.reserve(len, ty)?;
10114        let (bytes, extra) = build(self.superblock.base_address.relative(at.address())?)?;
10115        let addr = self.place(at, &bytes)?;
10116        Ok((addr, extra))
10117    }
10118
10119    /// Place one variable-length dataset's or attribute's already-built,
10120    /// self-contained global heap collections (from
10121    /// [`build_global_heap_collections`] or a
10122    /// [`VlStringStaging::collections`]) and return, in the same order, the
10123    /// base-relative addresses its variable-length references should be patched
10124    /// to. A `GCOL` blob embeds no addresses of its own, so it can be appended
10125    /// (or dropped into reused free space) at any point in the apply loop,
10126    /// unlike a group or dataset header, which must be built last so it can name
10127    /// its children's real addresses. Each collection is placed independently,
10128    /// so they need not land contiguously.
10129    fn place_vl_collections(&mut self, collections: &[Vec<u8>]) -> Result<Vec<u64>, Error> {
10130        collections
10131            .iter()
10132            .map(|collection| {
10133                let addr = self.alloc_or_append_typed(collection, PageType::Meta)?;
10134                self.superblock
10135                    .base_address
10136                    .relative(addr)
10137                    .map_err(Error::from)
10138            })
10139            .collect()
10140    }
10141
10142    /// Resolve a staged value overwrite's element bytes: place any global heap
10143    /// collections its variable-length references name, patch their addresses
10144    /// in, and return the bytes ready to write (issue #321). An overwrite that
10145    /// stages nothing — every one but a `with_vlen_strings` one — is handed
10146    /// back untouched.
10147    ///
10148    /// This allocates, so every caller is in the apply phase — the preflight
10149    /// that chose the plan only reads.
10150    ///
10151    /// It also carries the reclaim. The collections a *previous* overwrite of
10152    /// this same path placed are handed to the commit's free list, and the ones
10153    /// placed here are recorded in their stead, so rotating a dataset's strings
10154    /// reaches a steady state instead of leaking a generation per commit. Only
10155    /// collections this session placed are ever reclaimed, and only while
10156    /// nothing has been able to name them twice — see
10157    /// [`vl_overwrite_heaps`](Self::vl_overwrite_heaps) for why that provenance
10158    /// is the whole proof, and `repack` for the collections it cannot make.
10159    fn resolve_overwrite_bytes<'b>(
10160        &mut self,
10161        bytes: &'b OverwriteBytes,
10162    ) -> Result<Cow<'b, [u8]>, Error> {
10163        let Some(vlen) = &bytes.vlen else {
10164            // Nothing to resolve, and nothing to copy: every overwrite but a
10165            // variable-length one takes this path, and the in-place loop writes
10166            // straight through the borrow. Cloning here would charge each of
10167            // them a second copy of its whole payload, which the bounded
10168            // backing in particular is built not to pay.
10169            return Ok(Cow::Borrowed(&bytes.raw));
10170        };
10171        let staging = &vlen.staging;
10172        // Read rather than removed: the record is replaced (not consumed) when
10173        // the new collections land just below.
10174        let superseded = self
10175            .vl_overwrite_heaps
10176            .get(&vlen.path)
10177            .cloned()
10178            .unwrap_or_default();
10179        let mut raw = bytes.raw.clone();
10180        let base = self.superblock.base_address;
10181        // A staging with no collections patches nothing, which is the right
10182        // answer for it: an element with no heap object keeps the zero address
10183        // that reads back as null. It falls out of the general path rather than
10184        // being a case of its own — `patch_vl_refs_masked` has one offset per
10185        // object, so no offsets is no iterations — and it still records (an
10186        // empty record) and still supersedes.
10187        let addrs = self.place_vl_collections(&staging.collections)?;
10188        patch_vl_refs_masked(&mut raw, &staging.patch_offsets, &addrs);
10189        // `place_vl_collections` answers base-relative, since that is what an
10190        // element reference stores; the free list is absolute.
10191        let placed = addrs
10192            .iter()
10193            .zip(&staging.collections)
10194            .map(|(&a, c)| Ok((base.absolute(a)?, c.len() as u64)))
10195            .collect::<Result<Vec<_>, FormatError>>()?;
10196        self.vl_overwrite_heaps.insert(vlen.path.clone(), placed);
10197        self.superseded_heaps.extend(superseded);
10198        Ok(Cow::Owned(raw))
10199    }
10200
10201    /// Forget every heap collection this session recorded as exclusively one
10202    /// dataset's, because `staged` contains an edit that could name one of them
10203    /// a second time (issue #321).
10204    ///
10205    /// The provenance in [`vl_overwrite_heaps`](Self::vl_overwrite_heaps) says a
10206    /// collection was named once *when it was placed*. Two staged edits can
10207    /// falsify that afterwards, and both do it by re-emitting element bytes
10208    /// somebody else's references live in:
10209    ///
10210    /// - a **copy**, in-file or cross-file, which duplicates a dataset's element
10211    ///   references verbatim. This is not hypothetical: an in-file copy of a
10212    ///   variable-length dataset is exactly how two datasets come to name one
10213    ///   collection.
10214    /// - a **raw-bytes write** over a datatype that reaches a heap address
10215    ///   ([`datatype_holds_file_address`]). `with_raw_data` hands the engine
10216    ///   element bytes it does not interpret, so a caller that read them from
10217    ///   one dataset and wrote them to another has aliased whatever they named —
10218    ///   the same unscreened door issue #317 recorded for object references.
10219    ///
10220    /// Wholesale rather than per-path: what a copy's source references is not
10221    /// known without decoding it, and a proof that has to be argued per edit is
10222    /// the kind that goes stale. Reclaim is an optimization, and giving all of
10223    /// it up on a rare edit costs a leak, where keeping one entry too long costs
10224    /// a freed collection somebody still reads.
10225    fn invalidate_heap_provenance(&mut self, staged: &StagedEdits) {
10226        if self.vl_overwrite_heaps.is_empty() {
10227            return;
10228        }
10229        let aliasing_edit = !staged.copies.is_empty()
10230            || !staged.cross_copies.is_empty()
10231            // A dataset staged with element bytes of its own (`with_raw_data`)
10232            // rather than staging its strings here, whether it is being created
10233            // or overwritten.
10234            || staged
10235                .writes
10236                .iter()
10237                .chain(&staged.datasets)
10238                .any(|(_, fd)| {
10239                    fd.vl_string_staging.is_none()
10240                        && datatype_holds_file_address(&fd.dt)
10241                });
10242        if aliasing_edit {
10243            self.vl_overwrite_heaps.clear();
10244            return;
10245        }
10246        // A deleted dataset's record would otherwise outlive it and be applied
10247        // to whatever later takes its path.
10248        for path in &staged.deletes {
10249            self.vl_overwrite_heaps
10250                .retain(|recorded, _| !paths_overlap(recorded, path));
10251        }
10252    }
10253
10254    /// Resolve one object-reference element's target to the base-relative
10255    /// address that should be stored on disk. [`ObjectRefTarget::Raw`] is
10256    /// written back verbatim (a null or undefined reference is a sentinel, not
10257    /// a real address, so it needs no base adjustment — mirrors the whole-file
10258    /// writer). [`ObjectRefTarget::Path`] resolves, in order:
10259    ///
10260    /// 1. Against `path_addr` — every group and dataset this commit has
10261    ///    already placed (a sibling dataset placed earlier in the same
10262    ///    group's batch — see the apply loop's non-reference-first ordering —
10263    ///    or a descendant subtree fully processed earlier in the deepest-first
10264    ///    walk).
10265    /// 2. Against the pre-commit on-disk file
10266    ///    ([`resolve_path_any`](crate::group_v2::resolve_path_any)), but only
10267    ///    when the path is untouched by this commit, so its pre-commit
10268    ///    address is guaranteed to still be valid post-commit. "Touched"
10269    ///    means: a dirty group (`nodes`, new or merely rewritten because an
10270    ///    addition lives under it — its own address changes either way); a
10271    ///    path this commit adds, or that lies under a subtree this commit
10272    ///    copies in (`add_targets`, checked by prefix so a copy's interior is
10273    ///    covered even though only its root is enumerated there); or a
10274    ///    `write_dataset` target (`write_targets`) — conservatively refused
10275    ///    even for a same-length overwrite that does not actually relocate,
10276    ///    since resolving that distinction here is not worth the complexity.
10277    /// 3. If the path resolves nowhere at all (neither this commit nor the
10278    ///    pre-commit file has ever heard of it), as an undefined reference
10279    ///    (`HADDR_UNDEF`) — mirroring [`ObjectRefTarget::Path`]'s existing
10280    ///    whole-file-writer resolution convention for the same builder type.
10281    ///
10282    /// A path that step 1 misses but step 2 identifies as commit-touched is
10283    /// refused with a clear [`Error::EditUnsupported`] rather than resolved to
10284    /// a stale or wrong address — the one case this engine cannot resolve
10285    /// without the whole-file writer's two-pass dummy/real-address scheme.
10286    ///
10287    /// A path this same commit *deletes* (`delete_targets`) is refused for a
10288    /// different reason, and so carries its own message: the address step 2
10289    /// would resolve is not stale but doomed, and the next commit to reuse the
10290    /// span leaves a reference that dereferenced cleanly reading whatever
10291    /// landed there. The test is by **prefix**, because a deletion takes the
10292    /// whole subtree with it (`collect_free_spans` walks it): a reference to a
10293    /// child of a deleted group dangles exactly as a reference to the group
10294    /// itself does (issue #314).
10295    ///
10296    /// It is a conservative test in the same sense `write_targets` is. A child
10297    /// whose object survives the delete through another hard link keeps its
10298    /// address — `collect_free_spans` reclaims nothing when the incoming count
10299    /// is not 1 — and is refused here anyway, so the message states what the
10300    /// commit does rather than what the allocator will conclude.
10301    ///
10302    /// The delete test runs *after* the other three so that a replacement this
10303    /// commit has merely not placed yet — a sibling group later in the same
10304    /// depth band — is reported as an ordering problem rather than as a
10305    /// deletion. That ordering is a better default, not a partition: because
10306    /// `add_targets` claims a replaced path's whole subtree by prefix, a child
10307    /// the replacement does *not* recreate is genuinely doomed and still
10308    /// reports "still writing". Distinguishing it would mean enumerating what
10309    /// a replacement actually rebuilds, which neither list holds. A path the
10310    /// commit puts back is resolved by step 1 before either test is reached.
10311    fn resolve_reference_target(
10312        target: &ObjectRefTarget,
10313        path_addr: &BTreeMap<PathKey, u64>,
10314        nodes: &BTreeMap<PathKey, Node>,
10315        add_targets: &[PathKey],
10316        write_targets: &[PathKey],
10317        delete_targets: &[PathKey],
10318        invalidated: &InvalidatedAddresses,
10319        src: &(impl Source + ?Sized),
10320        superblock: &Superblock,
10321    ) -> Result<u64, Error> {
10322        let path = match target {
10323            // An address carries no name to test, so the delete check the path
10324            // arm makes by prefix is made here on the address itself.
10325            //
10326            // It fires on nothing today: every `Raw` this crate stages is a
10327            // sentinel, because `repack`'s faithful re-emit resolves a real
10328            // target to a `Path` and leaves only the null and undefined
10329            // references raw. It is here because the variant carries an
10330            // arbitrary address, and a producer that staged a real one would
10331            // otherwise write it past the screen its `Path` twin gets — which
10332            // is the shape of this bug in the first place (issue #317).
10333            ObjectRefTarget::Raw(addr) => {
10334                if let Some(refusal) = invalidated.refusal(*addr) {
10335                    return Err(Error::EditUnsupported(refusal));
10336                }
10337                return Ok(*addr);
10338            }
10339            ObjectRefTarget::Path(path) => path,
10340        };
10341        let base = superblock.base_address;
10342        let key = split_path(path);
10343        if let Some(&addr) = path_addr.get(&key) {
10344            return base.relative(addr).map_err(Error::from);
10345        }
10346        if nodes.contains_key(&key)
10347            || add_targets.iter().any(|t| is_prefix(t, &key))
10348            || write_targets.contains(&key)
10349        {
10350            return Err(Error::EditUnsupported(
10351                "an object-reference dataset targets a path this commit is still writing; \
10352                 use separate commits",
10353            ));
10354        }
10355        // After the three above; see this function's doc for why, and for what
10356        // that ordering does and does not buy.
10357        if delete_targets.iter().any(|d| is_prefix(d, &key)) {
10358            return Err(Error::EditUnsupported(
10359                "an object-reference dataset targets an object this commit deletes, or one \
10360                 under it; the reference would be left pointing at storage the delete can \
10361                 reclaim",
10362            ));
10363        }
10364        match crate::group_v2::resolve_path_any_from_source(src, superblock, path) {
10365            Ok(addr) => base.relative(addr).map_err(Error::from),
10366            Err(_) => Ok(UNDEF),
10367        }
10368    }
10369
10370    /// Prove, before any byte of this commit is written, that every
10371    /// object-reference target across every staged dataset will resolve
10372    /// successfully — either against a pre-existing untouched object or
10373    /// against something this same commit places. [`resolve_reference_target`]
10374    /// classifies a target purely from *whether* a `PathKey` has been placed
10375    /// yet (`path_addr.get`), never from the address *value*, so replaying the
10376    /// apply loop's placement order here with a placeholder address standing in
10377    /// for "already placed" reproduces the exact same verdict the apply loop's
10378    /// own calls will reach later, without writing anything.
10379    ///
10380    /// The placeholder is the file's **base address**, not zero. Any value
10381    /// reproduces the verdict, but it is also handed to the same `addr - base`
10382    /// that converts a real address into its stored form: a zero placeholder
10383    /// underflows that on every file with a userblock, panicking in a debug
10384    /// build on an otherwise legal edit. At the base address it converts to a
10385    /// stored `0`, which is exactly the "placed, real address not known yet"
10386    /// this stands for. If
10387    /// this preflight pass returns `Ok`, none of the apply loop's own
10388    /// `resolve_reference_target` calls can fail, so a reference-resolution
10389    /// error can no longer leave earlier-processed groups' real writes
10390    /// orphaned in the file (the failure surfaces here instead, before the
10391    /// apply loop's first `place`/`write_at`).
10392    fn preflight_reference_targets(
10393        keys: &[PathKey],
10394        flat: &BTreeMap<&PathKey, Vec<&FlatDataset>>,
10395        nodes: &BTreeMap<PathKey, Node>,
10396        add_targets: &[PathKey],
10397        write_targets: &[PathKey],
10398        delete_targets: &[PathKey],
10399        invalidated: &InvalidatedAddresses,
10400        src: &(impl Source + ?Sized),
10401        superblock: &Superblock,
10402    ) -> Result<(), Error> {
10403        let mut by_depth = keys.to_vec();
10404        // Stable on purpose, for the same reason as the apply loop's copy: this
10405        // simulation is only faithful if it walks the groups in that same order.
10406        by_depth.sort_by_key(|k| std::cmp::Reverse(k.len()));
10407        let mut sim_addr: BTreeMap<PathKey, u64> = BTreeMap::new();
10408        for key in &by_depth {
10409            if let Some(datasets) = flat.get(key) {
10410                // Mirrors the apply loop's `group_datasets.sort_by_key(|fd|
10411                // fd.reference_targets.is_some())`: non-reference datasets are
10412                // placed (and so become resolvable) before any reference
10413                // dataset in the same group.
10414                let mut ordered: Vec<&FlatDataset> = datasets.to_vec();
10415                // Stable on purpose, like the loop it mirrors.
10416                ordered.sort_by_key(|fd| fd.reference_targets.is_some());
10417                for fd in ordered {
10418                    if let Some(patches) = &fd.reference_targets {
10419                        for patch in patches {
10420                            Self::resolve_reference_target(
10421                                &patch.target,
10422                                &sim_addr,
10423                                nodes,
10424                                add_targets,
10425                                write_targets,
10426                                delete_targets,
10427                                invalidated,
10428                                src,
10429                                superblock,
10430                            )?;
10431                        }
10432                    }
10433                    let mut full = key.clone();
10434                    full.push(fd.name.clone());
10435                    sim_addr.insert(full, superblock.base_address.get());
10436                }
10437            }
10438            sim_addr.insert(key.clone(), superblock.base_address.get());
10439        }
10440        Ok(())
10441    }
10442
10443    /// Lay out a chunked / filtered / extensible dataset and return its object
10444    /// header bytes (which the caller links into the parent group).
10445    ///
10446    /// The chunk data and index (fixed-array / extensible-array, with any filter
10447    /// pipeline applied) are produced as one relocatable blob, whose internal
10448    /// layout — and therefore total size — is independent of the base address it
10449    /// is given. That is what lets the dataset be *sized before it is placed*
10450    /// ([`chunked_data_len`]): the blob goes into a freed region big enough to
10451    /// hold it, or at end-of-file when there is none, and is then assembled for
10452    /// whichever address it got, so every absolute address it embeds (chunk
10453    /// addresses, index-structure addresses, the addresses in the data-layout
10454    /// message) lands exactly where the bytes are written. The header is then
10455    /// built with [`build_chunked_dataset_oh`] — the same function the whole-file
10456    /// writer uses — so the header is byte-identical to one written fresh.
10457    ///
10458    /// The dataset's single pass of the filter pipeline happens in
10459    /// [`compress_chunks`], before either decision; sizing and assembly work from
10460    /// the compressed set, so choosing an address never costs a recompression
10461    /// (issue #261).
10462    fn build_chunked_dataset(&mut self, fd: &FlatDataset) -> Result<Vec<u8>, Error> {
10463        let chunk_dims = fd.chunk_options.resolve_chunk_dims(&fd.ds.dimensions);
10464        let ctx = ChunkContext::from_datatype(&chunk_dims, &fd.dt)?;
10465        // The overhang of a partial edge chunk holds the staged fill value
10466        // (issue #296).
10467        let elem = crate::convert::nonzero_usize_from(ctx.element_size)?;
10468        let fill = crate::fill_value::FillPattern::new(fd.fill.as_deref(), elem);
10469        let set = compress_chunks(
10470            &fd.raw,
10471            &fd.ds.dimensions,
10472            ctx,
10473            &fd.chunk_options,
10474            fd.maxshape.as_deref(),
10475            fill,
10476            // `flatten_dataset` refuses an unallocated dataset outright, so
10477            // every dataset this engine builds allocates its storage.
10478            StorageAllocation::Allocated,
10479        )?;
10480        // Chunk data and the index beside it are raw (see `chunked_storage_spans`,
10481        // which reclaims both as raw).
10482        let (_addr, (layout_message, pipeline_message)) =
10483            self.place_relocatable(chunked_data_len(&set)?, PageType::Raw, |stored_base| {
10484                let result = assemble_chunked_at(&set, stored_base)?;
10485                Ok((
10486                    result.data_bytes,
10487                    (result.layout_message, result.pipeline_message),
10488                ))
10489            })?;
10490        // As in the contiguous branch: a set too large for the header goes to a
10491        // fractal heap, which the header then names instead of carrying it.
10492        let attr_info = self.place_dense_attrs_if_needed(fd)?;
10493        Ok(build_chunked_dataset_oh(
10494            &fd.dt,
10495            &DatatypeLocation::Inline,
10496            &fd.ds,
10497            &layout_message,
10498            pipeline_message.as_deref(),
10499            &fd.attrs,
10500            attr_info.as_deref(),
10501            fd.fill.as_deref(),
10502        )?)
10503    }
10504
10505    /// On-disk byte spans `(addr, len)` of every chunk of the version 2 object
10506    /// header at `addr`: chunk 0 (signature, prefix, messages, checksum) plus
10507    /// each continuation (`OCHK`) block. Used to reclaim a header's storage when
10508    /// its object is deleted. An error (propagated from [`oh_region_at`] or a
10509    /// malformed continuation) means the header is not a plain v2 header this
10510    /// engine can fully account for, and the caller leaves it as dead bytes
10511    /// rather than guess its extent.
10512    fn oh_chunk_spans(&self, addr: usize) -> Result<Vec<(u64, u64)>, Error> {
10513        Ok(
10514            read_oh_chunks(&self.image(), addr as u64, self.superblock.base_address)?
10515                .into_iter()
10516                .map(|chunk| chunk.span)
10517                .collect(),
10518        )
10519    }
10520
10521    /// Count, for every object-header address reachable from the root, how many
10522    /// hard links in the *pre-commit* file point to it. The result drives the
10523    /// last-hard-link reclaim guard in [`collect_free_spans`](Self::collect_free_spans):
10524    /// an object is freed only when its count is 1.
10525    ///
10526    /// Walks the whole link graph from the root, following hard links through
10527    /// groups of any on-disk format (v0/v1 symbol-table, v2 compact, v2 dense)
10528    /// via [`resolve_group_entries`], tallying each hard-link edge. Datasets and
10529    /// other leaves contribute no edges. Returns `None` — so the caller reclaims
10530    /// nothing for the deletions, a safe leak — if the graph cannot be walked in
10531    /// full: an unparseable header, a group whose links cannot be enumerated, or
10532    /// more than [`MAX_LINK_GRAPH_NODES`] objects. Cycles are handled by visiting
10533    /// each object once. Base-aware: stored child addresses are shifted by the
10534    /// userblock base, so the returned keys are absolute file offsets.
10535    fn count_incoming_hard_links(&self) -> Option<HashMap<u64, u32>> {
10536        let os = self.superblock.offset_size;
10537        let ls = self.superblock.length_size;
10538        let base = self.superblock.base_address;
10539        let mut counts: HashMap<u64, u32> = HashMap::new();
10540        let mut visited: HashSet<u64> = HashSet::new();
10541        let mut stack: Vec<u64> = vec![self.superblock.root_group_address];
10542        let mut budget = MAX_LINK_GRAPH_NODES;
10543        while let Some(addr) = stack.pop() {
10544            if !visited.insert(addr) {
10545                continue; // already expanded (also breaks hard-link cycles)
10546            }
10547            if budget == 0 {
10548                return None; // graph larger than we will walk; leak conservatively
10549            }
10550            budget -= 1;
10551            let off = usize::try_from(addr).ok()?;
10552            let header =
10553                ObjectHeader::parse_from_source(&self.image(), off as u64, os, ls, base).ok()?;
10554            // Datasets and other leaves are not groups and own no links.
10555            let is_group = header.messages.iter().any(|m| {
10556                matches!(
10557                    m.msg_type,
10558                    MessageType::SymbolTable | MessageType::Link | MessageType::LinkInfo
10559                )
10560            });
10561            if !is_group {
10562                continue;
10563            }
10564            // A group we cannot enumerate fully would undercount incoming links
10565            // and risk over-reclaim; bail to the safe-leak fallback instead.
10566            let entries =
10567                resolve_group_entries_from_source(&self.image(), &header, os, ls, base).ok()?;
10568            for e in entries {
10569                let child = base.absolute(e.object_header_address).ok()?;
10570                *counts.entry(child).or_insert(0) += 1;
10571                stack.push(child);
10572            }
10573        }
10574        Some(counts)
10575    }
10576
10577    /// Best-effort enumeration of every on-disk block owned by the object at
10578    /// `addr` (and, for a group, its whole subtree), accumulating `(addr, len)`
10579    /// spans into `out` for reclamation after a delete.
10580    ///
10581    /// Contiguous datasets (header + data block), chunked datasets (header +
10582    /// chunk index + chunk data, via [`chunked_storage_spans`](Self::chunked_storage_spans)),
10583    /// and whole group subtrees are reclaimed. Deliberately conservative: any
10584    /// object whose layout it cannot fully account for — a non-v2 header, an
10585    /// unsupported or only-partially-enumerable chunk index, a group holding a
10586    /// soft/external link, dense attribute storage — contributes nothing and is
10587    /// not descended into, so `out` never names a region that might still be in
10588    /// use. Bounded by [`MAX_COPY_DEPTH`] against a hard-link cycle.
10589    /// Variable-length data in global-heap collections is never reclaimed here (a
10590    /// collection can be shared between objects), so it is simply left behind.
10591    ///
10592    /// `incoming` is the file-wide hard-link count per object-header address
10593    /// (from [`count_incoming_hard_links`](Self::count_incoming_hard_links)). An
10594    /// object is reclaimed — and, for a group, descended into — only when its
10595    /// count is exactly 1, i.e. the link being removed is its last: an object
10596    /// still reachable through another hard link is live and is left untouched
10597    /// (so is everything below a surviving group), which is what keeps deleting
10598    /// one of several hard links from corrupting the survivor.
10599    fn collect_free_spans(
10600        &self,
10601        addr: usize,
10602        depth: u32,
10603        incoming: &HashMap<u64, u32>,
10604        out: &mut Vec<(u64, u64, FreeClass)>,
10605    ) {
10606        // `addr` is an absolute file offset (the caller resolves it from the live
10607        // file, and the group recursion below re-absolutizes each child). `incoming`
10608        // is keyed by absolute offset, and `oh_chunk_spans`/`chunked_storage_spans`
10609        // both take an absolute address and return absolute spans, so the whole
10610        // walk works in absolute file offsets. The one shift this method must apply
10611        // itself is on the *stored* (base-relative) addresses `read_object` returns
10612        // for a contiguous data block and a group's child links: each is converted
10613        // to an absolute offset by adding `base` (a no-op on a base-0 file) before
10614        // it is bounds-checked, recorded, or descended into.
10615        let base = self.superblock.base_address;
10616        let file_len = self.image().len();
10617        if depth >= MAX_COPY_DEPTH {
10618            return;
10619        }
10620        // Reclaim only when this delete removes the object's last hard link. A
10621        // count other than 1 (it has surviving links, or the graph walk could
10622        // not account for it) means the object — and a group's whole subtree —
10623        // stays live and must not be freed.
10624        if incoming.get(&(addr as u64)) != Some(&1) {
10625            return;
10626        }
10627        // The header's own chunks. If they cannot be mapped, account for nothing.
10628        let spans = match self.oh_chunk_spans(addr) {
10629            Ok(s) => s,
10630            Err(_) => return,
10631        };
10632        match Self::read_object(&self.image(), addr as u64, self.superblock.base_address) {
10633            Ok(ObjModel::DatasetVerbatim { .. }) => out.extend(meta_spans(spans)),
10634            Ok(ObjModel::DatasetContiguous {
10635                data_addr,
10636                data_size,
10637                ..
10638            }) => {
10639                out.extend(meta_spans(spans));
10640                // A defined, in-bounds contiguous data block is owned outright;
10641                // an empty dataset stores the undefined address and owns none. The
10642                // stored address is base-relative, so shift it to an absolute file
10643                // offset before bounds-checking and recording it.
10644                if data_addr != u64::MAX && data_size > 0 {
10645                    if let (Some(abs), Ok(len)) =
10646                        (base.absolute(data_addr).ok(), usize::try_from(data_size))
10647                    {
10648                        if let Ok(start) = usize::try_from(abs) {
10649                            if start.checked_add(len).is_some_and(|e| e as u64 <= file_len) {
10650                                // A contiguous data block is raw data.
10651                                out.push((abs, data_size, FreeClass::Page(PageType::Raw)));
10652                            }
10653                        }
10654                    }
10655                }
10656            }
10657            Ok(ObjModel::Group { children, .. }) => {
10658                out.extend(meta_spans(spans));
10659                // Child link targets are stored base-relative; re-absolutize each
10660                // before descending so the recursion keeps working in absolute
10661                // offsets (matching `incoming`'s keys and `oh_chunk_spans`).
10662                for (_, _, child) in children {
10663                    if let Some(c) = base
10664                        .absolute(child)
10665                        .ok()
10666                        .and_then(|a| usize::try_from(a).ok())
10667                    {
10668                        self.collect_free_spans(c, depth + 1, incoming, out);
10669                    }
10670                }
10671            }
10672            // A chunked dataset: reclaim its chunk index and chunk data blocks
10673            // alongside its header. `chunked_storage_spans` returns `None` for
10674            // anything it cannot account for exhaustively (an index type with no
10675            // walker, an undefined index address, or spans that fail the
10676            // bounds/overlap check), leaving the whole dataset as dead bytes
10677            // rather than freeing a region that might still be in use.
10678            Ok(ObjModel::DatasetChunked { .. }) => {
10679                if let Some(storage) = self.chunked_storage_spans(addr) {
10680                    out.extend(meta_spans(spans));
10681                    // Already page-typed: chunk data raw, index structure metadata.
10682                    out.extend(storage);
10683                }
10684            }
10685            // A truly unsupported object (one `read_object` cannot model): leave
10686            // its bytes in place rather than guess its extent.
10687            //
10688            // An externally stored dataset lands here as of #336, where before it
10689            // was modelled as the contiguous dataset it structurally is and had
10690            // its header chunks reclaimed. Deleting one therefore leaves those
10691            // chunks behind: measured on the fixture in
10692            // `crates/crosscheck/tests/external_storage.rs`, a commit that deletes it
10693            // reports 147 B reusable where it reported 431 B. That is a leak and
10694            // not a hazard — `oh_chunk_spans` never covered the local heap the
10695            // External Data Files message names either, so neither the old
10696            // behaviour nor this one accounted for the whole object — and it is
10697            // the price of stating the refusal once, in the one function both the
10698            // copy planner and this walk read objects through.
10699            Err(_) => {}
10700        }
10701    }
10702
10703    /// Best-effort enumeration of every on-disk block a *chunked* dataset at
10704    /// `addr` owns: its chunk index structure (B-tree v1 nodes, or fixed- /
10705    /// extensible-array header, index, super, and data blocks) plus every
10706    /// allocated chunk data block. The object-header chunks are freed by the
10707    /// caller ([`collect_free_spans`](Self::collect_free_spans)); this returns
10708    /// only the storage the data-layout message points at.
10709    ///
10710    /// Returns `None` — contribute nothing, leave the object as dead bytes —
10711    /// whenever the dataset cannot be enumerated *exhaustively* and safely: a
10712    /// header that does not parse or is not a chunked dataset, a chunk index
10713    /// with no walker (a version 2 B-tree, index type 5), an undefined index
10714    /// address (an empty, never-written dataset), or any resulting span that
10715    /// falls outside the file image or overlaps another. This upholds the
10716    /// editor's invariant that reclaimed space is never a region still in use:
10717    /// under-reclaiming only wastes space, while over-reclaiming would corrupt.
10718    ///
10719    /// Chunk data addresses and sizes come from the same index walkers the
10720    /// reader uses, so they match the bytes the writer laid down exactly. The
10721    /// per-layout enumeration lives in
10722    /// [`chunked_read::collect_chunked_storage_spans`](crate::chunked_read::collect_chunked_storage_spans);
10723    /// this method only locates the layout and dataspace messages and validates
10724    /// the result. Variable-length data in global-heap collections is still
10725    /// never reclaimed (a collection can be shared between objects); see the
10726    /// [module docs](self).
10727    fn chunked_storage_spans(&self, addr: usize) -> Option<Vec<(u64, u64, FreeClass)>> {
10728        // Locate the data-layout and dataspace messages in the object header.
10729        let region =
10730            Self::gather_oh_messages(&self.image(), addr as u64, self.superblock.base_address)
10731                .ok()?;
10732        let mut layout_msg: Option<(usize, usize)> = None;
10733        let mut dataspace_msg: Option<(usize, usize)> = None;
10734        let mut p = 0;
10735        loop {
10736            match region.next_message(p) {
10737                Ok(Some((msg_type, body, body_end))) => {
10738                    match msg_type {
10739                        MessageType::DataLayout => layout_msg = Some((body, body_end)),
10740                        MessageType::Dataspace => dataspace_msg = Some((body, body_end)),
10741                        _ => {}
10742                    }
10743                    p = body_end;
10744                }
10745                Ok(None) => break,
10746                Err(_) => return None,
10747            }
10748        }
10749        let (lb, le) = layout_msg?;
10750        let (db, de) = dataspace_msg?;
10751
10752        let layout = DataLayout::parse(&region[lb..le], OFFSET_SIZE, LENGTH_SIZE).ok()?;
10753        if !matches!(layout, DataLayout::Chunked { .. }) {
10754            return None;
10755        }
10756        let dataspace = Dataspace::parse(&region[db..de], LENGTH_SIZE).ok()?;
10757
10758        // Delegate the per-index-type enumeration to the chunked reader (the
10759        // single owner of chunk-storage layout knowledge), then validate: every
10760        // span must lie inside the current file image and be pairwise disjoint,
10761        // or the free list would later hand out live bytes (and a debug build
10762        // would panic on the double-free). On any error or violation, leave the
10763        // whole dataset unreclaimed rather than free a region still in use.
10764        //
10765        // The layout's stored addresses are relative to the userblock base, so the
10766        // enumeration runs on a base-relative view of the file and each returned
10767        // span address is shifted back to an absolute file offset by adding `base`
10768        // (a no-op on a base-0 file). The free list and the bounds check below both
10769        // work in absolute file offsets.
10770        let base = self.superblock.base_address;
10771        let split = crate::chunked_read::collect_chunked_storage_spans(
10772            &BaseOffsetSource {
10773                inner: &self.image(),
10774                base,
10775            },
10776            &layout,
10777            &dataspace,
10778            OFFSET_SIZE,
10779            LENGTH_SIZE,
10780        )
10781        .ok()?;
10782        // Chunk data is raw under every writer, so its spans are raw outright. The
10783        // index is only raw where this crate placed it — see
10784        // [`index_is_provably_raw`](Self::index_is_provably_raw) — and is recorded
10785        // as dead rather than free otherwise.
10786        let mut data: Vec<(u64, u64)> = Vec::with_capacity(split.data.len());
10787        for (addr, len) in split.data {
10788            data.push((base.absolute(addr).ok()?, len));
10789        }
10790        let mut index: Vec<(u64, u64)> = Vec::with_capacity(split.index.len());
10791        for (addr, len) in split.index {
10792            index.push((base.absolute(addr).ok()?, len));
10793        }
10794        // Validate both halves together — they must be disjoint from each other as
10795        // well as internally — before either is trusted, including by the proof.
10796        let mut plain: Vec<(u64, u64)> = data.iter().chain(index.iter()).copied().collect();
10797        if !spans_disjoint_in_bounds(&mut plain, self.image.len()) {
10798            return None;
10799        }
10800        let index_class = if self.index_is_provably_raw(&data, &index) {
10801            FreeClass::Page(PageType::Raw)
10802        } else {
10803            FreeClass::Dead
10804        };
10805        let mut spans: Vec<(u64, u64, FreeClass)> = data
10806            .iter()
10807            .map(|&(a, l)| (a, l, FreeClass::Page(PageType::Raw)))
10808            .collect();
10809        spans.extend(index.into_iter().map(|(a, l)| (a, l, index_class)));
10810        Some(spans)
10811    }
10812
10813    /// Whether a chunked dataset's `index` provably occupies raw pages, given the
10814    /// `data` spans it accompanies. Both are absolute file offsets.
10815    ///
10816    /// A chunk index is *metadata* by the format's taxonomy, and the reference C
10817    /// library allocates one as such — out of metadata pages, nowhere near the
10818    /// chunk data. This crate instead lays a dataset's index down in the same run
10819    /// as its chunk data, inside raw pages, so that a reader following the layout
10820    /// message walks one contiguous blob. Both are valid; what matters on a paged
10821    /// file is that freeing an index records it under the page type it actually
10822    /// sits in, since that decides what a later allocation may overwrite there.
10823    ///
10824    /// Nothing in the file says which writer produced it, so the index is placed
10825    /// from the layout itself, by two tests it must pass together: some chunk-data
10826    /// span must **abut** it — ending exactly where the index begins, or beginning
10827    /// exactly where it ends — and no byte of it may lie in **page 0**.
10828    ///
10829    /// The abutment is this crate's single blob, and is not a layout the reference
10830    /// library sets out to produce. A chunk index is metadata to that library,
10831    /// allocated out of metadata pages — and those are allocated from the bottom
10832    /// of the file, while large raw data goes above, so its index lands far below
10833    /// the chunk data it indexes rather than against it. Measured across seven
10834    /// files it wrote (Extensible and Fixed Array indexes, page sizes
10835    /// 512/4096/8192, 8 to 16 chunks): the index began in page 0 every time, with
10836    /// the chunk data starting at page 9 or higher, so neither join address was
10837    /// within reach of it.
10838    ///
10839    /// The page-0 screen closes the one way that measured layout can nonetheless
10840    /// satisfy the abutment. An index block that ends exactly on a page boundary
10841    /// with a raw page after it — a Fixed Array data block that exactly fills its
10842    /// page, say — is abutted by the first chunk in that raw page, and the whole
10843    /// index run would then be filed as raw, its header included, *even though
10844    /// that header sits in page 0 beside the superblock*. A later raw allocation
10845    /// would land inside a metadata page, which is the page-mixing defect of
10846    /// issue #261. Page 0 of a paged file always begins with the superblock, so it
10847    /// is a metadata page by construction and an index with a byte in it cannot be
10848    /// raw whatever abuts it. The screen costs this crate nothing: it allocates
10849    /// chunk data and the indexes beside it out of raw pages, and page 0 — never
10850    /// wholly free, since the superblock lives there — is never one of them.
10851    ///
10852    /// The tighter rule considered instead was to require the join address to fall
10853    /// *strictly inside* a page, which proves rawness outright: the byte before
10854    /// the join and the byte after it are then in one page, and a page holding
10855    /// chunk data is raw. It is sound, and a paged file the reference library
10856    /// wrote cannot satisfy it at all, since such a file never puts raw and
10857    /// metadata bytes in one page and so can only ever join them on a boundary.
10858    /// It was rejected because it also refuses layouts *this* crate produces:
10859    /// whenever a blob's chunk bytes total a page multiple from a page-aligned
10860    /// start, its index begins on a boundary too. Measured over 100
10861    /// `append_staged` commits at a 4096-byte page with 2048-byte chunks, that
10862    /// stranded about 500 bytes per commit — 62 KB against the 10 KB the abutment
10863    /// rule left — because the two writers produce the same geometry there and
10864    /// nothing in the file distinguishes them.
10865    ///
10866    /// Both sides have to be admitted, not just the index that follows its data.
10867    /// A staged append keeps the existing chunks where they are and places the new
10868    /// ones past the index it is superseding, so from the second append onward the
10869    /// dataset has chunk data on both sides of that index and the highest data
10870    /// address is no longer the one beside it. Asking only whether the index
10871    /// begins where the *last* chunk ends refused every such index, and a
10872    /// repeatedly appended dataset dropped one per commit (issue #388).
10873    ///
10874    /// An index the test does not place is [dead](FreeClass::Dead) to the caller
10875    /// rather than free: its bytes are no longer in use, but advertising space
10876    /// inside a possible metadata page for raw reuse would mix the page
10877    /// (issue #261), so they are reusable only once the whole page around them is
10878    /// empty.
10879    ///
10880    /// A non-paged file has no page types to keep apart, so everything is
10881    /// reclaimable there; the tag is ignored by its commit tail entirely.
10882    fn index_is_provably_raw(&self, data: &[(u64, u64)], index: &[(u64, u64)]) -> bool {
10883        match &self.paged {
10884            None => true,
10885            Some(paged) => {
10886                index_abuts_chunk_data(data, index)
10887                    && !index_touches_page_zero(index, paged.page_size)
10888            }
10889        }
10890    }
10891
10892    /// Every on-disk byte span of a chunked dataset's *index structure only* (not
10893    /// its chunk data), for reclaiming the old index after a relocating append
10894    /// ([`MovingWrite::AppendedChunks`]) that keeps the chunk data in place. Mirror
10895    /// of [`chunked_storage_spans`](Self::chunked_storage_spans) but delegating to
10896    /// [`chunk_index_spans_buffered`], which enumerates only the EA header/index/
10897    /// data/super blocks and never a chunk-data address, so the shared kept chunk
10898    /// data is never freed. Base-aware and validated disjoint/in-bounds; returns
10899    /// `None` (leave unreclaimed) on any error or violation.
10900    fn chunked_index_spans(&self, addr: usize) -> Option<Vec<(u64, u64)>> {
10901        let region =
10902            Self::gather_oh_messages(&self.image(), addr as u64, self.superblock.base_address)
10903                .ok()?;
10904        let mut layout_msg: Option<(usize, usize)> = None;
10905        let mut p = 0;
10906        loop {
10907            match region.next_message(p) {
10908                Ok(Some((msg_type, body, body_end))) => {
10909                    if msg_type == MessageType::DataLayout {
10910                        layout_msg = Some((body, body_end));
10911                    }
10912                    p = body_end;
10913                }
10914                Ok(None) => break,
10915                Err(_) => return None,
10916            }
10917        }
10918        let (lb, le) = layout_msg?;
10919        let layout = DataLayout::parse(&region[lb..le], OFFSET_SIZE, LENGTH_SIZE).ok()?;
10920        if !matches!(layout, DataLayout::Chunked { .. }) {
10921            return None;
10922        }
10923        let base = self.superblock.base_address;
10924        let mut spans = chunk_index_spans_from_source(
10925            &BaseOffsetSource {
10926                inner: &self.image(),
10927                base,
10928            },
10929            &layout,
10930            OFFSET_SIZE,
10931            LENGTH_SIZE,
10932        )
10933        .ok()?;
10934        for (a, _) in &mut spans {
10935            *a = base.absolute(*a).ok()?;
10936        }
10937        if !spans_disjoint_in_bounds(&mut spans, self.image.len()) {
10938            return None;
10939        }
10940        Some(spans)
10941    }
10942}
10943
10944/// A dirty group in the edit plan: its base object-header message region and the
10945/// additions targeting it.
10946#[derive(Default)]
10947struct Node {
10948    is_new: bool,
10949    /// Names of links to remove from this group (from `delete`).
10950    deletes: Vec<String>,
10951    /// Copies to add to this group: (new link name, the source subtree read out
10952    /// for writing). Built at staging time from either this file (an in-file
10953    /// [`copy`](crate::File::copy)) or another open file (a cross-file
10954    /// [`copy_from`](crate::File::copy_from)).
10955    copies: Vec<(String, CopyTree)>,
10956    /// New link names this commit adds to this group by cross-file copy. The
10957    /// subtrees themselves stay in the staged set — which the preflight may
10958    /// still refuse, and must not empty (issue #316) — and join `copies` at the
10959    /// point of no return.
10960    cross_copies: Vec<String>,
10961    /// Value overwrites whose dataset header relocates (a resize or compact
10962    /// rewrite by `write_dataset`, a staged append, an attribute edit), as (child
10963    /// link name, the pre-commit object-header address, the relocation plan). On
10964    /// apply, the new data and header are written and this group's existing link
10965    /// to the moved header is patched to its new address — exactly like an
10966    /// existing child group's link. The old address is carried rather than
10967    /// re-resolved: the screen above and the reclaim below both need it, and one
10968    /// derivation cannot disagree with itself.
10969    writes: Vec<(String, u64, MovingWrite)>,
10970    base_region: OhRegion,
10971    existing_links: Vec<String>,
10972    /// What this group's attribute edits left for the apply loop, staged by
10973    /// [`plan_attr_ops`] and resolved by [`WriteEngine::place_edited_attrs`]
10974    /// right before this node's header is built. Default (an empty compact set)
10975    /// for a group with no attribute edits.
10976    attrs: EditedAttrs,
10977}
10978
10979/// A staged compact attribute edit for a group or dataset (shared by
10980/// [`Group::set_attr`](crate::Group::set_attr)/`remove_group_attr` and
10981/// [`Dataset::set_attr`](crate::Dataset::set_attr)/`remove_dataset_attr`).
10982enum AttrOp {
10983    Set { name: String, value: AttrValue },
10984    Remove { name: String },
10985}
10986
10987/// A source object parsed for copying. Headers are reproduced from their
10988/// verbatim message bytes; only the contiguous data address and child link
10989/// targets are repointed to the freshly-written copies.
10990enum ObjModel {
10991    /// A compact dataset (data inline in the header): copy the region verbatim.
10992    /// `dense_attrs` is empty unless the source stored its attributes densely, in
10993    /// which case the Attribute Info message and inline Attribute messages have
10994    /// been stripped from `region` and the parsed set is carried here to be
10995    /// re-emitted into a fresh fractal heap on write.
10996    DatasetVerbatim {
10997        region: OhRegion,
10998        dense_attrs: DenseAttrSet,
10999    },
11000    /// A contiguous dataset: copy the region, repointing the data address at
11001    /// `addr_off` (region-relative) to a fresh copy of `[data_addr, +data_size)`.
11002    /// See [`DatasetVerbatim`](ObjModel::DatasetVerbatim) for `dense_attrs`.
11003    DatasetContiguous {
11004        region: OhRegion,
11005        addr_off: usize,
11006        data_addr: u64,
11007        data_size: u64,
11008        dense_attrs: DenseAttrSet,
11009    },
11010    /// A chunked (and possibly filtered) dataset: the verbatim header `region`
11011    /// (datatype, dataspace, fill value, data layout, and filter pipeline kept as
11012    /// written). The chunk data is not captured here — [`read_copy_subtree`](WriteEngine::read_copy_subtree)
11013    /// enumerates and reads the chunks (it holds the source buffer), repointing the
11014    /// rebuilt index on write. See [`DatasetVerbatim`](ObjModel::DatasetVerbatim)
11015    /// for `dense_attrs`.
11016    DatasetChunked {
11017        region: OhRegion,
11018        dense_attrs: DenseAttrSet,
11019    },
11020    /// A group: every non-link message verbatim, plus its hard-link children to
11021    /// copy and re-link as `(name, creation index, target address)`. See
11022    /// [`DatasetVerbatim`](ObjModel::DatasetVerbatim) for `dense_attrs`.
11023    Group {
11024        non_link_region: OhRegion,
11025        children: Vec<(String, Option<u64>, u64)>,
11026        dense_attrs: DenseAttrSet,
11027    },
11028}
11029
11030/// An object subtree fully read out of a source buffer and owning every byte it
11031/// will write, the read result of [`WriteEngine::read_copy_subtree`] and the
11032/// input to [`WriteEngine::write_copy_subtree`]. Unlike [`ObjModel`] (a single
11033/// object still referencing source addresses) it is recursive and self-contained:
11034/// a contiguous dataset owns its data bytes, and a group owns its children, so it
11035/// can be written into the destination without the source buffer still in hand —
11036/// which is what lets a cross-file copy read the source at staging time and apply
11037/// it at commit time.
11038enum CopyTree {
11039    /// A compact dataset: the header region is written verbatim (data is inline).
11040    /// `dense_attrs`, when non-empty, is re-emitted into a freshly built fractal
11041    /// heap appended just before the header, whose Attribute Info message is
11042    /// spliced into the region on write.
11043    DatasetVerbatim {
11044        region: OhRegion,
11045        dense_attrs: DenseAttrSet,
11046    },
11047    /// A contiguous dataset: `data` is written first and its new address patched
11048    /// into the header `region` at `addr_off` before the header is written. See
11049    /// [`DatasetVerbatim`](CopyTree::DatasetVerbatim) for `dense_attrs`.
11050    ///
11051    /// `data` is `None` when the source allocated no storage at all: nothing is
11052    /// written and `addr_off` keeps the undefined address the source stored, so
11053    /// the copy declares the same absent storage rather than a block at a real
11054    /// address. That is a statement about storage, not about length (issue
11055    /// #336).
11056    DatasetContiguous {
11057        region: OhRegion,
11058        addr_off: usize,
11059        data: Option<Vec<u8>>,
11060        dense_attrs: DenseAttrSet,
11061    },
11062    /// A chunked (and possibly filtered) dataset. The header `region` is written
11063    /// verbatim except its data-layout message, which is swapped for one naming the
11064    /// freshly rebuilt index; `chunk_bytes` (each chunk's already-compressed bytes,
11065    /// in dense row-major grid order, with sizes/masks in `meta`) and the source
11066    /// `pipeline_message` are carried unchanged, so the copy preserves the filter
11067    /// pipeline and chunk payloads byte-for-byte. The on-disk index *type* is
11068    /// reselected from `maxshape`/chunk count (single / fixed-array / extensible-
11069    /// array), so a B-tree-v1 or implicit source is reproduced with a v4 index. See
11070    /// [`DatasetVerbatim`](CopyTree::DatasetVerbatim) for `dense_attrs`.
11071    DatasetChunked {
11072        region: OhRegion,
11073        /// The dataset's current shape. Held because the index's element
11074        /// numbering is taken over the *maximum* chunk grid, which needs both
11075        /// extents (see [`crate::chunk_grid`]).
11076        shape: Vec<u64>,
11077        chunk_dims: Vec<u64>,
11078        element_size: NonZeroUsize,
11079        maxshape: Option<Vec<u64>>,
11080        pipeline_message: Option<Vec<u8>>,
11081        meta: Vec<ChunkMeta>,
11082        chunk_bytes: Vec<Vec<u8>>,
11083        dense_attrs: DenseAttrSet,
11084    },
11085    /// A group: every non-link message verbatim, plus the `(name, creation
11086    /// index, subtree)` children to write first and re-link by name. See
11087    /// [`DatasetVerbatim`](CopyTree::DatasetVerbatim) for `dense_attrs`.
11088    Group {
11089        non_link_region: OhRegion,
11090        children: Vec<(String, Option<u64>, CopyTree)>,
11091        dense_attrs: DenseAttrSet,
11092    },
11093}
11094
11095/// The addresses this commit invalidates, against which a reference it writes is
11096/// screened (issue #317).
11097///
11098/// An object reference *is* an object-header address, so a commit can falsify one
11099/// two ways. It can **remove** the object: a deletion frees its header and
11100/// storage, and a group's whole subtree with it
11101/// ([`WriteEngine::collect_free_spans`] is the walk that enumerates it), so the
11102/// address survives the commit only until the next allocation reuses the span and
11103/// then reads as whatever landed there. Or it can **move** it: a dirty group and
11104/// a relocating dataset write are both rebuilt at a fresh address, and the old
11105/// header is freed once the superblock is repointed.
11106///
11107/// Both are screened here because on the *path* side neither can come out as a
11108/// pre-commit address: [`WriteEngine::resolve_reference_target`] refuses a deleted
11109/// path by name, and a dirty group or a write target it either refuses as "still
11110/// writing" or — once this commit has placed the target — resolves to the address
11111/// it lands on. An address that skipped either check would be the one form that
11112/// writes the value the commit is vacating, which is the shape of the defect this
11113/// exists to close.
11114///
11115/// The removal spans are the very ones the commit hands to the free-space
11116/// manager, taken from one walk rather than two, so the screen and the reclaimer
11117/// cannot come to disagree about what this commit removes.
11118struct InvalidatedAddresses {
11119    /// Absolute `(offset, length)` file spans this commit's deletions reclaim, as
11120    /// [`collect_free_spans`](WriteEngine::collect_free_spans) reports them.
11121    removed: Vec<(u64, u64)>,
11122    /// Absolute pre-commit object-header addresses this commit rewrites
11123    /// elsewhere: every existing group it dirties, and every dataset write that
11124    /// relocates (a resizing or compact overwrite, a staged append, an attribute
11125    /// edit). An in-place overwrite does not relocate and is not here. The path
11126    /// side refuses it anyway, deliberately conservatively — `write_targets`
11127    /// records that a dataset is written, not which plan it got — while this list
11128    /// is filled from the plans themselves.
11129    moved: Vec<u64>,
11130    /// The superblock base address. A stored object reference is *base-relative*
11131    /// and both lists above are absolute, so the comparison needs it; it is zero
11132    /// for every file without a userblock.
11133    base: BaseAddress,
11134}
11135
11136impl InvalidatedAddresses {
11137    /// Whether this commit invalidates anything at all, so a caller with nothing
11138    /// cheap to check can skip its own work. Worth testing on the copy screen,
11139    /// whose `moved` is empty by construction; the supplied screen's never is,
11140    /// since every commit that builds one rebuilds its root group.
11141    fn is_empty(&self) -> bool {
11142        self.removed.is_empty() && self.moved.is_empty()
11143    }
11144
11145    /// The refusal `stored` earns — one object-reference element exactly as it is
11146    /// written to disk — or `None` when it still names what it named.
11147    ///
11148    /// The null (`0`) and undefined ([`UNDEF`]) references name no object at all,
11149    /// so neither is screened: the same two values
11150    /// [`crate::repack`](crate::repack) carries through verbatim rather than
11151    /// resolving, and the two [`crate::reader`] refuses to dereference.
11152    fn refusal(&self, stored: u64) -> Option<&'static str> {
11153        if stored == 0 || stored == UNDEF {
11154            return None;
11155        }
11156        // An address that cannot even be shifted into the file is not one of
11157        // ours; leave it to whatever reads it.
11158        let abs = self.base.absolute(stored).ok()?;
11159        if self
11160            .removed
11161            .iter()
11162            .any(|&(off, len)| abs >= off && abs - off < len)
11163        {
11164            return Some(REFERENCE_INTO_RECLAIMED_SPACE);
11165        }
11166        if self.moved.contains(&abs) {
11167            return Some(REFERENCE_TO_A_MOVED_OBJECT);
11168        }
11169        None
11170    }
11171}
11172
11173/// The validated, chunk-collapsed message region and existing link names of a
11174/// group header.
11175struct GroupInfo {
11176    region: OhRegion,
11177    link_names: Vec<String>,
11178}
11179
11180/// Element bytes staged for a value overwrite, together with the
11181/// variable-length staging (if any) whose global heap collections must be
11182/// placed — and whose addresses patched into the element references in `raw` —
11183/// before those bytes reach the file.
11184///
11185/// The two halves travel together because resolving them is an **apply-phase**
11186/// step while planning is a preflight one: placing a collection allocates, and
11187/// [`commit`](WriteEngine::commit)'s preflight only reads. So a plan carries
11188/// the bytes unresolved and every consumer resolves them where it writes, with
11189/// [`resolve_overwrite_bytes`](WriteEngine::resolve_overwrite_bytes) (issue
11190/// #321).
11191///
11192/// Patching never changes `raw`'s length — an element reference is fixed-width
11193/// whatever string it names — which is what lets the plan be *chosen* from the
11194/// unresolved bytes. The one place that is not enough is a filtered chunked
11195/// dataset, whose compressed chunk sizes do depend on the addresses: see
11196/// [`ChunkPayload::Deferred`].
11197struct OverwriteBytes {
11198    raw: Vec<u8>,
11199    /// `None` for every overwrite that stages no variable-length data, which is
11200    /// all of them but a `with_vlen_strings` one.
11201    vlen: Option<VlenOverwrite>,
11202}
11203
11204/// The variable-length half of an [`OverwriteBytes`]: the staged collections to
11205/// place, and the path they are recorded under once placed.
11206struct VlenOverwrite {
11207    staging: VlStringStaging,
11208    /// The overwritten dataset's path, the key its collections are recorded
11209    /// under in [`vl_overwrite_heaps`](WriteEngine::vl_overwrite_heaps) — and
11210    /// the key the ones a previous overwrite left are read back from, to be
11211    /// freed once this commit lands.
11212    path: PathKey,
11213}
11214
11215impl OverwriteBytes {
11216    /// Bytes that need no resolving, and so name nothing to record or free.
11217    fn ready(raw: Vec<u8>) -> Self {
11218        Self { raw, vlen: None }
11219    }
11220}
11221
11222/// How a staged value overwrite (`write_dataset`) will be applied, decided by
11223/// [`WriteEngine::prepare_write`] during the all-or-nothing preflight.
11224// `allow` rather than `expect`, which is otherwise this module's habit: the lint
11225// fires on a 64-bit pointer width and not on i686, where the `Vec` and `usize`
11226// fields inside `MovingWrite` shrink and the variant gap falls back under
11227// clippy's threshold. An `expect` is therefore unfulfilled on exactly the two
11228// 32-bit CI jobs, both of which deny `unfulfilled_lint_expectations`.
11229#[allow(
11230    clippy::large_enum_variant,
11231    reason = "boxing the large variant would cost more than it saves: this enum is returned by \
11232              `prepare_write` and destructured at its one call site, which moves the `MovingWrite` \
11233              straight into a `Vec<MovingWrite>` that stores it unboxed either way -- so a box \
11234              here adds an allocation and a free without removing a single copy"
11235)]
11236enum WritePlan {
11237    /// A contiguous dataset whose new data is the same length as its existing,
11238    /// defined data block: overwrite the bytes straight in place at `data_addr`.
11239    /// No object header is rewritten.
11240    ///
11241    /// The superblock root is not flipped either — *unless* the bytes still
11242    /// carry variable-length staging, which appends a heap collection and so
11243    /// moves end-of-file, a figure only the superblock records. `commit`'s
11244    /// fast path excludes exactly that case (issue #321).
11245    InPlace {
11246        data_addr: usize,
11247        bytes: OverwriteBytes,
11248    },
11249    /// A chunked dataset overwritten chunk-by-chunk in place: each `(addr, bytes)`
11250    /// pair is written straight over an existing chunk slot. Used when every new
11251    /// (re-encoded) chunk is the same byte length as the slot it replaces — an
11252    /// unfiltered chunked overwrite (chunk sizes are fixed by the unchanged shape)
11253    /// or a filtered one whose re-encoded chunks happen to match. Like
11254    /// [`InPlace`](WritePlan::InPlace) it touches no header and no chunk index, so
11255    /// the superblock root is not flipped.
11256    InPlaceChunks { writes: Vec<(usize, Vec<u8>)> },
11257    /// The dataset's header relocates: a contiguous resize, a compact rewrite, or
11258    /// a chunked rebuild. The parent group is rebuilt and its link patched. See
11259    /// [`MovingWrite`].
11260    Moving(MovingWrite),
11261}
11262
11263/// A value overwrite that relocates the dataset's object header — a contiguous
11264/// dataset whose data length changed (or had no data block) or a compact dataset
11265/// whose inline bytes are replaced. On apply the new data and a rewritten header
11266/// are written at end-of-file (or into reusable freed space), and the parent
11267/// group's link is repointed at the new header address.
11268enum MovingWrite {
11269    /// A contiguous dataset: write `raw` elsewhere, patch the data-layout address
11270    /// at `addr_off` in the verbatim header `region`, rewrite the header, and free
11271    /// `old_extent` (the prior data block, if any) after the commit lands.
11272    Contiguous {
11273        region: OhRegion,
11274        addr_off: usize,
11275        bytes: OverwriteBytes,
11276        old_extent: Option<(u64, u64)>,
11277    },
11278    /// A compact dataset: rebuild the header `region` with the bytes inline.
11279    Compact {
11280        region: OhRegion,
11281        bytes: OverwriteBytes,
11282    },
11283    /// A chunked dataset whose new (re-encoded) chunks do not all fit their
11284    /// existing slots, so its whole storage is rebuilt and relocated. A fresh
11285    /// chunk-data blob and index are placed — in a freed region that fits, else at
11286    /// end-of-file (via the verbatim
11287    /// layout path, carrying the chunk bytes and the source filter `pipeline_message`
11288    /// unchanged — no recompression and no filter-parameter reconstruction), the
11289    /// data-layout message in the verbatim header `region` is swapped for the new
11290    /// one (every other header message — datatype, dataspace, fill value, filter
11291    /// pipeline, and attributes, including a dense attribute heap referenced by an
11292    /// untouched Attribute Info message — is preserved verbatim), and the old
11293    /// chunk storage at `old_addr` is freed after the commit lands.
11294    Chunked {
11295        region: OhRegion,
11296        /// See [`CopyTree::DatasetChunked::shape`].
11297        shape: Vec<u64>,
11298        chunk_dims: Vec<u64>,
11299        element_size: NonZeroUsize,
11300        maxshape: Option<Vec<u64>>,
11301        pipeline_message: Option<Vec<u8>>,
11302        payload: ChunkPayload,
11303        old_addr: u64,
11304    },
11305    /// A relocating **append** to a chunked, unlimited, Extensible-Array-indexed
11306    /// dataset (`append_dataset`). The dataset's existing chunk *data* stays in
11307    /// place; only the newly-appended chunks and any rewritten trailing partial
11308    /// chunk (`new_chunk_bytes`, already compressed through the on-disk pipeline)
11309    /// are placed (reusing freed raw space where it fits), a fresh Extensible
11310    /// Array is rebuilt over
11311    /// `kept_chunks ++ new_chunk_bytes`, the verbatim header `region`'s dataspace
11312    /// message is grown (`new_dataspace_body`) and its data-layout message
11313    /// repointed at the new index (every other message — datatype, filter
11314    /// pipeline, fill value, attributes — preserved verbatim), and the header is
11315    /// relocated. After the commit lands, only the old index structure at
11316    /// `old_addr`, the old header, and the relocated old trailing chunk
11317    /// (`old_tail_extent`) are freed — never the kept chunk data, which both the
11318    /// old and new index share during the commit.
11319    AppendedChunks {
11320        region: OhRegion,
11321        /// The grown dataspace message body (v2-serialized), current axis-0
11322        /// dimension increased, maximum dimensions (unlimited) preserved.
11323        new_dataspace_body: Vec<u8>,
11324        /// Rank-only spatial chunk dimensions, for the rebuilt v4 layout message.
11325        chunk_dims_u32: Vec<u32>,
11326        element_size: NonZeroUsize,
11327        /// Full (uncompressed) chunk byte size = product(spatial) * element_size.
11328        has_filters: bool,
11329        /// Existing complete chunks, in index order, carried by metadata alone —
11330        /// their base-relative addresses, on-disk stored sizes, and filter masks
11331        /// preserved exactly (a nonzero mask from a C/h5py-skipped filter is kept).
11332        kept_chunks: Vec<WrittenChunk>,
11333        /// The appended chunks in index order: the recompressed trailing partial
11334        /// chunk first (when present), then the remaining new full chunks.
11335        new_chunk_bytes: Vec<Vec<u8>>,
11336        /// The dataset header address, for old-index and old-header reclaim.
11337        old_addr: u64,
11338        /// The absolute `(addr, len)` of the old trailing partial chunk's data
11339        /// block when it was rewritten, freed after the commit lands. `None` when
11340        /// the append was chunk-aligned (no partial chunk to rewrite).
11341        old_tail_extent: Option<(u64, u64)>,
11342    },
11343    /// A dataset-attribute edit (`set_dataset_attr` / `remove_dataset_attr`).
11344    /// The verbatim header `region` already carries whatever the preflight could
11345    /// resolve; `attrs` is what [`WriteEngine::write_moving`] still has to place —
11346    /// a variable-length attribute's heap collection, or a whole dense set to
11347    /// rebuild. The rewritten header is relocated and the parent link repointed,
11348    /// exactly like the other relocating writes — but the data-layout message is
11349    /// preserved verbatim, so the dataset's chunk data and index stay in place;
11350    /// only the old header is freed.
11351    AttrEdit {
11352        region: OhRegion,
11353        attrs: EditedAttrs,
11354    },
11355}
11356
11357/// The chunk data a relocating chunked overwrite ([`MovingWrite::Chunked`])
11358/// will place, either already encoded or still to be.
11359enum ChunkPayload {
11360    /// Split and encoded by the preflight, which had to do that anyway to find
11361    /// out whether the chunks still fit their slots.
11362    Encoded(Vec<Vec<u8>>),
11363    /// Element bytes that still carry unresolved variable-length references
11364    /// ([`OverwriteBytes::vlen`]), so the split has to wait for the apply
11365    /// phase: a filtered chunk's compressed length depends on the heap
11366    /// addresses patched into it, which is why the preflight cannot split
11367    /// first and patch after (issue #321).
11368    ///
11369    /// Such an overwrite therefore always relocates, which is what keeps the
11370    /// single-hard-link refusal (the one every [`MovingWrite`] answers to) in
11371    /// the preflight, where it belongs: whether it could have stayed in its
11372    /// slots is not knowable until those lengths exist.
11373    Deferred {
11374        bytes: OverwriteBytes,
11375        /// What the edge overhang of a partial chunk must hold (issue #296).
11376        padding: crate::fill_value::PaddingFill,
11377        /// The dataset's datatype, for the filter pipeline's [`ChunkContext`].
11378        dt: crate::datatype::Datatype,
11379    },
11380}
11381
11382/// A staged dataset reduced to the pieces the writer needs.
11383struct FlatDataset {
11384    name: String,
11385    dt: crate::datatype::Datatype,
11386    ds: Dataspace,
11387    raw: Vec<u8>,
11388    attrs: Vec<crate::attribute::AttributeMessage>,
11389    /// Chunked/filtered storage options. When [`ChunkOptions::is_chunked`] is
11390    /// false and `maxshape` is `None`, the dataset is written as contiguous,
11391    /// unfiltered storage; otherwise its chunk data and index are built by
11392    /// [`WriteEngine::build_chunked_dataset`].
11393    chunk_options: ChunkOptions,
11394    /// Maximum dimensions for an extensible dataset (an unlimited dimension is
11395    /// `u64::MAX`), mirrored into `ds.max_dimensions`. `None` for a fixed-shape
11396    /// dataset. A maxshape with an unlimited dimension selects the
11397    /// extensible-array chunk index; a finite maxshape stays fixed-array/single.
11398    maxshape: Option<Vec<u64>>,
11399    /// Variable-length attributes still carrying a placeholder heap address:
11400    /// (index into `attrs`, that attribute's global heap collections).
11401    /// Resolved in the apply loop right before this dataset's header is built.
11402    vl_attrs: Vec<(usize, Vec<Vec<u8>>)>,
11403    /// Whether `attrs` goes in a fractal heap rather than the object header,
11404    /// decided by `file_writer::needs_dense_attrs` where the set was validated
11405    /// against what that heap can represent.
11406    ///
11407    /// Carried rather than asked again in the apply loop, so the set that was
11408    /// checked and the set that is placed cannot be decided differently — and
11409    /// because the question costs a serialization of every attribute to answer,
11410    /// which is most expensive for exactly the oversized attribute it selects
11411    /// for. Patching a variable-length attribute's references is
11412    /// length-preserving, so nothing between the two phases can change the
11413    /// answer.
11414    attrs_are_dense: bool,
11415    /// A staged variable-length-string dataset's element references (still
11416    /// carrying placeholder heap addresses in `raw`) and global heap
11417    /// collections. Resolved in the apply loop right before `raw` is appended.
11418    vl_string_staging: Option<VlStringStaging>,
11419    /// An object-reference dataset's per-element targets, still unresolved.
11420    /// Resolved (see [`WriteEngine::resolve_reference_target`]) and patched
11421    /// into `raw` in the apply loop, once every object this commit places has
11422    /// a known address. `None` for an ordinary dataset.
11423    reference_targets: Option<Vec<ObjectRefPatch>>,
11424    /// A user-defined fill value, encoded in the dataset's datatype, or `None`
11425    /// for the library default. Validated against the datatype element size in
11426    /// [`flatten_dataset`].
11427    fill: Option<Vec<u8>>,
11428    /// The provenance attributes' own inputs, kept so they can be rebuilt when
11429    /// `raw` changes before the commit writes it. `None` for a dataset staged
11430    /// without [`DatasetBuilder::with_provenance`](crate::DatasetBuilder::with_provenance).
11431    #[cfg(feature = "provenance")]
11432    provenance: Option<StagedProvenance>,
11433}
11434
11435/// A staged dataset's provenance metadata, and where in
11436/// [`FlatDataset::attrs`] the attributes it produced sit.
11437///
11438/// The hash is over the dataset's raw bytes, so it is only correct as of the
11439/// bytes it was taken from; [`FlatDataset::rebuild_provenance`] is what keeps
11440/// it that way when [`WriteEngine::extend_staged_dataset`] grows them.
11441#[cfg(feature = "provenance")]
11442struct StagedProvenance {
11443    /// What [`crate::provenance::Provenance::build_attrs`] was, and is, called
11444    /// with. Held rather than re-derived from the attributes it produced, which
11445    /// a caller may also have set by hand.
11446    inputs: crate::provenance::Provenance,
11447    /// The index in `attrs` where its attributes begin. They are appended last
11448    /// and are the only ones this may replace, so everything below it — the
11449    /// caller's own attributes, and the `vl_attrs` indices into them — is left
11450    /// exactly as it was.
11451    attrs_start: usize,
11452}
11453
11454#[cfg(feature = "provenance")]
11455impl FlatDataset {
11456    /// Recompute this dataset's provenance attributes over the bytes it now
11457    /// holds. A no-op for a dataset staged without provenance.
11458    ///
11459    /// The rebuilt set is the same attributes in the same order and of the same
11460    /// serialized length — the digest is 64 hex characters whatever it hashes —
11461    /// so `attrs_are_dense`, decided once in [`flatten_dataset`] over the
11462    /// original set, still describes this one.
11463    fn rebuild_provenance(&mut self) {
11464        let Some(prov) = &self.provenance else {
11465            return;
11466        };
11467        self.attrs.truncate(prov.attrs_start);
11468        let rebuilt = prov.inputs.build_attrs(&self.raw);
11469        self.attrs.extend(rebuilt);
11470    }
11471}
11472
11473/// A borrow adapter that drives the shared Extensible-Array append engine
11474/// ([`crate::chunk_index_inplace`]) against the engine's *own* image and
11475/// superblock, so a session runs an immediate O(1) in-place append without
11476/// constructing a second writable handle (which would take a second exclusive
11477/// lock and keep a divergent view of the file). It borrows only those two
11478/// fields, leaving [`WriteEngine::located`] independently borrowable.
11479///
11480/// [`Store`] is the append engine's view of a file — an image *plus* the
11481/// superblock, which the image itself knows nothing about. Pairing them here is
11482/// all this adapter does; every primitive delegates, so the image's own
11483/// write-ordering discipline is what applies.
11484///
11485/// It carries the session's paged-file state too, so `alloc_raw` keeps a paged
11486/// file's pages homogeneous through exactly the rule the staged commit uses
11487/// ([`PagedEdit::begin`]). Before issue #198 there were two copies of that state —
11488/// one per engine — and the whole-file editor's copy was reachable only from the
11489/// commit path, so it had to refuse an in-place append to a paged file outright.
11490struct EditStore<'a> {
11491    image: &'a mut dyn FileImage,
11492    superblock: &'a mut Superblock,
11493    sb_sig_off: usize,
11494    /// The session's paged state when the file is paged, `None` otherwise. A
11495    /// borrow rather than a copy: padding recorded here has to reach the manager
11496    /// rewrite at the next commit or at close.
11497    paged: Option<&'a mut PagedEdit>,
11498    /// The list an immediate append may draw from, and `None` when it may draw
11499    /// from none — [`WriteEngine::immediate_reuse_allowed`] states which. It is
11500    /// the session's own free list on a file that forgets its holes at close, and
11501    /// [`WriteEngine::reserved`] — space already taken out of the on-disk
11502    /// managers — on one that persists them.
11503    free: Option<&'a mut FreeList>,
11504    /// The session's `fsync` cadence, carried by value: the append engine's own
11505    /// ordered barriers ([`apply_ea_append`]) are durability points like the
11506    /// commit's, and answer to the same policy.
11507    sync_policy: SyncPolicy,
11508}
11509
11510impl EditStore<'_> {
11511    /// Append `bytes` into a raw page, padding the tail page first when a paged
11512    /// file's tail holds metadata. A plain append on the common non-paged file.
11513    ///
11514    /// Raw is the only page type this adapter allocates: see
11515    /// [`Store::alloc_raw`](crate::chunk_index_inplace::Store::alloc_raw) for why
11516    /// an extensible-array index block belongs in a raw page here.
11517    fn append_into_raw_page(&mut self, bytes: &[u8]) -> Result<u64, Error> {
11518        if let Some(pg) = self.paged.as_deref_mut() {
11519            pg.begin(self.image, PageType::Raw)?;
11520        }
11521        self.image.append(bytes)
11522    }
11523}
11524
11525impl crate::source::Source for EditStore<'_> {
11526    fn len(&self) -> u64 {
11527        self.image.len()
11528    }
11529    fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), crate::error::FormatError> {
11530        self.image.read_at(offset, buf)
11531    }
11532    fn read_metadata_at(
11533        &self,
11534        offset: u64,
11535        len: usize,
11536    ) -> Result<Vec<u8>, crate::error::FormatError> {
11537        self.image.read_metadata_at(offset, len)
11538    }
11539
11540    // Forwarded because this is a `Source` wrapper over the image and the
11541    // trait requires it, not because anything asks yet: `File`'s own accessor
11542    // reaches the image directly. A wrapper that answered `None` for a live
11543    // cache is the failure that rule exists to prevent.
11544    fn metadata_cache_stats(&self) -> Option<crate::source::MetadataCacheStats> {
11545        self.image.metadata_cache_stats()
11546    }
11547
11548    fn reset_metadata_cache_stats(&self) {
11549        self.image.reset_metadata_cache_stats();
11550    }
11551}
11552
11553impl Store for EditStore<'_> {
11554    fn offset_size(&self) -> u8 {
11555        self.superblock.offset_size
11556    }
11557    fn length_size(&self) -> u8 {
11558        self.superblock.length_size
11559    }
11560    fn alloc_raw(&mut self, bytes: &[u8]) -> Result<u64, Error> {
11561        if let Some(free) = self.free.as_deref_mut() {
11562            if let Some(addr) = free.alloc(bytes.len() as u64) {
11563                self.image.write_at(addr, bytes)?;
11564                return Ok(addr);
11565            }
11566        }
11567        self.append_into_raw_page(bytes)
11568    }
11569    fn write_at(&mut self, offset: u64, bytes: &[u8]) -> Result<(), Error> {
11570        self.image.write_at(offset, bytes)
11571    }
11572    fn patch_superblock_eof(&mut self) -> Result<(), Error> {
11573        // Advance only the recorded end-of-file and re-serialize the superblock in
11574        // place. Unlike `WriteEngine::commit`, this deliberately does NOT clear the
11575        // consistency flags and does NOT repoint the root group: base_address is 0
11576        // for every in-place-append-eligible file, so the normalized-absolute root
11577        // address serializes back to the same stored value.
11578        let eof = self.image.len();
11579        self.superblock.eof_address = eof;
11580        let bytes = self.superblock.serialize();
11581        self.write_at(self.sb_sig_off as u64, &bytes)
11582    }
11583    fn sync(&mut self) -> Result<(), Error> {
11584        barrier_data(self.image, self.sync_policy)
11585    }
11586}
11587
11588/// Whether two object paths are equal or one is an ancestor of the other.
11589fn paths_overlap(a: &[String], b: &[String]) -> bool {
11590    a.starts_with(b) || b.starts_with(a)
11591}
11592
11593/// Whether two appender claims cover the same dataset. A path-less claim (a
11594/// handle reached by object reference) names its dataset by an address nothing
11595/// else can compare against, so it conflicts with every other claim.
11596fn claims_conflict(a: Option<&[String]>, b: Option<&[String]>) -> bool {
11597    match (a, b) {
11598        (Some(x), Some(y)) => x == y,
11599        _ => true,
11600    }
11601}
11602
11603/// Re-tag a refusal from the shared append engine (`AppendUnsupported`) as the
11604/// fast-path [`Error::AppendInPlaceUnsupported`], so a caller can catch it and fall
11605/// back to the staged [`append_dataset`](WriteEngine::append_dataset) — which
11606/// handles the filtered partial-trailing-chunk case, index-geometry limits, and
11607/// platform-width limits that the engine reports this way. Genuine I/O and format
11608/// errors pass through unchanged.
11609pub(crate) fn as_inplace_error(e: Error) -> Error {
11610    match e {
11611        Error::AppendUnsupported(m) => Error::AppendInPlaceUnsupported(m),
11612        other => other,
11613    }
11614}
11615
11616/// Validate a gathered append's bytes against a located dataset: the byte
11617/// length must be a whole number of elements, and the element datatype must
11618/// match the on-disk datatype (or, for a raw append, be raw-appendable).
11619/// Returns the appended element count (`0` = nothing to do). Shared by
11620/// [`WriteEngine::append_inplace_gathered`]'s path and the bounded backend's immediate
11621/// append so the acceptance rules stay identical.
11622pub(crate) fn validate_gathered_append(st: &LocatedState, b: &AppendBuilder) -> Result<u64, Error> {
11623    let raw = b.raw();
11624    if raw.len() % st.element_size != 0 {
11625        return Err(Error::AppendInPlaceUnsupported(
11626            "appended byte length is not a whole number of elements",
11627        ));
11628    }
11629    match b.elem_dt() {
11630        Some(expected) if *expected != st.datatype => {
11631            return Err(Error::AppendInPlaceUnsupported(
11632                "append datatype does not match the on-disk dataset (wrong element \
11633                 type or byte order)",
11634            ));
11635        }
11636        Some(_) => {}
11637        None => {
11638            if !datatype_is_raw_appendable(&st.datatype) {
11639                return Err(Error::AppendInPlaceUnsupported(
11640                    "append_raw onto this dataset's datatype (non-little-endian, \
11641                     variable-length, or reference) could misencode the bytes; use a \
11642                     typed append",
11643                ));
11644            }
11645        }
11646    }
11647    Ok((raw.len() / st.element_size) as u64)
11648}
11649
11650/// Locate the dataset at `oh_addr` in `file` and build its [`LocatedState`],
11651/// validating in-place append eligibility (rank-1 / unlimited / Extensible-Array
11652/// indexed, a nonzero chunk length, and a re-encodable filter pipeline). Mirrors
11653/// the append writer's `ensure_located`, reporting through
11654/// [`Error::AppendInPlaceUnsupported`].
11655pub(crate) fn locate_dataset_state<F: Store>(
11656    file: &F,
11657    oh_addr: u64,
11658) -> Result<LocatedState, Error> {
11659    let result = Located::locate_at(file, oh_addr, Error::AppendInPlaceUnsupported)?;
11660    if result.located.chunk_elems == 0 {
11661        return Err(Error::AppendInPlaceUnsupported(
11662            "in-place append requires a nonzero chunk length",
11663        ));
11664    }
11665    let (dt_off, dt_size) = result.spans.datatype;
11666    let dt_bytes = file
11667        .read_metadata_at(dt_off, dt_size)
11668        .map_err(|_| Error::AppendInPlaceUnsupported("dataset datatype could not be parsed"))?;
11669    let (datatype, _) = Datatype::parse(&dt_bytes)
11670        .map_err(|_| Error::AppendInPlaceUnsupported("dataset datatype could not be parsed"))?;
11671    let pipeline = match result.spans.filter {
11672        Some((fb, fsize)) => {
11673            let fp_bytes = file.read_metadata_at(fb, fsize).map_err(|_| {
11674                Error::AppendInPlaceUnsupported("dataset filter pipeline could not be parsed")
11675            })?;
11676            let parsed = FilterPipeline::parse(&fp_bytes).map_err(|_| {
11677                Error::AppendInPlaceUnsupported("dataset filter pipeline could not be parsed")
11678            })?;
11679            if !pipeline_reencodable(&parsed) {
11680                return Err(Error::AppendInPlaceUnsupported(
11681                    "dataset uses a filter this engine cannot re-encode",
11682                ));
11683            }
11684            Some(parsed)
11685        }
11686        None => None,
11687    };
11688    let element_size = result.located.elem_bytes;
11689    let spatial = vec![result.located.chunk_elems];
11690    // A fill message that cannot be read leaves `PaddingFill::Unknown`, which
11691    // fails only where a chunk actually needs padding — an append that lands on
11692    // a chunk boundary asks nothing of it.
11693    let fill = match result.spans.fill {
11694        Some((msg_type, off, size)) => match file.read_metadata_at(off, size) {
11695            Ok(body) => crate::fill_value::PaddingFill::from_message(msg_type, &body),
11696            Err(_) => crate::fill_value::PaddingFill::Unknown,
11697        },
11698        None => crate::fill_value::PaddingFill::Zero,
11699    };
11700    Ok(LocatedState {
11701        loc: result.located,
11702        datatype,
11703        spatial,
11704        element_size,
11705        pipeline,
11706        fill,
11707    })
11708}
11709
11710/// Split a path into non-empty components.
11711fn split_path(path: &str) -> PathKey {
11712    path.split('/')
11713        .filter(|s| !s.is_empty())
11714        .map(String::from)
11715        .collect()
11716}
11717
11718/// Group `(parent group, item)` pairs by their parent, preserving the input
11719/// order within each group.
11720///
11721/// The one rule by which a commit's staged datasets become per-group batches:
11722/// the preflight groups borrowed ones to prove its guards, and the apply loop
11723/// groups the owned ones it places, so the order
11724/// [`preflight_reference_targets`](WriteEngine::preflight_reference_targets)
11725/// replays is the order the apply loop uses by construction.
11726fn group_by_parent<K: Ord, T>(items: impl IntoIterator<Item = (K, T)>) -> BTreeMap<K, Vec<T>> {
11727    let mut out: BTreeMap<K, Vec<T>> = BTreeMap::new();
11728    for (parent, item) in items {
11729        out.entry(parent).or_default().push(item);
11730    }
11731    out
11732}
11733
11734/// Ensure a node exists for every ancestor prefix of `path` (so each is rebuilt
11735/// and can re-wire its child link). Does not set `is_new`.
11736fn ensure_ancestors(nodes: &mut BTreeMap<PathKey, Node>, path: &[String]) {
11737    for len in 0..=path.len() {
11738        nodes.entry(path[..len].to_vec()).or_default();
11739    }
11740}
11741
11742/// Validate that every reclaim span `(addr, len)` is non-empty, ends at or
11743/// before `eof`, and that no two overlap; sorts `spans` by address as a side
11744/// effect. Returns `false` on any violation so the caller can decline to
11745/// reclaim the object rather than feed the free list an out-of-bounds or
11746/// overlapping (double-free) region. Touching spans are allowed — the free list
11747/// coalesces them.
11748fn spans_disjoint_in_bounds(spans: &mut [(u64, u64)], eof: u64) -> bool {
11749    for &(addr, len) in spans.iter() {
11750        match addr.checked_add(len) {
11751            Some(end) if len > 0 && end <= eof => {}
11752            _ => return false,
11753        }
11754    }
11755    spans.sort_unstable_by_key(|&(addr, _)| addr);
11756    spans.windows(2).all(|w| w[0].0 + w[0].1 <= w[1].0)
11757}
11758
11759/// Sanitize the accumulated free spans for a whole commit so the free list never
11760/// sees an out-of-bounds or overlapping (double-free) region: drop empty or
11761/// past-`eof` spans, sort by address, then drop any span overlapping one already
11762/// kept. Dropping only leaks (the bytes stay allocated); it never frees a live
11763/// region. With the last-hard-link guard in force nothing should be dropped for
11764/// a well-formed file — this is a backstop, not the primary defense.
11765fn retain_disjoint_in_bounds(spans: &mut Vec<(u64, u64, FreeClass)>, eof: u64) {
11766    spans.retain(|&(addr, len, _)| len > 0 && addr.checked_add(len).is_some_and(|e| e <= eof));
11767    spans.sort_unstable_by_key(|&(addr, _, _)| addr);
11768    let mut kept_end = 0u64;
11769    spans.retain(|&(addr, len, _)| {
11770        if addr >= kept_end {
11771            kept_end = addr + len;
11772            true
11773        } else {
11774            false // overlaps a span already kept; leak it rather than double-free
11775        }
11776    });
11777}
11778
11779/// Whether some `data` span abuts the run of `index` blocks — ends exactly where
11780/// the index begins, or begins exactly where it ends. The geometric half of
11781/// [`WriteEngine::index_is_provably_raw`], where the reasoning lives; split out so
11782/// the rule can be stated against spans alone.
11783///
11784/// An empty index is vacuously placed: there is nothing to file.
11785fn index_abuts_chunk_data(data: &[(u64, u64)], index: &[(u64, u64)]) -> bool {
11786    if index.is_empty() {
11787        return true;
11788    }
11789    // The index as a whole: this crate writes its blocks as one run beside the
11790    // chunk data, so a data span abutting either end places all of them.
11791    let (Some(index_start), Some(index_end)) = (
11792        index.iter().map(|&(a, _)| a).min(),
11793        index.iter().filter_map(|&(a, l)| a.checked_add(l)).max(),
11794    ) else {
11795        return false;
11796    };
11797    // A zero-length span touches an address without occupying a byte beside it,
11798    // so it places nothing.
11799    data.iter().any(|&(addr, len)| {
11800        len > 0 && (addr.checked_add(len) == Some(index_start) || addr == index_end)
11801    })
11802}
11803
11804/// Whether any byte of the `index` run lies in page 0 of a paged file, whose
11805/// first bytes are the superblock and which is therefore a metadata page. The
11806/// screening half of [`WriteEngine::index_is_provably_raw`], where the reasoning
11807/// lives.
11808///
11809/// A page size of zero is not a paged file's; it cannot be reasoned about, so it
11810/// screens everything out.
11811fn index_touches_page_zero(index: &[(u64, u64)], page_size: u64) -> bool {
11812    if page_size == 0 {
11813        return !index.is_empty();
11814    }
11815    index.iter().any(|&(addr, len)| len > 0 && addr < page_size)
11816}
11817
11818/// Tag object-header chunk spans as file metadata. Every span
11819/// [`oh_chunk_spans`](EditSession::oh_chunk_spans) returns is part of an object
11820/// header, so the page type is the same for all of them.
11821fn meta_spans(spans: Vec<(u64, u64)>) -> impl Iterator<Item = (u64, u64, FreeClass)> {
11822    spans
11823        .into_iter()
11824        .map(|(a, l)| (a, l, FreeClass::Page(PageType::Meta)))
11825}
11826
11827/// Validate a staged dataset and reduce it to a [`FlatDataset`]. Contiguous,
11828/// unfiltered datasets are emitted as such; chunked, filtered, or extensible
11829/// datasets carry their [`ChunkOptions`] and maxshape through to the commit,
11830/// where [`WriteEngine::build_chunked_dataset`] lays out their chunk data and
11831/// index. An empty (zero-element) shape is allowed under either storage,
11832/// mirroring the whole-file writer: a contiguous one takes the `HADDR_UNDEF`
11833/// data address (see the apply loop) and a chunked one an index over zero
11834/// chunks, which is what an extensible dataset is created as before the first
11835/// append fills it. The geometry validation below still requires explicit chunk
11836/// dimensions for it — auto-chunking has no shape to derive them from. A
11837/// `provenance` dataset has its SHA-256/creator/timestamp/source attributes
11838/// computed here from `raw`, exactly as the whole-file writer does. A
11839/// variable-length attribute's global heap collection is built here (it is
11840/// fully self-contained — no address of its own) but placed and patched later,
11841/// in the apply loop, once its final address is known; likewise a
11842/// variable-length-string dataset's staged references and collection
11843/// (`db.vl_string_staging`) are carried through unresolved. An object-reference
11844/// dataset's per-element targets (`db.reference_targets`) are likewise carried
11845/// through unresolved — resolving a path target requires knowing every other
11846/// object this commit places, which is only known well into the apply loop
11847/// (see [`WriteEngine::resolve_reference_target`]). Rejects any remaining
11848/// feature this engine cannot reproduce faithfully: a
11849/// chunked/extensible variable-length-string or object-reference dataset, or a
11850/// filter pipeline the build cannot construct.
11851fn flatten_dataset(db: DatasetBuilder) -> Result<FlatDataset, Error> {
11852    if db.name.is_empty() {
11853        return Err(Error::EditUnsupported("dataset path has an empty name"));
11854    }
11855    let dt = db
11856        .datatype
11857        .ok_or(Error::EditUnsupported("dataset has no datatype/data"))?;
11858    let shape = db
11859        .shape
11860        .ok_or(Error::EditUnsupported("dataset has no shape"))?;
11861    let is_empty = shape.contains(&0);
11862    let chunked = db.chunk_options.is_chunked() || db.maxshape.is_some();
11863    // Storage this engine allocates for every dataset it places: it appends into
11864    // an existing layout, and the undefined data address the whole-file writer
11865    // gives an unallocated dataset (issue #293) has no equivalent here. Refused
11866    // by name rather than left to the "dataset has no data" arm below, which is
11867    // the same symptom from the opposite cause, and refused rather than ignored,
11868    // since ignoring it would write out the grid of fill values the caller asked
11869    // not to have.
11870    if db.allocation == StorageAllocation::Unallocated {
11871        return Err(Error::EditUnsupported(
11872            "a dataset with unallocated storage cannot be added to an existing file in place",
11873        ));
11874    }
11875    // Variable-length string element references live in the global heap, whose
11876    // address is only known once the apply loop places the collection. For
11877    // chunked/filtered/resizable storage the references sit inside chunks
11878    // written before that address exists, so patching them in is impossible.
11879    //
11880    // The whole-file writer lifted the same restriction by placing such a
11881    // dataset's collections ahead of everything else (issue #109); this engine
11882    // appends into an existing layout, where there is no "ahead" to place them
11883    // in, so the equivalent fix is a separate piece of work.
11884    if db.vl_string_staging.is_some() && chunked {
11885        return Err(Error::EditUnsupported(
11886            "chunked or extensible variable-length-string datasets cannot be added in place yet",
11887        ));
11888    }
11889    // Object-reference elements are resolved (see `resolve_reference_target`)
11890    // and patched into `raw` right before it is appended; for chunked storage
11891    // that patch would need to reach inside already-built chunk data, which
11892    // this engine does not support (mirrors the variable-length-string
11893    // refusal above — untested and unneeded combination for v1).
11894    if db.reference_targets.is_some() && chunked {
11895        return Err(Error::EditUnsupported(
11896            "chunked or extensible object-reference datasets cannot be added in place yet",
11897        ));
11898    }
11899    let raw = if is_empty {
11900        db.data.unwrap_or_default()
11901    } else {
11902        db.data
11903            .ok_or(Error::EditUnsupported("dataset has no data"))?
11904    };
11905
11906    // Refused for the same reason the whole-file writer refuses it: nothing
11907    // occupies zero bytes per element, the writers divide by the element size,
11908    // and a caller-built `Datatype` never passes through `Datatype::parse`.
11909    // Taking it as a `NonZeroUsize` hands the proof to the staging below rather
11910    // than leaving each step to re-derive it.
11911    let elem_size = dt.element_size_usize()?;
11912
11913    let elem = elem_size.get() as u64;
11914    // Multiply with checked arithmetic: an absurd shape whose element count
11915    // (or byte size) overflows `u64` is refused rather than panicking in a
11916    // debug build or silently wrapping in release (which could let a wrapped
11917    // product spuriously match `raw.len()`). For a zero-element shape this
11918    // expected length is always 0 (a `0` dimension makes every checked
11919    // multiplication `Some(0)` regardless of the other dimensions), so this
11920    // also catches data mistakenly supplied for a shape that holds nothing.
11921    let expected = shape
11922        .iter()
11923        .try_fold(1u64, |acc, &d| acc.checked_mul(d))
11924        .and_then(|n| n.checked_mul(elem));
11925    match expected {
11926        Some(expected) if raw.len() as u64 == expected => {}
11927        Some(_) => {
11928            return Err(Error::EditUnsupported(
11929                "dataset data length does not match its shape",
11930            ));
11931        }
11932        None => {
11933            return Err(Error::EditUnsupported(
11934                "dataset shape is too large to address on this platform",
11935            ));
11936        }
11937    }
11938
11939    if chunked {
11940        // Refuse malformed chunk geometry up front (the same validation the
11941        // whole-file writer applies), so a bad request — chunk dimensions of the
11942        // wrong rank, a zero chunk dimension, an inconsistent maximum shape, or
11943        // chunking a scalar — never reaches and panics the chunk splitter, nor
11944        // yields a dataset the reader cannot decode.
11945        db.chunk_options
11946            .validate_geometry(&shape, db.maxshape.as_deref())
11947            .map_err(Error::EditUnsupported)?;
11948        // A filter this build cannot apply is refused up front rather than
11949        // failing mid-apply when a chunk is compressed.
11950        db.chunk_options
11951            .refuse_unavailable_filters()
11952            .map_err(Error::EditUnsupported)?;
11953        // Validate the requested filter pipeline now — before any file bytes are
11954        // written — so an unsupported filter, an incompatible datatype, or a
11955        // fill value the filter cannot record is refused up front; the chunk
11956        // data itself is laid out in the commit's apply phase. A filter the
11957        // build compiled out is not among them: `build_pipeline` emits its
11958        // descriptor regardless, which is why the check above exists. Chunked/filtered
11959        // storage flows through the very builder the normal writer uses
11960        // ([`compress_chunks`] + [`assemble_chunked_at`] + [`build_chunked_dataset_oh`]),
11961        // so the
11962        // resulting object header is byte-identical to a freshly written one.
11963        //
11964        // The fill value goes in for the same reason the apply phase passes it:
11965        // scale-offset records it in the filter's parameters, so a validation
11966        // that left it out would be checking a pipeline the commit does not
11967        // build — and would pass a fill value the encoder later refuses.
11968        let chunk_dims = db.chunk_options.resolve_chunk_dims(&shape);
11969        let ctx = ChunkContext::from_datatype(&chunk_dims, &dt)?;
11970        db.chunk_options
11971            .build_pipeline(
11972                &ctx,
11973                crate::fill_value::FillPattern::new(
11974                    db.fill.as_deref(),
11975                    crate::convert::nonzero_usize_from(ctx.element_size)?,
11976                ),
11977            )
11978            .map_err(|_| {
11979                Error::EditUnsupported(
11980                    "this dataset's filter pipeline cannot be added in place \
11981                     (an unsupported filter, an incompatible datatype, or a \
11982                     fill value the filter cannot record)",
11983                )
11984            })?;
11985    }
11986
11987    // The link message body (whose length is independent of the address) must
11988    // fit the object-header message's u16 size field; a pathologically long
11989    // name would otherwise overflow it into silent corruption. Measured with a
11990    // creation index present — the widest form, written into a group that tracks
11991    // link creation order — since the parent group is not known here.
11992    let mut sized = make_link(&db.name, 0);
11993    sized.creation_order = Some(0);
11994    if sized.serialize(OFFSET_SIZE).len() > OBJECT_HEADER_MESSAGE_MAX {
11995        return Err(Error::EditUnsupported(
11996            "dataset name is too long to encode as a link message",
11997        ));
11998    }
11999
12000    let ds = Dataspace {
12001        space_type: if shape.is_empty() {
12002            DataspaceType::Scalar
12003        } else {
12004            DataspaceType::Simple
12005        },
12006        #[expect(
12007            clippy::cast_possible_truncation,
12008            reason = "dataspace rank fits the 1-byte dimensionality field (HDF5 caps rank at 32)"
12009        )]
12010        rank: shape.len() as u8,
12011        dimensions: shape,
12012        // A chunked, extensible dataset records its maximum dimensions (an
12013        // unlimited dimension is `u64::MAX`); a fixed-shape dataset has none.
12014        max_dimensions: db.maxshape.clone(),
12015    };
12016    let mut attrs: Vec<crate::attribute::AttributeMessage> = Vec::with_capacity(db.attrs.len());
12017    for (n, v) in &db.attrs {
12018        attrs.push(v.to_message(n));
12019    }
12020    // The message above already carries a placeholder (heap address 0) for each
12021    // element of a variable-length string attribute; stage its self-contained
12022    // global heap collections here (no address of their own to resolve yet) and
12023    // record which `attrs` slot they patch once the apply loop places them.
12024    let mut vl_attrs: Vec<(usize, Vec<Vec<u8>>)> = Vec::new();
12025    for (i, (_, v)) in db.attrs.iter().enumerate() {
12026        if let Some(strings) = v.var_len_strings() {
12027            vl_attrs.push((i, build_global_heap_collections(strings)));
12028        }
12029    }
12030    // Appended last, and recorded as such: `raw` can still grow before the
12031    // commit writes it (`WriteEngine::extend_staged_dataset`), and the digest
12032    // has to follow it.
12033    #[cfg(feature = "provenance")]
12034    let provenance = db.provenance.as_ref().map(|prov| {
12035        let inputs = crate::provenance::Provenance {
12036            creator: prov.creator.clone(),
12037            timestamp: prov.timestamp.clone(),
12038            source: prov.source.clone(),
12039        };
12040        let attrs_start = attrs.len();
12041        attrs.extend(inputs.build_attrs(&raw));
12042        StagedProvenance {
12043            inputs,
12044            attrs_start,
12045        }
12046    });
12047    // More attributes than an object header keeps compactly, or one whose message
12048    // overflows its 2-byte message-size field, sends the set to a fractal heap —
12049    // the same disjunction, and the same heap, the whole-file writer uses. What
12050    // that heap cannot represent is refused here, in the preflight, so a staged
12051    // dataset that cannot be written is refused before the commit places a byte.
12052    let attrs_are_dense = crate::file_writer::needs_dense_attrs(&attrs);
12053    if attrs_are_dense {
12054        crate::file_writer::dense_attrs_check(&attrs).map_err(Error::Format)?;
12055    }
12056
12057    // A committed datatype is an object of its own, which an in-place edit has no
12058    // way to place: it appends into an existing file rather than laying one out,
12059    // so there is nothing to resolve the named path against. Writing the type
12060    // inline instead would produce a dataset that reads correctly but no longer
12061    // shares the named type, so refuse by name. The whole-file writer places
12062    // them; [`crate::repack`] is the route from an edited file to one.
12063    if db.datatype_location.is_committed()
12064        || attrs.iter().any(|a| a.datatype_location.is_committed())
12065    {
12066        return Err(Error::EditUnsupported(
12067            "a dataset or attribute naming a committed (shared) datatype cannot be added in \
12068             place; write the file with FileBuilder instead",
12069        ));
12070    }
12071
12072    // A user-defined fill value is one element wide, so its byte length must
12073    // equal the datatype's element size (mirrors the whole-file writer's check).
12074    if let Some(fill) = &db.fill {
12075        let expected = elem.to_usize()?;
12076        if fill.len() != expected {
12077            return Err(Error::Format(FormatError::FillValueSizeMismatch {
12078                expected,
12079                actual: fill.len(),
12080            }));
12081        }
12082    }
12083
12084    Ok(FlatDataset {
12085        name: db.name,
12086        dt,
12087        ds,
12088        raw,
12089        attrs,
12090        chunk_options: db.chunk_options,
12091        maxshape: db.maxshape,
12092        vl_attrs,
12093        attrs_are_dense,
12094        vl_string_staging: db.vl_string_staging,
12095        reference_targets: db.reference_targets,
12096        fill: db.fill,
12097        #[cfg(feature = "provenance")]
12098        provenance,
12099    })
12100}
12101
12102/// A minimal Group Info message body (type 0x000A): version 0 with neither the
12103/// link-phase-change nor the estimated-entry fields stored. With both absent the
12104/// HDF5 C library fills `max_compact`/`min_dense` from its own defaults (8 and
12105/// 6). See [`ensure_group_info`] for why every group needs this message.
12106const GROUP_INFO_BODY: [u8; 2] = [0, 0];
12107
12108/// Frame one chunk-0 object-header message record: a 1-byte type, a 2-byte
12109/// little-endian body length, a 1-byte flags field (always 0 here), then the
12110/// body. This is the v2 message-record layout used throughout a group's chunk-0
12111/// message region. Callers pass bodies that fit the u16 length field: link
12112/// bodies are validated in [`flatten_dataset`], and the Link Info / Group Info
12113/// bodies are fixed and short.
12114/// Whether a chunked dataset with this data-layout version and chunk index type
12115/// can be enumerated chunk-by-chunk (and therefore overwritten or copied in
12116/// place). Mirrors the dispatch in
12117/// [`chunked_read::collect_chunks_for_layout_from_source`](crate::chunked_read):
12118/// version-3 B-tree v1 and the version-4 single / implicit / fixed-array /
12119/// extensible-array indexes have walkers; a version-2 B-tree (index type 5) or
12120/// any unknown index type does not.
12121fn chunk_index_enumerable(version: u8, chunk_index_type: Option<u8>) -> bool {
12122    matches!((version, chunk_index_type), (3, _) | (4, Some(1..=4)))
12123}
12124
12125/// Whether every filter in `pipeline` is one this crate can *apply* (re-encode a
12126/// chunk through) — not merely decode. A pipeline with any other filter cannot be
12127/// re-encoded for an in-place overwrite, so the caller refuses with a typed error
12128/// rather than letting `compress_chunk` surface a raw `UnsupportedFilter`.
12129pub(crate) fn pipeline_reencodable(pipeline: &FilterPipeline) -> bool {
12130    pipeline.filters.iter().all(|f| match f.filter_id {
12131        FILTER_DEFLATE | FILTER_SHUFFLE | FILTER_FLETCHER32 | FILTER_SCALEOFFSET | FILTER_LZF => {
12132            true
12133        }
12134        #[cfg(feature = "zfp")]
12135        crate::filter_pipeline::FILTER_ZFP => true,
12136        _ => false,
12137    })
12138}
12139
12140/// Whether re-encoding a chunk through `pipeline` reproduces the values it was
12141/// decoded from — the condition for rewriting a chunk that is **already
12142/// committed**, as growing a partial trailing chunk does.
12143///
12144/// Stricter than [`pipeline_reencodable`], and for a different question. That
12145/// one asks whether this crate can *apply* the filters at all, which is what a
12146/// brand-new chunk needs. This one asks whether decode-then-encode is the
12147/// identity, which is what a chunk somebody has already read needs. Deflate,
12148/// shuffle, fletcher32 and LZF are lossless by construction; scale-offset only
12149/// in its integer mode; ZFP fixed-rate quantizes every block to a bit budget, so
12150/// re-encoding it against a *different* set of neighbours in the same block
12151/// re-quantizes the values that were already there.
12152///
12153/// This is the line [`repack`](crate::repack)'s `check_pipeline` already draws
12154/// for its two re-encoding paths, stated once more here because the append
12155/// engine reaches it by a different route.
12156pub(crate) fn pipeline_lossless(pipeline: &FilterPipeline) -> bool {
12157    pipeline.filters.iter().all(|f| match f.filter_id {
12158        FILTER_DEFLATE | FILTER_SHUFFLE | FILTER_FLETCHER32 | FILTER_LZF => true,
12159        // The integer mode subtracts a per-chunk minimum and packs the residuals
12160        // whole; float D-scale rounds to a decimal count and is documented lossy.
12161        // Anything else (float E-scale) this crate neither writes nor decodes.
12162        FILTER_SCALEOFFSET => matches!(
12163            crate::scaleoffset::scale_offset_mode(&f.client_data),
12164            Some((crate::scaleoffset::ScaleOffset::Integer(_), _))
12165        ),
12166        // Unknown ids included: a filter whose semantics are unknown is not one
12167        // to assume round-trips.
12168        _ => false,
12169    })
12170}
12171
12172/// The refusal both append paths raise for a lossy pipeline sitting on a partial
12173/// trailing chunk. Named here beside the predicate it goes with, and used from
12174/// [`chunk_index_inplace`](crate::chunk_index_inplace) as well as the staged
12175/// rebuild above.
12176pub(crate) const LOSSY_TAIL_REFUSAL: &str = "this dataset's filter pipeline is lossy (ZFP, or float D-scale scale-offset), and its \
12177     length is not a whole multiple of the chunk length: growing that trailing chunk would \
12178     decode and re-encode values that are already committed, changing them. Append whole \
12179     chunks from a chunk-aligned length instead";
12180
12181/// Rebuild a header message `region`, replacing the single Data Layout message's
12182/// record with one carrying `new_layout_body` and leaving every other message
12183/// (datatype, dataspace, fill value, filter pipeline, attributes, attribute info)
12184/// byte-for-byte. The replacement may differ in length from the original — a
12185/// chunked rebuild can change the index type and thus the layout message size — so
12186/// the record is rebuilt via [`region_message`] rather than patched in place. The
12187/// chunked overwrite and copy paths use this to relocate a dataset's chunk storage
12188/// while preserving the rest of its header exactly.
12189fn replace_layout_message(region: &OhRegion, new_layout_body: &[u8]) -> Result<OhRegion, Error> {
12190    let mut out = Vec::with_capacity(region.len());
12191    let mut p = 0;
12192    let mut replaced = false;
12193    while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
12194        if msg_type == MessageType::DataLayout && !replaced {
12195            out.extend_from_slice(
12196                &region
12197                    .layout()
12198                    .record(MessageType::DataLayout, new_layout_body),
12199            );
12200            replaced = true;
12201        } else {
12202            out.extend_from_slice(&region[p..body_end]);
12203        }
12204        p = body_end;
12205    }
12206    if !replaced {
12207        return Err(Error::EditUnsupported(
12208            "chunked dataset header has no data-layout message to relocate",
12209        ));
12210    }
12211    Ok(region.with_bytes(out))
12212}
12213
12214/// Rebuild a header message `region`, replacing the single Dataspace message's
12215/// record with one carrying `new_dataspace_body` (the grown current dimensions,
12216/// v2-serialized, maximum dimensions preserved) and leaving every other message
12217/// byte-for-byte. Used by the append path to grow a dataset's axis-0 dimension.
12218/// The replacement may differ in length from the original (a v1 on-disk
12219/// dataspace is normalized to v2 in the rebuilt header), so the record is rebuilt
12220/// via [`region_message`] rather than patched in place.
12221fn replace_dataspace_message(
12222    region: &OhRegion,
12223    new_dataspace_body: &[u8],
12224) -> Result<OhRegion, Error> {
12225    let mut out = Vec::with_capacity(region.len());
12226    let mut p = 0;
12227    let mut replaced = false;
12228    while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
12229        if msg_type == MessageType::Dataspace && !replaced {
12230            out.extend_from_slice(
12231                &region
12232                    .layout()
12233                    .record(MessageType::Dataspace, new_dataspace_body),
12234            );
12235            replaced = true;
12236        } else {
12237            out.extend_from_slice(&region[p..body_end]);
12238        }
12239        p = body_end;
12240    }
12241    if !replaced {
12242        return Err(Error::AppendUnsupported(
12243            "dataset header has no dataspace message to grow",
12244        ));
12245    }
12246    Ok(region.with_bytes(out))
12247}
12248
12249/// Whether a datatype's raw on-disk bytes can be appended verbatim from a caller
12250/// via [`AppendBuilder::append_raw`]. True only when every scalar leaf is safe to
12251/// write as flat little-endian bytes:
12252///
12253/// - numeric leaves (fixed-point, floating-point, time, bit field) must be
12254///   little-endian, or the caller's little-endian bytes would silently misencode
12255///   into a big-endian (or VAX) field;
12256/// - string and opaque leaves are byte arrays with no numeric byte order, so they
12257///   are order-agnostic and safe;
12258/// - aggregates (enumeration, array, compound) are appendable iff every leaf is;
12259/// - variable-length and reference leaves embed global-heap or object addresses
12260///   that a flat byte append cannot reproduce, so they are never raw-appendable.
12261///
12262/// A typed `append_*` bypasses this: it checks full datatype equality instead, so
12263/// it already refuses every non-little-endian and non-scalar dataset.
12264pub(crate) fn datatype_is_raw_appendable(dt: &Datatype) -> bool {
12265    match dt {
12266        Datatype::FixedPoint { byte_order, .. }
12267        | Datatype::FloatingPoint { byte_order, .. }
12268        | Datatype::Time { byte_order, .. }
12269        | Datatype::BitField { byte_order, .. } => *byte_order == DatatypeByteOrder::LittleEndian,
12270        Datatype::String { .. } | Datatype::Opaque { .. } => true,
12271        Datatype::Enumeration { base_type, .. } | Datatype::Array { base_type, .. } => {
12272            datatype_is_raw_appendable(base_type)
12273        }
12274        Datatype::Compound { members, .. } => members
12275            .iter()
12276            .all(|m| datatype_is_raw_appendable(&m.datatype)),
12277        Datatype::VariableLength { .. } | Datatype::Reference { .. } => false,
12278    }
12279}
12280
12281/// The datatype, dataspace, parsed chunked data layout, and verbatim filter-
12282/// pipeline message bytes (if any) of a chunked dataset header, parsed by
12283/// [`parse_chunked_header`].
12284struct ChunkedHeaderParts {
12285    dt: crate::datatype::Datatype,
12286    ds: Dataspace,
12287    layout: DataLayout,
12288    pipeline_message: Option<Vec<u8>>,
12289}
12290
12291/// Parse the datatype, dataspace, chunked data layout, and verbatim filter-
12292/// pipeline message bytes (if any) from a chunked dataset header `region`. Used by
12293/// the chunked copy path to derive chunk geometry and the on-disk filter pipeline.
12294/// Errors if any required message is missing or the layout is not chunked.
12295fn parse_chunked_header(region: &OhRegion) -> Result<ChunkedHeaderParts, Error> {
12296    let mut datatype: Option<(usize, usize)> = None;
12297    let mut dataspace: Option<(usize, usize)> = None;
12298    let mut layout: Option<(usize, usize)> = None;
12299    let mut pipeline: Option<(usize, usize)> = None;
12300    let mut p = 0;
12301    while let Some((msg_type, body, body_end)) = region.next_message(p)? {
12302        match msg_type {
12303            MessageType::Datatype => datatype = Some((body, body_end)),
12304            MessageType::Dataspace => dataspace = Some((body, body_end)),
12305            MessageType::DataLayout => layout = Some((body, body_end)),
12306            MessageType::FilterPipeline => pipeline = Some((body, body_end)),
12307            _ => {}
12308        }
12309        p = body_end;
12310    }
12311    let (dt_b, dt_e) = datatype.ok_or(Error::EditUnsupported("dataset header has no datatype"))?;
12312    let (ds_b, ds_e) =
12313        dataspace.ok_or(Error::EditUnsupported("dataset header has no dataspace"))?;
12314    let (lb, le) = layout.ok_or(Error::EditUnsupported("dataset header has no data layout"))?;
12315    let (dt, _) = crate::datatype::Datatype::parse(&region[dt_b..dt_e])
12316        .map_err(|_| Error::EditUnsupported("dataset header datatype could not be parsed"))?;
12317    let ds = Dataspace::parse(&region[ds_b..ds_e], LENGTH_SIZE)
12318        .map_err(|_| Error::EditUnsupported("dataset header dataspace could not be parsed"))?;
12319    let dl = DataLayout::parse(&region[lb..le], OFFSET_SIZE, LENGTH_SIZE)
12320        .map_err(|_| Error::EditUnsupported("dataset header data layout could not be parsed"))?;
12321    if !matches!(dl, DataLayout::Chunked { .. }) {
12322        return Err(Error::EditUnsupported("dataset is not chunked"));
12323    }
12324    let pipeline_message = pipeline.map(|(b, e)| region[b..e].to_vec());
12325    Ok(ChunkedHeaderParts {
12326        dt,
12327        ds,
12328        layout: dl,
12329        pipeline_message,
12330    })
12331}
12332
12333/// The chunk geometry a verbatim chunked rebuild needs, derived by
12334/// [`chunked_geometry`] from a chunked dataset's datatype, dataspace, and parsed
12335/// [`DataLayout::Chunked`].
12336struct ChunkedGeometry {
12337    /// Rank-only spatial chunk dimensions.
12338    spatial: Vec<u64>,
12339    /// Element size in bytes, proven non-zero: the chunk splitter divides by
12340    /// it, and so does the append path's element-count arithmetic.
12341    element_size: NonZeroUsize,
12342    /// Full (uncompressed) chunk byte size, `product(spatial) * element_size`.
12343    /// This is what the chunk index's element width is derived from, so it has
12344    /// to be the geometry's product and not any written chunk's size.
12345    raw_size: u64,
12346    /// The on-disk maximum dimensions when they differ from the current shape; an
12347    /// unlimited dimension selects the extensible-array index, a finite one the
12348    /// fixed-array index. `None` keeps the fixed-array / single-chunk index.
12349    maxshape: Option<Vec<u64>>,
12350}
12351
12352/// Derive the [`ChunkedGeometry`] for a chunked dataset from its datatype,
12353/// dataspace, and parsed [`DataLayout::Chunked`].
12354fn chunked_geometry(
12355    dt: &crate::datatype::Datatype,
12356    ds: &Dataspace,
12357    layout: &DataLayout,
12358) -> Result<ChunkedGeometry, Error> {
12359    let DataLayout::Chunked {
12360        chunk_dimensions, ..
12361    } = layout
12362    else {
12363        return Err(Error::EditUnsupported("dataset is not chunked"));
12364    };
12365    let rank = ds.dimensions.len();
12366    if chunk_dimensions.len() <= rank {
12367        return Err(Error::EditUnsupported(
12368            "chunked layout has malformed dimensions",
12369        ));
12370    }
12371    let spatial: Vec<u64> = chunk_dimensions[..rank]
12372        .iter()
12373        .map(|&c| u64::from(c))
12374        .collect();
12375    let element_size = dt.element_size_usize()?;
12376    let raw_size = spatial
12377        .iter()
12378        .copied()
12379        .product::<u64>()
12380        .saturating_mul(element_size.get() as u64);
12381    let maxshape = ds
12382        .max_dimensions
12383        .as_ref()
12384        .filter(|ms| *ms != &ds.dimensions)
12385        .cloned();
12386    Ok(ChunkedGeometry {
12387        spatial,
12388        element_size,
12389        raw_size,
12390        maxshape,
12391    })
12392}
12393
12394/// The element bytes a staged value overwrite will write, paired with the
12395/// variable-length staging that has still to be resolved into them and the
12396/// `path` they belong to.
12397///
12398/// Cloned rather than moved because the staged set must survive a refused
12399/// commit whole (issue #316), and this runs in the preflight that may refuse.
12400fn staged_bytes(fd: &FlatDataset, path: &PathKey) -> OverwriteBytes {
12401    OverwriteBytes {
12402        raw: fd.raw.clone(),
12403        vlen: fd.vl_string_staging.clone().map(|staging| VlenOverwrite {
12404            staging,
12405            path: path.clone(),
12406        }),
12407    }
12408}
12409
12410/// Split `raw` into full-size chunk buffers in dense row-major grid order and
12411/// re-encode each through `pipeline_message`, the dataset's on-disk filter
12412/// pipeline.
12413///
12414/// The overhang past the dataset's edge holds the dataset's own fill value, not
12415/// zeros: an allocated chunk is expected to carry it wherever nothing was
12416/// written, and those slots are what a reader returns once the dataset is
12417/// extended into them (issue #296).
12418///
12419/// The caller has already refused a pipeline
12420/// [`pipeline_reencodable`] rejects, so the only errors here are a malformed
12421/// pipeline message (unreachable for the same reason) and the encoder's own.
12422fn split_and_encode_chunks(
12423    raw: &[u8],
12424    shape: &[u64],
12425    chunk_dims: &[u64],
12426    element_size: NonZeroUsize,
12427    padding: &crate::fill_value::PaddingFill,
12428    pipeline_message: Option<&[u8]>,
12429    dt: &crate::datatype::Datatype,
12430) -> Result<Vec<Vec<u8>>, Error> {
12431    let split = split_into_chunks(
12432        raw,
12433        shape,
12434        chunk_dims,
12435        element_size,
12436        padding.pattern(element_size),
12437    )
12438    .map_err(Error::Format)?;
12439    let Some(pm) = pipeline_message else {
12440        return Ok(split);
12441    };
12442    let pipeline = FilterPipeline::parse(pm)
12443        .map_err(|_| Error::EditUnsupported("dataset filter pipeline could not be parsed"))?;
12444    let ctx = ChunkContext::from_datatype(chunk_dims, dt)?;
12445    let mut encoded = Vec::with_capacity(split.len());
12446    // One encoder across the rewrite; see `FilterScratch`.
12447    let mut scratch = FilterScratch::new();
12448    for buf in &split {
12449        encoded.push(compress_chunk_with(&mut scratch, buf, &pipeline, ctx)?);
12450    }
12451    Ok(encoded)
12452}
12453
12454/// Try to overwrite a chunked dataset's chunks in place. When the dataset's
12455/// on-disk chunks form a dense grid aligned with `new_bytes` (dense row-major
12456/// order), every slot is unmasked (`filter_mask == 0`), and every new chunk
12457/// **fits** the slot it replaces (`new_len <= slot`), return the in-place
12458/// `(address, bytes)` writes:
12459///
12460/// - When every new chunk is **exactly** its slot's size, only the chunk data is
12461///   written; the index is untouched (so any enumerable index type works, and a
12462///   crash can tear at most a chunk's value bytes, not the structure).
12463/// - When some new chunks are **smaller** (fit with slack), the chunk index
12464///   records each chunk's stored size, so the index is rebuilt in place to record
12465///   the new sizes (see [`try_rebuild_index_in_place`]). This is supported only
12466///   for a v4 fixed-array or extensible-array index occupying a single contiguous
12467///   on-disk region; any other case returns `None` to relocate.
12468///
12469/// Returns `None` — so the caller relocates the dataset instead — when the index
12470/// cannot be enumerated, the grid is sparse, a slot is masked, a new chunk does
12471/// not fit, the index cannot be rebuilt in place, or any write would be out of
12472/// bounds or overlap another.
12473fn try_inplace_chunk_writes<S: Source + ?Sized>(
12474    src: &S,
12475    layout: &DataLayout,
12476    ds: &Dataspace,
12477    spatial: &[u64],
12478    raw_size: u64,
12479    new_bytes: &[Vec<u8>],
12480) -> Option<Vec<(usize, Vec<u8>)>> {
12481    let infos = enumerate_chunks_from_source(src, layout, ds, OFFSET_SIZE, LENGTH_SIZE).ok()?;
12482    let grid = plan_dense_grid(infos, &ds.dimensions, spatial)?;
12483    if grid.grid_order.len() != new_bytes.len() {
12484        return None;
12485    }
12486    let mut writes = Vec::with_capacity(new_bytes.len() + 1);
12487    let mut spans: Vec<(u64, u64)> = Vec::with_capacity(new_bytes.len() + 1);
12488    let mut any_shrunk = false;
12489    for (ci, bytes) in grid.grid_order.iter().zip(new_bytes.iter()) {
12490        // A nonzero filter mask means the source left some filter unapplied for
12491        // this chunk; re-encoding always applies every filter (mask 0), so an
12492        // in-place overwrite would desync the index-recorded mask. Relocate.
12493        if ci.filter_mask != 0 {
12494            return None;
12495        }
12496        let new_len = bytes.len() as u64;
12497        let slot = u64::from(ci.chunk_size);
12498        // A chunk that no longer fits its slot must relocate.
12499        if new_len > slot {
12500            return None;
12501        }
12502        if new_len < slot {
12503            any_shrunk = true;
12504        }
12505        let start = usize::try_from(ci.address).ok()?;
12506        start
12507            .checked_add(bytes.len())
12508            .filter(|&e| e as u64 <= src.len())?;
12509        writes.push((start, bytes.clone()));
12510        spans.push((ci.address, new_len));
12511    }
12512
12513    // A shrinking overwrite changes the index-recorded chunk sizes, so the index
12514    // must be rebuilt in place to match; an equal-size one leaves it untouched.
12515    if any_shrunk {
12516        let (index_addr, index_bytes) = try_rebuild_index_in_place(
12517            src,
12518            layout,
12519            ds,
12520            spatial,
12521            raw_size,
12522            &grid.grid_order,
12523            new_bytes,
12524        )?;
12525        spans.push((index_addr as u64, index_bytes.len() as u64));
12526        writes.push((index_addr, index_bytes));
12527    }
12528
12529    // Refuse to perform overlapping in-place writes (a malformed source index, or
12530    // an index region that overlaps a chunk slot); relocate instead so two writes
12531    // never clobber each other.
12532    if !spans_disjoint_in_bounds(&mut spans, src.len()) {
12533        return None;
12534    }
12535    Some(writes)
12536}
12537
12538/// Rebuild a chunked dataset's index **in place** so it records the new
12539/// (smaller) per-chunk stored sizes after a fits-with-slack overwrite, returning
12540/// the `(address, bytes)` write that replaces it. The chunks keep their existing
12541/// addresses (only their stored bytes shrank), so the rebuilt index points at the
12542/// same slots with the new sizes.
12543///
12544/// Supported only for a v4 **fixed-array** or **extensible-array** index whose
12545/// on-disk structure is a single contiguous region starting at the index address
12546/// — the layout this crate's own writer produces. The element width derives from
12547/// the unchanged raw chunk size, so the rebuilt structure is byte-for-byte the
12548/// same length as the original; this is required to match exactly, which rejects a
12549/// scattered or differently-laid-out (e.g. C-written) index, leaving the caller
12550/// to relocate. Single-chunk (size in the layout message) and B-tree-v1 (no
12551/// writer) indexes are not rebuilt here.
12552///
12553/// Like any in-place value overwrite (the HDF5 `H5Dwrite` model) this is not
12554/// atomic: a crash mid-write can tear the index and leave the dataset needing a
12555/// rewrite. It is used only on the in-place path, whose linearization point is the
12556/// synced data write.
12557#[allow(clippy::too_many_arguments)]
12558fn try_rebuild_index_in_place<S: Source + ?Sized>(
12559    src: &S,
12560    layout: &DataLayout,
12561    ds: &Dataspace,
12562    spatial: &[u64],
12563    raw_size: u64,
12564    grid_order: &[crate::chunked_read::ChunkInfo],
12565    new_bytes: &[Vec<u8>],
12566) -> Option<(usize, Vec<u8>)> {
12567    let DataLayout::Chunked {
12568        btree_address: Some(index_addr),
12569        chunk_index_type,
12570        version,
12571        ..
12572    } = layout
12573    else {
12574        return None;
12575    };
12576    let written: Vec<crate::chunked_write::WrittenChunk> = grid_order
12577        .iter()
12578        .zip(new_bytes)
12579        .map(|(ci, b)| crate::chunked_write::WrittenChunk {
12580            address: ci.address,
12581            compressed_size: b.len() as u64,
12582            filter_mask: 0,
12583        })
12584        .collect();
12585    // `grid_order` is dense row-major over the *current* shape; where each of
12586    // those chunks sits in the index is the maximum grid's business, and the
12587    // rebuild has to reproduce the numbering the original index was written
12588    // with. Same rule, same function as the writer.
12589    let (_, slot_of_chunk, index_slots) = crate::chunked_write::plan_index_slots(
12590        &ds.dimensions,
12591        spatial,
12592        ds.max_dimensions.as_deref(),
12593        raw_size,
12594        true,
12595        // Rebuilding the index of a dataset whose chunks are in hand.
12596        crate::chunked_write::StorageAllocation::Allocated,
12597    )
12598    .ok()?;
12599    let slots =
12600        crate::chunked_write::IndexSlots::new(&written, &slot_of_chunk, index_slots).ok()?;
12601    let new_index = match (version, chunk_index_type) {
12602        // `raw_size` is the whole-chunk byte size, which is what the element
12603        // width derives from — the same value the original index was built with,
12604        // so the rebuilt structure matches its length. (An index written by a
12605        // version that derived the width from the written chunks instead can
12606        // disagree; the length check below then rejects it and the caller
12607        // relocates, which is the safe direction.)
12608        (4, Some(3)) => crate::chunked_write::build_fixed_array_at(
12609            &slots,
12610            raw_size,
12611            OFFSET_SIZE,
12612            LENGTH_SIZE,
12613            true,
12614            *index_addr,
12615        ),
12616        (4, Some(4)) => crate::chunked_write::build_extensible_array_at(
12617            &slots,
12618            raw_size,
12619            OFFSET_SIZE,
12620            LENGTH_SIZE,
12621            true,
12622            *index_addr,
12623        )
12624        .ok()?,
12625        // Single-chunk records its size in the layout message (a header rewrite),
12626        // and a B-tree-v1 index has no writer; both relocate instead.
12627        _ => return None,
12628    };
12629
12630    // The on-disk index must be a single contiguous region starting at the index
12631    // address, and the rebuilt structure must be exactly the same length (true for
12632    // an index this crate wrote). A scattered or different on-disk layout fails
12633    // the check and the caller relocates.
12634    let mut spans =
12635        crate::chunked_read::chunk_index_spans_from_source(src, layout, OFFSET_SIZE, LENGTH_SIZE)
12636            .ok()?;
12637    if spans.is_empty() {
12638        return None;
12639    }
12640    spans.sort_unstable_by_key(|&(a, _)| a);
12641    if spans[0].0 != *index_addr {
12642        return None;
12643    }
12644    let mut end = *index_addr;
12645    for &(a, l) in &spans {
12646        if a != end {
12647            return None; // a gap means the index is not contiguous
12648        }
12649        end = a.checked_add(l)?;
12650    }
12651    if new_index.len() as u64 != end - *index_addr {
12652        return None;
12653    }
12654    let start = usize::try_from(*index_addr).ok()?;
12655    start
12656        .checked_add(new_index.len())
12657        .filter(|&e| e as u64 <= src.len())?;
12658    Some((start, new_index))
12659}
12660
12661/// A [`ChunkProvider`] over chunk bytes already held in memory, in dense
12662/// row-major grid order. Used by the editor's chunked copy and relocating
12663/// overwrite, which own each chunk's bytes (a [`CopyTree`] or [`MovingWrite`]
12664/// captured them) rather than streaming from a source file like repack.
12665struct SliceChunkProvider<'a> {
12666    chunks: &'a [Vec<u8>],
12667}
12668
12669impl ChunkProvider for SliceChunkProvider<'_> {
12670    fn chunk_bytes(&self, index: usize, out: &mut Vec<u8>) -> Result<(), FormatError> {
12671        let chunk = self.chunks.get(index).ok_or_else(|| {
12672            FormatError::ChunkedReadError("chunk index out of range for in-memory provider".into())
12673        })?;
12674        out.extend_from_slice(chunk);
12675        Ok(())
12676    }
12677}
12678
12679/// The chunk-0 message region of a fresh, empty compact-link group: a LinkInfo
12680/// message advertising no dense storage, followed by a GroupInfo message.
12681/// Mirrors `build_group_oh`.
12682fn fresh_group_region() -> OhRegion {
12683    let mut li = Vec::with_capacity(18);
12684    li.push(0); // version
12685    li.push(0); // flags
12686    li.extend_from_slice(&u64::MAX.to_le_bytes()); // fractal heap addr = UNDEF
12687    li.extend_from_slice(&u64::MAX.to_le_bytes()); // btree name index addr = UNDEF
12688    let mut region = OhRegion::empty(OhHeaderProps::PLAIN);
12689    region.push(MessageType::LinkInfo, &li);
12690    region.push(MessageType::GroupInfo, &GROUP_INFO_BODY);
12691    region
12692}
12693
12694/// Ensure a group's chunk-0 message `region` carries a Group Info message,
12695/// appending a minimal one when absent.
12696///
12697/// The HDF5 C library refuses to insert a link into a group whose object header
12698/// has a Link Info message but no Group Info message: on the new-format path
12699/// `H5G_obj_insert` reads the Group Info message unconditionally and fails with
12700/// "message type not found". Such a group round-trips for *reading* but cannot
12701/// be *modified* by the C library. Earlier hdf5-pure releases wrote groups that
12702/// way, so heal any such header whenever we rewrite one in place.
12703fn ensure_group_info(region: &mut OhRegion) -> Result<(), Error> {
12704    let mut p = 0;
12705    while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
12706        if msg_type == MessageType::GroupInfo {
12707            return Ok(());
12708        }
12709        p = body_end;
12710    }
12711    region.push(MessageType::GroupInfo, &GROUP_INFO_BODY);
12712    Ok(())
12713}
12714
12715/// Ensure a chunk-0 message `region` that carries inline Attribute messages also
12716/// carries an Attribute Info message, appending the compact-storage one when
12717/// absent.
12718///
12719/// On a version 2 object header the reference C library never counts attribute
12720/// messages: `H5O__attr_count_real` reads the count out of the Attribute Info
12721/// message, and reports zero when there is none, even though `H5Aiterate` and
12722/// `H5Aopen_by_name` still find every attribute. Tools that size their work by
12723/// that count then skip the attributes silently — `h5repack` copies such an
12724/// object with none of them. Releases through 0.33.0 wrote every compact
12725/// attribute set that way, so heal any such header whenever we rewrite one, the
12726/// same reason [`ensure_group_info`] exists.
12727///
12728/// A region already carrying an Attribute Info message is left alone, whichever
12729/// storage it names: a defined heap address means dense storage, whose count the
12730/// C library takes from the heap's B-tree instead.
12731fn ensure_attribute_info(region: &mut OhRegion) -> Result<(), Error> {
12732    let mut has_attrs = false;
12733    let mut p = 0;
12734    while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
12735        match msg_type {
12736            MessageType::AttributeInfo => return Ok(()),
12737            MessageType::Attribute => has_attrs = true,
12738            _ => {}
12739        }
12740        p = body_end;
12741    }
12742    if has_attrs {
12743        let body = compact_attribute_info_body(region)?;
12744        region.push(MessageType::AttributeInfo, &body);
12745    }
12746    Ok(())
12747}
12748
12749/// Encode a complete object-header Link message (4-byte record header + body)
12750/// for a hard link `name -> addr`. The caller must have validated that the body
12751/// fits the u16 size field (see [`flatten_dataset`]); group names are short.
12752///
12753/// `creation_order` is the link's creation index, which a group tracking **link**
12754/// creation order carries on every link and no other group carries at all
12755/// ([`LinkCreationOrder`] is what hands one out). It is a flagged field of the
12756/// Link message *body*, quite separate from the object header's own per-message
12757/// creation index: that one records an *attribute*'s creation order, and the
12758/// reference C library writes it as the zero [`OhRecordLayout::record`] passes
12759/// for every other message type.
12760fn encode_link_message(
12761    layout: OhRecordLayout,
12762    name: &str,
12763    addr: u64,
12764    creation_order: Option<u64>,
12765) -> Vec<u8> {
12766    let mut link = make_link(name, addr);
12767    link.creation_order = creation_order;
12768    let body = link.serialize(OFFSET_SIZE);
12769    layout.record(MessageType::Link, &body)
12770}
12771
12772/// Patch an existing hard Link message in a chunk-0 message `region`, retargeting
12773/// the link named `name` to `new_addr` (used to repoint a parent at a relocated
12774/// child group). The target address is the trailing `OFFSET_SIZE` bytes of the
12775/// link body for a hard link.
12776fn patch_link_target(region: &mut OhRegion, name: &str, new_addr: u64) -> Result<(), Error> {
12777    let mut p = 0;
12778    while let Some((msg_type, body, body_end)) = region.next_message(p)? {
12779        if msg_type == MessageType::Link {
12780            if let Ok(link) = LinkMessage::parse(&region[body..body_end], OFFSET_SIZE) {
12781                if link.name == name {
12782                    return match link.link_target {
12783                        LinkTarget::Hard { .. } => {
12784                            let ofs = body_end - OFFSET_SIZE as usize;
12785                            region.bytes_mut()[ofs..body_end]
12786                                .copy_from_slice(&new_addr.to_le_bytes());
12787                            Ok(())
12788                        }
12789                        _ => Err(Error::EditUnsupported(
12790                            "a group on the edited path is reached by a soft/external link",
12791                        )),
12792                    };
12793                }
12794            }
12795        }
12796        p = body_end;
12797    }
12798    Err(Error::EditUnsupported(
12799        "expected child link not found in parent group",
12800    ))
12801}
12802
12803/// Bytes a compact Data Layout message carries ahead of its inline data:
12804/// version(1) + class(1) + the 2-byte inline size.
12805const COMPACT_LAYOUT_PREAMBLE: usize = 4;
12806
12807/// Copy a chunk-0 message `region`, replacing the single (compact) Data Layout
12808/// message's inline data with `raw` and preserving every other message verbatim.
12809/// Used by `write_dataset` to overwrite a compact dataset's values. The message
12810/// header (type and flags) and version byte are kept; only the inline data — and
12811/// the message size and 2-byte inline-size fields — change. `raw` must fit both
12812/// the compact layout's own 2-byte size field (HDF5's 64 KiB compact-storage
12813/// limit) and, once the 4-byte layout preamble is added, the object header's
12814/// 2-byte message-size field — the tighter of the two, which an overwrite of an
12815/// existing compact dataset always satisfies.
12816fn rebuild_compact_layout_region(region: &OhRegion, raw: &[u8]) -> Result<OhRegion, Error> {
12817    // The bound is on the *message body* the layout becomes — version, class,
12818    // and the 2-byte inline size ahead of the data — not on `raw` alone, or the
12819    // last four lengths below the limit would truncate the size field written
12820    // for them.
12821    if raw.len() > OBJECT_HEADER_MESSAGE_MAX - COMPACT_LAYOUT_PREAMBLE {
12822        return Err(Error::EditUnsupported(
12823            "compact dataset data is too large to overwrite in place",
12824        ));
12825    }
12826    let mut out = Vec::with_capacity(region.len() + raw.len());
12827    let mut p = 0;
12828    let mut replaced = false;
12829    while let Some((msg_type, body, body_end)) = region.next_message(p)? {
12830        if msg_type == MessageType::DataLayout {
12831            if body_end - body < 2 || region[body + 1] != 0 {
12832                return Err(Error::EditUnsupported(
12833                    "compact-layout overwrite found a non-compact data layout",
12834                ));
12835            }
12836            // New compact layout body: version (kept), class=0, 2-byte inline
12837            // size, then the data.
12838            let mut layout = Vec::with_capacity(COMPACT_LAYOUT_PREAMBLE + raw.len());
12839            layout.push(region[body]); // version (3 or 4)
12840            layout.push(0); // class = compact
12841            #[expect(
12842                clippy::cast_possible_truncation,
12843                reason = "raw.len() bounded below the u16 inline-size field above"
12844            )]
12845            layout.extend_from_slice(&(raw.len() as u16).to_le_bytes());
12846            layout.extend_from_slice(raw);
12847            // Message record: type byte, 2-byte size (LE), then the rest of
12848            // the prefix — flags, and a creation index where the header has one
12849            // — kept verbatim.
12850            out.push(region[p]);
12851            #[expect(
12852                clippy::cast_possible_truncation,
12853                reason = "the guard above bounds COMPACT_LAYOUT_PREAMBLE + raw.len(), this \
12854                          body's exact length, to the 2-byte message-size field"
12855            )]
12856            out.extend_from_slice(&(layout.len() as u16).to_le_bytes());
12857            out.extend_from_slice(&region[p + 3..p + region.layout().prefix_len()]);
12858            out.extend_from_slice(&layout);
12859            replaced = true;
12860        } else {
12861            out.extend_from_slice(&region[p..body_end]);
12862        }
12863        p = body_end;
12864    }
12865    if p < region.len() {
12866        out.extend_from_slice(&region[p..]);
12867    }
12868    if !replaced {
12869        return Err(Error::EditUnsupported(
12870            "compact dataset header has no data-layout message",
12871        ));
12872    }
12873    Ok(region.with_bytes(out))
12874}
12875
12876/// The link creation indexes one commit hands out to the links it adds to one
12877/// group, and the running maximum its Link Info message ends up recording.
12878///
12879/// Link creation order is a separate mechanism from the attribute creation order
12880/// the object header's own flags describe: a group that tracks it — h5py's
12881/// `track_order=True`, `H5Pset_link_creation_order`, and every group netCDF-4
12882/// writes — carries a creation index on every Link message and, in its Link Info
12883/// message, the maximum it has ever assigned. The reference C library hands out
12884/// that maximum and increments it (`H5G_obj_insert`), so the recorded value is
12885/// the *next* index rather than the highest in use, and deleting a link never
12886/// lowers it. This is the link-side counterpart of [`next_attr_creation_index`],
12887/// and it works the same way.
12888///
12889/// A group that does not track the order has no maximum recorded, hands out no
12890/// index, and records nothing back — which is every group this crate writes
12891/// itself.
12892struct LinkCreationOrder {
12893    /// The next index to assign, on a group that tracks link creation order.
12894    next: Option<u64>,
12895    /// Whether any index has been handed out. Nothing is recorded back
12896    /// otherwise, so a commit that adds no link to the group leaves its Link
12897    /// Info message byte-identical.
12898    assigned: bool,
12899}
12900
12901impl LinkCreationOrder {
12902    /// Read a group's counter out of its object-header message `region`.
12903    fn for_region(region: &OhRegion) -> Result<Self, Error> {
12904        let next = find_link_info(region)?.and_then(|(_, _, info)| info.max_creation_order);
12905        Ok(Self {
12906            next,
12907            assigned: false,
12908        })
12909    }
12910
12911    /// The creation index for one link being added, or `None` on a group that
12912    /// does not track the order. Consecutive calls return consecutive indexes,
12913    /// so links added in one commit are ordered by the order they are placed in.
12914    fn take(&mut self) -> Result<Option<u64>, Error> {
12915        let Some(index) = self.next else {
12916            return Ok(None);
12917        };
12918        self.next = Some(index.checked_add(1).ok_or(Error::EditUnsupported(
12919            "a group has assigned every link creation index its link-info message can record",
12920        ))?);
12921        self.assigned = true;
12922        Ok(Some(index))
12923    }
12924
12925    /// Write the running maximum back into the group's Link Info message, once
12926    /// every link this commit adds to that group has taken an index.
12927    ///
12928    /// The maximum is patched in place rather than re-encoded: it is present
12929    /// (that is what [`Self::for_region`] read) and it is the eight bytes
12930    /// following the message's version and flags whatever the file's offset
12931    /// size, so the record's length — and every offset into the region — is
12932    /// unchanged.
12933    fn record(&self, region: &mut OhRegion) -> Result<(), Error> {
12934        if !self.assigned {
12935            return Ok(());
12936        }
12937        let Some(next) = self.next else {
12938            return Ok(());
12939        };
12940        let (_, body, _) = find_link_info(region)?.ok_or(Error::EditUnsupported(
12941            "a group's link-info message went missing while its links were being added",
12942        ))?;
12943        let max = body.start + 2..body.start + 10;
12944        if max.end > body.end {
12945            return Err(Error::EditUnsupported(
12946                "a group's link-info message is too short to record its link creation order",
12947            ));
12948        }
12949        region.bytes_mut()[max].copy_from_slice(&next.to_le_bytes());
12950        Ok(())
12951    }
12952}
12953
12954/// The Link Info message in `region`, as `(record start, body range, parsed
12955/// message)`. An unparseable message is reported as absent, the same reading
12956/// [`find_attribute_info`] gives an unparseable Attribute Info message — safe
12957/// here because a group whose link storage this editor cannot account for is
12958/// refused by [`inspect_group`](WriteEngine::inspect_group) before it reaches
12959/// this.
12960fn find_link_info(
12961    region: &OhRegion,
12962) -> Result<Option<(usize, core::ops::Range<usize>, LinkInfoMessage)>, Error> {
12963    let mut p = 0;
12964    while let Some((msg_type, body, body_end)) = region.next_message(p)? {
12965        if msg_type == MessageType::LinkInfo
12966            && let Ok(info) = LinkInfoMessage::parse(&region[body..body_end], OFFSET_SIZE)
12967        {
12968            return Ok(Some((p, body..body_end, info)));
12969        }
12970        p = body_end;
12971    }
12972    Ok(None)
12973}
12974
12975/// The number of links a group keeps in its object header before they move into
12976/// a fractal heap: the "maximum compact value" its Group Info message (type
12977/// 0x000A) declares, or the reference C library's default of 8 where that
12978/// message stores no link-phase-change values.
12979///
12980/// Body: version(1), flags(1), then the maximum compact (2) and minimum dense
12981/// (2) values if bit 0 of the flags is set, then the estimated entry count (2)
12982/// and name length (2) if bit 1 is. An absent or truncated message reads as the
12983/// default, which is the value the C library itself would use for it.
12984///
12985/// Not to be confused with [`AttrPhaseChange`], the *attribute* thresholds
12986/// (`H5Pset_attr_phase_change`) that live in the object header's own prefix:
12987/// this is the *link* phase change, it lives in a message, and it is what
12988/// [`reject_dense_link_creation_order`] measures an addition against.
12989fn max_compact_links(region: &OhRegion) -> Result<u16, Error> {
12990    /// `H5G_CRT_GINFO_MAX_COMPACT`, the C library's default.
12991    const DEFAULT_MAX_COMPACT: u16 = 8;
12992    let mut p = 0;
12993    while let Some((msg_type, body, body_end)) = region.next_message(p)? {
12994        if msg_type == MessageType::GroupInfo {
12995            let stores_phase_change = body_end - body >= 2 && region[body + 1] & 0x01 != 0;
12996            if stores_phase_change && body + 4 <= body_end {
12997                return Ok(u16::from_le_bytes([region[body + 2], region[body + 3]]));
12998            }
12999            return Ok(DEFAULT_MAX_COMPACT);
13000        }
13001        p = body_end;
13002    }
13003    Ok(DEFAULT_MAX_COMPACT)
13004}
13005
13006/// Refuse to add a link to a group that tracks **link** creation order when the
13007/// addition would move that group's links into *dense* (fractal-heap) storage.
13008///
13009/// `links_after_commit` is how many links the group would hold once this commit's
13010/// deletions and additions are applied. Past the threshold its Group Info message
13011/// declares, the reference C library moves a tracked group's links into a fractal
13012/// heap indexed by *two* B-trees: one on name, and a type 6 one on creation
13013/// order. This crate emits neither, so the point where the group would stop
13014/// being compact is the point where an addition stops being reproducible — that
13015/// work belongs with dense link storage as a whole (issue #102).
13016///
13017/// Below the threshold an addition is written rather than refused
13018/// ([`LinkCreationOrder`] numbers it), as are the two things that write no
13019/// creation order at all: **removing** a link, which leaves a gap in the order
13020/// exactly as dropping an Attribute message does and copies the running maximum
13021/// through untouched (the C library does not lower it on a deletion either), and
13022/// **retargeting** one ([`patch_link_target`], how a relocated child is rewired),
13023/// which rewrites an address inside a Link message and leaves every other field
13024/// of it, creation index included, where it was.
13025///
13026/// A group whose links are *already* dense never reaches here at all:
13027/// [`inspect_group`](WriteEngine::inspect_group) refuses every dense-link group,
13028/// tracked or not, as it reads the header.
13029fn reject_dense_link_creation_order(
13030    region: &OhRegion,
13031    links_after_commit: usize,
13032) -> Result<(), Error> {
13033    let tracked =
13034        find_link_info(region)?.is_some_and(|(_, _, info)| info.max_creation_order.is_some());
13035    if tracked && links_after_commit > usize::from(max_compact_links(region)?) {
13036        return Err(Error::EditUnsupported(
13037            "a group on the edited path tracks link creation order, and the links this commit \
13038             adds to it would take it past the compact storage its group-info message allows; \
13039             dense (fractal-heap) link storage cannot be written in place yet",
13040        ));
13041    }
13042    Ok(())
13043}
13044
13045/// Copy a chunk-0 message `region`, dropping the single Link message named
13046/// `name` and preserving every other message verbatim (used by `delete`). Errors
13047/// if no such link is present.
13048fn remove_link_from_region(region: &OhRegion, name: &str) -> Result<OhRegion, Error> {
13049    let mut out = Vec::with_capacity(region.len());
13050    let mut p = 0;
13051    let mut removed = false;
13052    while let Some((msg_type, body, body_end)) = region.next_message(p)? {
13053        let mut skip = false;
13054        if msg_type == MessageType::Link {
13055            if let Ok(link) = LinkMessage::parse(&region[body..body_end], OFFSET_SIZE) {
13056                if link.name == name {
13057                    skip = true;
13058                    removed = true;
13059                }
13060            }
13061        }
13062        if !skip {
13063            out.extend_from_slice(&region[p..body_end]);
13064        }
13065        p = body_end;
13066    }
13067    if p < region.len() {
13068        out.extend_from_slice(&region[p..]);
13069    }
13070    if !removed {
13071        return Err(Error::EditUnsupported(
13072            "link to delete not found in its parent group",
13073        ));
13074    }
13075    Ok(region.with_bytes(out))
13076}
13077
13078/// Apply attribute edits to an object's header `region` *compactly*, preserving
13079/// every non-attribute message verbatim. A fixed-size `Set`/`Remove` is resolved
13080/// into `region` directly; a variable-length `Set` (one whose value reports
13081/// [`AttrValue::var_len_strings`]) is
13082/// instead collected into the returned `pending_vl_attrs` — its placeholder
13083/// heap address is only patched, and the message appended to the object's
13084/// header, by the apply loop once its global heap collection's real address
13085/// is known (see [`WriteEngine::place_vl_collection`]). A later op for the
13086/// same name (another `Set`, fixed-size or not, or a `Remove`) replaces or
13087/// cancels an earlier still-pending variable-length entry, keeping the net
13088/// effect the same regardless of op order within one commit.
13089///
13090/// Only [`plan_attr_ops`] calls this, and only for an object whose attributes
13091/// are stored compactly: appending an inline Attribute message to an object
13092/// whose set lives in a fractal heap would leave it carrying two storage forms,
13093/// so a dense object is routed to a heap rebuild there instead.
13094fn apply_compact_attr_ops(
13095    region: &OhRegion,
13096    ops: &[&AttrOp],
13097) -> Result<(OhRegion, PendingVlAttrs), Error> {
13098    let mut out = region.clone();
13099    let mut pending_vl: PendingVlAttrs = Vec::new();
13100    for op in ops {
13101        match op {
13102            AttrOp::Set { name, value } => {
13103                pending_vl.retain(|a| &a.msg.name != name);
13104                if let Some(strings) = value.var_len_strings() {
13105                    // The message this replaces is dropped now and re-appended
13106                    // in the apply phase, so the creation index it carried has
13107                    // to be read before it goes: an overwrite keeps the index
13108                    // the attribute already had.
13109                    let creation_index = attr_creation_index(&out, name)?;
13110                    // Nothing yet to remove from `region` if this name has
13111                    // never been set as a fixed-size attribute.
13112                    out = remove_attr_from_region(&out, name, false)?;
13113                    let msg = build_attr_message(name, value);
13114                    if msg.serialize(LENGTH_SIZE).len() > OBJECT_HEADER_MESSAGE_MAX {
13115                        return Err(Error::EditUnsupported(
13116                            "attribute is too large to encode in place",
13117                        ));
13118                    }
13119                    pending_vl.push(PendingVlAttr {
13120                        msg,
13121                        collections: build_global_heap_collections(strings),
13122                        creation_index,
13123                    });
13124                } else {
13125                    out = set_attr_in_region(&out, name, value)?;
13126                }
13127            }
13128            AttrOp::Remove { name } => {
13129                let before = pending_vl.len();
13130                pending_vl.retain(|a| &a.msg.name != name);
13131                if pending_vl.len() == before {
13132                    out = remove_attr_from_region(&out, name, true)?;
13133                }
13134            }
13135        }
13136    }
13137    Ok((out, pending_vl))
13138}
13139
13140/// The attribute storage an edit resolves to, and the header region that carries
13141/// it. Produced by [`plan_attr_ops`] in the commit preflight, so the apply phase
13142/// only places bytes.
13143struct AttrEdits {
13144    /// The object's chunk-0 message region with the edit applied. Compact: it
13145    /// already carries every fixed-size attribute. Dense: it carries no attribute
13146    /// message at all, and the Attribute Info message naming the heap is appended
13147    /// once that heap is placed.
13148    region: OhRegion,
13149    /// What the apply phase still has to place, and where the result is stored.
13150    attrs: EditedAttrs,
13151}
13152
13153/// What an attribute edit leaves for the apply phase, in the storage the result
13154/// belongs in. The two are alternatives rather than a pair of fields, because
13155/// every object has exactly one attribute storage: a compact result's
13156/// variable-length attributes are appended to the header as messages, a dense
13157/// one's are patched into the set the heap is then built over.
13158#[derive(Clone)]
13159enum EditedAttrs {
13160    /// Variable-length attributes still to be placed and appended as messages.
13161    /// Empty for an edit that resolved entirely in the preflight, and for one
13162    /// that left the object with no attributes at all.
13163    Compact(PendingVlAttrs),
13164    /// A set to rebuild into a fresh fractal heap.
13165    Dense(DenseAttrEdit),
13166}
13167
13168impl Default for EditedAttrs {
13169    /// An object with nothing left to place: what a group carries before an
13170    /// attribute edit reaches it, and what one without attribute edits keeps.
13171    fn default() -> Self {
13172        Self::Compact(Vec::new())
13173    }
13174}
13175
13176/// A dense (fractal-heap) attribute set and what its object records about
13177/// attribute creation order.
13178///
13179/// The two travel together because a dense set is never copied as bytes: the
13180/// heap is rebuilt from the parsed attributes wherever it lands, so the creation
13181/// indexes the old heap's indexes carried have to be carried alongside or they
13182/// are lost. An object that does not track the order leaves this
13183/// [`DenseAttrCreationOrder::Untracked`], which is every set this crate's
13184/// whole-file writer produces.
13185#[derive(Clone, Default)]
13186struct DenseAttrSet {
13187    attrs: Vec<crate::attribute::AttributeMessage>,
13188    creation: DenseAttrCreationOrder,
13189}
13190
13191impl DenseAttrSet {
13192    fn is_empty(&self) -> bool {
13193        self.attrs.is_empty()
13194    }
13195}
13196
13197/// A dense (fractal-heap) attribute set an edit rebuilds, staged by
13198/// [`plan_attr_ops`] and placed by the apply phase.
13199#[derive(Clone)]
13200struct DenseAttrEdit {
13201    /// Every attribute the object will carry: the ones it already had, in the
13202    /// order it had them, with this edit's applied over them by name — and what
13203    /// the object records about their creation order.
13204    set: DenseAttrSet,
13205    /// `(index into `attrs`, its global heap collections)` for each
13206    /// variable-length attribute this edit *sets*. The heap is built over the
13207    /// attribute message bytes, so each of these has to be patched with its
13208    /// collection's real address before the build rather than after it, which is
13209    /// why they are carried to the apply phase instead of resolved here.
13210    ///
13211    /// A variable-length attribute the object already had is not in this list and
13212    /// needs no patch: it was read back carrying the addresses it is stored with,
13213    /// and they name collections in this same file.
13214    vl: Vec<(usize, Vec<Vec<u8>>)>,
13215}
13216
13217/// Apply attribute edits to an object's header `region`, choosing the storage
13218/// the result belongs in (issue #102).
13219///
13220/// `addr` is the object's pre-commit object-header address, or `None` for a
13221/// group this commit creates, which has no stored attributes to read.
13222///
13223/// Three outcomes, in the order they are tried:
13224///
13225/// - **Compact.** The region is rewritten message by message, so every attribute
13226///   the edit does not name keeps its exact bytes. An ordinary `set_attr` on an
13227///   ordinary object takes this route.
13228/// - **Dense.** The object already stores its attributes in a fractal heap, or
13229///   this edit takes it past what an object header holds compactly — more than
13230///   [`MAX_COMPACT_ATTRS`] attributes, or one whose message overflows the
13231///   header's 2-byte message-size field. The object's whole attribute set is read
13232///   back, the edits are applied to it, and the apply phase builds a fresh heap
13233///   for the result. Those are the two halves of the disjunction
13234///   `file_writer::needs_dense_attrs` selects on, so an object an edit sends to a
13235///   heap is one the whole-file writer would have written to a heap — measured
13236///   here over the ops as staged, which is why setting an oversized attribute and
13237///   removing it again in one commit still sends the object to a heap.
13238/// - **Neither.** The edit removes the object's last attribute, so the rebuilt
13239///   header carries no attribute message and no Attribute Info message — what
13240///   this crate and the reference C library both write for an object with none.
13241///
13242/// A dense rebuild is a re-encoding rather than a byte copy: the attributes are
13243/// read into messages and re-serialized, as [`WriteEngine::read_object`] already
13244/// does for a copied object's heap. Name, datatype, dataspace and value survive
13245/// it; a version 1 or 2 message becomes the version 3 one dense storage holds,
13246/// and attribute creation order — which this crate indexes for neither storage
13247/// form — does not.
13248///
13249/// An object that is dense stays dense, even where the edit takes its set back
13250/// under the threshold. The reference C library keeps one dense down to its
13251/// `min_dense` (6 by default) as well, and re-encoding a set that is only losing
13252/// members buys nothing.
13253fn plan_attr_ops<S: Source + ?Sized>(
13254    src: &S,
13255    base: BaseAddress,
13256    addr: Option<u64>,
13257    region: &OhRegion,
13258    ops: &[&AttrOp],
13259) -> Result<AttrEdits, Error> {
13260    let dense_now = region_uses_dense_attrs(region)?;
13261    if !dense_now {
13262        // An attribute past the header's message-size field has no compact form
13263        // at all, so the compact pass would refuse it rather than report a count
13264        // this could act on. Ask before running it, not after.
13265        let oversized = ops.iter().any(|op| match op {
13266            AttrOp::Set { name, value } => {
13267                build_attr_message(name, value).serialize(LENGTH_SIZE).len()
13268                    > OBJECT_HEADER_MESSAGE_MAX
13269            }
13270            AttrOp::Remove { .. } => false,
13271        });
13272        if !oversized {
13273            let (out, pending_vl) = apply_compact_attr_ops(region, ops)?;
13274            // An edit that only *removes* stays compact whatever the count is.
13275            // A region already holding more attributes than this writer would
13276            // emit came from another writer — the C library's `max_compact` is a
13277            // property list setting — and taking one away is no reason to
13278            // re-encode the ones that remain.
13279            let only_removes = !ops.iter().any(|op| matches!(op, AttrOp::Set { .. }));
13280            if only_removes || compact_attr_count(&out)? + pending_vl.len() <= MAX_COMPACT_ATTRS {
13281                return Ok(AttrEdits {
13282                    region: out,
13283                    attrs: EditedAttrs::Compact(pending_vl),
13284                });
13285            }
13286        }
13287    }
13288
13289    // The compact pass refuses a shared attribute message as it walks the
13290    // messages it copies; this path never walks them, so it asks here — before
13291    // the read below resolves one into a message indistinguishable from a private
13292    // attribute.
13293    if region_has_shared_attr(region)? {
13294        return Err(Error::EditUnsupported(SHARED_ATTRIBUTE_MESSAGE));
13295    }
13296
13297    // A heap is rebuilt from the object's whole attribute set — half of which may
13298    // live in a heap the header only names — so read that set back before
13299    // applying the edits to it. A group this commit creates has none.
13300    let existing = match addr {
13301        Some(addr) => read_object_attrs(src, addr, base)?,
13302        None => Vec::new(),
13303    };
13304    // Each attribute travels with the global heap collections it still needs
13305    // placed (`Some` only for a variable-length attribute this edit sets) and
13306    // with the creation index its object records for it, so no removal can shift
13307    // one away from the bytes — or the index — it belongs to.
13308    let existing = dense_attr_set(region, existing)?;
13309    let tracked = region.layout().tracks_creation_order();
13310    // The next index the object would hand out: what a *new* attribute takes.
13311    // `dense_attr_set` has already raised it past every index in use.
13312    let mut next_index = match &existing.creation {
13313        DenseAttrCreationOrder::Tracked { max, .. } => *max,
13314        DenseAttrCreationOrder::Untracked => 0,
13315    };
13316    let mut set: Vec<DenseAttrSlot> = existing
13317        .attrs
13318        .into_iter()
13319        .enumerate()
13320        .map(|(i, msg)| DenseAttrSlot {
13321            msg,
13322            collections: None,
13323            creation_index: existing.creation.index_at(i),
13324        })
13325        .collect();
13326    for op in ops {
13327        match op {
13328            AttrOp::Set { name, value } => {
13329                let msg = build_attr_message(name, value);
13330                let collections = value.var_len_strings().map(build_global_heap_collections);
13331                match set.iter_mut().find(|slot| &slot.msg.name == name) {
13332                    // Setting an attribute the object already has replaces it
13333                    // where it stands, so a repeated `set_attr` does not reorder
13334                    // the set — and it keeps the creation index it had, so an
13335                    // iteration by creation order does not reorder it either.
13336                    Some(slot) => {
13337                        slot.msg = msg;
13338                        slot.collections = collections;
13339                    }
13340                    None => {
13341                        let creation_index = tracked.then_some(next_index);
13342                        if tracked {
13343                            next_index = bump_creation_index(next_index)?;
13344                        }
13345                        set.push(DenseAttrSlot {
13346                            msg,
13347                            collections,
13348                            creation_index,
13349                        });
13350                    }
13351                }
13352            }
13353            AttrOp::Remove { name } => {
13354                let before = set.len();
13355                // A deletion leaves a gap in the creation order rather than
13356                // renumbering what is left, and does not lower the maximum:
13357                // the reference C library hands out indexes from a counter that
13358                // only ever rises.
13359                set.retain(|slot| &slot.msg.name != name);
13360                if set.len() == before {
13361                    return Err(Error::EditUnsupported("attribute to remove was not found"));
13362                }
13363            }
13364        }
13365    }
13366
13367    // Every attribute message is rebuilt from the set above, so the region keeps
13368    // none of them, nor the Attribute Info message naming the storage they were
13369    // in.
13370    let region = strip_attr_messages(region)?;
13371    // No attributes left: the header carries neither an Attribute message nor an
13372    // Attribute Info one. `append_dense_attrs` would decline to build a heap for
13373    // an empty set anyway; saying `None` here is what makes that a property of
13374    // this decision rather than of the emitter that carries it out.
13375    if set.is_empty() {
13376        return Ok(AttrEdits {
13377            region,
13378            attrs: EditedAttrs::default(),
13379        });
13380    }
13381    let mut attrs = Vec::with_capacity(set.len());
13382    let mut indices = Vec::with_capacity(set.len());
13383    let mut vl = Vec::new();
13384    for (i, slot) in set.into_iter().enumerate() {
13385        if let Some(collections) = slot.collections {
13386            vl.push((i, collections));
13387        }
13388        // Every slot of a tracked object carries an index: one read from its
13389        // storage, or one this edit just handed out. `indices` is discarded for
13390        // an untracked object, where none of them do.
13391        debug_assert!(
13392            !tracked || slot.creation_index.is_some(),
13393            "a tracked object's attribute must carry a creation index"
13394        );
13395        indices.push(slot.creation_index.unwrap_or_default());
13396        attrs.push(slot.msg);
13397    }
13398    let creation = if tracked {
13399        DenseAttrCreationOrder::Tracked {
13400            indices,
13401            max: next_index,
13402            indexed: region.layout().indexes_creation_order(),
13403        }
13404    } else {
13405        DenseAttrCreationOrder::Untracked
13406    };
13407    // Moving a set into a heap takes it out of reach of the reference repointing
13408    // a commit does as its last act: `reference_patch::scan_object` reads an
13409    // object whose attributes are dense as unproven and collects no edit from it.
13410    // An object *already* dense is not this refusal's business — its attributes
13411    // stood outside that walk before this edit and still do — but converting one
13412    // would strand a reference the header was keeping reachable, and permanently,
13413    // since an object that goes dense stays dense (issue #324).
13414    if !dense_now {
13415        for attr in &attrs {
13416            if crate::reference_patch::attribute_references_are_repointable(&attr.datatype) {
13417                return Err(Error::EditUnsupported(
13418                    REFERENCE_ATTRIBUTE_WOULD_LEAVE_THE_HEADER,
13419                ));
13420            }
13421        }
13422    }
13423    // What the heap itself cannot represent is the last refusal, and it belongs
13424    // in the preflight: past here the commit is placing bytes.
13425    crate::file_writer::dense_attrs_check(&attrs).map_err(Error::Format)?;
13426    Ok(AttrEdits {
13427        region,
13428        attrs: EditedAttrs::Dense(DenseAttrEdit {
13429            set: DenseAttrSet { attrs, creation },
13430            vl,
13431        }),
13432    })
13433}
13434
13435/// One attribute of the set a dense rebuild is assembled from.
13436struct DenseAttrSlot {
13437    msg: crate::attribute::AttributeMessage,
13438    /// Global heap collections still to be placed, for a variable-length
13439    /// attribute *this edit* sets.
13440    collections: Option<Vec<Vec<u8>>>,
13441    /// The creation index the object records for it, where it tracks the order.
13442    creation_index: Option<u16>,
13443}
13444
13445/// Whether an object's chunk-0 message `region` stores its attributes densely —
13446/// an Attribute Info message naming a fractal heap. See
13447/// [`attribute_info_is_dense`] for why the message's mere presence is not that.
13448fn region_uses_dense_attrs(region: &OhRegion) -> Result<bool, Error> {
13449    let mut p = 0;
13450    while let Some((msg_type, body, body_end)) = region.next_message(p)? {
13451        if msg_type == MessageType::AttributeInfo
13452            && attribute_info_is_dense(&region[body..body_end])
13453        {
13454            return Ok(true);
13455        }
13456        p = body_end;
13457    }
13458    Ok(false)
13459}
13460
13461/// Whether any Attribute message in `region` carries a message flag — which for
13462/// an attribute means the body is a shared (SOHM) record rather than the
13463/// attribute itself. Reads the same byte [`parse_compact_attr_name`] does, so the
13464/// two attribute paths refuse exactly the same messages.
13465fn region_has_shared_attr(region: &OhRegion) -> Result<bool, Error> {
13466    let mut p = 0;
13467    while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
13468        if msg_type == MessageType::Attribute && region[p + 3] != 0 {
13469            return Ok(true);
13470        }
13471        p = body_end;
13472    }
13473    Ok(false)
13474}
13475
13476/// Copy a chunk-0 message `region`, dropping every Attribute message and the
13477/// Attribute Info message that names their storage. What the object carries in
13478/// their place is [`plan_attr_ops`]'s decision, appended by the apply phase.
13479fn strip_attr_messages(region: &OhRegion) -> Result<OhRegion, Error> {
13480    let mut out = Vec::with_capacity(region.len());
13481    let mut p = 0;
13482    while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
13483        if !matches!(
13484            msg_type,
13485            MessageType::Attribute | MessageType::AttributeInfo
13486        ) {
13487            out.extend_from_slice(&region[p..body_end]);
13488        }
13489        p = body_end;
13490    }
13491    if p < region.len() {
13492        out.extend_from_slice(&region[p..]);
13493    }
13494    Ok(region.with_bytes(out))
13495}
13496
13497/// Every attribute an object carries, compact or dense, parsed into messages.
13498///
13499/// This read is what makes a dense edit possible at all: the set an edit works
13500/// from is the object's whole attribute set, and for a dense object none of it
13501/// is in the header — `extract_attributes_full_from_source` walks the fractal
13502/// heap the Attribute Info message names. It reads both storage forms, so no
13503/// caller has to ask which one an object uses.
13504///
13505/// Shared with [`WriteEngine::read_object`], which needs the same set for the
13506/// same reason when it copies a dense object. That caller checks
13507/// [`crate::file_writer::dense_attrs_check`] on what it reads; the attribute
13508/// editor checks it after applying its edits, on the set it will actually write.
13509fn read_object_attrs<S: Source + ?Sized>(
13510    src: &S,
13511    addr: u64,
13512    base: BaseAddress,
13513) -> Result<Vec<crate::attribute::StoredAttribute>, Error> {
13514    let header = ObjectHeader::parse_from_source(src, addr, OFFSET_SIZE, LENGTH_SIZE, base)
13515        .map_err(|_| Error::EditUnsupported("an object header could not be parsed"))?;
13516    if base.get() > src.len() {
13517        return Err(Error::EditUnsupported(
13518            "this file's userblock is larger than the file itself",
13519        ));
13520    }
13521    // Heap addresses in the Attribute Info message are stored relative to the
13522    // base address, so the walk gets the file framed past its userblock — the
13523    // same view the reader uses. `base` is 0 for a plain file, where this is
13524    // `src` itself.
13525    let framed = BaseOffsetSource { inner: src, base };
13526    crate::attribute::extract_stored_attributes_from_source(
13527        &framed,
13528        &header,
13529        OFFSET_SIZE,
13530        LENGTH_SIZE,
13531        // No shared-message table: this engine refuses an object carrying a
13532        // shared attribute message before it gets here ([`SHARED_ATTRIBUTE_MESSAGE`]),
13533        // and resolving one silently is exactly what that refusal prevents — the
13534        // resolved copy is indistinguishable from a private attribute, and would
13535        // be re-emitted as one, leaving the file's reference count naming an
13536        // attribute that no longer exists.
13537        None,
13538    )
13539    .map_err(|_| {
13540        Error::EditUnsupported("an object's dense (fractal-heap) attributes could not be read")
13541    })
13542}
13543
13544/// Assemble a [`DenseAttrSet`] from an object's stored attributes and what its
13545/// header `region` says about attribute creation order.
13546///
13547/// An object that does not track the order gets
13548/// [`DenseAttrCreationOrder::Untracked`], which is the whole story for every
13549/// file this crate writes itself. One that does keeps each attribute's stored
13550/// index, and the maximum its Attribute Info message records — the next index it
13551/// would hand out, which a deletion does not lower, so it cannot be recomputed
13552/// from the attributes that remain. An attribute whose storage recorded no index
13553/// (a header that claims to track the order and then does not) is given the next
13554/// unused one rather than silently colliding with a real index.
13555fn dense_attr_set(
13556    region: &OhRegion,
13557    stored: Vec<crate::attribute::StoredAttribute>,
13558) -> Result<DenseAttrSet, Error> {
13559    let layout = region.layout();
13560    if !layout.tracks_creation_order() {
13561        return Ok(DenseAttrSet {
13562            attrs: stored.into_iter().map(|a| a.message).collect(),
13563            creation: DenseAttrCreationOrder::Untracked,
13564        });
13565    }
13566    let mut next = next_attr_creation_index(region)?;
13567    let mut attrs = Vec::with_capacity(stored.len());
13568    let mut indices = Vec::with_capacity(stored.len());
13569    for attr in stored {
13570        let index = match attr.creation_index {
13571            Some(index) => index,
13572            None => {
13573                let index = next;
13574                next = bump_creation_index(next)?;
13575                index
13576            }
13577        };
13578        next = next.max(bump_creation_index(index)?);
13579        attrs.push(attr.message);
13580        indices.push(index);
13581    }
13582    Ok(DenseAttrSet {
13583        attrs,
13584        creation: DenseAttrCreationOrder::Tracked {
13585            indices,
13586            max: next,
13587            indexed: layout.indexes_creation_order(),
13588        },
13589    })
13590}
13591
13592/// One past `index`, refusing the object that has exhausted the 2-byte creation
13593/// index its Attribute Info message records.
13594fn bump_creation_index(index: u16) -> Result<u16, Error> {
13595    index.checked_add(1).ok_or(Error::EditUnsupported(
13596        "an object has assigned every attribute creation index its header can record",
13597    ))
13598}
13599
13600/// Whether an Attribute Info (0x0015) message body denotes *dense* (fractal-heap)
13601/// attribute storage — a *defined* heap address. The reference C library and h5py
13602/// emit an Attribute Info message with an *undefined* heap address even for
13603/// compact, inline attributes in the latest format (to carry creation-order
13604/// metadata), so its mere presence is not dense storage; only a defined heap
13605/// address is. An unparseable message is treated as dense (refused conservatively).
13606/// Mirrors the copy path's dense detection so the compact-attribute editors accept
13607/// the undefined-address message that nearly every real-world object carries.
13608pub(crate) fn attribute_info_is_dense(body: &[u8]) -> bool {
13609    match crate::attribute_info::AttributeInfoMessage::parse(body, OFFSET_SIZE) {
13610        Ok(ai) => ai.fractal_heap_address.is_some(),
13611        Err(_) => true,
13612    }
13613}
13614
13615/// Copy a message region, dropping all Attribute messages named `name` and then
13616/// appending a fresh compact Attribute message for `value`.
13617fn set_attr_in_region(region: &OhRegion, name: &str, value: &AttrValue) -> Result<OhRegion, Error> {
13618    let body = encode_attr_body(name, value)?;
13619    let keep = attr_creation_index(region, name)?;
13620    put_attr_message(region, name, &body, keep)
13621}
13622
13623/// The creation index the Attribute message named `name` carries, or `None`
13624/// where the object has no such attribute — or does not track the order at all.
13625fn attr_creation_index(region: &OhRegion, name: &str) -> Result<Option<u16>, Error> {
13626    if !region.layout().tracks_creation_order() {
13627        return Ok(None);
13628    }
13629    let mut p = 0;
13630    while let Some((msg_type, body, body_end)) = region.next_message(p)? {
13631        if msg_type == MessageType::Attribute
13632            && parse_compact_attr_name(region, p, body, body_end)? == name
13633        {
13634            return Ok(region.creation_index(p));
13635        }
13636        p = body_end;
13637    }
13638    Ok(None)
13639}
13640
13641/// The highest creation index any Attribute record in `region` carries.
13642fn highest_attr_creation_index(region: &OhRegion) -> Result<Option<u16>, Error> {
13643    let mut highest = None;
13644    let mut p = 0;
13645    while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
13646        if msg_type == MessageType::Attribute {
13647            highest = highest.max(region.creation_index(p));
13648        }
13649        p = body_end;
13650    }
13651    Ok(highest)
13652}
13653
13654/// The next unused attribute creation index for an object whose messages are
13655/// `region`.
13656///
13657/// The reference C library hands out `ainfo.max_crt_idx` and increments it
13658/// (`H5O__attr_create`), so the recorded maximum is the *next* index rather
13659/// than the highest in use, and deleting an attribute never lowers it. Read it
13660/// from the Attribute Info message where there is one, and otherwise derive one
13661/// past the highest index the records carry — the best evidence a header with no
13662/// such message leaves.
13663fn next_attr_creation_index(region: &OhRegion) -> Result<u16, Error> {
13664    let recorded = find_attribute_info(region)?
13665        .and_then(|(_, _, info)| info.max_creation_index)
13666        .unwrap_or(0);
13667    let derived = highest_attr_creation_index(region)?.map_or(0, |i| u32::from(i) + 1);
13668    let next = u32::from(recorded).max(derived);
13669    u16::try_from(next).map_err(|_| {
13670        Error::EditUnsupported(
13671            "an object has assigned every attribute creation index its header can record",
13672        )
13673    })
13674}
13675
13676/// The Attribute Info message in `region`, as `(record start, body range, parsed
13677/// message)`. An unparseable message is reported as absent, which leaves it
13678/// untouched: `attribute_info_is_dense` reads the same message and refuses the
13679/// object rather than letting an edit rewrite one it did not understand.
13680fn find_attribute_info(
13681    region: &OhRegion,
13682) -> Result<Option<(usize, core::ops::Range<usize>, AttributeInfoMessage)>, Error> {
13683    let mut p = 0;
13684    while let Some((msg_type, body, body_end)) = region.next_message(p)? {
13685        if msg_type == MessageType::AttributeInfo
13686            && let Ok(info) = AttributeInfoMessage::parse(&region[body..body_end], OFFSET_SIZE)
13687        {
13688            return Ok(Some((p, body..body_end, info)));
13689        }
13690        p = body_end;
13691    }
13692    Ok(None)
13693}
13694
13695/// The Attribute Info message body an object storing its attributes compactly
13696/// needs, in `region`'s layout.
13697///
13698/// A header that tracks attribute creation order records the maximum index ever
13699/// assigned here, and nowhere else, so a header carrying no such message leaves
13700/// only the records to derive it from: one past the highest index in use. That
13701/// under-counts an object whose most recently created attributes have since been
13702/// deleted, which is why an edit *patches* an existing message
13703/// ([`put_attr_message`]) rather than rebuilding one.
13704fn compact_attribute_info_body(region: &OhRegion) -> Result<Vec<u8>, Error> {
13705    let layout = region.layout();
13706    let info = AttributeInfoMessage {
13707        max_creation_index: layout
13708            .tracks_creation_order()
13709            .then(|| next_attr_creation_index(region))
13710            .transpose()?,
13711        indexes_creation_order: layout.indexes_creation_order(),
13712        fractal_heap_address: None,
13713        btree_name_index_address: None,
13714        btree_creation_order_address: None,
13715    };
13716    Ok(info.serialize(OFFSET_SIZE))
13717}
13718
13719/// Copy a message region, dropping every Attribute message named `name` and
13720/// appending one carrying `body`, then keep the object's Attribute Info message
13721/// in step with the creation index the new record takes.
13722///
13723/// `keep` is the index an *overwrite* preserves: the reference C library modifies
13724/// an attribute in place, so writing over one does not move it in an iteration
13725/// by creation order. `None` asks for the object's next unused index, which then
13726/// becomes the maximum the Attribute Info message records. Both are inert on a
13727/// header that does not track the order, where no record has the field.
13728fn put_attr_message(
13729    region: &OhRegion,
13730    name: &str,
13731    body: &[u8],
13732    keep: Option<u16>,
13733) -> Result<OhRegion, Error> {
13734    let layout = region.layout();
13735    let (creation_index, new_max) = if layout.tracks_creation_order() {
13736        match keep {
13737            Some(index) => (index, None),
13738            None => {
13739                let index = next_attr_creation_index(region)?;
13740                let next = index.checked_add(1).ok_or(Error::EditUnsupported(
13741                    "an object has assigned every attribute creation index its header can record",
13742                ))?;
13743                (index, Some(next))
13744            }
13745        }
13746    } else {
13747        (0, None)
13748    };
13749    let new_msg = layout.record_with_creation_index(MessageType::Attribute, body, creation_index);
13750
13751    let mut out = Vec::with_capacity(region.len() + new_msg.len());
13752    let mut p = 0;
13753    while let Some((msg_type, msg_body, body_end)) = region.next_message(p)? {
13754        match msg_type {
13755            MessageType::Attribute
13756                if parse_compact_attr_name(region, p, msg_body, body_end)? == name =>
13757            {
13758                p = body_end;
13759                continue;
13760            }
13761            // Bumping the recorded maximum re-encodes the message, since a
13762            // header that was not recording one at all needs the field added.
13763            MessageType::AttributeInfo if new_max.is_some() => {
13764                if let Ok(mut info) =
13765                    AttributeInfoMessage::parse(&region[msg_body..body_end], OFFSET_SIZE)
13766                {
13767                    info.max_creation_index = new_max;
13768                    info.indexes_creation_order |= layout.indexes_creation_order();
13769                    let encoded = info.serialize(OFFSET_SIZE);
13770                    let len = u16::try_from(encoded.len()).map_err(|_| {
13771                        Error::EditUnsupported("an Attribute Info message is too large to record")
13772                    })?;
13773                    // Only the body and its length change: the record keeps its
13774                    // own flags — the reference C library marks this message
13775                    // "don't share" — and its creation index.
13776                    out.push(region[p]);
13777                    out.extend_from_slice(&len.to_le_bytes());
13778                    out.extend_from_slice(&region[p + 3..msg_body]);
13779                    out.extend_from_slice(&encoded);
13780                    p = body_end;
13781                    continue;
13782                }
13783                out.extend_from_slice(&region[p..body_end]);
13784            }
13785            _ => out.extend_from_slice(&region[p..body_end]),
13786        }
13787        p = body_end;
13788    }
13789    out.extend_from_slice(&new_msg);
13790    if p < region.len() {
13791        out.extend_from_slice(&region[p..]);
13792    }
13793    let mut out = region.with_bytes(out);
13794    // An object that had no Attribute Info message to bump gets the one
13795    // `build_v2_object_header` would add anyway, now, so the maximum this edit
13796    // just assigned is recorded rather than re-derived from the records.
13797    if new_max.is_some() && find_attribute_info(&out)?.is_none() {
13798        let body = compact_attribute_info_body(&out)?;
13799        out.push(MessageType::AttributeInfo, &body);
13800    }
13801    Ok(out)
13802}
13803
13804/// Copy a message region, dropping all Attribute messages named `name`. When
13805/// `required` is true, an absent `name` is an [`Error::EditUnsupported`] (a
13806/// `Remove` of a nonexistent attribute); when false, it is not an error (a
13807/// `Set` of a fresh variable-length attribute may have no fixed-size message
13808/// to remove from the region yet).
13809fn remove_attr_from_region(
13810    region: &OhRegion,
13811    name: &str,
13812    required: bool,
13813) -> Result<OhRegion, Error> {
13814    let mut out = Vec::with_capacity(region.len());
13815    let mut p = 0;
13816    let mut removed = false;
13817    while let Some((msg_type, body, body_end)) = region.next_message(p)? {
13818        let mut skip = false;
13819        if msg_type == MessageType::Attribute {
13820            let attr_name = parse_compact_attr_name(region, p, body, body_end)?;
13821            if attr_name == name {
13822                skip = true;
13823                removed = true;
13824            }
13825        }
13826        if !skip {
13827            out.extend_from_slice(&region[p..body_end]);
13828        }
13829        p = body_end;
13830    }
13831    if p < region.len() {
13832        out.extend_from_slice(&region[p..]);
13833    }
13834    if !removed && required {
13835        return Err(Error::EditUnsupported("attribute to remove was not found"));
13836    }
13837    Ok(region.with_bytes(out))
13838}
13839
13840/// How many attributes an object's message `region` carries inline. Only a
13841/// compact region is ever counted this way: a dense object carries no inline
13842/// Attribute message at all, and would count zero — which is why
13843/// [`plan_attr_ops`] decides an object's storage before it reaches here.
13844fn compact_attr_count(region: &OhRegion) -> Result<usize, Error> {
13845    let mut count = 0usize;
13846    let mut p = 0;
13847    while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
13848        if msg_type == MessageType::Attribute {
13849            count += 1;
13850        }
13851        p = body_end;
13852    }
13853    Ok(count)
13854}
13855
13856fn parse_compact_attr_name(
13857    region: &[u8],
13858    msg_start: usize,
13859    body: usize,
13860    body_end: usize,
13861) -> Result<String, Error> {
13862    if region[msg_start + 3] != 0 {
13863        return Err(Error::EditUnsupported(SHARED_ATTRIBUTE_MESSAGE));
13864    }
13865    // Only the name is wanted here, and it is the one field that never depends on
13866    // the datatype or dataspace — either of which may be a reference to a
13867    // committed message this walk has no file context to follow. Reading the name
13868    // alone lets an edit pass over such an attribute instead of refusing the
13869    // whole object because one of its neighbours is committed.
13870    crate::attribute::message_name(&region[body..body_end])
13871        .map_err(|_| Error::EditUnsupported("a target object has an unreadable attribute message"))
13872}
13873
13874fn encode_attr_body(name: &str, value: &AttrValue) -> Result<Vec<u8>, Error> {
13875    // `apply_compact_attr_ops`'s `Set` branch — this function's only caller —
13876    // handles a value that needs the global heap itself (staging it into
13877    // `pending_vl` instead of calling `set_attr_in_region`/here), so this value
13878    // is always inline by construction, not by a check made at this call site.
13879    debug_assert!(
13880        value.var_len_strings().is_none(),
13881        "a variable-length attribute must be intercepted by apply_compact_attr_ops before reaching encode_attr_message"
13882    );
13883    let body = build_attr_message(name, value).serialize(LENGTH_SIZE);
13884    if body.len() > OBJECT_HEADER_MESSAGE_MAX {
13885        return Err(Error::EditUnsupported(
13886            "group attribute is too large to encode in place",
13887        ));
13888    }
13889    Ok(body)
13890}
13891
13892/// Whether `a` is a path prefix of (or equal to) `b`.
13893fn is_prefix(a: &[String], b: &[String]) -> bool {
13894    a.len() <= b.len() && b[..a.len()] == *a
13895}
13896
13897/// Parse the version-2 object-header message record at `p` within a chunk-0
13898/// message region, returning `(message type, body start, body end)`; the next
13899/// record begins at `body end`. Returns `Ok(None)` once fewer than 4 bytes
13900/// remain (a clean end of the region), and `Err` if a record's declared body
13901/// runs past the region. Centralizes the bounds check shared by every walker.
13902/// Rebuild a superblock-extension object header's message region (as collapsed by
13903/// [`WriteEngine::gather_oh_messages`]) with
13904/// its File Space Info message replaced by `info`, preserving every other message
13905/// verbatim. The persisting message is fixed-size, so the region length is stable.
13906/// Shared by the whole-file mirror commit and the bounded finalize so both write
13907/// the same extension bytes.
13908pub(crate) fn rewrite_extension_region_bytes(
13909    region: &OhRegion,
13910    info: &FileSpaceInfo,
13911) -> Result<OhRegion, Error> {
13912    let new_body = info.serialize();
13913    // The message body is the fixed-size File Space Info record (≤ 125 bytes),
13914    // so it always fits the u16 size field; `try_from` keeps this off the
13915    // 32-bit narrowing-cast ledger.
13916    let new_len = u16::try_from(new_body.len())
13917        .map_err(|_| Error::EditUnsupported("File Space Info message too large"))?;
13918    let mut out = Vec::with_capacity(region.len());
13919    let mut p = 0;
13920    let mut replaced = false;
13921    while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
13922        if msg_type == MessageType::FileSpaceInfo {
13923            out.push(region[p]); // message type byte
13924            out.extend_from_slice(&new_len.to_le_bytes());
13925            // Preserve the message flags (0x14) and, where the header carries
13926            // one, the record's creation index.
13927            out.extend_from_slice(&region[p + 3..p + region.layout().prefix_len()]);
13928            out.extend_from_slice(&new_body);
13929            replaced = true;
13930        } else {
13931            out.extend_from_slice(&region[p..body_end]);
13932        }
13933        p = body_end;
13934    }
13935    if !replaced {
13936        // Persistence is armed only when the extension already carries a File
13937        // Space Info message, so this is unreachable; refuse rather than
13938        // silently restructure an extension we did not understand.
13939        return Err(Error::EditUnsupported(
13940            "a persisting file's superblock extension has no File Space Info message",
13941        ));
13942    }
13943    Ok(region.with_bytes(out))
13944}
13945
13946/// Parse and validate a version 2 object header's prefix, returning the absolute
13947/// `[start, end)` byte range of its chunk-0 message region.
13948///
13949/// `prefix` holds the bytes at `[addr, addr + prefix.len())` — up to
13950/// [`OH_PREFIX_MAX`], fewer when the header sits near the end of the image — and
13951/// `file_len` is the length of the image the header lives in, which bounds the
13952/// region. Rejects headers that are not OHDR v2. Everything the prefix declares
13953/// ([`OhHeaderProps`]) is returned with the region: the record layout, because a
13954/// header that tracks attribute creation order carries 6-byte message records
13955/// and every walk of this region has to step by that width, and the two optional
13956/// blocks, because nothing below the prefix records them and a rebuild has to
13957/// put them back.
13958fn oh_region_at(
13959    prefix: &[u8],
13960    addr: u64,
13961    file_len: u64,
13962) -> Result<(u64, u64, OhHeaderProps), Error> {
13963    if prefix.len() < 6 || &prefix[..4] != b"OHDR" || prefix[4] != 2 {
13964        return Err(Error::EditUnsupported(
13965            "an object does not use a version 2 object header",
13966        ));
13967    }
13968    let flags = prefix[5];
13969    let layout = OhRecordLayout::from_header_flags(flags);
13970    let mut pos = 6usize;
13971    let mut take = |len: usize| -> Result<usize, Error> {
13972        let at = pos;
13973        pos += len;
13974        if prefix.len() < pos {
13975            return Err(Error::EditUnsupported("truncated object header"));
13976        }
13977        Ok(at)
13978    };
13979    let times = if flags & OH_FLAG_STORE_TIMES != 0 {
13980        let at = take(ObjectTimes::LEN)?;
13981        Some(ObjectTimes::parse(prefix, at))
13982    } else {
13983        None
13984    };
13985    let attr_phase_change = if flags & OH_FLAG_ATTR_PHASE_CHANGE != 0 {
13986        let at = take(AttrPhaseChange::LEN)?;
13987        Some(AttrPhaseChange::parse(prefix, at))
13988    } else {
13989        None
13990    };
13991    let props = OhHeaderProps {
13992        layout,
13993        times,
13994        attr_phase_change,
13995    };
13996    let size_width = match flags & 0x03 {
13997        0 => 1usize,
13998        1 => 2,
13999        2 => 4,
14000        _ => 8,
14001    };
14002    if prefix.len() < pos + size_width {
14003        return Err(Error::EditUnsupported("truncated object header"));
14004    }
14005    let chunk0_size = read_le(&prefix[pos..pos + size_width]) as u64;
14006    pos += size_width;
14007    let region_start = addr
14008        .checked_add(pos as u64)
14009        .ok_or(Error::EditUnsupported("truncated object header"))?;
14010    // The region is followed by a 4-byte checksum, which must also be present.
14011    let region_end = region_start
14012        .checked_add(chunk0_size)
14013        .filter(|e| e.checked_add(4).is_some_and(|end| end <= file_len))
14014        .ok_or(Error::EditUnsupported("truncated object header"))?;
14015    Ok((region_start, region_end, props))
14016}
14017
14018/// One chunk of a version 2 object header, read out of a file image.
14019///
14020/// The buffer stops at the end of the chunk's message region: the trailing
14021/// checksum is never walked, and it has already been confirmed present. `span`
14022/// covers the *whole* on-disk chunk including that checksum, so it can be handed
14023/// to the free list when the header is reclaimed.
14024pub(crate) struct OhChunk {
14025    /// Absolute file address and full on-disk length of the chunk.
14026    pub(crate) span: (u64, u64),
14027    /// The chunk's bytes, from `span.0` through the end of its message region.
14028    buf: Vec<u8>,
14029    /// Offset of the first message within [`buf`](Self::buf).
14030    messages_start: usize,
14031    /// What chunk 0 of this header declared in its prefix, which every chunk of
14032    /// the header shares.
14033    props: OhHeaderProps,
14034}
14035
14036impl OhChunk {
14037    /// The slice to walk messages in, and the offset to start at. The two are
14038    /// returned together because [`OhRecordLayout::next_message`] must not read
14039    /// past the end of the message region into the checksum.
14040    pub(crate) fn message_region(&self) -> (&[u8], usize) {
14041        (&self.buf, self.messages_start)
14042    }
14043
14044    /// The record layout to walk [`message_region`](Self::message_region) in.
14045    pub(crate) fn layout(&self) -> OhRecordLayout {
14046        self.props.layout
14047    }
14048
14049    /// Everything this header's chunk-0 prefix declared.
14050    pub(crate) fn props(&self) -> OhHeaderProps {
14051        self.props
14052    }
14053}
14054
14055/// Read chunk 0 of the version 2 object header at `addr` out of `src`.
14056fn read_oh_chunk0<S: Source + ?Sized>(src: &S, addr: u64) -> Result<OhChunk, Error> {
14057    let file_len = src.len();
14058    let window = file_len
14059        .saturating_sub(addr)
14060        .min(OH_PREFIX_MAX as u64)
14061        .to_usize()?;
14062    let prefix = src.read_metadata_at(addr, window)?;
14063    let (rs, re, props) = oh_region_at(&prefix, addr, file_len)?;
14064    // `re >= rs > addr`, so both differences are non-negative, and `oh_region_at`
14065    // has checked that the 4-byte checksum past `re` is present.
14066    let len = (re - addr).to_usize()?;
14067    Ok(OhChunk {
14068        span: (addr, len as u64 + 4),
14069        buf: src.read_metadata_at(addr, len)?,
14070        messages_start: (rs - addr).to_usize()?,
14071        props,
14072    })
14073}
14074
14075/// Read every chunk of the version 2 object header at `addr`, chunk 0 first,
14076/// following each `Continuation` message to its `OCHK` block.
14077///
14078/// This is the one traversal of a header's chunk chain: [`gather_oh_messages`]
14079/// collects the messages out of the result and
14080/// [`oh_chunk_spans`](WriteEngine::oh_chunk_spans) collects the extents, so the
14081/// two cannot disagree about what a header occupies.
14082pub(crate) fn read_oh_chunks<S: Source + ?Sized>(
14083    src: &S,
14084    addr: u64,
14085    base: BaseAddress,
14086) -> Result<Vec<OhChunk>, Error> {
14087    let mut chunks = vec![read_oh_chunk0(src, addr)?];
14088    let mut i = 0;
14089    while i < chunks.len() {
14090        if chunks.len() > MAX_OH_CHUNKS {
14091            return Err(Error::EditUnsupported(
14092                "object header has too many continuation chunks",
14093            ));
14094        }
14095        // Collect this chunk's continuations before extending the worklist, so the
14096        // borrow of `chunks[i]` ends first.
14097        let mut found = Vec::new();
14098        let props = chunks[i].props();
14099        let layout = props.layout;
14100        let (region, mut p) = chunks[i].message_region();
14101        while let Some((msg_type, body, body_end)) = layout.next_message(region, p)? {
14102            if msg_type == MessageType::ObjectHeaderContinuation {
14103                found.push(read_oh_continuation(
14104                    src, region, body, body_end, base, props,
14105                )?);
14106            }
14107            p = body_end;
14108        }
14109        i += 1;
14110        chunks.extend(found);
14111    }
14112    Ok(chunks)
14113}
14114
14115/// Read the `OCHK` continuation block a continuation message points at.
14116///
14117/// `region[body..body_end]` is the continuation message's body: the block's
14118/// base-relative address followed by its length.
14119fn read_oh_continuation<S: Source + ?Sized>(
14120    src: &S,
14121    region: &[u8],
14122    body: usize,
14123    body_end: usize,
14124    base: BaseAddress,
14125    props: OhHeaderProps,
14126) -> Result<OhChunk, Error> {
14127    if body_end - body < (OFFSET_SIZE + LENGTH_SIZE) as usize {
14128        return Err(Error::EditUnsupported("malformed continuation message"));
14129    }
14130    let off = u64::from_le_bytes(region[body..body + 8].try_into().unwrap());
14131    let len = u64::from_le_bytes(region[body + 8..body + 16].try_into().unwrap());
14132    // The block address is stored relative to the base address; shift it to an
14133    // absolute file offset before reading.
14134    let off = base
14135        .absolute(off)
14136        .map_err(|_| Error::EditUnsupported("continuation address overflow"))?;
14137    // An OCHK block is signature(4) + messages + checksum(4).
14138    let end = off
14139        .checked_add(len)
14140        .filter(|&e| e <= src.len() && len >= 8)
14141        .ok_or(Error::EditUnsupported("continuation block out of bounds"))?;
14142    let want = (end - off)
14143        .to_usize()
14144        .map_err(|_| Error::EditUnsupported("continuation length exceeds this platform"))?;
14145    let mut buf = src.read_metadata_at(off, want)?;
14146    if buf[..4] != *b"OCHK" {
14147        return Err(Error::EditUnsupported(
14148            "invalid continuation block signature",
14149        ));
14150    }
14151    // Trim the trailing checksum so the message walk stops at the last message.
14152    buf.truncate(want - 4);
14153    Ok(OhChunk {
14154        span: (off, len),
14155        buf,
14156        messages_start: 4,
14157        props,
14158    })
14159}
14160
14161/// How a version 2 object header's message records are laid out, and what its
14162/// flags say about attribute creation order.
14163///
14164/// Every record opens with a type byte, a 2-byte body size and a flags byte. A
14165/// header that *tracks* attribute creation order — bit 2 of the object header's
14166/// own flags, what `H5Pset_attr_creation_order` and h5py's `track_order=True`
14167/// turn on, and what netCDF-4 sets on every object it writes — follows those
14168/// with a 2-byte creation index, so its records are 6 bytes wide rather than 4.
14169/// A header that also *indexes* that order (bit 3) carries a creation-order
14170/// B-tree beside the name index once its attributes go dense; the reference C
14171/// library reads that bit back out of the header when it builds an Attribute
14172/// Info message, so a rewrite that dropped it would quietly stop indexing.
14173///
14174/// Both bits are properties of the whole header, so chunk 0 and every
14175/// continuation block of one header share a layout. Carrying it beside the bytes
14176/// ([`OhRegion`]) is what keeps the two dozen walkers and the emitters in this
14177/// module from having to agree about it one by one.
14178#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
14179pub(crate) struct OhRecordLayout {
14180    /// Records carry a 2-byte creation index (object header flags bit 2).
14181    tracked: bool,
14182    /// Dense attribute storage indexes that order (object header flags bit 3).
14183    indexed: bool,
14184}
14185
14186/// Object header flags bit 2: message creation order is tracked, so every
14187/// message record carries a creation index.
14188const OH_FLAG_CREATION_ORDER_TRACKED: u8 = 0x04;
14189
14190/// Object header flags bit 3: attribute creation order is *indexed* as well as
14191/// tracked, so dense attribute storage carries a creation-order B-tree beside
14192/// its name index.
14193const OH_FLAG_CREATION_ORDER_INDEXED: u8 = 0x08;
14194
14195/// Object header flags bit 4: the header prefix carries the attribute
14196/// phase-change thresholds (`H5O_HDR_ATTR_STORE_PHASE_CHANGE`).
14197const OH_FLAG_ATTR_PHASE_CHANGE: u8 = 0x10;
14198
14199/// Object header flags bit 5: the header prefix carries the four access,
14200/// modification, change and birth timestamps (`H5O_HDR_STORE_TIMES`).
14201const OH_FLAG_STORE_TIMES: u8 = 0x20;
14202
14203/// The four timestamps a version 2 object header stores when
14204/// [`OH_FLAG_STORE_TIMES`] is set, each 4 bytes of seconds since the Unix epoch
14205/// and stored in this order.
14206///
14207/// The reference C library stores them on **every** version 2 header it writes:
14208/// `H5O_CRT_OHDR_FLAGS_DEF` is `H5O_HDR_STORE_TIMES`, so a header from libhdf5,
14209/// h5py or netCDF-4 carries all four, and `H5Oget_info` reads them straight out
14210/// of this block.
14211#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14212pub(crate) struct ObjectTimes {
14213    access: u32,
14214    modification: u32,
14215    change: u32,
14216    birth: u32,
14217}
14218
14219impl ObjectTimes {
14220    /// Bytes this block occupies in a header prefix.
14221    const LEN: usize = 16;
14222
14223    /// Parse the block at `at`. The caller must have checked that 16 bytes are
14224    /// available there.
14225    fn parse(prefix: &[u8], at: usize) -> Self {
14226        let field =
14227            |i: usize| u32::from_le_bytes(prefix[at + 4 * i..at + 4 * i + 4].try_into().unwrap());
14228        Self {
14229            access: field(0),
14230            modification: field(1),
14231            change: field(2),
14232            birth: field(3),
14233        }
14234    }
14235
14236    /// The block's on-disk bytes.
14237    fn to_bytes(self) -> [u8; Self::LEN] {
14238        let mut out = [0u8; Self::LEN];
14239        out[0..4].copy_from_slice(&self.access.to_le_bytes());
14240        out[4..8].copy_from_slice(&self.modification.to_le_bytes());
14241        out[8..12].copy_from_slice(&self.change.to_le_bytes());
14242        out[12..16].copy_from_slice(&self.birth.to_le_bytes());
14243        out
14244    }
14245
14246    /// The same times with the modification and change times moved to `now`.
14247    /// The access and birth times are the object's own history and are left
14248    /// where they were.
14249    ///
14250    /// The reference C library's `H5O_touch_oh` reaches the same two-of-four
14251    /// shape by a different pair: on a version 2 header it writes
14252    /// `oh->atime = oh->ctime = now` and carries a source comment saying the
14253    /// modification time still needs code to update. A rewrite is a
14254    /// modification, and it is not an *access*, so this writes the field that
14255    /// says so; both agree on the change time, and neither disturbs the birth
14256    /// time.
14257    fn touched(self, now: u32) -> Self {
14258        Self {
14259            modification: now,
14260            change: now,
14261            ..self
14262        }
14263    }
14264}
14265
14266/// The attribute phase-change thresholds a version 2 object header stores when
14267/// [`OH_FLAG_ATTR_PHASE_CHANGE`] is set: `H5Pset_attr_phase_change`'s maximum
14268/// number of attributes kept compact (in the header) and minimum kept dense (in
14269/// a fractal heap).
14270///
14271/// The reference C library writes this block only when the pair differs from its
14272/// defaults of 8 and 6, so most headers carry no such block at all. Preserved
14273/// verbatim: this editor's own compact/dense decision still uses
14274/// [`MAX_COMPACT_ATTRS`], so a non-default pair survives a rewrite without yet
14275/// steering it.
14276#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14277pub(crate) struct AttrPhaseChange {
14278    max_compact: u16,
14279    min_dense: u16,
14280}
14281
14282impl AttrPhaseChange {
14283    /// Bytes this block occupies in a header prefix.
14284    const LEN: usize = 4;
14285
14286    /// Parse the block at `at`. The caller must have checked that 4 bytes are
14287    /// available there.
14288    fn parse(prefix: &[u8], at: usize) -> Self {
14289        Self {
14290            max_compact: u16::from_le_bytes(prefix[at..at + 2].try_into().unwrap()),
14291            min_dense: u16::from_le_bytes(prefix[at + 2..at + 4].try_into().unwrap()),
14292        }
14293    }
14294
14295    /// The block's on-disk bytes.
14296    fn to_bytes(self) -> [u8; Self::LEN] {
14297        let mut out = [0u8; Self::LEN];
14298        out[0..2].copy_from_slice(&self.max_compact.to_le_bytes());
14299        out[2..4].copy_from_slice(&self.min_dense.to_le_bytes());
14300        out
14301    }
14302}
14303
14304/// Everything chunk 0's prefix declares about a version 2 object header: the
14305/// record layout its messages are written in, and the two optional blocks the
14306/// prefix itself may carry.
14307///
14308/// All of it is a property of the *header*, shared by chunk 0 and every
14309/// continuation block, and none of it can be re-derived from the message bytes —
14310/// so it travels beside them ([`OhRegion`]) from the parse right through to the
14311/// rebuild. A rewrite that dropped the optional blocks would silently zero every
14312/// timestamp `H5Oget_info` reports on a file the C library wrote, and reset the
14313/// phase-change thresholds a caller set with `H5Pset_attr_phase_change`.
14314#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
14315pub(crate) struct OhHeaderProps {
14316    /// How wide a message record prefix is, and whether creation order is
14317    /// tracked and indexed.
14318    layout: OhRecordLayout,
14319    /// The access/modification/change/birth block, where the header stores one.
14320    times: Option<ObjectTimes>,
14321    /// The compact/dense attribute thresholds, where the header stores them.
14322    attr_phase_change: Option<AttrPhaseChange>,
14323}
14324
14325impl OhHeaderProps {
14326    /// 4-byte records, no creation order, and neither optional block: what this
14327    /// crate's whole-file writer emits, and what a header this editor creates
14328    /// from nothing uses.
14329    pub(crate) const PLAIN: Self = Self::with_layout(OhRecordLayout::PLAIN);
14330
14331    /// A header in `layout` carrying neither optional block.
14332    pub(crate) const fn with_layout(layout: OhRecordLayout) -> Self {
14333        Self {
14334            layout,
14335            times: None,
14336            attr_phase_change: None,
14337        }
14338    }
14339
14340    /// The object-header flag bits these properties imply, above the two size
14341    /// bits the emitter chooses from the region's length.
14342    const fn header_flags(self) -> u8 {
14343        let times = if self.times.is_some() {
14344            OH_FLAG_STORE_TIMES
14345        } else {
14346            0
14347        };
14348        let phase = if self.attr_phase_change.is_some() {
14349            OH_FLAG_ATTR_PHASE_CHANGE
14350        } else {
14351            0
14352        };
14353        self.layout.header_flags() | times | phase
14354    }
14355
14356    /// Bytes the optional blocks add to the header prefix.
14357    const fn optional_len(self) -> usize {
14358        let times = if self.times.is_some() {
14359            ObjectTimes::LEN
14360        } else {
14361            0
14362        };
14363        let phase = if self.attr_phase_change.is_some() {
14364            AttrPhaseChange::LEN
14365        } else {
14366            0
14367        };
14368        times + phase
14369    }
14370}
14371
14372impl OhRecordLayout {
14373    /// 4-byte record prefixes and no creation order at all: what this crate's
14374    /// whole-file writer emits, and what a header this editor creates from
14375    /// nothing uses.
14376    pub(crate) const PLAIN: Self = Self {
14377        tracked: false,
14378        indexed: false,
14379    };
14380
14381    /// The layout a version 2 object header's flags byte declares.
14382    pub(crate) const fn from_header_flags(flags: u8) -> Self {
14383        Self {
14384            tracked: flags & OH_FLAG_CREATION_ORDER_TRACKED != 0,
14385            indexed: flags & OH_FLAG_CREATION_ORDER_INDEXED != 0,
14386        }
14387    }
14388
14389    /// Bytes a message record spends before its body.
14390    pub(crate) const fn prefix_len(self) -> usize {
14391        if self.tracked { 6 } else { 4 }
14392    }
14393
14394    /// The object-header flag bits this layout implies.
14395    const fn header_flags(self) -> u8 {
14396        let tracked = if self.tracked {
14397            OH_FLAG_CREATION_ORDER_TRACKED
14398        } else {
14399            0
14400        };
14401        let indexed = if self.indexed {
14402            OH_FLAG_CREATION_ORDER_INDEXED
14403        } else {
14404            0
14405        };
14406        tracked | indexed
14407    }
14408
14409    /// Whether records carry a creation index at all.
14410    pub(crate) const fn tracks_creation_order(self) -> bool {
14411        self.tracked
14412    }
14413
14414    /// Whether dense attribute storage on this object indexes that order, with
14415    /// a creation-order B-tree beside the name index.
14416    pub(crate) const fn indexes_creation_order(self) -> bool {
14417        self.indexed
14418    }
14419
14420    /// Parse the message record at `p` within a chunk's message region,
14421    /// returning `(message type, body start, body end)`; the next record begins
14422    /// at `body end`. Returns `Ok(None)` once fewer bytes remain than a record
14423    /// prefix takes (a clean end of the region, or the gap the reference C
14424    /// library leaves when a chunk's free space is too small to hold a message),
14425    /// and `Err` if a record's declared body runs past the region. Centralizes
14426    /// the bounds check shared by every walker.
14427    pub(crate) fn next_message(
14428        self,
14429        region: &[u8],
14430        p: usize,
14431    ) -> Result<Option<(MessageType, usize, usize)>, Error> {
14432        if p + self.prefix_len() > region.len() {
14433            return Ok(None);
14434        }
14435        let msg_type = MessageType::from_u16(region[p] as u16);
14436        let msg_size = u16::from_le_bytes([region[p + 1], region[p + 2]]) as usize;
14437        let body = p + self.prefix_len();
14438        let body_end = body + msg_size;
14439        if body_end > region.len() {
14440            return Err(Error::EditUnsupported("malformed object header message"));
14441        }
14442        Ok(Some((msg_type, body, body_end)))
14443    }
14444
14445    /// The creation index the record at `msg_start` carries, or `None` where the
14446    /// layout has no such field. The caller must have located `msg_start` with
14447    /// [`next_message`](Self::next_message), which bounds the read.
14448    fn creation_index(self, region: &[u8], msg_start: usize) -> Option<u16> {
14449        self.tracked
14450            .then(|| u16::from_le_bytes([region[msg_start + 4], region[msg_start + 5]]))
14451    }
14452
14453    /// Encode one message record: this layout's prefix, then `body`.
14454    ///
14455    /// `creation_index` is written only where the layout carries one. It is
14456    /// meaningful for an Attribute message, whose creation index *is* the
14457    /// attribute's creation order; the reference C library writes zero on every
14458    /// other message type, which is what [`Self::record`] passes.
14459    fn record_with_creation_index(
14460        self,
14461        msg_type: MessageType,
14462        body: &[u8],
14463        creation_index: u16,
14464    ) -> Vec<u8> {
14465        let mut m = Vec::with_capacity(self.prefix_len() + body.len());
14466        #[expect(
14467            clippy::cast_possible_truncation,
14468            reason = "message type ids are a small enum that fits the 1-byte v2 type field"
14469        )]
14470        m.push(msg_type.to_u16() as u8);
14471        #[expect(
14472            clippy::cast_possible_truncation,
14473            reason = "callers pass bodies that fit the 2-byte message-size field (see doc comment)"
14474        )]
14475        m.extend_from_slice(&(body.len() as u16).to_le_bytes());
14476        m.push(0); // message flags
14477        if self.tracks_creation_order() {
14478            m.extend_from_slice(&creation_index.to_le_bytes());
14479        }
14480        m.extend_from_slice(body);
14481        m
14482    }
14483
14484    /// Encode one message record whose creation index, if the layout has one, is
14485    /// the zero the reference C library writes for every non-attribute message.
14486    fn record(self, msg_type: MessageType, body: &[u8]) -> Vec<u8> {
14487        self.record_with_creation_index(msg_type, body, 0)
14488    }
14489}
14490
14491/// A version 2 object header's message records, in the layout the header
14492/// declares them in.
14493///
14494/// The editor's model of a header is one contiguous run of message records:
14495/// [`WriteEngine::gather_oh_messages`] collapses a multi-chunk header into it,
14496/// the rewriters copy it message by message, and
14497/// [`build_v2_object_header`] wraps it back up. Every one of those has to agree
14498/// with the header's own flags about how wide a record prefix is, so the layout
14499/// travels with the bytes rather than being re-derived — or assumed — at each
14500/// step.
14501#[derive(Clone, Debug, Default)]
14502pub(crate) struct OhRegion {
14503    bytes: Vec<u8>,
14504    props: OhHeaderProps,
14505}
14506
14507impl core::ops::Deref for OhRegion {
14508    type Target = [u8];
14509    fn deref(&self) -> &[u8] {
14510        &self.bytes
14511    }
14512}
14513
14514impl OhRegion {
14515    /// A region of `bytes` belonging to a header with these `props`.
14516    pub(crate) fn new(bytes: Vec<u8>, props: OhHeaderProps) -> Self {
14517        Self { bytes, props }
14518    }
14519
14520    /// An empty region for a header with these `props`, to be filled record by
14521    /// record.
14522    fn empty(props: OhHeaderProps) -> Self {
14523        Self::new(Vec::new(), props)
14524    }
14525
14526    /// The same header's properties over different bytes: what every rewriter
14527    /// that copies a region message by message returns.
14528    fn with_bytes(&self, bytes: Vec<u8>) -> Self {
14529        Self::new(bytes, self.props)
14530    }
14531
14532    pub(crate) fn layout(&self) -> OhRecordLayout {
14533        self.props.layout
14534    }
14535
14536    /// Everything the header's prefix declares, which a rebuild re-emits.
14537    pub(crate) fn props(&self) -> OhHeaderProps {
14538        self.props
14539    }
14540
14541    /// [`OhRecordLayout::next_message`] over this region's own bytes.
14542    pub(crate) fn next_message(
14543        &self,
14544        p: usize,
14545    ) -> Result<Option<(MessageType, usize, usize)>, Error> {
14546        self.props.layout.next_message(&self.bytes, p)
14547    }
14548
14549    /// The creation index of the record at `msg_start`, or `None` for a header
14550    /// that does not track it.
14551    fn creation_index(&self, msg_start: usize) -> Option<u16> {
14552        self.props.layout.creation_index(&self.bytes, msg_start)
14553    }
14554
14555    /// Append one already-encoded record (or run of records) written in this
14556    /// region's layout.
14557    fn push_bytes(&mut self, record: &[u8]) {
14558        self.bytes.extend_from_slice(record);
14559    }
14560
14561    /// The bytes, for a patch that changes a field inside a record without
14562    /// changing any record's length.
14563    fn bytes_mut(&mut self) -> &mut [u8] {
14564        &mut self.bytes
14565    }
14566
14567    /// Append a hard Link message record for `name -> addr`, carrying
14568    /// `creation_order` where the group tracks link creation order and `None`
14569    /// where it does not (see [`LinkCreationOrder`], which is what decides
14570    /// which).
14571    fn push_link(&mut self, name: &str, addr: u64, creation_order: Option<u64>) {
14572        let record = encode_link_message(self.props.layout, name, addr, creation_order);
14573        self.push_bytes(&record);
14574    }
14575
14576    /// Append a message record for `body`, with the zero creation index the
14577    /// reference C library writes for every non-attribute message.
14578    fn push(&mut self, msg_type: MessageType, body: &[u8]) {
14579        let record = self.props.layout.record(msg_type, body);
14580        self.push_bytes(&record);
14581    }
14582
14583    /// Append a message whose body is a *reference* to the one copy of it stored
14584    /// elsewhere, rather than the message itself.
14585    ///
14586    /// The flag is what tells every reader to follow the body instead of
14587    /// decoding it, so it travels with the bytes: a rewrite that dropped it
14588    /// would leave a reference to be read as content.
14589    fn push_shared(&mut self, msg_type: MessageType, reference: &[u8]) {
14590        let mut record = self.props.layout.record(msg_type, reference);
14591        record[3] = MSG_FLAG_SHARED;
14592        self.push_bytes(&record);
14593    }
14594}
14595
14596/// Version-2 object-header message flag bit marking a message as *shared* (stored
14597/// once in the shared-message table and referenced by an object-header address or
14598/// fractal-heap id) rather than inline. Whatever the message type, that reference
14599/// points into the source file and is meaningless after a cross-file copy.
14600pub(crate) const MSG_FLAG_SHARED: u8 = 0x02;
14601
14602/// Refuse to copy a dataset whose element bytes live in files outside this one
14603/// (`H5Pset_external`, the External Data Files header message, type 7), which
14604/// this crate does not follow (issue #336).
14605///
14606/// Such a dataset carries a *contiguous* layout message with an undefined data
14607/// address, the same encoding a never-written dataset uses — and
14608/// [`read_copy_subtree`](WriteEngine::read_copy_subtree) copies that encoding as
14609/// the storage it does not have. The layout alone cannot tell the two apart, so
14610/// naming this message is the only thing standing between a dataset that holds
14611/// data and a copy carrying its schema and none of it; [`crate::repack`] refuses
14612/// the same shape for the same reason.
14613///
14614/// Applied on both copy paths rather than only the cross-file one: an in-file
14615/// copy would reproduce the header without its data just as readily. It is also
14616/// the only screen that reads this message at all — [`reject_foreign_addresses`]
14617/// inspects shared messages, datatypes, and attributes, not this body, which
14618/// carries a local-heap address that would dangle in another file.
14619fn reject_external_storage(region: &OhRegion) -> Result<(), Error> {
14620    let mut p = 0;
14621    while let Some((msg_type, _, body_end)) = region.next_message(p)? {
14622        if msg_type == MessageType::ExternalDataFiles {
14623            return Err(Error::EditUnsupported(
14624                "a dataset stores its elements in external files (H5Pset_external), \
14625                 which a copy cannot reproduce -- its data lives in files this crate \
14626                 does not read",
14627            ));
14628        }
14629        p = body_end;
14630    }
14631    Ok(())
14632}
14633
14634/// Refuse to copy an object whose header embeds a *source-file* absolute address
14635/// that a verbatim copy into another file cannot translate. An in-file copy keeps
14636/// these valid by sharing the source file's heaps and objects; a cross-file copy
14637/// cannot. Three things qualify:
14638///
14639/// - a **variable-length** datatype, whose element bytes are global-heap
14640///   references (collection address + index) into the source file's heap;
14641/// - a **reference** datatype (object or dataset-region), whose element bytes are
14642///   absolute object addresses in the source file;
14643/// - any **shared message** (the `MSG_FLAG_SHARED` bit set) — a committed datatype,
14644///   but also a shared dataspace, fill value, or filter-pipeline message — whose
14645///   body is a reference into the source file's shared-message storage.
14646///
14647/// The scan covers a copied object's whole message region (a dataset's or a
14648/// group's): it refuses any shared message outright, and inspects Datatype
14649/// messages (the element type) and Attribute messages (their own datatype),
14650/// recursing through compound members, array elements, and enumeration bases so a
14651/// nested variable-length or reference occurrence is caught too. It is applied
14652/// only on the cross-file path; the same-file [`copy`](crate::File::copy)
14653/// deliberately keeps these forms (their addresses stay valid in one file).
14654fn reject_foreign_addresses(region: &OhRegion) -> Result<(), Error> {
14655    let mut p = 0;
14656    while let Some((msg_type, body, body_end)) = region.next_message(p)? {
14657        // A *shared* message stores, in place of its real body, a reference into
14658        // the source file's shared-message storage — an object-header address or a
14659        // fractal-heap (SOHM) id — which means nothing in another file. This
14660        // catches committed (shared) datatypes and shared attributes as well as a
14661        // shared dataspace, fill value, or filter-pipeline message, all of which
14662        // HDF5 may place in the shared-message table. Refuse any of them, whatever
14663        // the message type. The flags byte is the 4th of the record header (type,
14664        // size, flags); `next_message` returning `Some` guarantees
14665        // `p + 4 <= region.len()`.
14666        if region[p + 3] & MSG_FLAG_SHARED != 0 {
14667            return Err(Error::EditUnsupported(
14668                "a shared (committed/SOHM) object-header message cannot be copied to another file yet",
14669            ));
14670        }
14671        match msg_type {
14672            MessageType::Datatype => {
14673                let (dt, _) =
14674                    crate::datatype::Datatype::parse(&region[body..body_end]).map_err(|_| {
14675                        Error::EditUnsupported("a source datatype could not be parsed for copying")
14676                    })?;
14677                if datatype_holds_file_address(&dt) {
14678                    return Err(Error::EditUnsupported(
14679                        "variable-length or reference datasets cannot be copied to another file yet",
14680                    ));
14681                }
14682            }
14683            MessageType::Attribute => {
14684                // An attribute's *own* datatype or dataspace field can be a
14685                // reference to a committed message, which the record's shared
14686                // flag above does not report: that flag describes the attribute
14687                // message, not the fields inside it. The reference addresses the
14688                // source file, so it cannot travel any more than a shared record
14689                // can — and the parse below cannot resolve it here in any case.
14690                if crate::attribute::message_shares_a_field(&region[body..body_end]) {
14691                    return Err(Error::EditUnsupported(
14692                        "an attribute with a committed (shared) datatype cannot be copied to another file yet",
14693                    ));
14694                }
14695                let attr =
14696                    crate::attribute::AttributeMessage::parse(&region[body..body_end], LENGTH_SIZE)
14697                        .map_err(|_| {
14698                            Error::EditUnsupported(
14699                                "a source attribute could not be parsed for copying",
14700                            )
14701                        })?;
14702                if datatype_holds_file_address(&attr.datatype) {
14703                    return Err(Error::EditUnsupported(
14704                        "variable-length or reference attributes cannot be copied to another file yet",
14705                    ));
14706                }
14707            }
14708            _ => {}
14709        }
14710        p = body_end;
14711    }
14712    Ok(())
14713}
14714
14715/// Cross-file screen for a dense (fractal-heap) attribute set. The bytes parsed
14716/// out of the source heap can embed source-file absolute addresses just as inline
14717/// attribute messages can — variable-length (global-heap) or reference attribute
14718/// data — which would dangle in another file. [`reject_foreign_addresses`] screens
14719/// the verbatim object-header region but not heap-resident attribute bytes, so a
14720/// dense attribute set is screened here instead. Same-file copies skip this (their
14721/// addresses stay valid); the fresh heap built on write is same-file by
14722/// construction, so only the source datatypes matter.
14723fn reject_foreign_dense_attrs(attrs: &[crate::attribute::AttributeMessage]) -> Result<(), Error> {
14724    for attr in attrs {
14725        if datatype_holds_file_address(&attr.datatype) {
14726            return Err(Error::EditUnsupported(
14727                "variable-length or reference dense (fractal-heap) attributes cannot be copied to another file yet",
14728            ));
14729        }
14730    }
14731    Ok(())
14732}
14733
14734/// Re-encode a shared-message reference in the modern form, for a message being
14735/// rewrapped from a version 1 object header into a version 2 one.
14736///
14737/// A version 1 header carries the oldest reference encoding: a symbol-table
14738/// entry, with the object-header address buried behind a local-heap address.
14739/// Copying those bytes into a version 2 header would put an encoding there that
14740/// no writer of such a header has produced since the format gained one; parsing
14741/// and re-encoding gives the shape every current reader and writer uses.
14742///
14743/// Which shape that is depends on where the message lives. An object header is
14744/// named by the version 2 form this crate already writes for a committed
14745/// datatype. The shared-message heap has no encoding before version 3, so a heap
14746/// reference gets that one. Both name exactly what the version 1 reference named,
14747/// so the message keeps its single copy and its reference count is unchanged.
14748fn modernize_shared_reference(
14749    body: &[u8],
14750    offset_size: u8,
14751    length_size: u8,
14752) -> Result<Vec<u8>, Error> {
14753    let reference = crate::shared_message::parse_shared_ref(body, offset_size, length_size)?;
14754    Ok(match reference.location {
14755        crate::shared_message::SharedLocation::ObjectHeader(addr) => {
14756            crate::shared_message::encode_committed_ref(addr, offset_size)
14757        }
14758        crate::shared_message::SharedLocation::SohmHeap(id) => {
14759            crate::shared_message::encode_sohm_ref(&id)
14760        }
14761    })
14762}
14763
14764/// Refuse a commit that removes or moves an object header the file's
14765/// shared-message (SOHM) index names.
14766///
14767/// A heap-stored record names no object, so it is not screened here: what a
14768/// commit does to it is a *reference count* that goes stale, which the engine
14769/// refuses at the point it would change one (see [`SHARED_ATTRIBUTE_MESSAGE`]).
14770/// This screens the other record shape, whose address would dangle.
14771fn screen_shared_message_records(
14772    records: &[crate::sohm::SohmRecord],
14773    invalidated: &InvalidatedAddresses,
14774) -> Result<(), Error> {
14775    for record in records {
14776        if let crate::sohm::SohmLocation::ObjectHeader { address, .. } = record.location
14777            && invalidated.refusal(address).is_some()
14778        {
14779            return Err(Error::EditUnsupported(
14780                SHARED_MESSAGE_INDEX_NAMES_A_MOVED_OBJECT,
14781            ));
14782        }
14783    }
14784    Ok(())
14785}
14786
14787/// Refuse a staged dataset whose element bytes already hold *resolved* object
14788/// references naming space this commit reclaims (issue #317).
14789///
14790/// This is the address-side half of the rule
14791/// [`WriteEngine::resolve_reference_target`] enforces on the path side. A target
14792/// named as a path is screened there, by name; a target supplied as an address —
14793/// `DatasetBuilder::with_reference_data`, or `with_raw_data` over a datatype that
14794/// holds a reference — never reaches that function at all, and before this screen
14795/// existed it was written straight through to disk.
14796///
14797/// Element bytes still carrying placeholders are unaffected: an unresolved slot
14798/// holds zero, which [`InvalidatedAddresses::refusal`] passes, and the target
14799/// that replaces it is screened by `resolve_reference_target` instead — by name
14800/// for a path, by address for a raw one. So this runs over every staged
14801/// dataset's `raw` without asking which builder filled it: the rule is about the
14802/// bytes, not the door they came through.
14803fn screen_resolved_references(
14804    dt: &Datatype,
14805    raw: &[u8],
14806    invalidated: &InvalidatedAddresses,
14807) -> Result<(), Error> {
14808    if !datatype_holds_object_address(dt) {
14809        return Ok(());
14810    }
14811    // The datatype declares an object reference, so the walker must find one.
14812    // Two ways it does not: slots that do not fit the element size (`None`), and
14813    // a reference `embedded_reference_slots` does not map — it locates the
14814    // 8-byte object reference only, and reports any other width as no slots at
14815    // all. Both mean the addresses cannot be read, and an empty list taken for
14816    // "nothing to check" would wave through exactly the datatype
14817    // `datatype_holds_object_address` had just recognised. Neither shape is
14818    // one this crate builds; refuse rather than write references past a screen
14819    // that could not see them.
14820    let Some(slots) = embedded_reference_slots(dt).filter(|slots| !slots.is_empty()) else {
14821        // Unconditional, unlike the per-address checks below. Those refuse an
14822        // address that lands somewhere this commit vacates; this one cannot read
14823        // the addresses at all, so there is nothing to compare against and no
14824        // amount of screening would help. Every commit that reaches here rebuilds
14825        // at least the root group, so `for_supplied.moved` is never empty and
14826        // gating this would have changed nothing anyway. A commit whose only
14827        // staged edit is a same-length in-place overwrite never reaches here at
14828        // all — it takes the fast path above, which vacates nothing. An in-file
14829        // copy reaches this only beside a deletion, because it is screened
14830        // against `for_copied`, whose `moved` is empty by construction.
14831        //
14832        // The `filter` is belt-and-braces: `embedded_reference_slots` already
14833        // reports an unaddressable reference as `None` rather than as an empty
14834        // list, and reading an empty list as "nothing to check" is exactly the
14835        // hole this screen was found to have.
14836        return Err(Error::EditUnsupported(
14837            "a reference this commit writes sits in a datatype whose addresses this screen \
14838             cannot read — a width other than eight, a dataset-region reference, a \
14839             variable length of them, or a compound holding one beside a reference it can \
14840             read — so it cannot be checked against what this commit vacates; supply an \
14841             8-byte object reference (`with_reference_data`)",
14842        ));
14843    };
14844    // Non-empty slots mean the datatype has room for at least one 8-byte
14845    // address, so the element size is at least 8.
14846    for (_, stored) in stored_object_references(raw, dt.type_size() as usize, &slots) {
14847        if let Some(refusal) = invalidated.refusal(stored) {
14848            return Err(Error::EditUnsupported(refusal));
14849        }
14850    }
14851    Ok(())
14852}
14853
14854/// Screen an in-file copy's subtree against the space this commit reclaims
14855/// (issue #317).
14856///
14857/// An in-file copy re-emits its source's element bytes verbatim, which is what
14858/// keeps a copied variable-length or reference dataset valid: the addresses
14859/// still name the same file, so nothing has to be rewritten. A deletion in the
14860/// same commit takes that away for object references alone — a global heap
14861/// collection is never reclaimed by a delete, so variable-length elements keep
14862/// pointing at data that is still there.
14863///
14864/// Every element that can be read is screened by address, through the same
14865/// [`screen_resolved_references`] a staged dataset's bytes go through: a
14866/// contiguous dataset's data block, a compact dataset's inline data, and every
14867/// attribute, inline or dense. A committed (shared) datatype is resolved through
14868/// `src` first, so a copy of an object with a named type is screened like any
14869/// other rather than refused for carrying a type this could not read.
14870///
14871/// One form cannot be read at all and so is refused by *datatype*, and only when
14872/// it holds an object reference: a **chunked** dataset, whose addresses sit
14873/// inside chunks this path carries compressed and never decodes — the same
14874/// obstacle that makes [`crate::repack`] refuse a chunked object-reference
14875/// dataset outright.
14876///
14877/// `src` is the session's image framed at its base address, the view a stored
14878/// (base-relative) shared-message address indexes directly.
14879fn screen_copied_references(
14880    tree: &CopyTree,
14881    invalidated: &InvalidatedAddresses,
14882    src: &(impl Source + ?Sized),
14883) -> Result<(), Error> {
14884    if invalidated.is_empty() {
14885        return Ok(());
14886    }
14887    use crate::shared_message::SharedResolver as _;
14888    // No shared-message table: a heap-stored message is refused rather than
14889    // followed here, and this screen treats an unreadable datatype as one it
14890    // cannot clear, which is the conservative answer.
14891    let resolver = crate::shared_message::SourceResolver::new(src, OFFSET_SIZE, LENGTH_SIZE, None);
14892    let (region, dense_attrs) = match tree {
14893        CopyTree::DatasetVerbatim {
14894            region,
14895            dense_attrs,
14896        }
14897        | CopyTree::DatasetContiguous {
14898            region,
14899            dense_attrs,
14900            ..
14901        }
14902        | CopyTree::DatasetChunked {
14903            region,
14904            dense_attrs,
14905            ..
14906        }
14907        | CopyTree::Group {
14908            non_link_region: region,
14909            dense_attrs,
14910            ..
14911        } => (region, dense_attrs),
14912    };
14913    for attr in &dense_attrs.attrs {
14914        screen_resolved_references(&attr.datatype, &attr.raw_data, invalidated)?;
14915    }
14916
14917    // One walk of the header: the object's own element datatype, the compact
14918    // data the layout message may carry, and every inline attribute.
14919    let mut element_dt: Option<Datatype> = None;
14920    let mut compact: Option<Vec<u8>> = None;
14921    let mut p = 0;
14922    while let Some((msg_type, body, body_end)) = region.next_message(p)? {
14923        // The flags byte is the 4th of the record header (type, size, flags);
14924        // `next_message` returning `Some` guarantees it is in bounds.
14925        let shared = region[p + 3] & MSG_FLAG_SHARED != 0;
14926        match msg_type {
14927            MessageType::Datatype => {
14928                // A committed datatype's message body is a pointer into the
14929                // file's shared-message storage rather than an encoded type, so
14930                // read the type it names before parsing.
14931                let committed;
14932                let encoded = if shared {
14933                    committed = resolver
14934                        .resolve(&region[body..body_end], MessageType::Datatype)
14935                        .map_err(|_| {
14936                            Error::EditUnsupported(
14937                                "a copy in this commit names a committed (shared) datatype that \
14938                                 could not be read, so its elements cannot be screened against \
14939                                 the same commit's deletions; use separate commits",
14940                            )
14941                        })?;
14942                    &committed[..]
14943                } else {
14944                    &region[body..body_end]
14945                };
14946                let (dt, _) = Datatype::parse(encoded).map_err(|_| {
14947                    Error::EditUnsupported("a source datatype could not be parsed for copying")
14948                })?;
14949                element_dt = Some(dt);
14950            }
14951            MessageType::DataLayout => {
14952                if let Ok(DataLayout::Compact { data }) =
14953                    DataLayout::parse(&region[body..body_end], OFFSET_SIZE, LENGTH_SIZE)
14954                {
14955                    compact = Some(data);
14956                }
14957            }
14958            MessageType::Attribute => {
14959                // A *shared record* is the whole attribute message held in the
14960                // file's shared-message table, which is a different indirection
14961                // from the committed datatype `parse_resolving` follows inside
14962                // the fields — and a rare one this path has never modelled. Its
14963                // elements cannot be reached here, so it is refused.
14964                if shared {
14965                    return Err(Error::EditUnsupported(
14966                        "a copy in this commit carries a shared (SOHM) attribute message, whose \
14967                         elements cannot be screened against the same commit's deletions; use \
14968                         separate commits",
14969                    ));
14970                }
14971                // `parse_resolving` rather than `parse`: an attribute's own
14972                // datatype field can name a committed message, which the record's
14973                // shared flag does not report — that flag describes the attribute
14974                // message, not the fields inside it — and `parse` refuses one.
14975                let attr = crate::attribute::AttributeMessage::parse_resolving(
14976                    &region[body..body_end],
14977                    LENGTH_SIZE,
14978                    &resolver,
14979                )
14980                .map_err(|_| {
14981                    Error::EditUnsupported("a source attribute could not be parsed for copying")
14982                })?;
14983                screen_resolved_references(&attr.datatype, &attr.raw_data, invalidated)?;
14984            }
14985            _ => {}
14986        }
14987        p = body_end;
14988    }
14989
14990    match tree {
14991        // Compact: the elements are inline in the data-layout message. A layout
14992        // that did not yield them leaves a reference datatype unscreened, so it
14993        // is refused for the same reason a chunked one is.
14994        CopyTree::DatasetVerbatim { .. } => match (&element_dt, &compact) {
14995            (Some(dt), Some(data)) => screen_resolved_references(dt, data, invalidated)?,
14996            (Some(dt), None) if datatype_holds_object_address(dt) => {
14997                return Err(Error::EditUnsupported(
14998                    "a compact object-reference dataset's elements could not be read to screen \
14999                     them against this commit's deletions; use separate commits",
15000                ));
15001            }
15002            _ => {}
15003        },
15004        // No Datatype message means no declared reference, here and in the
15005        // chunked arm below: such a header is not a dataset any reader can
15006        // interpret, so there is nothing in it to dangle. That is why only the
15007        // compact arm above refuses on a missing piece — there the datatype is
15008        // present and says a reference is in bytes it could not reach.
15009        CopyTree::DatasetContiguous { data, .. } => {
15010            // A dataset whose storage the source never allocated (`None`) stores
15011            // no elements, so it holds no address that could dangle. Skipped
15012            // rather than screened as an empty run of element bytes: when
15013            // `screen_resolved_references` cannot map a datatype's addresses — a
15014            // dataset-region reference, a variable length of references, one
15015            // wider than eight bytes — it refuses *unconditionally*, before it
15016            // reads a byte, so an empty run would refuse this dataset for
15017            // elements it does not have.
15018            if let (Some(dt), Some(data)) = (&element_dt, data) {
15019                screen_resolved_references(dt, data, invalidated)?;
15020            }
15021        }
15022        // Chunked: `chunk_bytes` are carried exactly as the source stored them,
15023        // filters and all, so there is nothing here to decode addresses out of.
15024        CopyTree::DatasetChunked { .. } => {
15025            if element_dt
15026                .as_ref()
15027                .is_some_and(datatype_holds_object_address)
15028            {
15029                return Err(Error::EditUnsupported(
15030                    "a chunked object-reference dataset cannot be copied in a commit that also \
15031                     deletes objects: its addresses live inside chunks this path does not \
15032                     decode; use separate commits",
15033                ));
15034            }
15035        }
15036        CopyTree::Group { children, .. } => {
15037            for (_, _, child) in children {
15038                screen_copied_references(child, invalidated, src)?;
15039            }
15040        }
15041    }
15042    Ok(())
15043}
15044
15045/// Wrap a chunk-0 message region in a fresh single-chunk version 2 object header
15046/// (`OHDR` prefix + region + Jenkins checksum), first normalizing the region's
15047/// attribute storage with [`ensure_attribute_info`]. Mirrors the encoding in
15048/// [`crate::object_header_writer::ObjectHeaderWriter::serialize`].
15049///
15050/// The normalization belongs here rather than at the fifteen call sites because
15051/// carrying an Attribute Info message is a property of a version 2 header holding
15052/// inline attributes, not of any one edit operation — and a site that forgot it
15053/// would reintroduce the zero-count defect silently.
15054///
15055/// A region that cannot be walked is reported, not asserted away. Every region
15056/// reaching here should have been built by this crate or already walked
15057/// message-by-message on the way in, but "should" is a claim about a file this
15058/// session did not write: a header whose message size field overruns the region
15059/// is a malformed *file*, which is the caller's input and so takes an
15060/// [`Error::EditUnsupported`], the way every other malformed-header path in this
15061/// module does. The `debug_assert!(false)` this replaced made the two build
15062/// profiles disagree about whether such a file was writable at all — a panic in
15063/// a test build, and in a release build a header silently missing its Attribute
15064/// Info message, which is the zero-count defect this function exists to prevent.
15065pub(crate) fn build_v2_object_header(region: &OhRegion) -> Result<Vec<u8>, Error> {
15066    let mut owned = region.clone();
15067    ensure_attribute_info(&mut owned)?;
15068    Ok(build_v2_object_header_verbatim(&owned))
15069}
15070
15071/// [`build_v2_object_header`] without the attribute-storage normalization, for
15072/// the region that function has already normalized.
15073///
15074/// **The header's optional prefix blocks are re-emitted, and its timestamps are
15075/// stamped.** A region carries whatever chunk 0's prefix declared
15076/// ([`OhHeaderProps`]), so a rewrite of a header from the reference C library,
15077/// h5py or netCDF-4 puts its four timestamps and any attribute phase-change
15078/// thresholds back where it found them, with the flag bits that announce them.
15079///
15080/// Of those, the **modification and change times are moved to now**: this
15081/// function runs once per rebuilt header, and every rebuild is a modification.
15082/// Access and birth times are the object's own history and are copied verbatim
15083/// ([`ObjectTimes::touched`] says how that compares with `H5O_touch_oh`).
15084/// Under `no_std` there is no clock to read, so all four are preserved as they
15085/// were rather than zeroed — a stale modification time being the honest reading
15086/// of "this build cannot tell the time", where a zero would claim the epoch.
15087/// A header this crate creates from nothing stores no times at all, so nothing
15088/// here applies to it.
15089fn build_v2_object_header_verbatim(region: &OhRegion) -> Vec<u8> {
15090    let total = region.len();
15091    let (size_flags, width) = if total <= 255 {
15092        (0u8, 1usize)
15093    } else if total <= 65535 {
15094        (1u8, 2)
15095    } else {
15096        (2u8, 4)
15097    };
15098    let props = region.props();
15099    // The creation-order bits and the two optional-block bits are the header's
15100    // own claim about what follows, so they come from the properties the region
15101    // was parsed with.
15102    let flags = size_flags | props.header_flags();
15103    let mut buf = Vec::with_capacity(8 + props.optional_len() + total + 4);
15104    buf.extend_from_slice(b"OHDR");
15105    buf.push(2); // version
15106    buf.push(flags);
15107    if let Some(times) = props.times {
15108        let stamped = match unix_time_now() {
15109            Some(now) => times.touched(now),
15110            None => times,
15111        };
15112        buf.extend_from_slice(&stamped.to_bytes());
15113    }
15114    if let Some(phase) = props.attr_phase_change {
15115        buf.extend_from_slice(&phase.to_bytes());
15116    }
15117    #[expect(
15118        clippy::cast_possible_truncation,
15119        reason = "width was selected just above to be the smallest field that holds total"
15120    )]
15121    match width {
15122        1 => buf.push(total as u8),
15123        2 => buf.extend_from_slice(&(total as u16).to_le_bytes()),
15124        _ => buf.extend_from_slice(&(total as u32).to_le_bytes()),
15125    }
15126    buf.extend_from_slice(region);
15127    let checksum = jenkins_lookup3(&buf);
15128    buf.extend_from_slice(&checksum.to_le_bytes());
15129    buf
15130}
15131
15132/// Seconds since the Unix epoch, for the object-header timestamps a rebuild
15133/// stamps, or `None` where this build has no clock.
15134///
15135/// The crate carries no other wall-clock reader: every other date it writes
15136/// comes from its caller. `no_std` builds have no `SystemTime` at all and return
15137/// `None`, and so does a `std` build whose clock is set before 1970 — neither is
15138/// a reason to fail a commit, so both leave the header's stored times alone (see
15139/// [`build_v2_object_header_verbatim`]). The 4-byte field saturates rather than
15140/// wrapping, which matters only past 2106.
15141#[cfg(feature = "std")]
15142fn unix_time_now() -> Option<u32> {
15143    std::time::SystemTime::now()
15144        .duration_since(std::time::UNIX_EPOCH)
15145        .ok()
15146        .map(|d| u32::try_from(d.as_secs()).unwrap_or(u32::MAX))
15147}
15148
15149/// No wall clock outside `std`; see the `std` definition.
15150#[cfg(not(feature = "std"))]
15151fn unix_time_now() -> Option<u32> {
15152    None
15153}
15154
15155/// Read a little-endian unsigned integer of `bytes.len()` (≤ 8) bytes.
15156#[expect(
15157    clippy::cast_possible_truncation,
15158    reason = "callers parse in-file sizes/offsets bounded by the in-memory image; downstream \
15159              slicing is length-checked, so a malformed oversized field errors rather than reads OOB"
15160)]
15161fn read_le(bytes: &[u8]) -> usize {
15162    let mut v = 0u64;
15163    for (i, &b) in bytes.iter().enumerate() {
15164        v |= (b as u64) << (8 * i);
15165    }
15166    v as usize
15167}
15168
15169/// The engine as the target a [`reference_patch::Plan`] is applied to
15170/// (issue #324).
15171///
15172/// Deliberately the whole engine rather than the image alone: an object header
15173/// is republished as one checksummed write, so applying a plan reads as well as
15174/// writes, and both have to go through the same image the rest of the commit
15175/// used — the same write-gathering, the same pending-write overlay, the same
15176/// end-of-file bound.
15177///
15178/// [`reference_patch::Plan`]: crate::reference_patch::Plan
15179impl crate::reference_patch::PatchTarget for WriteEngine {
15180    fn read(&self, at: u64, len: usize) -> Result<Vec<u8>, Error> {
15181        self.image().read_exact_at(at, len).map_err(Error::Format)
15182    }
15183    fn write(&mut self, at: u64, bytes: &[u8]) -> Result<(), Error> {
15184        self.image.write_at(at, bytes)
15185    }
15186}
15187
15188#[cfg(test)]
15189mod tests {
15190    use super::*;
15191
15192    /// The rule that places a chunk index on a paged file: some chunk-data span
15193    /// abuts it. Both sides count, which is what a repeatedly appended dataset
15194    /// needs — its blobs leave chunk data above the index as well as below it, so
15195    /// the highest data address is not the one beside the index (issue #388).
15196    #[test]
15197    fn a_chunk_index_is_placed_by_the_chunk_data_that_abuts_it() {
15198        // Data below, index above: the from-scratch layout.
15199        assert!(index_abuts_chunk_data(&[(100, 60)], &[(160, 40)]));
15200        // Index below, data above: a blob placed in a hole under existing chunks.
15201        assert!(index_abuts_chunk_data(&[(200, 60)], &[(160, 40)]));
15202        // Data on both sides, with the abutting span not the highest one — the
15203        // shape every staged append after the first produces.
15204        assert!(index_abuts_chunk_data(
15205            &[(100, 60), (900, 60)],
15206            &[(160, 40)]
15207        ));
15208        // A multi-block index is placed as one run by the span beside its edge.
15209        assert!(index_abuts_chunk_data(
15210            &[(100, 60)],
15211            &[(180, 20), (160, 20)]
15212        ));
15213
15214        // Neither edge is touched: the reference library's layout, where the index
15215        // sits in a metadata page far below the chunk data.
15216        assert!(!index_abuts_chunk_data(&[(9000, 60)], &[(160, 40)]));
15217        // A gap of one byte is not an abutment.
15218        assert!(!index_abuts_chunk_data(&[(100, 59)], &[(160, 40)]));
15219        // No chunk data at all — an empty resizable dataset's eagerly built index
15220        // — places nothing.
15221        assert!(!index_abuts_chunk_data(&[], &[(160, 40)]));
15222        // Nothing to place.
15223        assert!(index_abuts_chunk_data(&[], &[]));
15224        // A zero-length data span touches the index without occupying a byte
15225        // beside it.
15226        assert!(!index_abuts_chunk_data(&[(160, 0)], &[(160, 40)]));
15227    }
15228
15229    /// The screen that keeps the abutment from admitting a reference-library
15230    /// layout: an index with a byte in page 0 sits beside the superblock, so it is
15231    /// in a metadata page however its far end lines up with chunk data.
15232    #[test]
15233    fn a_chunk_index_in_page_zero_is_never_raw() {
15234        const PAGE: u64 = 512;
15235        // The C-written shape this exists to refuse: the index header sits in
15236        // page 0 behind the superblock, its last block fills page 1 exactly, and
15237        // the first raw page begins where that block ends. The abutment alone
15238        // called the whole run raw.
15239        let c_written = [(48u64, 80u64), (512, 512)];
15240        assert!(index_abuts_chunk_data(&[(1024, 512)], &c_written));
15241        assert!(index_touches_page_zero(&c_written, PAGE));
15242        // An index wholly above page 0 — every one this crate writes, since it
15243        // places chunk data and the index beside it out of raw pages — is not
15244        // screened out.
15245        assert!(!index_touches_page_zero(&[(1024, 200)], PAGE));
15246        // The boundary: the last byte of page 0 is still page 0.
15247        assert!(index_touches_page_zero(&[(511, 200)], PAGE));
15248        assert!(!index_touches_page_zero(&[(512, 200)], PAGE));
15249        // A zero-length span occupies nothing, in page 0 as anywhere else.
15250        assert!(!index_touches_page_zero(&[(0, 0)], PAGE));
15251        // Nothing to screen.
15252        assert!(!index_touches_page_zero(&[], PAGE));
15253        // A page size a paged file never has cannot place anything.
15254        assert!(index_touches_page_zero(&[(1024, 200)], 0));
15255    }
15256
15257    /// A page every byte of which is free or dead belongs to no page type, so it
15258    /// is promoted whole into the raw list — the free-page convention the rest of
15259    /// the paged allocator already follows. The partial edges keep their type,
15260    /// since the pages they sit in may still hold something live.
15261    #[test]
15262    fn a_page_that_is_wholly_free_or_dead_is_promoted_whole() {
15263        const PAGE: u64 = 4096;
15264        let (mut meta, mut raw, mut dead) = (FreeList::new(), FreeList::new(), FreeList::new());
15265        // Page 1 is half free metadata and half dead, so neither list can show it
15266        // empty on its own.
15267        meta.free(PAGE, 2048);
15268        dead.free(PAGE + 2048, 2048);
15269        // Page 2 is wholly dead.
15270        dead.free(2 * PAGE, PAGE);
15271        // Page 3 keeps something live at each end, so only its middle is free —
15272        // and not adjacent to page 2, so the promotion is visible on its own.
15273        raw.free(3 * PAGE + 1024, 1024);
15274        PagedEdit::promote_whole_free_pages(&mut meta, &mut raw, &mut dead, PAGE);
15275
15276        assert_eq!(
15277            raw.sections(),
15278            [(PAGE, 2 * PAGE), (3 * PAGE + 1024, 1024)],
15279            "the two empty pages join the raw list as one run; the partial page stays"
15280        );
15281        assert!(
15282            meta.sections().is_empty(),
15283            "the promoted page left the metadata list"
15284        );
15285        assert!(
15286            dead.sections().is_empty(),
15287            "every dead byte was inside a promoted page"
15288        );
15289    }
15290
15291    /// Dead space that does not complete a page stays dead: it is not free space,
15292    /// and handing it out as either page type would mix the page it sits in.
15293    #[test]
15294    fn dead_space_short_of_a_whole_page_is_not_promoted() {
15295        const PAGE: u64 = 4096;
15296        let (mut meta, mut raw, mut dead) = (FreeList::new(), FreeList::new(), FreeList::new());
15297        dead.free(PAGE, 512);
15298        raw.free(PAGE + 512, 1024);
15299        PagedEdit::promote_whole_free_pages(&mut meta, &mut raw, &mut dead, PAGE);
15300        assert_eq!(dead.sections(), [(PAGE, 512)]);
15301        assert_eq!(raw.sections(), [(PAGE + 512, 1024)]);
15302    }
15303
15304    /// The shared-message flag is read from the message header's fourth byte, and
15305    /// the two attribute paths have to read the same one: the compact pass
15306    /// refuses such a message as it copies, the dense pass before it reads a set
15307    /// that would resolve it into something indistinguishable from a private
15308    /// attribute. A wrong offset here reads a *size* byte and refuses or accepts
15309    /// by accident.
15310    #[test]
15311    fn a_shared_attribute_message_is_told_from_a_private_one_by_its_flags() {
15312        let body = crate::type_builders::build_attr_message("a", &AttrValue::I64(1))
15313            .serialize(LENGTH_SIZE);
15314        let private = message_record(MessageType::Attribute, &body);
15315        assert!(!region_has_shared_attr(&plain_region(private.clone())).unwrap());
15316
15317        let mut shared = private.clone();
15318        shared[3] = 0x02; // H5O_MSG_FLAG_SHARED
15319        assert!(region_has_shared_attr(&plain_region(shared)).unwrap());
15320
15321        // The flag is only read on an Attribute message: the same byte set on a
15322        // neighbouring message says nothing about attribute storage.
15323        let mut other = message_record(MessageType::Dataspace, &body);
15324        other[3] = 0x02;
15325        assert!(!region_has_shared_attr(&plain_region(other)).unwrap());
15326    }
15327
15328    /// An object-reference attribute named `name` pointing at `address`.
15329    ///
15330    /// Built by taking a `u64` attribute — whose value is already the 8 little-
15331    /// endian bytes an object reference is stored as — and relabelling its
15332    /// datatype, because no public API stages one: [`AttrValue`] has no
15333    /// reference variant, so a file carrying such an attribute was written by
15334    /// the reference C library, and the copy path re-emits its bytes verbatim
15335    /// like any other attribute's.
15336    fn reference_attr(name: &str, address: u64) -> crate::attribute::AttributeMessage {
15337        let mut attr = crate::type_builders::build_attr_message(name, &AttrValue::U64(address));
15338        attr.datatype = Datatype::Reference {
15339            size: 8,
15340            ref_type: crate::datatype::ReferenceType::Object,
15341        };
15342        assert_eq!(
15343            attr.raw_data,
15344            address.to_le_bytes(),
15345            "the value is the address"
15346        );
15347        attr
15348    }
15349
15350    /// Wrap a message body in the object-header record a header region holds it
15351    /// in: type, body size, flags, body.
15352    fn message_record(msg_type: MessageType, body: &[u8]) -> Vec<u8> {
15353        OhRecordLayout::PLAIN.record(msg_type, body)
15354    }
15355
15356    /// A region of plain (4-byte-record) messages, the layout every writer in
15357    /// this crate emits.
15358    fn plain_region(bytes: Vec<u8>) -> OhRegion {
15359        OhRegion::new(bytes, OhHeaderProps::PLAIN)
15360    }
15361
15362    /// A header region holding one attribute inline.
15363    fn inline_attr_region(attr: &crate::attribute::AttributeMessage) -> OhRegion {
15364        plain_region(message_record(
15365            MessageType::Attribute,
15366            &attr.serialize_v3(LENGTH_SIZE),
15367        ))
15368    }
15369
15370    /// The header region of a one-element *compact* object-reference dataset:
15371    /// its datatype, and a version 3 compact data-layout message carrying the
15372    /// element inline (version, class 0, a 2-byte inline size, then the data).
15373    fn compact_reference_region(address: u64) -> OhRegion {
15374        let mut region = message_record(
15375            MessageType::Datatype,
15376            &crate::type_builders::make_object_reference_type().serialize(),
15377        );
15378        let mut layout = vec![3u8, 0];
15379        layout.extend_from_slice(&8u16.to_le_bytes());
15380        layout.extend_from_slice(&address.to_le_bytes());
15381        region.extend_from_slice(&message_record(MessageType::DataLayout, &layout));
15382        plain_region(region)
15383    }
15384
15385    /// A *compact* dataset keeps its elements inside the data-layout message
15386    /// rather than in a data block, so the copy screen reads them straight out
15387    /// of the header region (issue #317).
15388    ///
15389    /// Driven from a hand-built region because no writer in this crate emits a
15390    /// compact layout — the files that carry one come from the reference C
15391    /// library, which is also why the userblock suite notes that its
15392    /// compact-layout fixtures have to come from there.
15393    #[test]
15394    fn a_copied_compact_reference_dataset_is_screened() {
15395        let empty = BytesSource::new(Vec::new());
15396        let invalidated = InvalidatedAddresses {
15397            removed: vec![(248, 71)],
15398            moved: Vec::new(),
15399            base: BaseAddress::ZERO,
15400        };
15401        for (address, refused) in [(248u64, true), (318, true), (319, false)] {
15402            let tree = CopyTree::DatasetVerbatim {
15403                region: compact_reference_region(address),
15404                dense_attrs: DenseAttrSet::default(),
15405            };
15406            let got = screen_copied_references(&tree, &invalidated, &empty);
15407            assert_eq!(got.is_err(), refused, "compact element {address}: {got:?}");
15408        }
15409
15410        // A region whose layout message yields no inline data leaves a reference
15411        // datatype unscreened, so it is refused rather than waved through.
15412        // `read_object` builds this variant only from a compact layout, so this
15413        // is a header that did not parse as one — malformed input, not a shape
15414        // the copy path produces.
15415        let no_layout = CopyTree::DatasetVerbatim {
15416            region: plain_region(message_record(
15417                MessageType::Datatype,
15418                &crate::type_builders::make_object_reference_type().serialize(),
15419            )),
15420            dense_attrs: DenseAttrSet::default(),
15421        };
15422        let err = screen_copied_references(&no_layout, &invalidated, &empty).unwrap_err();
15423        assert!(
15424            err.to_string().contains("could not be read to screen"),
15425            "got: {err}"
15426        );
15427    }
15428
15429    /// A datatype that declares an object reference the element bytes have no
15430    /// room for cannot be walked, so its addresses cannot be screened — and a
15431    /// commit that reclaims space refuses it rather than writing references past
15432    /// a screen that could not read them (issue #317).
15433    ///
15434    /// Reachable only from a malformed source header: every datatype this crate
15435    /// builds sizes its element to its members. Driven directly for that reason.
15436    #[test]
15437    fn a_datatype_whose_reference_slots_do_not_fit_is_refused() {
15438        use crate::datatype::{CompoundMember, ReferenceType};
15439        // An 8-byte compound declaring an 8-byte reference at offset 4: the slot
15440        // runs four bytes past the element.
15441        let dt = Datatype::Compound {
15442            size: 8,
15443            members: vec![CompoundMember {
15444                name: "r".to_string(),
15445                byte_offset: 4,
15446                datatype: Datatype::Reference {
15447                    size: 8,
15448                    ref_type: ReferenceType::Object,
15449                },
15450            }],
15451        };
15452        assert!(
15453            embedded_reference_slots(&dt).is_none(),
15454            "the fixture must be one the walker cannot map"
15455        );
15456
15457        let raw = [0u8; 8];
15458        let invalidated = InvalidatedAddresses {
15459            removed: vec![(248, 71)],
15460            moved: Vec::new(),
15461            base: BaseAddress::ZERO,
15462        };
15463        let err = screen_resolved_references(&dt, &raw, &invalidated).unwrap_err();
15464        assert!(
15465            err.to_string().contains("this screen cannot read"),
15466            "got: {err}"
15467        );
15468
15469        // Unconditional: a screen holding nothing still refuses, because the
15470        // refusal is about addresses that cannot be read rather than about what
15471        // the commit is vacating. `moved` is never empty in a real commit, so
15472        // there is no case this could have been made conditional on.
15473        let nothing = InvalidatedAddresses {
15474            removed: Vec::new(),
15475            moved: Vec::new(),
15476            base: BaseAddress::ZERO,
15477        };
15478        assert!(screen_resolved_references(&dt, &raw, &nothing).is_err());
15479    }
15480
15481    /// A variable-length *of object references* is refused rather than skipped:
15482    /// its addresses live in the global heap the elements point at, not in the
15483    /// element bytes this screen reads (issue #317).
15484    ///
15485    /// The reference C library writes such a datatype (`H5T_VLEN` of
15486    /// `H5T_STD_REF_OBJ`) and an in-file copy carries it, so it reaches the
15487    /// screen through `copy` even though nothing here builds one. A
15488    /// variable-length *string* is unaffected — the heap it points at is never
15489    /// reclaimed by a delete, so its elements stay valid.
15490    #[test]
15491    fn a_variable_length_of_object_references_is_refused() {
15492        use crate::datatype::{CharacterSet, ReferenceType};
15493        let of_references = Datatype::VariableLength {
15494            is_string: false,
15495            padding: None,
15496            charset: None,
15497            base_type: Box::new(Datatype::Reference {
15498                size: 8,
15499                ref_type: ReferenceType::Object,
15500            }),
15501        };
15502        let of_strings = crate::type_builders::make_vlen_string_type(CharacterSet::Utf8);
15503        let raw = vec![0u8; 32];
15504        let invalidated = InvalidatedAddresses {
15505            removed: vec![(248, 71)],
15506            moved: Vec::new(),
15507            base: BaseAddress::ZERO,
15508        };
15509
15510        let err = screen_resolved_references(&of_references, &raw, &invalidated).unwrap_err();
15511        assert!(
15512            err.to_string().contains("this screen cannot read"),
15513            "got: {err}"
15514        );
15515        assert!(
15516            screen_resolved_references(&of_strings, &raw, &invalidated).is_ok(),
15517            "a variable-length string points at a heap no delete reclaims"
15518        );
15519    }
15520
15521    /// An object reference wider than the 8 bytes the slot walker maps is
15522    /// refused, not skipped (issue #317).
15523    ///
15524    /// [`datatype_holds_object_address`] answers for an object reference of
15525    /// *any* width, while [`embedded_reference_slots`] locates only the 8-byte
15526    /// one — so the walker reports a width it cannot address as unwalkable
15527    /// rather than as "no slots here", which a screen would read as "nothing to
15528    /// check". `Dataset::dereference` reads such an element, taking the address
15529    /// from its first eight bytes, so the reference is live.
15530    #[test]
15531    fn an_object_reference_wider_than_eight_bytes_is_refused() {
15532        use crate::datatype::ReferenceType;
15533        let dt = Datatype::Reference {
15534            size: 16,
15535            ref_type: ReferenceType::Object,
15536        };
15537        assert!(
15538            embedded_reference_slots(&dt).is_none(),
15539            "the walker must report a width it cannot map, not an empty slot list"
15540        );
15541
15542        let mut raw = vec![0u8; 16];
15543        raw[..8].copy_from_slice(&300u64.to_le_bytes());
15544        let invalidated = InvalidatedAddresses {
15545            removed: vec![(248, 71)],
15546            moved: Vec::new(),
15547            base: BaseAddress::ZERO,
15548        };
15549        let err = screen_resolved_references(&dt, &raw, &invalidated).unwrap_err();
15550        assert!(
15551            err.to_string().contains("this screen cannot read"),
15552            "got: {err}"
15553        );
15554
15555        // Unconditional, for the reason
15556        // `a_datatype_whose_reference_slots_do_not_fit_is_refused` states.
15557        let nothing = InvalidatedAddresses {
15558            removed: Vec::new(),
15559            moved: Vec::new(),
15560            base: BaseAddress::ZERO,
15561        };
15562        assert!(screen_resolved_references(&dt, &raw, &nothing).is_err());
15563    }
15564
15565    /// A reference target supplied as a *raw address* is screened exactly as one
15566    /// supplied as a path (issue #317), and the address it would have resolved
15567    /// to is returned unchanged when it names space this commit keeps.
15568    ///
15569    /// Driven directly because nothing stages a `Raw` target carrying a real
15570    /// address: a builder reachable from a session produces only
15571    /// [`ObjectRefTarget::Path`], and [`crate::repack`]'s faithful re-emit
15572    /// resolves every real target to a `Path`, leaving `Raw` for the null and
15573    /// undefined references alone. The arm exists so that the two ways a target
15574    /// can name an object answer to the same rule rather than to whichever one
15575    /// a caller happened to use, and this is what holds it to that.
15576    /// A file with nothing a reference could live in is walked once and never
15577    /// again, and one that holds a reference is walked every time.
15578    ///
15579    /// The first half is the whole of `proved_free_of_references`, and it is
15580    /// invisible from outside the session: a build that never caches the proof
15581    /// writes byte-identical files and passes every other test here, having
15582    /// silently gone back to walking the file on every commit. The second half
15583    /// is what stops a too-eager proof from being the fix for the first.
15584    #[test]
15585    fn a_file_proved_free_of_references_is_walked_once_and_no_more() {
15586        use crate::reference_patch::{reset_walks, walks};
15587        use tempfile::tempdir;
15588        let dir = tempdir().unwrap();
15589
15590        let commit_three = |path: &std::path::Path| {
15591            let session = crate::File::open_rw(path).unwrap();
15592            for i in 0..3 {
15593                session
15594                    .root()
15595                    .create_dataset(&format!("added{i}"), |b| {
15596                        b.with_i32_data(&[i]);
15597                    })
15598                    .unwrap();
15599                session.commit().unwrap();
15600            }
15601        };
15602
15603        let plain = dir.path().join("plain.h5");
15604        let mut b = crate::writer::FileBuilder::new();
15605        b.create_dataset("d").with_i32_data(&[1, 2, 3]);
15606        b.write(&plain).unwrap();
15607        reset_walks();
15608        commit_three(&plain);
15609        assert_eq!(
15610            walks(),
15611            1,
15612            "the first commit's walk proves the file reference-free; the rest \
15613             must take its word for it"
15614        );
15615
15616        let referencing = dir.path().join("referencing.h5");
15617        let mut b = crate::writer::FileBuilder::new();
15618        b.create_dataset("d").with_i32_data(&[1, 2, 3]);
15619        b.create_dataset("refs").with_path_references(&["d"]);
15620        b.write(&referencing).unwrap();
15621        reset_walks();
15622        commit_three(&referencing);
15623        assert_eq!(
15624            walks(),
15625            3,
15626            "a file that holds a reference is never proved free of one, so every \
15627             commit walks it"
15628        );
15629    }
15630
15631    #[test]
15632    fn a_raw_reference_target_is_screened_like_a_path_one() {
15633        use tempfile::tempdir;
15634        let dir = tempdir().unwrap();
15635        let path = dir.path().join("raw_target.h5");
15636        let mut b = crate::writer::FileBuilder::new();
15637        b.create_dataset("d").with_i32_data(&[1, 2, 3]);
15638        b.write(&path).unwrap();
15639        let engine = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
15640
15641        let nodes: BTreeMap<PathKey, Node> = BTreeMap::new();
15642        let path_addr: BTreeMap<PathKey, u64> = BTreeMap::new();
15643        let resolve = |address: u64, removed: Vec<(u64, u64)>, moved: Vec<u64>| {
15644            WriteEngine::resolve_reference_target(
15645                &ObjectRefTarget::Raw(address),
15646                &path_addr,
15647                &nodes,
15648                &[],
15649                &[],
15650                &[],
15651                &InvalidatedAddresses {
15652                    removed,
15653                    moved,
15654                    base: BaseAddress::ZERO,
15655                },
15656                &engine.image(),
15657                engine.superblock(),
15658            )
15659        };
15660
15661        assert!(
15662            resolve(300, vec![(248, 71)], Vec::new()).is_err(),
15663            "an address inside a reclaimed span is refused"
15664        );
15665        assert!(
15666            resolve(300, Vec::new(), vec![300]).is_err(),
15667            "an address this commit rewrites elsewhere is refused"
15668        );
15669        assert_eq!(
15670            resolve(300, vec![(400, 71)], vec![299, 301]).unwrap(),
15671            300,
15672            "an address outside every reclaimed span and every moved header is carried through"
15673        );
15674        assert_eq!(
15675            resolve(300, Vec::new(), Vec::new()).unwrap(),
15676            300,
15677            "a commit that reclaims nothing screens nothing"
15678        );
15679        // The two sentinels name no object, so they are carried through even
15680        // when they fall inside a reclaimed span.
15681        assert_eq!(resolve(0, vec![(0, 4096)], vec![0]).unwrap(), 0);
15682        assert_eq!(
15683            resolve(UNDEF, vec![(0, u64::MAX)], vec![UNDEF]).unwrap(),
15684            UNDEF
15685        );
15686    }
15687
15688    /// A copied object's *attributes* are screened against the space the commit
15689    /// reclaims, in either storage an object can hold them in — inline in the
15690    /// header region, or in the fractal heap a dense object uses — and an
15691    /// address outside those spans passes in both (issue #317).
15692    #[test]
15693    fn a_copied_reference_attribute_is_screened_in_both_storages() {
15694        let empty = BytesSource::new(Vec::new());
15695        let invalidated = InvalidatedAddresses {
15696            removed: vec![(248, 71)],
15697            moved: Vec::new(),
15698            base: BaseAddress::ZERO,
15699        };
15700        // 248 is the first byte of the reclaimed span, 318 its last, 319 the
15701        // byte after it.
15702        for (address, refused) in [(248u64, true), (318, true), (319, false), (247, false)] {
15703            let attr = reference_attr("target", address);
15704            let dense = CopyTree::DatasetVerbatim {
15705                region: OhRegion::default(),
15706                dense_attrs: DenseAttrSet {
15707                    attrs: vec![attr.clone()],
15708                    creation: DenseAttrCreationOrder::Untracked,
15709                },
15710            };
15711            let inline = CopyTree::DatasetVerbatim {
15712                region: inline_attr_region(&attr),
15713                dense_attrs: DenseAttrSet::default(),
15714            };
15715            for (storage, tree) in [("dense", &dense), ("inline", &inline)] {
15716                let got = screen_copied_references(tree, &invalidated, &empty);
15717                assert_eq!(
15718                    got.is_err(),
15719                    refused,
15720                    "{storage} attribute at {address}: {got:?}"
15721                );
15722            }
15723        }
15724    }
15725
15726    /// Collect the message types present in a chunk-0 region, in order.
15727    fn region_types(region: &OhRegion) -> Vec<MessageType> {
15728        let mut out = Vec::new();
15729        let mut p = 0;
15730        while let Some((mt, _, end)) = region.next_message(p).unwrap() {
15731            out.push(mt);
15732            p = end;
15733        }
15734        out
15735    }
15736
15737    /// Stopping an in-place append (`append_inplace`) at any phase boundary must
15738    /// leave the file readable as a consistent prefix — the old length until the
15739    /// phase-4 dimension commit, the new length after it — even though a
15740    /// partial-tail append repoints the visible trailing element in place. Mirrors
15741    /// `Dataset::append`'s crash-consistency harness, but driven through
15742    /// the in-place edit engine's own mirror (disk-before-mirror ordering) to prove the shared
15743    /// engine is crash-safe under both owners. Three starting layouts: the trailing
15744    /// element inline in the index block (chunk 4, n 6), in a data block
15745    /// (chunk 2, n 9, slot 0), and a *filtered* inline one (issue #393), whose
15746    /// repointed element is the three-field record rather than a bare address and
15747    /// whose relocated chunk has to decode to the old prefix at every phase
15748    /// before the dimension moves.
15749    #[test]
15750    fn append_inplace_crash_consistency_partial_tail_prefix() {
15751        use crate::reader::File as PureFile;
15752        use crate::writer::FileBuilder;
15753        use tempfile::tempdir;
15754
15755        let build = |path: &std::path::Path, n: i32, chunk: u64, deflate: bool| {
15756            let data: Vec<i32> = (0..n).collect();
15757            let mut b = FileBuilder::new();
15758            let d = b
15759                .create_dataset("d")
15760                .with_i32_data(&data)
15761                .with_shape(&[n as u64])
15762                .with_maxshape(&[u64::MAX])
15763                .with_chunks(&[chunk]);
15764            if deflate {
15765                d.with_deflate(6);
15766            }
15767            b.write(path).unwrap();
15768        };
15769
15770        for (n, chunk, add, deflate) in [
15771            (6i32, 4u64, 5i32, false),
15772            (9, 2, 6, false),
15773            (6, 4, 5, true),
15774            (9, 4, 3, true),
15775        ] {
15776            let dir = tempdir().unwrap();
15777            let base = dir.path().join("base.h5");
15778            build(&base, n, chunk, deflate);
15779
15780            for max_phase in 1u8..=4 {
15781                let p = dir
15782                    .path()
15783                    .join(format!("crash_{n}_{chunk}_{deflate}_{max_phase}.h5"));
15784                std::fs::copy(&base, &p).unwrap();
15785                {
15786                    let mut s = WriteEngine::open_with_locking(&p, FileLocking::Enabled).unwrap();
15787                    s.append_inplace_i32_phased("d", &(n..n + add).collect::<Vec<_>>(), max_phase)
15788                        .unwrap();
15789                    // session dropped here, simulating a crash after `max_phase`
15790                }
15791                let expected_len = if max_phase == 4 { n + add } else { n };
15792                let f = PureFile::from_bytes(std::fs::read(&p).unwrap()).unwrap();
15793                assert_eq!(
15794                    f.dataset("d").unwrap().read_i32().unwrap(),
15795                    (0..expected_len).collect::<Vec<_>>(),
15796                    "inconsistent view after crash at phase {max_phase} (n={n}, chunk={chunk}, \
15797                     deflate={deflate})"
15798                );
15799            }
15800        }
15801    }
15802
15803    /// Growing a partial trailing chunk decodes and re-encodes elements that are
15804    /// already committed, so a **lossy** pipeline is refused on the immediate
15805    /// in-place path, with the file left exactly as it was.
15806    ///
15807    /// The values are the point rather than the error: measured on this fixture
15808    /// before the refusal existed, the committed pair `[4.5, 5.5]` read back as
15809    /// `[16.0, 0.0]` once two more elements joined their ZFP block. That is
15810    /// quantization following the block's new contents, not a decode that lost
15811    /// its place — a *fresh* chunk of `[99.0, 98.0, 97.0]` at this rate reads
15812    /// back as `[98.0, 102.0, 90.0]` with nothing decoded anywhere, which is what
15813    /// eight bits per value buys and is not this test's business.
15814    #[cfg(feature = "zfp")]
15815    #[test]
15816    fn append_onto_a_lossy_partial_tail_is_refused() {
15817        use crate::reader::File as PureFile;
15818        use crate::writer::FileBuilder;
15819        use tempfile::tempdir;
15820
15821        let dir = tempdir().unwrap();
15822        let committed: Vec<f64> = vec![0.5, 1.5, 2.5, 3.5, 4.5, 5.5];
15823
15824        // `d` is 6 of a chunk of 4: a partial trailing chunk holding 4.5 and 5.5.
15825        let build = |path: &std::path::Path| {
15826            let mut b = FileBuilder::new();
15827            b.create_dataset("d")
15828                .with_f64_data(&committed)
15829                .with_shape(&[6])
15830                .with_maxshape(&[u64::MAX])
15831                .with_chunks(&[4])
15832                .with_zfp(8.0);
15833            b.write(path).unwrap();
15834        };
15835
15836        // The immediate in-place path.
15837        let p = dir.path().join("inplace.h5");
15838        build(&p);
15839        let before = std::fs::read(&p).unwrap();
15840        {
15841            let f = crate::reader::File::open_rw(&p).unwrap();
15842            let err = f
15843                .dataset("d")
15844                .unwrap()
15845                .append(&[99.0f64, 98.0, 97.0])
15846                .expect_err("a lossy pipeline must not have its trailing chunk re-encoded");
15847            assert!(
15848                matches!(&err, Error::AppendInPlaceUnsupported(m) if m.contains("lossy")),
15849                "got: {err:?}"
15850            );
15851        }
15852        assert_eq!(
15853            std::fs::read(&p).unwrap(),
15854            before,
15855            "the refusal wrote bytes"
15856        );
15857        assert_eq!(
15858            PureFile::open(&p)
15859                .unwrap()
15860                .dataset("d")
15861                .unwrap()
15862                .read_f64()
15863                .unwrap(),
15864            committed
15865        );
15866
15867        // A chunk-aligned start needs no rewrite, so it is still accepted and the
15868        // committed values are still exactly what they were.
15869        let p = dir.path().join("aligned.h5");
15870        {
15871            let mut b = FileBuilder::new();
15872            b.create_dataset("d")
15873                .with_f64_data(&committed[..4])
15874                .with_shape(&[4])
15875                .with_maxshape(&[u64::MAX])
15876                .with_chunks(&[4])
15877                .with_zfp(8.0);
15878            b.write(&p).unwrap();
15879        }
15880        {
15881            let f = crate::reader::File::open_rw(&p).unwrap();
15882            f.dataset("d").unwrap().append(&[9.5f64, 8.5, 7.5]).unwrap();
15883        }
15884        let back = PureFile::open(&p)
15885            .unwrap()
15886            .dataset("d")
15887            .unwrap()
15888            .read_f64()
15889            .unwrap();
15890        assert_eq!(back[..4], committed[..4], "an untouched chunk changed");
15891
15892        // The staged path rebuilds the index at commit and rewrote the same chunk
15893        // on the way; it refuses at the call that stages the append, so nothing is
15894        // staged and the file is left alone.
15895        let p = dir.path().join("staged.h5");
15896        build(&p);
15897        let before = std::fs::read(&p).unwrap();
15898        {
15899            let f = crate::reader::File::open_rw(&p).unwrap();
15900            let err = f
15901                .dataset("d")
15902                .unwrap()
15903                .append_staged(|b| {
15904                    b.append_f64(&[99.0, 98.0, 97.0]);
15905                })
15906                .expect_err("a staged append must not re-encode the committed tail either");
15907            assert!(
15908                matches!(&err, Error::AppendUnsupported(m) if m.contains("lossy")),
15909                "got: {err:?}"
15910            );
15911            f.close().unwrap();
15912        }
15913        assert_eq!(
15914            std::fs::read(&p).unwrap(),
15915            before,
15916            "the refusal wrote bytes"
15917        );
15918
15919        // A buffered appender over the unaligned shape is refused when it is
15920        // made, since its very first write would be the rewrite above.
15921        let p = dir.path().join("buffered.h5");
15922        build(&p);
15923        {
15924            let f = crate::reader::File::open_rw(&p).unwrap();
15925            let mut ds = f.dataset("d").unwrap();
15926            let err = ds
15927                .buffered_appender()
15928                .expect_err("its first write would re-encode the committed tail");
15929            assert!(
15930                matches!(&err, Error::AppendInPlaceUnsupported(m) if m.contains("lossy")),
15931                "got: {err:?}"
15932            );
15933        }
15934    }
15935
15936    /// Float D-scale scale-offset is the other lossy mode, and is refused for the
15937    /// same reason — while the **integer** mode, which is lossless, grows its
15938    /// partial trailing chunk with every value intact. Both halves matter: a
15939    /// predicate that refused all of scale-offset would pass the first assertion
15940    /// and take a working case away.
15941    #[test]
15942    fn a_lossy_scale_offset_mode_is_refused_and_a_lossless_one_is_not() {
15943        use crate::reader::File as PureFile;
15944        use crate::scaleoffset::ScaleOffset;
15945        use crate::writer::FileBuilder;
15946        use tempfile::tempdir;
15947
15948        let dir = tempdir().unwrap();
15949
15950        let p = dir.path().join("dscale.h5");
15951        {
15952            let mut b = FileBuilder::new();
15953            b.create_dataset("d")
15954                .with_f64_data(&[0.5, 1.5, 2.5, 3.5, 4.5, 5.5])
15955                .with_shape(&[6])
15956                .with_maxshape(&[u64::MAX])
15957                .with_chunks(&[4])
15958                .with_scale_offset(ScaleOffset::FloatDScale(1));
15959            b.write(&p).unwrap();
15960        }
15961        {
15962            let f = crate::reader::File::open_rw(&p).unwrap();
15963            let err = f
15964                .dataset("d")
15965                .unwrap()
15966                .append(&[99.0f64, 98.0, 97.0])
15967                .expect_err("float D-scale is lossy");
15968            assert!(
15969                matches!(&err, Error::AppendInPlaceUnsupported(m) if m.contains("lossy")),
15970                "got: {err:?}"
15971            );
15972        }
15973
15974        let p = dir.path().join("int.h5");
15975        {
15976            let mut b = FileBuilder::new();
15977            b.create_dataset("d")
15978                .with_i32_data(&(0..6).collect::<Vec<_>>())
15979                .with_shape(&[6])
15980                .with_maxshape(&[u64::MAX])
15981                .with_chunks(&[4])
15982                .with_scale_offset(ScaleOffset::Integer(0));
15983            b.write(&p).unwrap();
15984        }
15985        {
15986            let f = crate::reader::File::open_rw(&p).unwrap();
15987            let mut ds = f.dataset("d").unwrap();
15988            ds.append(&[6i32, 7, 8]).unwrap();
15989            ds.append(&[9i32]).unwrap();
15990        }
15991        assert_eq!(
15992            PureFile::open(&p)
15993                .unwrap()
15994                .dataset("d")
15995                .unwrap()
15996                .read_i32()
15997                .unwrap(),
15998            (0..10).collect::<Vec<_>>()
15999        );
16000    }
16001
16002    /// A *filtered* append whose length is not a whole number of chunks writes a
16003    /// partial last chunk. That chunk's index element is a fresh insert past the
16004    /// old dimension — the same position every whole chunk beside it occupies —
16005    /// so stopping anywhere in the durability sequence must still read back as
16006    /// the old prefix, exactly as an aligned filtered append does. This is the
16007    /// case the in-place refusal used to cover and no longer does; without the
16008    /// phase sweep, "it reads back fine" would only be testing phase 4.
16009    #[test]
16010    fn append_inplace_crash_consistency_filtered_partial_last_chunk() {
16011        use crate::reader::File as PureFile;
16012        use crate::writer::FileBuilder;
16013        use tempfile::tempdir;
16014
16015        let build = |path: &std::path::Path, n: i32, chunk: u64| {
16016            let data: Vec<i32> = (0..n).collect();
16017            let mut b = FileBuilder::new();
16018            b.create_dataset("d")
16019                .with_i32_data(&data)
16020                .with_shape(&[n as u64])
16021                .with_maxshape(&[u64::MAX])
16022                .with_chunks(&[chunk])
16023                .with_shuffle()
16024                .with_deflate(4);
16025            b.write(path).unwrap();
16026        };
16027
16028        // Aligned starts, unaligned lengths: one that stays inside a single new
16029        // chunk, and one that spans several and ends partway through the last.
16030        // (An unaligned *start* is the sibling sweep,
16031        // `append_inplace_crash_consistency_partial_tail_prefix`.)
16032        for (n, chunk, add) in [(8i32, 4u64, 2i32), (8, 4, 9), (0, 4, 3)] {
16033            let dir = tempdir().unwrap();
16034            let base = dir.path().join("base.h5");
16035            build(&base, n, chunk);
16036
16037            for max_phase in 1u8..=4 {
16038                let p = dir
16039                    .path()
16040                    .join(format!("crash_f_{n}_{chunk}_{max_phase}.h5"));
16041                std::fs::copy(&base, &p).unwrap();
16042                {
16043                    let mut s = WriteEngine::open_with_locking(&p, FileLocking::Enabled).unwrap();
16044                    s.append_inplace_i32_phased("d", &(n..n + add).collect::<Vec<_>>(), max_phase)
16045                        .unwrap();
16046                    // session dropped here, simulating a crash after `max_phase`
16047                }
16048                let expected_len = if max_phase == 4 { n + add } else { n };
16049                let f = PureFile::from_bytes(std::fs::read(&p).unwrap()).unwrap();
16050                assert_eq!(
16051                    f.dataset("d").unwrap().read_i32().unwrap(),
16052                    (0..expected_len).collect::<Vec<_>>(),
16053                    "inconsistent view after crash at phase {max_phase} (n={n}, chunk={chunk}, \
16054                     add={add})"
16055                );
16056            }
16057        }
16058    }
16059
16060    /// Build a one-element-per-chunk unlimited `d` holding `0..n`, the shape the
16061    /// crash-consistency harnesses below grow.
16062    fn build_unit_chunked(path: &std::path::Path, n: i32) {
16063        use crate::writer::FileBuilder;
16064        let data: Vec<i32> = (0..n).collect();
16065        let mut b = FileBuilder::new();
16066        b.create_dataset("d")
16067            .with_i32_data(&data)
16068            .with_shape(&[n as u64])
16069            .with_maxshape(&[u64::MAX])
16070            .with_chunks(&[1]);
16071        b.write(path).unwrap();
16072    }
16073
16074    /// Stop an append after `max_phase` durability phases and hand back the
16075    /// resulting file. Dropping the engine inside is the simulated crash: no
16076    /// further phases run and no close barrier is written.
16077    fn append_stopped_at(
16078        base: &std::path::Path,
16079        out: &std::path::Path,
16080        values: std::ops::Range<i32>,
16081        max_phase: u8,
16082    ) {
16083        std::fs::copy(base, out).unwrap();
16084        let mut s = WriteEngine::open_with_locking(out, FileLocking::Enabled).unwrap();
16085        s.append_inplace_i32_phased("d", &values.collect::<Vec<_>>(), max_phase)
16086            .unwrap();
16087    }
16088
16089    /// Growing an Extensible-Array index across its inline -> direct-block ->
16090    /// super-block boundaries touches far more index structure than the
16091    /// partial-tail case above. Stopping at any phase boundary must still read
16092    /// back as a consistent prefix.
16093    ///
16094    /// Restores coverage lost with the deprecated `SwmrWriter` (issue #202); the
16095    /// owned path drives the same `apply_ea_append` engine.
16096    #[test]
16097    fn append_inplace_crash_consistency_across_ea_boundaries() {
16098        use crate::reader::File as PureFile;
16099        use tempfile::tempdir;
16100
16101        let dir = tempdir().unwrap();
16102        let base = dir.path().join("base.h5");
16103        let (n, target) = (50i32, 250i32);
16104        build_unit_chunked(&base, n);
16105
16106        for max_phase in 1u8..=4 {
16107            let p = dir.path().join(format!("crash_ea_{max_phase}.h5"));
16108            append_stopped_at(&base, &p, n..target, max_phase);
16109            let expected_len = if max_phase == 4 { target } else { n };
16110            let f = PureFile::from_bytes(std::fs::read(&p).unwrap()).unwrap();
16111            assert_eq!(
16112                f.dataset("d").unwrap().read_i32().unwrap(),
16113                (0..expected_len).collect::<Vec<_>>(),
16114                "inconsistent view after crash at phase {max_phase}"
16115            );
16116        }
16117    }
16118
16119    /// The same guarantee for an append that crosses the paged-data-block
16120    /// boundary (~131,060 chunks), where phase 1 allocates a paged super block,
16121    /// paged data blocks, the per-page checksums, and the page-init bitmap. This
16122    /// is the most intricate in-place growth the engine performs, and truncating
16123    /// it partway is exactly what a power loss does.
16124    ///
16125    /// Restores coverage lost with the deprecated `SwmrWriter` (issue #202).
16126    /// Opening a paged persisting file must seed each free section into the list
16127    /// for the page type its *slot* names, not one derived from its size.
16128    ///
16129    /// The managers a paged file uses mean different things — SUPER (slot 0) is
16130    /// metadata, DRAW (slot 2) is raw — and a freed section's size says nothing
16131    /// about which it came from. Getting this wrong is invisible from the outside:
16132    /// the total free space is unchanged, the reference library still opens the
16133    /// file, and only a later allocation drawn from the wrong list would mix a
16134    /// page. So assert the routing directly.
16135    #[test]
16136    fn paged_open_seeds_each_manager_by_slot() {
16137        use crate::writer::FileBuilder;
16138        use tempfile::tempdir;
16139
16140        let dir = tempdir().unwrap();
16141        let path = dir.path().join("paged_seed.h5");
16142        let mut b = FileBuilder::new();
16143        b.create_dataset("d")
16144            .with_i32_data(&(0..1000).collect::<Vec<i32>>())
16145            .with_shape(&[1000]);
16146        b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
16147            .with_file_space_page_size(4096);
16148        b.write(&path).unwrap();
16149
16150        // Read the file's recorded free space *before* opening the session: the
16151        // session holds an exclusive OS lock, and on Windows those locks are
16152        // mandatory, so a concurrent `File::open` would fail outright.
16153        let on_disk: u64 = crate::reader::File::open(&path)
16154            .unwrap()
16155            .persisted_free_space()
16156            .iter()
16157            .map(|&(_, l)| l)
16158            .sum();
16159
16160        let s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16161        let pg = s.paged.as_ref().expect("a paged file installs paged state");
16162        assert_eq!(pg.page_size, 4096);
16163
16164        // The from-scratch writer leaves a page tail free in both the metadata and
16165        // the raw pages, so both per-type managers are populated. If every slot
16166        // were funnelled into one list, one of these would be empty.
16167        assert!(
16168            !pg.meta.sections().is_empty(),
16169            "SUPER (slot 0) sections seed the metadata list"
16170        );
16171        assert!(
16172            !pg.raw.sections().is_empty(),
16173            "DRAW (slot 2) sections seed the raw list, not the metadata list"
16174        );
16175
16176        // Nothing is double-counted or dropped: the lists partition exactly the
16177        // free space the file records, and no two sections overlap. A file this
16178        // crate wrote has nothing unclassified — it only ever files a whole
16179        // aligned page under the generic-large manager — so summing it in is a
16180        // statement about that too.
16181        let mut all = pg.reusable_sections();
16182        all.extend(pg.unclassified.sections());
16183        assert!(
16184            pg.unclassified.sections().is_empty(),
16185            "our own paged writer files nothing whose page type is unknown"
16186        );
16187        let flat: u64 = all.iter().map(|&(_, l)| l).sum();
16188        assert_eq!(
16189            flat, on_disk,
16190            "the split lists hold exactly the file's free space"
16191        );
16192        all.sort_by_key(|&(a, _)| a);
16193        let mut prev_end = 0u64;
16194        for (addr, len) in all {
16195            assert!(addr >= prev_end, "the per-type lists do not overlap");
16196            prev_end = addr + len;
16197        }
16198    }
16199
16200    /// Deleting a chunked dataset from a paged file the *reference library* wrote
16201    /// must not offer its chunk index for raw reuse.
16202    ///
16203    /// A chunk index is metadata by the format's taxonomy, and the C library
16204    /// allocates one accordingly — out of metadata pages, which on a small file
16205    /// means page 0, alongside the superblock, the root group, and every other
16206    /// object's header. This crate places its own indexes in raw pages beside the
16207    /// chunk data instead, and the reclaim path used to assume that of every file.
16208    /// Freeing a C-written index under that assumption puts its bytes on the raw
16209    /// list, and the next commit writes a dataset's values into the middle of a
16210    /// live metadata page.
16211    ///
16212    /// Both libraries still read such a file — every address in it is still
16213    /// correct — so nothing downstream reports the damage. The assertion has to be
16214    /// about placement itself: no byte this commit writes may land in a page that
16215    /// held live metadata beforehand.
16216    #[test]
16217    // The file is a committed one the reference C library wrote, see
16218    // `crates/crosscheck/tests/c_test_data.rs`.
16219    fn a_c_written_chunk_index_is_not_reclaimed_as_raw() {
16220        use tempfile::tempdir;
16221
16222        const PAGE: u64 = 4096;
16223        let dir = tempdir().unwrap();
16224        let path = crate::test_data::copy("c/paged_index.h5", dir.path());
16225
16226        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16227        let page_size = s.paged.as_ref().expect("a paged file").page_size;
16228        assert_eq!(page_size, PAGE);
16229        // Where the C library put the index: every page it touches is a metadata
16230        // page, since the C library allocates an index as metadata.
16231        let victim_addr =
16232            crate::group_v2::resolve_path_any(s.image.as_slice().unwrap(), &s.superblock, "victim")
16233                .unwrap();
16234        let index_spans = s
16235            .chunked_index_spans(usize::try_from(victim_addr).unwrap())
16236            .expect("the C library's extensible-array index is enumerable");
16237        assert!(!index_spans.is_empty());
16238        // Page 0 always holds the superblock and the root group header; the index
16239        // pages are what this test is really about.
16240        let mut meta_pages: Vec<u64> = vec![0];
16241        for (addr, len) in &index_spans {
16242            for p in (addr / PAGE)..=((addr + len - 1) / PAGE) {
16243                meta_pages.push(p);
16244            }
16245        }
16246        meta_pages.sort_unstable();
16247        meta_pages.dedup();
16248
16249        // Delete the dataset, then write a small dataset that would fit in the
16250        // index's freed bytes.
16251        s.delete("/victim").unwrap();
16252        s.commit().unwrap();
16253        let mut db = crate::type_builders::DatasetBuilder::new("added");
16254        db.with_f64_data(&[2.5f64; 8]).with_shape(&[8]);
16255        s.stage_created_dataset("/added", db).unwrap();
16256        s.commit().unwrap();
16257
16258        // Release the session's exclusive OS lock before reading the file back;
16259        // those locks are mandatory on Windows.
16260        drop(s);
16261
16262        // Every raw byte of the new dataset must be outside those pages.
16263        let f = crate::reader::File::open(&path).unwrap();
16264        let added = f.dataset("added").unwrap();
16265        let crate::Layout::Contiguous {
16266            address: Some(addr),
16267            size,
16268        } = added.layout().unwrap()
16269        else {
16270            panic!("a small f64 dataset is stored contiguously");
16271        };
16272        for p in (addr / PAGE)..=((addr + size - 1) / PAGE) {
16273            assert!(
16274                !meta_pages.contains(&p),
16275                "the new dataset's raw data landed at ({addr}, {size}), in page {p}, \
16276                 which held live metadata before the edit: {meta_pages:?}"
16277            );
16278        }
16279
16280        assert_eq!(added.read_f64().unwrap(), vec![2.5f64; 8]);
16281        assert_eq!(
16282            f.dataset("keep").unwrap().read_i32().unwrap(),
16283            vec![1, 2, 3, 4]
16284        );
16285    }
16286
16287    /// Padding a tail page whose type is unknown must leave the padding
16288    /// untracked, not guess a type for it.
16289    ///
16290    /// A commit pads the file to a page boundary before laying down its manager
16291    /// blocks. When this session has made no typed allocation, the tail page is
16292    /// one a previous session left non-aligned — only a crash does that, since a
16293    /// clean close pads — and nothing says what it holds. Recording the padding
16294    /// under a guess would advertise those bytes for reuse of that type, and half
16295    /// the time they sit in a page of the other one. Under-reporting is the safe
16296    /// direction, and it is the call [`PagedEdit::begin`] already makes for the
16297    /// same situation.
16298    ///
16299    /// Reuse is what made this reachable: before it, every commit appended at
16300    /// least the root group's header, so the tail type was always known by the
16301    /// time the padding ran.
16302    #[test]
16303    fn padding_a_tail_page_of_unknown_type_records_nothing() {
16304        use crate::writer::FileBuilder;
16305        use tempfile::tempdir;
16306
16307        const PAGE: u64 = 4096;
16308        let dir = tempdir().unwrap();
16309
16310        let build = |path: &std::path::Path| {
16311            let mut b = FileBuilder::new();
16312            b.create_dataset("d")
16313                .with_i32_data(&(0..1000).collect::<Vec<i32>>())
16314                .with_shape(&[1000]);
16315            b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
16316                .with_file_space_page_size(PAGE);
16317            b.write(path).unwrap();
16318        };
16319
16320        // Unknown tail type: pad, but record nothing.
16321        let unknown = dir.path().join("unknown_tail.h5");
16322        build(&unknown);
16323        let mut s = WriteEngine::open_with_locking(&unknown, FileLocking::Enabled).unwrap();
16324        s.append(&[0u8; 100]).unwrap(); // leaves the image non-page-aligned
16325        assert!(s.paged.as_ref().unwrap().last.is_none());
16326        s.pad_to_page().unwrap();
16327        let pg = s.paged.as_ref().unwrap();
16328        assert_eq!(s.image.len() % PAGE, 0, "the file is padded to a page");
16329        assert!(
16330            pg.meta_pad.is_empty() && pg.raw_pad.is_empty(),
16331            "padding a tail page of unknown type must claim no page type"
16332        );
16333        drop(s);
16334
16335        // The control: a known tail type is recorded, so the test above is about
16336        // the unknown case and not about padding never being tracked at all.
16337        let known = dir.path().join("known_tail.h5");
16338        build(&known);
16339        let mut s = WriteEngine::open_with_locking(&known, FileLocking::Enabled).unwrap();
16340        s.begin_page(PageType::Meta).unwrap();
16341        s.append(&[0u8; 100]).unwrap();
16342        s.pad_to_page().unwrap();
16343        let pg = s.paged.as_ref().unwrap();
16344        assert_eq!(
16345            pg.meta_pad.len(),
16346            1,
16347            "a known metadata tail records its padding as metadata free space"
16348        );
16349        assert!(pg.raw_pad.is_empty());
16350    }
16351
16352    /// The reference C library's generic-large manager holds free space of *both*
16353    /// page types, so a section from it is only reusable when it covers whole
16354    /// aligned pages.
16355    ///
16356    /// `H5F_MEM_PAGE_GENERIC` is aliased to `H5F_MEM_PAGE_LARGE_SUPER` and
16357    /// commented in the C library's own header as *"large-sized generic: meta and
16358    /// raw"*. Under paged aggregation on a contiguous-address driver — the default
16359    /// — `H5MF__alloc_to_fs_type` sends **every** allocation of a page or more
16360    /// there whatever its type, and `H5MF__alloc_pagefs` then records that
16361    /// allocation's page-alignment tail as a free section in the same manager. The
16362    /// tail is smaller than a page and sits in a page whose earlier bytes are the
16363    /// live object, so filing it as raw and handing it to chunk data would put raw
16364    /// bytes inside a metadata page.
16365    ///
16366    /// This measures that the C library really does write such a section — the
16367    /// assertion is worthless if it does not — and then pins that this engine
16368    /// keeps it out of reach: it is recorded, so a rewrite gives it back to the
16369    /// manager it came from, but never offered to an allocation.
16370    #[test]
16371    // The file is a committed one the reference C library wrote, see
16372    // `crates/crosscheck/tests/c_test_data.rs`.
16373    fn a_generic_large_section_is_only_reusable_as_whole_pages() {
16374        use tempfile::tempdir;
16375
16376        const PAGE: u64 = 512;
16377        let dir = tempdir().unwrap();
16378        let path = crate::test_data::copy("c/generic_large.h5", dir.path());
16379
16380        // The premise: at least one section in the generic-large manager (slot 6)
16381        // is a sub-page fragment. Read the managers slot by slot, since the
16382        // flattened public view cannot say which manager a section came from.
16383        let opened = crate::reader::File::open(&path).unwrap();
16384        let info = opened.file_space_info().expect("a persisting file").clone();
16385        drop(opened);
16386        assert_eq!(info.page_size, PAGE);
16387        let bytes = std::fs::read(&path).unwrap();
16388        let src = crate::source::BytesSource::new(bytes.as_slice());
16389        let slot6 = info.manager_addrs[6];
16390        assert_ne!(slot6, UNDEF, "the C library populated the large manager");
16391        let (sections, _) = free_space_manager::read_persisted_sections_source(
16392            &src,
16393            &[slot6],
16394            BaseAddress::ZERO,
16395            8,
16396        )
16397        .unwrap();
16398        let fragments: Vec<&FreeSection> = sections
16399            .iter()
16400            .filter(|s| s.addr % PAGE != 0 || s.size % PAGE != 0)
16401            .collect();
16402        assert!(
16403            !fragments.is_empty(),
16404            "the premise of this test: the C library files sub-page fragments in \
16405             its generic-large manager, but this file has none ({sections:?})"
16406        );
16407
16408        // The behavior: every such fragment is recorded but unreachable, and only
16409        // whole aligned pages from that manager are placeable.
16410        let s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16411        let pg = s.paged.as_ref().expect("a paged file installs paged state");
16412        let unclassified = pg.unclassified.sections();
16413        let reusable = pg.reusable_sections();
16414        for f in &fragments {
16415            assert!(
16416                unclassified.contains(&(f.addr, f.size)),
16417                "fragment ({}, {}) must be recorded as unclassified, not lost",
16418                f.addr,
16419                f.size
16420            );
16421            assert!(
16422                !reusable
16423                    .iter()
16424                    .any(|&(a, l)| a < f.addr + f.size && f.addr < a + l),
16425                "fragment ({}, {}) must not be offered to any allocation: {reusable:?}",
16426                f.addr,
16427                f.size
16428            );
16429        }
16430    }
16431
16432    /// A paged allocation may be served from the other page type's free list only
16433    /// where that space covers whole free pages — never from a hole in a page the
16434    /// other type still occupies.
16435    ///
16436    /// This is the allocation-side half of the rule
16437    /// [`paged_open_seeds_each_manager_by_slot`] pins on the seeding side, and it
16438    /// is just as invisible from the outside: handing a raw allocation a hole in a
16439    /// live metadata page puts chunk bytes inside that page, which every reader —
16440    /// this crate's and the reference library's — resolves correctly, since the
16441    /// file's addresses all still point where they should. Only the paging
16442    /// degrades. So assert the choice directly, in both directions, with a
16443    /// same-type control proving the free region really was big enough to be
16444    /// taken, and the whole-page case proving the exception is reached rather than
16445    /// merely permitted.
16446    #[test]
16447    fn a_paged_allocation_only_crosses_page_types_over_whole_free_pages() {
16448        use crate::writer::FileBuilder;
16449        use tempfile::tempdir;
16450
16451        const PAGE: u64 = 4096;
16452
16453        /// A paged session whose free lists hold exactly one region each, of the
16454        /// page types named. Both regions are interior, so an allocation that
16455        /// takes one is distinguishable from one that appends.
16456        fn session(
16457            path: &std::path::Path,
16458            meta: Option<(u64, u64)>,
16459            raw: Option<(u64, u64)>,
16460        ) -> WriteEngine {
16461            let mut b = FileBuilder::new();
16462            b.create_dataset("d")
16463                .with_i32_data(&(0..4000).collect::<Vec<i32>>())
16464                .with_shape(&[4000]);
16465            b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
16466                .with_file_space_page_size(PAGE);
16467            b.write(path).unwrap();
16468            let mut s = WriteEngine::open_with_locking(path, FileLocking::Enabled).unwrap();
16469            let pg = s.paged.as_mut().expect("a paged file installs paged state");
16470            pg.meta = FreeList::new();
16471            pg.raw = FreeList::new();
16472            if let Some((addr, len)) = meta {
16473                pg.meta.free(addr, len);
16474            }
16475            if let Some((addr, len)) = raw {
16476                pg.raw.free(addr, len);
16477            }
16478            s
16479        }
16480
16481        let dir = tempdir().unwrap();
16482        // Two interior regions well inside the file the builder wrote (16 KiB of
16483        // raw data alone), each a *fragment* of a page whose other bytes are live,
16484        // so either could physically hold the request and only the page-type rule
16485        // decides. Deliberately not page-aligned and not a whole page: that is the
16486        // case the rule forbids outright.
16487        let hole_a = (PAGE + 512, 2048);
16488        let hole_b = (2 * PAGE + 512, 2048);
16489
16490        // Raw request, only a metadata fragment free: appends rather than mixing
16491        // the page.
16492        let mut s = session(&dir.path().join("a.h5"), Some(hole_a), None);
16493        assert!(
16494            matches!(
16495                s.reserve(1024, PageType::Raw).unwrap(),
16496                Placement::Appended { .. }
16497            ),
16498            "a raw allocation must not be served out of a live metadata page"
16499        );
16500        drop(s);
16501
16502        // Metadata request, only a raw fragment free: likewise.
16503        let mut s = session(&dir.path().join("b.h5"), None, Some(hole_b));
16504        assert!(
16505            matches!(
16506                s.reserve(1024, PageType::Meta).unwrap(),
16507                Placement::Appended { .. }
16508            ),
16509            "a metadata allocation must not be served out of a live raw page"
16510        );
16511        drop(s);
16512
16513        // The control: with the matching type free, the same request is reused —
16514        // so the two refusals above are the page-type rule, not a size failure.
16515        let mut s = session(&dir.path().join("c.h5"), Some(hole_a), Some(hole_b));
16516        assert!(
16517            matches!(
16518                s.reserve(1024, PageType::Raw).unwrap(),
16519                Placement::Reused { addr, .. } if addr == hole_b.0
16520            ),
16521            "a raw allocation takes the raw hole"
16522        );
16523        assert!(
16524            matches!(
16525                s.reserve(1024, PageType::Meta).unwrap(),
16526                Placement::Reused { addr, .. } if addr == hole_a.0
16527            ),
16528            "a metadata allocation takes the metadata hole"
16529        );
16530        drop(s);
16531
16532        // The exception, and the whole reason the file stops growing (issue #286):
16533        // a page with *nothing* in it holds no type to contradict, so either kind
16534        // may open it. The page is claimed whole and what the request does not use
16535        // becomes free space of the claiming type.
16536        let mut s = session(&dir.path().join("d.h5"), Some((PAGE, PAGE)), None);
16537        assert!(
16538            matches!(
16539                s.reserve(1024, PageType::Raw).unwrap(),
16540                Placement::Reused { addr, .. } if addr == PAGE
16541            ),
16542            "a raw allocation may open an empty metadata page"
16543        );
16544        let pg = s.paged.as_ref().expect("still paged");
16545        assert_eq!(
16546            pg.raw.sections(),
16547            [(PAGE + 1024, PAGE - 1024)],
16548            "the rest of the claimed page is free space of the claiming type"
16549        );
16550        assert!(
16551            pg.meta.sections().is_empty(),
16552            "the page left the list it was claimed from"
16553        );
16554        drop(s);
16555
16556        // And the claim starts at the empty page, not at the free run's own start.
16557        // A run that spans from mid-page into whole pages beyond it is the common
16558        // shape — the tail of a live page, then pages nothing is left in — and
16559        // taking it from the front would put the request in the live page.
16560        let mut s = session(
16561            &dir.path().join("e.h5"),
16562            None,
16563            Some((PAGE + 512, 3 * PAGE - 512)),
16564        );
16565        assert!(
16566            matches!(
16567                s.reserve(1024, PageType::Meta).unwrap(),
16568                Placement::Reused { addr, .. } if addr == 2 * PAGE
16569            ),
16570            "the claim must begin at the empty page, not at the run's start"
16571        );
16572        // Both edges the claim leaves behind survive it: they are ordinary free
16573        // space of the type that already held them, and dropping either is the same
16574        // silent leak this change exists to remove.
16575        let pg = s.paged.as_ref().expect("still paged");
16576        assert_eq!(
16577            pg.raw.sections(),
16578            [(PAGE + 512, PAGE - 512), (3 * PAGE, PAGE)],
16579            "the fragment below the claimed page and the page above it both stay free"
16580        );
16581        assert_eq!(
16582            pg.meta.sections(),
16583            [(2 * PAGE + 1024, PAGE - 1024)],
16584            "the rest of the claimed page is free space of the claiming type"
16585        );
16586    }
16587
16588    /// Every byte a paged session could still hand out, across both page types.
16589    fn free_total(s: &WriteEngine) -> u64 {
16590        let pg = s.paged.as_ref().expect("a paged session");
16591        pg.meta
16592            .sections()
16593            .into_iter()
16594            .chain(pg.raw.sections())
16595            .map(|(_, len)| len)
16596            .sum()
16597    }
16598
16599    /// A paged commit's tail removes from the free lists exactly the bytes it goes
16600    /// on to write — no more, and nothing at all when it declines to place itself.
16601    ///
16602    /// This is the invariant the whole change rests on, and the one an integration
16603    /// test cannot reach: the tail's length and the hole it lands in determine each
16604    /// other, so which settlement the arithmetic reaches depends on the shape of
16605    /// the free list, and a file the writer builds only ever produces some of them.
16606    /// In particular, best fit prefers the *smallest* region that fits, so a hole
16607    /// whose length is exactly the length the tail proposed is the one it will
16608    /// choose — and consuming a region outright drops a section from the managers,
16609    /// making the plan come out *shorter* than the space just reserved for it. A
16610    /// tail that accepted that would retire the difference from the free list with
16611    /// nothing recording it, which is issue #286 again, a few bytes at a time.
16612    ///
16613    /// Hence a contiguous sweep of hole sizes rather than a chosen one: the length
16614    /// the tail proposes is a property of the file and its free list, not something
16615    /// this test should have to know, and the sweep is certain to cross it.
16616    #[test]
16617    fn a_paged_tail_takes_exactly_the_space_it_fills() {
16618        use crate::writer::FileBuilder;
16619        use tempfile::tempdir;
16620
16621        const PAGE: u64 = 4096;
16622        /// Any plausible extension length exercises the same arithmetic, and the
16623        /// invariant holds for every one of them; the tail's real length is settled
16624        /// by the commit, which is not what is under test here.
16625        const EXT_LEN: u64 = 100;
16626
16627        let dir = tempdir().unwrap();
16628        let path = dir.path().join("tail_exact.h5");
16629        let mut b = FileBuilder::new();
16630        b.create_dataset("d")
16631            .with_i32_data(&(0..4000).collect::<Vec<i32>>())
16632            .with_shape(&[4000]);
16633        b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
16634            .with_file_space_page_size(PAGE);
16635        b.write(&path).unwrap();
16636        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16637        let os = s.superblock.offset_size;
16638
16639        let mut placed = 0usize;
16640        for hole in 120..420u64 {
16641            {
16642                let pg = s.paged.as_mut().expect("a paged file installs paged state");
16643                pg.meta = FreeList::new();
16644                pg.raw = FreeList::new();
16645                pg.unclassified = FreeList::new();
16646                pg.meta.free(PAGE, hole);
16647            }
16648            let free_before = free_total(&s);
16649            let layout = s.tail_layout(&[], &[], EXT_LEN, PAGE, os);
16650            let free_after = free_total(&s);
16651            match layout {
16652                Some((_, _, at, blocks_len, _)) => {
16653                    placed += 1;
16654                    assert_eq!(
16655                        free_before - free_after,
16656                        blocks_len,
16657                        "hole {hole}: the tail took {} bytes at {at} to write {blocks_len}",
16658                        free_before - free_after
16659                    );
16660                }
16661                None => assert_eq!(
16662                    free_after, free_before,
16663                    "hole {hole}: a tail that declines to place itself must hand back \
16664                     everything it tried"
16665                ),
16666            }
16667        }
16668        assert!(
16669            placed > 0,
16670            "the sweep must reach holes the tail can actually use, or it asserts \
16671             nothing about placement"
16672        );
16673    }
16674
16675    /// The shrink pass never grows the file: asked to rewrite the tail with
16676    /// nothing in the free list to put it in, it writes nothing at all
16677    /// (issue #418).
16678    ///
16679    /// That is the whole safety argument for running it. It exists to make the
16680    /// file smaller, and a tail it cannot place would otherwise go past
16681    /// end-of-file — a rewrite taken for the sake of a shorter file leaving a
16682    /// longer one. The second half of the test is what keeps the first half
16683    /// honest: under `TailPlacement::Anywhere` the same call on the same session
16684    /// does write a tail, so the fixture really did have one to write.
16685    #[test]
16686    fn a_reuse_only_tail_rewrite_that_cannot_place_itself_writes_nothing() {
16687        use crate::writer::FileBuilder;
16688        use tempfile::tempdir;
16689
16690        let dir = tempdir().unwrap();
16691        let path = dir.path().join("reuse_only.h5");
16692        let mut b = FileBuilder::new();
16693        b.create_dataset("d")
16694            .with_i32_data(&(0..400).collect::<Vec<i32>>())
16695            .with_shape(&[400]);
16696        b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
16697        b.write(&path).unwrap();
16698        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16699        // No hole anywhere: the only place a tail could go is past end-of-file.
16700        s.free = FreeList::new();
16701        let root = s.superblock.root_group_address;
16702        let len_before = s.image.len();
16703        let ext_before = s.superblock.superblock_extension_address;
16704
16705        s.commit_persisting(root, Vec::new(), TailPlacement::ReuseOnly)
16706            .unwrap();
16707        assert_eq!(
16708            s.image.len(),
16709            len_before,
16710            "a reuse-only rewrite with nothing to reuse must leave the file alone"
16711        );
16712        assert_eq!(
16713            s.superblock.superblock_extension_address, ext_before,
16714            "and must not have published a new extension either"
16715        );
16716
16717        s.commit_persisting(root, Vec::new(), TailPlacement::Anywhere)
16718            .unwrap();
16719        assert!(
16720            s.image.len() > len_before,
16721            "the same rewrite that may append does grow the file, so the case \
16722             above was a tail declined rather than a tail of no size"
16723        );
16724    }
16725
16726    /// A flat commit's tail removes from the free list exactly the bytes its
16727    /// recorded extent covers — no more, and nothing at all when it declines to
16728    /// place itself.
16729    ///
16730    /// The flat tail accepts a plan *shorter* than its reservation where the paged
16731    /// one refuses ([`WriteEngine::flat_tail_layout`] says why), so this is the
16732    /// invariant that keeps those spare bytes from leaking: what the layout hands
16733    /// back as the tail's length is the whole reservation, which the commit records
16734    /// as the extent to free when the next one supersedes it. Returning the plan's
16735    /// length instead would retire the difference from the free list with nothing
16736    /// recording it — issue #286 again, a few bytes at a time.
16737    ///
16738    /// Swept over hole sizes rather than a chosen one, for the reason the paged
16739    /// sweep gives: the length the tail proposes is a property of the file and its
16740    /// free list, and best fit takes a hole of exactly that length outright, which
16741    /// drops a section from the managers and is what makes the plan come out
16742    /// shorter than the reservation in the first place.
16743    #[test]
16744    fn a_flat_tail_takes_exactly_the_extent_it_records() {
16745        use crate::writer::FileBuilder;
16746        use tempfile::tempdir;
16747
16748        /// Any plausible extension length exercises the same arithmetic; the real
16749        /// one is settled by the commit, which is not what is under test here.
16750        const EXT_LEN: u64 = 100;
16751        /// Any address does: the layout only ever asks the free list for a length.
16752        const HOLE_AT: u64 = 512;
16753
16754        let dir = tempdir().unwrap();
16755        let path = dir.path().join("flat_tail_exact.h5");
16756        let mut b = FileBuilder::new();
16757        b.create_dataset("d")
16758            .with_i32_data(&(0..4000).collect::<Vec<i32>>())
16759            .with_shape(&[4000]);
16760        b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
16761        b.write(&path).unwrap();
16762        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16763        let os = s.superblock.offset_size;
16764
16765        let free_total =
16766            |s: &WriteEngine| -> u64 { s.free.sections().into_iter().map(|(_, len)| len).sum() };
16767
16768        let (mut placed, mut declined, mut with_slack) = (0usize, 0usize, 0usize);
16769        for hole in 120..420u64 {
16770            s.free = FreeList::new();
16771            s.free.free(HOLE_AT, hole);
16772            let free_before = free_total(&s);
16773            let (post, at, tail_len, _) = s.flat_tail_layout(&[], &[], EXT_LEN, os);
16774            let free_after = free_total(&s);
16775            // The blocks the commit will write into the extent it was handed. A hole
16776            // consumed outright drops a section from the managers, so this comes out
16777            // shorter than the extent for some hole sizes and the difference is what
16778            // the extent has to cover.
16779            let written = EXT_LEN + file_fsm_blocks_len(&free_sections(&post), os);
16780            match at {
16781                Some(at) => {
16782                    placed += 1;
16783                    assert_eq!(
16784                        free_before - free_after,
16785                        tail_len,
16786                        "hole {hole}: the tail took {} bytes at {at} for an extent of \
16787                         {tail_len}",
16788                        free_before - free_after
16789                    );
16790                    if written < tail_len {
16791                        with_slack += 1;
16792                    }
16793                }
16794                None => {
16795                    declined += 1;
16796                    assert_eq!(
16797                        free_after, free_before,
16798                        "hole {hole}: a tail that declines to place itself must hand \
16799                         back everything it tried"
16800                    );
16801                    assert_eq!(
16802                        tail_len, written,
16803                        "hole {hole}: a tail with nowhere to go is appended, and the \
16804                         length it reports is what it will write there"
16805                    );
16806                }
16807            }
16808        }
16809        assert!(
16810            placed > 0,
16811            "the sweep must reach holes the tail can actually use, or it asserts \
16812             nothing about placement"
16813        );
16814        assert!(
16815            declined > 0,
16816            "the sweep must reach holes too small for the tail, or the rule about \
16817             handing back a failed attempt is never exercised"
16818        );
16819        assert!(
16820            with_slack > 0,
16821            "the sweep must reach a hole the tail does not fill exactly, or the \
16822             rule about spare bytes is never exercised"
16823        );
16824    }
16825
16826    /// A paged commit whose tail finds no free space to sit in opens a page for it
16827    /// — and hands the rest of that page back as free metadata.
16828    ///
16829    /// Every paged file this crate writes has holes in it from the start, so the
16830    /// suite's ordinary fixtures always reuse and this branch is never taken. It is
16831    /// reachable in the wild, by a file whose free space is all spoken for, and the
16832    /// page it opens is the same page a page-per-commit leak used to strand. Emptying
16833    /// the free lists by hand is what puts the file in that state deliberately.
16834    #[test]
16835    fn a_paged_tail_with_nowhere_to_go_opens_a_page_and_frees_the_rest() {
16836        use crate::writer::FileBuilder;
16837        use tempfile::tempdir;
16838
16839        const PAGE: u64 = 4096;
16840        let dir = tempdir().unwrap();
16841        let path = dir.path().join("tail_appends.h5");
16842        let mut b = FileBuilder::new();
16843        b.create_dataset("d")
16844            .with_i32_data(&(0..4000).collect::<Vec<i32>>())
16845            .with_shape(&[4000]);
16846        b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
16847            .with_file_space_page_size(PAGE);
16848        b.write(&path).unwrap();
16849        let before = std::fs::metadata(&path).unwrap().len();
16850
16851        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16852        {
16853            let pg = s.paged.as_mut().expect("a paged file installs paged state");
16854            pg.meta = FreeList::new();
16855            pg.raw = FreeList::new();
16856            pg.unclassified = FreeList::new();
16857        }
16858        // A commit with nothing staged returns without writing, so give it one
16859        // small metadata object to place. It appends for want of anywhere else,
16860        // and the tail follows it into the same page.
16861        s.create_group("g").unwrap();
16862        s.commit().unwrap();
16863
16864        let after = std::fs::metadata(&path).unwrap().len();
16865        assert!(
16866            after > before && (after - before) % PAGE == 0,
16867            "the commit opened whole pages ({before} -> {after})"
16868        );
16869        // What the tail did not fill of the page it opened is metadata this session
16870        // can still spend. Losing it is how the file used to give up most of a page
16871        // on every commit; the managers just written cannot record it, so the
16872        // session carries it to the next commit.
16873        let pg = s.paged.as_ref().expect("still paged");
16874        let (addr, len) = pg
16875            .meta
16876            .sections()
16877            .into_iter()
16878            .find(|&(addr, len)| addr + len == after)
16879            .expect("the page the tail opened leaves a free remainder at end-of-file");
16880        assert!(
16881            len > 0 && len < PAGE && addr >= before,
16882            "the tail's blocks take the front of the page it opened and the rest is \
16883             free ({len} of {PAGE} at {addr}, file {before} -> {after})"
16884        );
16885    }
16886
16887    /// Deleting a chunked dataset from a paged file must not record its freed
16888    /// chunk index in the *metadata* manager.
16889    ///
16890    /// Every writer in this crate emits a chunk index in the same run as the chunk
16891    /// data it indexes, so the index sits in a raw page. Recording it as metadata
16892    /// would advertise a metadata-sized hole inside a page that still holds another
16893    /// dataset's live chunk data, and the reference library placing metadata there
16894    /// would mix the page — the one thing a paged file forbids. Page homogeneity is
16895    /// preserved on disk either way, so the mis-filing is invisible to a signature
16896    /// scan of the file and to the C library; the manager a section lands in has to
16897    /// be checked directly.
16898    #[test]
16899    fn deleted_chunk_index_is_freed_into_a_raw_manager() {
16900        use crate::writer::FileBuilder;
16901        use tempfile::tempdir;
16902
16903        let dir = tempdir().unwrap();
16904        let path = dir.path().join("paged_chunk_index_free.h5");
16905        let page = 4096u64;
16906        let mut b = FileBuilder::new();
16907        for name in ["drop", "keep"] {
16908            b.create_dataset(name)
16909                .with_i32_data(&(0..200).collect::<Vec<i32>>())
16910                .with_shape(&[200])
16911                .with_chunks(&[50]);
16912        }
16913        b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
16914            .with_file_space_page_size(page);
16915        b.write(&path).unwrap();
16916
16917        {
16918            let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16919            s.delete("/drop").unwrap();
16920            s.commit().unwrap();
16921        }
16922
16923        // Pages still occupied by the surviving dataset's chunk data. Read with the
16924        // session closed: its lock is mandatory on Windows.
16925        let live_raw_pages: Vec<u64> = {
16926            let f = crate::reader::File::open(&path).unwrap();
16927            let ds = f.dataset("keep").unwrap();
16928            let mut pages: Vec<u64> = ds
16929                .chunks()
16930                .unwrap()
16931                .iter()
16932                .filter(|c| c.storage_size > 0)
16933                .flat_map(|c| (c.address / page)..=((c.address + c.storage_size - 1) / page))
16934                .collect();
16935            pages.sort_unstable();
16936            pages.dedup();
16937            pages
16938        };
16939        assert!(!live_raw_pages.is_empty(), "expected live raw pages");
16940
16941        let s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16942        let pg = s.paged.as_ref().expect("a paged file installs paged state");
16943        for (addr, len) in pg.meta.sections() {
16944            for p in (addr / page)..=((addr + len - 1) / page) {
16945                assert!(
16946                    !live_raw_pages.contains(&p),
16947                    "metadata free section ({addr}, {len}) sits in page {p}, which still \
16948                     holds live raw chunk data"
16949                );
16950            }
16951        }
16952        // The index really was reclaimed somewhere, so this is not vacuous.
16953        let reclaimed: u64 = pg.reusable_sections().iter().map(|&(_, l)| l).sum();
16954        assert!(reclaimed > 0, "the delete reclaimed nothing");
16955    }
16956
16957    /// A paged commit that fails partway must leave the session's free lists
16958    /// exactly as it found them.
16959    ///
16960    /// Everything the commit gathers to free is still *live* until the superblock
16961    /// repoint: the objects occupying those regions are reachable from the old
16962    /// root, which a failed commit never replaces. A session that recorded them as
16963    /// free anyway would hand them out on the next commit, and the file would lose
16964    /// data with no error anywhere — in a release build, where the free list's
16965    /// double-free `debug_assert` is compiled out, silently.
16966    ///
16967    /// The failure is induced by pointing the superblock extension at a byte range
16968    /// that is not an object header, which fails the extension rewrite immediately
16969    /// after the regions are gathered.
16970    #[test]
16971    fn failed_paged_commit_leaves_the_free_lists_untouched() {
16972        use crate::writer::FileBuilder;
16973        use tempfile::tempdir;
16974
16975        let dir = tempdir().unwrap();
16976        let path = dir.path().join("paged_failed_commit.h5");
16977        let mut b = FileBuilder::new();
16978        b.create_dataset("keep")
16979            .with_i32_data(&(0..200).collect::<Vec<i32>>())
16980            .with_shape(&[200]);
16981        b.create_dataset("drop")
16982            .with_i32_data(&(0..200).collect::<Vec<i32>>())
16983            .with_shape(&[200]);
16984        b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
16985            .with_file_space_page_size(4096);
16986        b.write(&path).unwrap();
16987
16988        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16989        let before = s.space_accounting().reusable_free_space;
16990
16991        // Break the extension so the commit fails *after* it has gathered the
16992        // regions `drop` vacates and *before* the superblock repoint.
16993        let good_ext = s.superblock.superblock_extension_address;
16994        s.superblock.superblock_extension_address = Some(0);
16995        s.delete("/drop").unwrap();
16996        assert!(
16997            s.commit().is_err(),
16998            "a commit with an unreadable extension must fail"
16999        );
17000
17001        assert_eq!(
17002            s.space_accounting().reusable_free_space,
17003            before,
17004            "a failed commit must not record still-live regions as free"
17005        );
17006
17007        // The session stays usable: repair the extension and commit for real. If
17008        // the failed commit had folded its regions in, this second commit would
17009        // double-free them (a debug assertion) and publish `keep`'s live extent.
17010        s.superblock.superblock_extension_address = good_ext;
17011        s.delete("/drop").unwrap();
17012        s.commit()
17013            .expect("the session is usable after a failed commit");
17014
17015        // Release the session's exclusive OS lock before reading the file back.
17016        // Those locks are mandatory on Windows, so a `File::open` overlapping the
17017        // session fails outright there (advisory locks elsewhere would allow it).
17018        drop(s);
17019
17020        let f = crate::reader::File::open(&path).unwrap();
17021        let kept = f.dataset("keep").unwrap().read_i32().unwrap();
17022        assert_eq!(kept, (0..200).collect::<Vec<i32>>(), "keep survives intact");
17023        let freed: u64 = f.persisted_free_space().iter().map(|&(_, l)| l).sum();
17024        let live_end = f.file_size();
17025        assert!(
17026            freed < live_end,
17027            "the recorded free space cannot cover the whole file"
17028        );
17029    }
17030
17031    /// A commit that fails partway must roll back the heap-collection
17032    /// provenance along with the free lists (issue #321).
17033    ///
17034    /// `resolve_overwrite_bytes` places a collection and records it in the
17035    /// *apply* phase. A commit that then fails before its repoint gives that
17036    /// space back to the free list — and used to keep the record, which is the
17037    /// one piece of engine state naming file addresses that the rollback missed.
17038    /// The next overwrite of that path would free the region a *later* commit
17039    /// had since been handed, so the file's live variable-length data would sit
17040    /// in space the allocator considers free, and the next unrelated write would
17041    /// land on it.
17042    ///
17043    /// Induced the same way as
17044    /// [`failed_paged_commit_leaves_the_free_lists_untouched`]: an unreadable
17045    /// superblock extension fails the commit after the apply phase has run.
17046    #[test]
17047    fn a_failed_commit_rolls_back_the_heap_collection_provenance() {
17048        use crate::writer::FileBuilder;
17049        use tempfile::tempdir;
17050
17051        let dir = tempdir().unwrap();
17052        let path = dir.path().join("failed_commit_vl_provenance.h5");
17053        let mut b = FileBuilder::new();
17054        b.create_dataset("labels")
17055            .with_vlen_strings(&["seed-one", "seed-two"]);
17056        b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
17057        b.write(&path).unwrap();
17058
17059        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
17060
17061        // One good round, so there is a record to roll back.
17062        s.stage_dataset_write("/labels", {
17063            let mut db = crate::type_builders::DatasetBuilder::new("");
17064            db.with_vlen_strings(&["round-one-aaaa", "round-one-bbbb"]);
17065            db
17066        })
17067        .unwrap();
17068        s.commit().unwrap();
17069        let recorded = s.vl_overwrite_heaps.clone();
17070        assert!(!recorded.is_empty(), "the good round recorded nothing");
17071
17072        // Break the extension so the next commit fails after the apply phase.
17073        let good_ext = s.superblock.superblock_extension_address;
17074        s.superblock.superblock_extension_address = Some(0);
17075        s.stage_dataset_write("/labels", {
17076            let mut db = crate::type_builders::DatasetBuilder::new("");
17077            db.with_vlen_strings(&["round-two-aaaa", "round-two-bbbb"]);
17078            db
17079        })
17080        .unwrap();
17081        assert!(
17082            s.commit().is_err(),
17083            "a commit with an unreadable extension must fail"
17084        );
17085
17086        assert_eq!(
17087            s.vl_overwrite_heaps, recorded,
17088            "a failed commit must not leave a record naming space it gave back"
17089        );
17090
17091        // The session stays usable, and the collections the rolled-back record
17092        // names are still the live ones.
17093        s.superblock.superblock_extension_address = good_ext;
17094        s.stage_dataset_write("/labels", {
17095            let mut db = crate::type_builders::DatasetBuilder::new("");
17096            db.with_vlen_strings(&["round-three-a", "round-three-b"]);
17097            db
17098        })
17099        .unwrap();
17100        s.commit()
17101            .expect("the session is usable after a failed commit");
17102        drop(s);
17103
17104        let f = crate::reader::File::open(&path).unwrap();
17105        assert_eq!(
17106            f.dataset("labels").unwrap().read_string().unwrap(),
17107            vec!["round-three-a".to_string(), "round-three-b".to_string()]
17108        );
17109    }
17110
17111    /// A failed commit must not leave the free list offering a span the image
17112    /// still points into (issue #321).
17113    ///
17114    /// Resolving a staged variable-length overwrite *allocates* — a global heap
17115    /// collection, drawn from a freed region where one fits. If the overwrite
17116    /// were applied in place, that allocation's address would be written into a
17117    /// data block the current root already reaches; a commit failing after the
17118    /// write and before its repoint then hands the span back to the free list,
17119    /// while the image still names it. The next commit places something else
17120    /// there and the dataset reads that object's heap bytes, with every checksum
17121    /// in the file intact.
17122    ///
17123    /// A relocating overwrite has no such window — its new block is reachable
17124    /// from nothing until the repoint — which is why `prepare_write` refuses to
17125    /// plan a staged variable-length overwrite in place at all. This is that
17126    /// rule's test.
17127    ///
17128    /// The freed hole has to exist *first*, or the failed commit's collection is
17129    /// appended past end-of-file where nothing reuses it and the bug hides. The
17130    /// filler datasets each need a metadata span of the size the rolled-back one
17131    /// occupied, which is what draws on it.
17132    #[test]
17133    fn a_failed_commit_leaves_no_reusable_span_the_image_names() {
17134        use crate::writer::FileBuilder;
17135        use tempfile::tempdir;
17136
17137        let dir = tempdir().unwrap();
17138        let path = dir.path().join("failed_commit_live_span.h5");
17139        let mut b = FileBuilder::new();
17140        b.create_dataset("labels")
17141            .with_vlen_strings(&["seed-one", "seed-two"]);
17142        b.create_dataset("big").with_u8_data(&[0x5A; 40960]);
17143        b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
17144        b.write(&path).unwrap();
17145
17146        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
17147        // The hole the collections below are placed into by reuse.
17148        s.delete("/big").unwrap();
17149        s.commit().unwrap();
17150
17151        let round_one = ["round-one-aaaa", "round-one-bbbb"];
17152        s.stage_dataset_write("/labels", {
17153            let mut db = crate::type_builders::DatasetBuilder::new("");
17154            db.with_vlen_strings(&round_one);
17155            db
17156        })
17157        .unwrap();
17158        s.commit().unwrap();
17159
17160        // Fail a second overwrite after its apply phase has placed a collection.
17161        let good_ext = s.superblock.superblock_extension_address;
17162        s.superblock.superblock_extension_address = Some(0);
17163        s.stage_dataset_write("/labels", {
17164            let mut db = crate::type_builders::DatasetBuilder::new("");
17165            db.with_vlen_strings(&["round-two-aaaa", "round-two-bbbb"]);
17166            db
17167        })
17168        .unwrap();
17169        assert!(
17170            s.commit().is_err(),
17171            "a commit with an unreadable extension must fail"
17172        );
17173        s.superblock.superblock_extension_address = good_ext;
17174
17175        // Unrelated commits that each want a metadata span of the same size.
17176        // Whatever the failed attempt gave back, these are what would draw on it.
17177        for i in 0..4 {
17178            s.stage_created_dataset(&format!("/filler{i}"), {
17179                let mut db = crate::type_builders::DatasetBuilder::new("");
17180                db.with_vlen_strings(&["XXXXXXXXXXXXXXXXXXXX", "YYYYYYYYYYYYYYYYYYYY"]);
17181                db
17182            })
17183            .unwrap();
17184            s.commit().unwrap();
17185        }
17186
17187        // Read only once the session has released its lock on the file: those
17188        // locks are mandatory on Windows, so a `File::open` overlapping the
17189        // session fails outright there where advisory locks elsewhere allow it.
17190        drop(s);
17191
17192        let f = crate::reader::File::open(&path).unwrap();
17193        let got = f.dataset("labels").unwrap().read_string();
17194        assert!(
17195            got.as_ref()
17196                .is_ok_and(|v| v.iter().map(String::as_str).eq(round_one)),
17197            "/labels reads another dataset's heap objects: {got:?}"
17198        );
17199    }
17200
17201    /// A commit that writes into reused free space and then dies must leave the
17202    /// file exactly as it found it.
17203    ///
17204    /// This is the safety argument for reuse, stated as a test: a commit may
17205    /// overwrite a freed region *before* the superblock repoint precisely because
17206    /// nothing reachable from the on-disk root lives there any more, so an attempt
17207    /// that never reaches the repoint is invisible. A chunked dataset is the case
17208    /// worth pinning — it is the largest thing a commit places, so it overwrites
17209    /// the most, and it is the one that used only to append.
17210    ///
17211    /// The failure is induced the same way as
17212    /// [`failed_paged_commit_leaves_the_free_lists_untouched`]: an unreadable
17213    /// superblock extension, which the persisting tail hits *after* the apply loop
17214    /// has written every object.
17215    #[test]
17216    fn a_failed_commit_that_reused_free_space_leaves_the_file_intact() {
17217        use crate::writer::FileBuilder;
17218        use tempfile::tempdir;
17219
17220        let dir = tempdir().unwrap();
17221        let path = dir.path().join("reuse_failed_commit.h5");
17222        let victim: Vec<f64> = (0..4096).map(|i| (i % 13) as f64).collect();
17223        let ceiling: Vec<i32> = (0..500).collect();
17224        let mut b = FileBuilder::new();
17225        b.create_dataset("keep").with_i32_data(&[1, 2, 3]);
17226        b.create_dataset("victim")
17227            .with_f64_data(&victim)
17228            .with_shape(&[4096])
17229            .with_chunks(&[512]);
17230        // Above the victim, so the delete leaves an interior hole and the reuse
17231        // has live bytes on both sides of what it overwrites.
17232        b.create_dataset("ceiling")
17233            .with_i32_data(&ceiling)
17234            .with_shape(&[500]);
17235        b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 1);
17236        b.write(&path).unwrap();
17237
17238        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
17239        s.delete("/victim").unwrap();
17240        s.commit().unwrap();
17241        let free_before = s.space_accounting().reusable_free_space;
17242        let len_before = std::fs::metadata(&path).unwrap().len();
17243        assert!(
17244            free_before.iter().any(|&(_, l)| l > 4096 * 8 / 2),
17245            "the deleted chunked dataset left a hole worth reusing: {free_before:?}"
17246        );
17247
17248        // Break the extension so the commit fails in its persisting tail, after
17249        // the apply loop has written the new dataset into that hole.
17250        s.superblock.superblock_extension_address = Some(0);
17251        let mut db = crate::type_builders::DatasetBuilder::new("fresh");
17252        db.with_f64_data(&vec![7.5f64; 4096])
17253            .with_shape(&[4096])
17254            .with_chunks(&[512]);
17255        s.stage_created_dataset("/fresh", db).unwrap();
17256        assert!(
17257            s.commit().is_err(),
17258            "a commit with an unreadable extension must fail"
17259        );
17260        assert_eq!(
17261            s.space_accounting().reusable_free_space,
17262            free_before,
17263            "the failed commit gives back the region it drew from"
17264        );
17265        // Release the session's exclusive OS lock before reading the file back;
17266        // those locks are mandatory on Windows.
17267        drop(s);
17268
17269        // The file still describes the tree the last *successful* commit left: the
17270        // survivors read exactly, the half-written dataset is not linked, and the
17271        // superblock's end-of-file still matches the file.
17272        assert_eq!(std::fs::metadata(&path).unwrap().len(), len_before);
17273        let f = crate::reader::File::open(&path).unwrap();
17274        assert_eq!(f.file_size(), len_before);
17275        assert_eq!(
17276            f.dataset("keep").unwrap().read_i32().unwrap(),
17277            vec![1, 2, 3]
17278        );
17279        assert_eq!(f.dataset("ceiling").unwrap().read_i32().unwrap(), ceiling);
17280        assert!(f.dataset("victim").is_err());
17281        assert!(f.dataset("fresh").is_err());
17282    }
17283
17284    /// A refused commit leaves every dataset reading what it read before —
17285    /// including the one whose values it had already written over (issue #344).
17286    ///
17287    /// Everything else a commit places lands where nothing reaches it until the
17288    /// superblock is repointed, so an attempt that stops short of the repoint is
17289    /// invisible. A same-length value overwrite is the exception: it writes
17290    /// straight over the data block the *current* root already reaches, so there
17291    /// is no repoint to withhold and the write is live the moment it lands. Both
17292    /// halves of one refused batch are asserted here, because the defect was
17293    /// that they disagreed: the overwrite survived and the object creation
17294    /// beside it did not.
17295    ///
17296    /// Closing the session is enough to expose it — no later commit is needed,
17297    /// since the bytes are in the file already and write gathering only delays
17298    /// when they reach the disk.
17299    ///
17300    /// A second staged edit is what keeps this on the full commit path; a batch
17301    /// of nothing but same-length overwrites takes the fast path, which
17302    /// `a_failed_fast_path_commit_puts_back_the_value_it_overwrote` covers. The
17303    /// failure is induced the same way as
17304    /// [`failed_paged_commit_leaves_the_free_lists_untouched`]: an unreadable
17305    /// superblock extension, which fails the commit in its tail, after the apply
17306    /// phase has done the in-place write.
17307    #[test]
17308    fn a_failed_commit_puts_back_the_value_it_overwrote() {
17309        use crate::writer::FileBuilder;
17310        use tempfile::tempdir;
17311
17312        let dir = tempdir().unwrap();
17313        let path = dir.path().join("inplace_partial.h5");
17314        let mut b = FileBuilder::new();
17315        b.create_dataset("nums").with_i32_data(&[1, 2, 3]);
17316        b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
17317        b.write(&path).unwrap();
17318
17319        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
17320
17321        let good_ext = s.superblock.superblock_extension_address;
17322        s.superblock.superblock_extension_address = Some(0);
17323        s.stage_dataset_write("/nums", {
17324            let mut db = crate::type_builders::DatasetBuilder::new("");
17325            db.with_i32_data(&[9, 9, 9]);
17326            db
17327        })
17328        .unwrap();
17329        s.stage_created_dataset("/extra", {
17330            let mut db = crate::type_builders::DatasetBuilder::new("");
17331            db.with_i32_data(&[42]);
17332            db
17333        })
17334        .unwrap();
17335        let refused = s.commit();
17336        assert!(
17337            matches!(refused, Err(Error::EditUnsupported(_))),
17338            "the refusal, not a rollback failure: {refused:?}"
17339        );
17340
17341        // The session stays usable, and its next commit is not carrying any of
17342        // the refused batch.
17343        s.superblock.superblock_extension_address = good_ext;
17344        s.stage_created_dataset("/later", {
17345            let mut db = crate::type_builders::DatasetBuilder::new("");
17346            db.with_i32_data(&[7]);
17347            db
17348        })
17349        .unwrap();
17350        s.commit()
17351            .expect("the session is usable after a failed commit");
17352
17353        // Release the session's exclusive OS lock before reading the file back;
17354        // those locks are mandatory on Windows.
17355        drop(s);
17356
17357        let f = crate::reader::File::open(&path).unwrap();
17358        assert_eq!(
17359            f.dataset("nums").unwrap().read_i32().unwrap(),
17360            vec![1, 2, 3],
17361            "the refused batch's value overwrite is in the file"
17362        );
17363        assert!(
17364            f.dataset("extra").is_err(),
17365            "/extra was correctly discarded"
17366        );
17367        assert_eq!(f.dataset("later").unwrap().read_i32().unwrap(), vec![7]);
17368    }
17369
17370    /// The same guarantee on the commit fast path, where a batch of nothing but
17371    /// same-length overwrites skips the header rebuild and the superblock flip
17372    /// entirely (issue #344).
17373    ///
17374    /// That path has no tail to fail in, so the only failure it can suffer is
17375    /// one of its own writes — which is also the one failure the rollback cannot
17376    /// fully repair, since putting the prior bytes back means writing to the
17377    /// address that just refused them. Both outcomes are pinned here: the
17378    /// overwrite that *did* land is put back, the one that could not be is
17379    /// reported as [`Error::CommitPartiallyApplied`] rather than as a plain I/O
17380    /// error, and the values in the file are exactly what those two statements
17381    /// predict.
17382    ///
17383    /// `TornWriteImage` supplies the failure at a byte range rather than at a
17384    /// call ordinal, so the test names the block it means (`victim`'s) instead
17385    /// of depending on how many writes a commit happens to issue.
17386    ///
17387    /// `kept` is staged first on purpose. Staged writes are applied in the order
17388    /// they were staged (`StagedEdits::writes` is a `Vec`), so it is the entry
17389    /// already in the journal when `victim` fails — and restoring it is what
17390    /// shows the rollback carrying on past a failure rather than giving up at
17391    /// the first one. Stage them the other way round and that half goes quiet.
17392    #[test]
17393    fn a_failed_fast_path_commit_puts_back_the_value_it_overwrote() {
17394        use crate::writer::FileBuilder;
17395        use tempfile::tempdir;
17396
17397        let dir = tempdir().unwrap();
17398        let path = dir.path().join("inplace_fast_path_torn.h5");
17399        let mut b = FileBuilder::new();
17400        b.create_dataset("kept").with_i32_data(&[1, 2, 3]);
17401        b.create_dataset("victim").with_i32_data(&[4, 5, 6]);
17402        b.write(&path).unwrap();
17403
17404        // The data block the fake device refuses, named rather than guessed.
17405        let victim_block = {
17406            let f = crate::reader::File::open(&path).unwrap();
17407            match f.dataset("victim").unwrap().layout().unwrap() {
17408                crate::Layout::Contiguous {
17409                    address: Some(a),
17410                    size,
17411                } => a..a + size,
17412                other => panic!("expected a contiguous victim: {other:?}"),
17413            }
17414        };
17415
17416        let mut s = WriteEngine::open_torn_writes(&path, victim_block).unwrap();
17417        for (name, data) in [("/kept", [9, 9, 9]), ("/victim", [8, 8, 8])] {
17418            s.stage_dataset_write(name, {
17419                let mut db = crate::type_builders::DatasetBuilder::new("");
17420                db.with_i32_data(&data);
17421                db
17422            })
17423            .unwrap();
17424        }
17425        let refused = s.commit();
17426        assert!(
17427            matches!(refused, Err(Error::CommitPartiallyApplied { .. })),
17428            "a rollback that could not run is not an ordinary refusal: {refused:?}"
17429        );
17430        drop(s);
17431
17432        let f = crate::reader::File::open(&path).unwrap();
17433        assert_eq!(
17434            f.dataset("kept").unwrap().read_i32().unwrap(),
17435            vec![1, 2, 3],
17436            "the overwrite the rollback could reach is put back"
17437        );
17438        assert_eq!(
17439            f.dataset("victim").unwrap().read_i32().unwrap(),
17440            vec![8, 8, 8],
17441            "the overwrite it could not reach is the value the error is about"
17442        );
17443    }
17444
17445    /// Past the superblock repoint the overwrite is part of what the commit
17446    /// *published*, so a failure after that point must leave it standing —
17447    /// the same boundary [`FreeSnapshot`] draws for the free lists (issue #344).
17448    ///
17449    /// The window is real rather than theoretical: `repoint_stored_references`
17450    /// runs after the repoint and writes into live objects. A reference to the
17451    /// root group is what makes it run on any commit at all, since every commit
17452    /// rebuilds the root; the fake device then fails that write, so the commit
17453    /// returns an error having already committed.
17454    ///
17455    /// What the file holds afterwards is the new tree — the value overwrite
17456    /// included — with one stale reference. That is not a state a rollback can
17457    /// improve on: the root the reference would have to be re-pointed at is
17458    /// already the live one.
17459    #[test]
17460    fn a_commit_that_fails_past_its_repoint_keeps_the_value_it_published() {
17461        use crate::writer::FileBuilder;
17462        use tempfile::tempdir;
17463
17464        let dir = tempdir().unwrap();
17465        let path = dir.path().join("inplace_after_repoint.h5");
17466        let mut b = FileBuilder::new();
17467        b.create_dataset("nums").with_i32_data(&[1, 2, 3]);
17468        b.create_dataset("refs").with_path_references(&[""]);
17469        b.write(&path).unwrap();
17470
17471        let refs_block = {
17472            let f = crate::reader::File::open(&path).unwrap();
17473            match f.dataset("refs").unwrap().layout().unwrap() {
17474                crate::Layout::Contiguous {
17475                    address: Some(a),
17476                    size,
17477                } => a..a + size,
17478                other => panic!("expected a contiguous reference dataset: {other:?}"),
17479            }
17480        };
17481
17482        let mut s = WriteEngine::open_torn_writes(&path, refs_block).unwrap();
17483        s.stage_dataset_write("/nums", {
17484            let mut db = crate::type_builders::DatasetBuilder::new("");
17485            db.with_i32_data(&[9, 9, 9]);
17486            db
17487        })
17488        .unwrap();
17489        // A second edit, so the batch takes the full commit path and reaches a
17490        // repoint at all.
17491        s.stage_created_dataset("/extra", {
17492            let mut db = crate::type_builders::DatasetBuilder::new("");
17493            db.with_i32_data(&[42]);
17494            db
17495        })
17496        .unwrap();
17497        let failed = s.commit();
17498        assert!(
17499            matches!(failed, Err(Error::Io(_))),
17500            "the reference write is what failed, after the commit published: {failed:?}"
17501        );
17502        assert!(
17503            s.publish_attempted,
17504            "the failure has to be past the publish for this test to mean anything"
17505        );
17506        drop(s);
17507
17508        let f = crate::reader::File::open(&path).unwrap();
17509        assert_eq!(
17510            f.dataset("nums").unwrap().read_i32().unwrap(),
17511            vec![9, 9, 9],
17512            "the published overwrite must not be rolled back under it"
17513        );
17514        assert_eq!(f.dataset("extra").unwrap().read_i32().unwrap(), vec![42]);
17515    }
17516
17517    #[test]
17518    fn append_inplace_crash_consistency_paged_prefix() {
17519        use crate::reader::File as PureFile;
17520        use tempfile::tempdir;
17521
17522        let dir = tempdir().unwrap();
17523        let base = dir.path().join("base.h5");
17524        let (start, target) = (131_000i32, 132_000i32);
17525        build_unit_chunked(&base, start);
17526
17527        for max_phase in 1u8..=4 {
17528            let p = dir.path().join(format!("crash_paged_{max_phase}.h5"));
17529            append_stopped_at(&base, &p, start..target, max_phase);
17530            let expected_len = if max_phase == 4 { target } else { start };
17531            let f = PureFile::from_bytes(std::fs::read(&p).unwrap()).unwrap();
17532            assert_eq!(
17533                f.dataset("d").unwrap().read_i32().unwrap(),
17534                (0..expected_len).collect::<Vec<_>>(),
17535                "inconsistent paged view after crash at phase {max_phase}"
17536            );
17537        }
17538    }
17539
17540    /// After an append stopped at any phase, a reader sees the committed prefix
17541    /// and nothing of the rest, for both starting layouts: a partial trailing
17542    /// chunk and the EA-boundary growth above, which exercise different index
17543    /// writes.
17544    ///
17545    /// This reader is the lenient one. It bounds chunk reads by `min(EA count,
17546    /// dimension)`, so it tolerates a phase-3 state where the element count has
17547    /// advanced past the dimension, where the reference C library walks strictly
17548    /// by the dataspace dimension and re-validates block checksums. The C
17549    /// library read these states until it became a dependency of the crosscheck
17550    /// package alone; the phased append that produces them is private, so that
17551    /// coverage is gone, and a stale end-of-file, a half-grown index or a
17552    /// mis-checksummed block that this reader forgives is not caught here.
17553    ///
17554    /// Restores coverage lost with the deprecated `SwmrWriter` and `AppendWriter`
17555    /// (issue #202).
17556    #[test]
17557    fn append_inplace_crash_consistency_leaves_the_committed_prefix() {
17558        use tempfile::tempdir;
17559
17560        // (initial length, chunk length, appended length): a partial trailing
17561        // chunk, a chunk-aligned start, and the EA-boundary crossing.
17562        for (n, chunk, add) in [(6i32, 4u64, 5i32), (8, 2, 6), (50, 1, 200)] {
17563            let dir = tempdir().unwrap();
17564            let base = dir.path().join("base.h5");
17565            {
17566                use crate::writer::FileBuilder;
17567                let mut b = FileBuilder::new();
17568                b.create_dataset("d")
17569                    .with_i32_data(&(0..n).collect::<Vec<i32>>())
17570                    .with_shape(&[n as u64])
17571                    .with_maxshape(&[u64::MAX])
17572                    .with_chunks(&[chunk]);
17573                b.write(&base).unwrap();
17574            }
17575
17576            for max_phase in 1u8..=4 {
17577                let p = dir
17578                    .path()
17579                    .join(format!("crash_c_{n}_{chunk}_{max_phase}.h5"));
17580                append_stopped_at(&base, &p, n..n + add, max_phase);
17581                let expected_len = if max_phase == 4 { n + add } else { n };
17582                let pf = crate::reader::File::from_bytes(std::fs::read(&p).unwrap()).unwrap();
17583                assert_eq!(
17584                    pf.dataset("d").unwrap().read_i32().unwrap(),
17585                    (0..expected_len).collect::<Vec<_>>(),
17586                    "inconsistent view after crash at phase {max_phase} (n={n}, chunk={chunk})"
17587                );
17588            }
17589        }
17590    }
17591
17592    /// Crash recovery across the phase-3/phase-4 gap.
17593    ///
17594    /// A writer that crashes after publishing the Extensible-Array element count
17595    /// (phase 3) but before publishing the dataspace dimension (phase 4) leaves
17596    /// the on-disk count ahead of the committed dimension. A fresh writer must
17597    /// roll forward from the *committed dimension*, overwriting the uncommitted
17598    /// slots, rather than appending past them and leaving a gap.
17599    ///
17600    /// The crashed and recovering appends deliberately write different values at
17601    /// the overlapping positions, so a regression that seeds the chunk count from
17602    /// the stale EA header surfaces the crashed writer's values rather than
17603    /// merely producing plausible-looking data.
17604    ///
17605    /// Restores coverage lost with the deprecated `SwmrWriter` (issue #202); the
17606    /// surviving `recover_and_reappend_after_clean_phase4` covers only the clean
17607    /// case.
17608    #[test]
17609    fn append_inplace_recover_and_reappend_after_phase3_crash() {
17610        use crate::reader::File as PureFile;
17611        use tempfile::tempdir;
17612
17613        let dir = tempdir().unwrap();
17614        let path = dir.path().join("phase3_recover.h5");
17615        let n = 50i32;
17616        build_unit_chunked(&path, n);
17617
17618        // Writer 1 crashes after phase 3: the element count advances but the
17619        // dimension stays at `n`. Its values are far from the correct
17620        // continuation, so a leak is unmistakable.
17621        {
17622            let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
17623            s.append_inplace_i32_phased("d", &(1000..1200).collect::<Vec<_>>(), 3)
17624                .unwrap();
17625        }
17626        let committed: Vec<i32> = (0..n).collect();
17627        let pf = PureFile::from_bytes(std::fs::read(&path).unwrap()).unwrap();
17628        assert_eq!(
17629            pf.dataset("d").unwrap().read_i32().unwrap(),
17630            committed,
17631            "phase-3 crash exposed uncommitted data to the pure reader"
17632        );
17633
17634        // Writer 2 recovers: roll forward from the committed dimension,
17635        // overwriting the uncommitted slots with the real continuation.
17636        {
17637            let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
17638            s.append_inplace_i32_phased("d", &(n..150).collect::<Vec<_>>(), 4)
17639                .unwrap();
17640        }
17641
17642        let expected: Vec<i32> = (0..150).collect();
17643        let pf = PureFile::from_bytes(std::fs::read(&path).unwrap()).unwrap();
17644        assert_eq!(
17645            pf.dataset("d").unwrap().read_i32().unwrap(),
17646            expected,
17647            "recovery did not roll forward correctly (pure reader)"
17648        );
17649    }
17650
17651    #[test]
17652    fn raw_appendable_recurses_into_aggregates() {
17653        use crate::datatype::{CompoundMember, DatatypeByteOrder};
17654
17655        let f64_with = |byte_order| Datatype::FloatingPoint {
17656            size: 8,
17657            byte_order,
17658            bit_offset: 0,
17659            bit_precision: 64,
17660            exponent_location: 52,
17661            exponent_size: 11,
17662            mantissa_location: 0,
17663            mantissa_size: 52,
17664            exponent_bias: 1023,
17665        };
17666        let le_f64 = f64_with(DatatypeByteOrder::LittleEndian);
17667        let be_f64 = f64_with(DatatypeByteOrder::BigEndian);
17668
17669        // Little-endian scalar: appendable. Big-endian scalar: not.
17670        assert!(datatype_is_raw_appendable(&le_f64));
17671        assert!(!datatype_is_raw_appendable(&be_f64));
17672
17673        // The confirmed bug: a compound / array whose leaf is big-endian must be
17674        // refused (it was wrongly accepted before recursion was added).
17675        let be_member = Datatype::Compound {
17676            size: 8,
17677            members: vec![CompoundMember {
17678                name: "x".into(),
17679                byte_offset: 0,
17680                datatype: be_f64.clone(),
17681            }],
17682        };
17683        assert!(!datatype_is_raw_appendable(&be_member));
17684        let le_member = Datatype::Compound {
17685            size: 8,
17686            members: vec![CompoundMember {
17687                name: "x".into(),
17688                byte_offset: 0,
17689                datatype: le_f64.clone(),
17690            }],
17691        };
17692        assert!(datatype_is_raw_appendable(&le_member));
17693        assert!(!datatype_is_raw_appendable(&Datatype::Array {
17694            base_type: Box::new(be_f64.clone()),
17695            dimensions: vec![4],
17696        }));
17697
17698        // Variable-length / reference leaves are never raw-appendable, even LE.
17699        assert!(!datatype_is_raw_appendable(&Datatype::VariableLength {
17700            is_string: false,
17701            padding: None,
17702            charset: None,
17703            base_type: Box::new(le_f64.clone()),
17704        }));
17705        assert!(!datatype_is_raw_appendable(&Datatype::Reference {
17706            size: 8,
17707            ref_type: crate::datatype::ReferenceType::Object,
17708        }));
17709    }
17710
17711    #[test]
17712    fn fresh_group_region_pairs_link_info_with_group_info() {
17713        // A new-style group must carry both a Link Info and a Group Info message
17714        // (the C library requires the pair before it will insert a link).
17715        let types = region_types(&fresh_group_region());
17716        assert_eq!(types, vec![MessageType::LinkInfo, MessageType::GroupInfo]);
17717    }
17718
17719    #[test]
17720    fn ensure_group_info_appends_when_missing() {
17721        // A region with a Link Info message but no Group Info message (how older
17722        // hdf5-pure releases wrote groups) gains exactly one Group Info message.
17723        let li_body = {
17724            let mut b = vec![0u8, 0];
17725            b.extend_from_slice(&u64::MAX.to_le_bytes());
17726            b.extend_from_slice(&u64::MAX.to_le_bytes());
17727            b
17728        };
17729        let mut region = plain_region(message_record(MessageType::LinkInfo, &li_body));
17730        ensure_group_info(&mut region).unwrap();
17731        assert_eq!(
17732            region_types(&region),
17733            vec![MessageType::LinkInfo, MessageType::GroupInfo]
17734        );
17735
17736        // The appended message decodes as a minimal Group Info body.
17737        let mut p = 0;
17738        while let Some((mt, body, end)) = region.next_message(p).unwrap() {
17739            if mt == MessageType::GroupInfo {
17740                assert_eq!(&region[body..end], &GROUP_INFO_BODY);
17741            }
17742            p = end;
17743        }
17744    }
17745
17746    /// A group header region that tracks link creation order: a Link Info
17747    /// message recording `max` as the next index it would hand out (with the
17748    /// creation-order *index* declared when `indexed`, as h5py's
17749    /// `track_order=True` does), a Group Info message, and one compact Link
17750    /// message per name in `links`, numbered from zero.
17751    fn link_tracked_region(max: u64, indexed: bool, links: &[&str]) -> OhRegion {
17752        let mut li = vec![0u8, if indexed { 0x03 } else { 0x01 }];
17753        li.extend_from_slice(&max.to_le_bytes());
17754        li.extend_from_slice(&u64::MAX.to_le_bytes()); // fractal heap: compact
17755        li.extend_from_slice(&u64::MAX.to_le_bytes()); // b-tree name index
17756        if indexed {
17757            li.extend_from_slice(&u64::MAX.to_le_bytes()); // b-tree creation order
17758        }
17759        let mut region = OhRegion::empty(OhHeaderProps::PLAIN);
17760        region.push(MessageType::LinkInfo, &li);
17761        region.push(MessageType::GroupInfo, &GROUP_INFO_BODY);
17762        for (i, name) in links.iter().enumerate() {
17763            region.push_link(name, 0x100 + i as u64, Some(i as u64));
17764        }
17765        region
17766    }
17767
17768    /// Every Link message in `region`, as `(name, creation index)`.
17769    fn region_links(region: &OhRegion) -> Vec<(String, Option<u64>)> {
17770        let mut out = Vec::new();
17771        let mut p = 0;
17772        while let Some((mt, body, end)) = region.next_message(p).unwrap() {
17773            if mt == MessageType::Link {
17774                let link = LinkMessage::parse(&region[body..end], OFFSET_SIZE).unwrap();
17775                out.push((link.name, link.creation_order));
17776            }
17777            p = end;
17778        }
17779        out
17780    }
17781
17782    /// The maximum creation index `region`'s Link Info message records.
17783    fn recorded_link_max(region: &OhRegion) -> Option<u64> {
17784        find_link_info(region)
17785            .unwrap()
17786            .and_then(|(_, _, info)| info.max_creation_order)
17787    }
17788
17789    #[test]
17790    fn links_added_to_a_tracked_group_take_consecutive_creation_indexes() {
17791        // A group that has handed out three indexes numbers the next two links
17792        // 3 and 4, in the order they are placed, and its Link Info message ends
17793        // up recording 5 — the next index, not the highest in use.
17794        let mut region = link_tracked_region(3, false, &["a", "b", "c"]);
17795        let mut order = LinkCreationOrder::for_region(&region).unwrap();
17796        region.push_link("d", 0x200, order.take().unwrap());
17797        region.push_link("e", 0x300, order.take().unwrap());
17798        order.record(&mut region).unwrap();
17799
17800        assert_eq!(
17801            region_links(&region),
17802            vec![
17803                ("a".to_string(), Some(0)),
17804                ("b".to_string(), Some(1)),
17805                ("c".to_string(), Some(2)),
17806                ("d".to_string(), Some(3)),
17807                ("e".to_string(), Some(4)),
17808            ],
17809        );
17810        assert_eq!(recorded_link_max(&region), Some(5));
17811    }
17812
17813    #[test]
17814    fn a_tracked_group_resumes_past_a_gap_a_deletion_left() {
17815        // Two links of three deleted, so the surviving link is index 1 and the
17816        // recorded maximum still says 3: the addition takes 3, not one of the
17817        // indexes the deletions freed.
17818        let region = link_tracked_region(3, false, &["a", "b", "c"]);
17819        let mut region = remove_link_from_region(&region, "a").unwrap();
17820        region = remove_link_from_region(&region, "c").unwrap();
17821        let mut order = LinkCreationOrder::for_region(&region).unwrap();
17822        region.push_link("d", 0x200, order.take().unwrap());
17823        order.record(&mut region).unwrap();
17824
17825        assert_eq!(
17826            region_links(&region),
17827            vec![("b".to_string(), Some(1)), ("d".to_string(), Some(3))],
17828        );
17829        assert_eq!(recorded_link_max(&region), Some(4));
17830    }
17831
17832    #[test]
17833    fn a_creation_order_index_shifts_no_field_the_maximum_is_written_into() {
17834        // A group that also declares the creation-order *index* (h5py's
17835        // `track_order=True`) has a longer Link Info body with a third address
17836        // in it. The maximum still precedes every address, so the patch lands on
17837        // it and leaves the three addresses undefined.
17838        let mut region = link_tracked_region(1, true, &["a"]);
17839        let mut order = LinkCreationOrder::for_region(&region).unwrap();
17840        region.push_link("b", 0x200, order.take().unwrap());
17841        order.record(&mut region).unwrap();
17842
17843        let (_, _, info) = find_link_info(&region).unwrap().unwrap();
17844        assert_eq!(info.max_creation_order, Some(2));
17845        assert_eq!(info.fractal_heap_address, None);
17846        assert_eq!(info.btree_name_index_address, None);
17847        assert_eq!(info.btree_creation_order_address, None);
17848        assert_eq!(region_links(&region).last().unwrap().1, Some(1));
17849    }
17850
17851    #[test]
17852    fn an_untracked_group_numbers_none_of_its_links() {
17853        // The groups this crate writes itself: no maximum recorded, so no link
17854        // carries a creation index and the Link Info message is left as it was.
17855        let mut region = fresh_group_region();
17856        let before = region.clone();
17857        let mut order = LinkCreationOrder::for_region(&region).unwrap();
17858        assert_eq!(order.take().unwrap(), None);
17859        region.push_link("d", 0x200, None);
17860        order.record(&mut region).unwrap();
17861
17862        assert_eq!(region_links(&region), vec![("d".to_string(), None)]);
17863        assert_eq!(recorded_link_max(&region), None);
17864        // Only the appended link is new; every message that was there is
17865        // untouched.
17866        assert_eq!(&region[..before.len()], &*before);
17867    }
17868
17869    #[test]
17870    fn a_tracked_group_that_gains_no_link_keeps_its_maximum_byte_for_byte() {
17871        // A commit that only deletes, or only edits attributes, must not bump
17872        // the counter — the gap it leaves is never handed out again.
17873        let region = link_tracked_region(3, false, &["a", "b", "c"]);
17874        let mut after = remove_link_from_region(&region, "b").unwrap();
17875        let order = LinkCreationOrder::for_region(&after).unwrap();
17876        let before = after.clone();
17877        order.record(&mut after).unwrap();
17878        assert_eq!(*after, *before);
17879        assert_eq!(recorded_link_max(&after), Some(3));
17880    }
17881
17882    #[test]
17883    fn max_compact_links_reads_the_declared_threshold_or_the_library_default() {
17884        // A minimal Group Info message stores no link-phase-change values, so
17885        // the C library's default of 8 applies.
17886        assert_eq!(max_compact_links(&fresh_group_region()).unwrap(), 8);
17887
17888        // One that stores them (flags bit 0) declares its own maximum compact
17889        // value in the two bytes after the flags.
17890        let mut gi = vec![0u8, 0x01];
17891        gi.extend_from_slice(&4u16.to_le_bytes()); // max compact
17892        gi.extend_from_slice(&2u16.to_le_bytes()); // min dense
17893        let mut region = OhRegion::empty(OhHeaderProps::PLAIN);
17894        region.push(MessageType::GroupInfo, &gi);
17895        assert_eq!(max_compact_links(&region).unwrap(), 4);
17896    }
17897
17898    #[test]
17899    fn a_tracked_group_is_refused_only_where_the_addition_would_go_dense() {
17900        // Eight links is the default threshold, so a group holding seven takes
17901        // one more and is refused the second.
17902        let region = link_tracked_region(7, false, &["a", "b", "c", "d", "e", "f", "g"]);
17903        reject_dense_link_creation_order(&region, 8).expect("eight links still store compactly");
17904        let err = reject_dense_link_creation_order(&region, 9).unwrap_err();
17905        assert!(
17906            matches!(&err, Error::EditUnsupported(m) if m.contains("link creation order")
17907                && m.contains("dense")),
17908            "got: {err}",
17909        );
17910
17911        // A group that does not track the order is not screened at all: this
17912        // crate writes more than eight compact links routinely.
17913        let mut untracked = fresh_group_region();
17914        untracked.push_link("a", 0x100, None);
17915        reject_dense_link_creation_order(&untracked, 99)
17916            .expect("an untracked group has no creation order to lose");
17917    }
17918
17919    #[test]
17920    fn ensure_group_info_is_idempotent() {
17921        // A region that already has a Group Info message is left untouched, so
17922        // re-editing a healed (or C-written) group does not duplicate it.
17923        let mut region = fresh_group_region();
17924        let before = region.clone();
17925        ensure_group_info(&mut region).unwrap();
17926        assert_eq!(*region, *before);
17927    }
17928
17929    // ---- shared (SOHM) messages (issue #417) ----
17930
17931    /// A shared-message index record naming an object header is a stored address
17932    /// like any other, and the one no repoint walk reaches: it lives outside
17933    /// every object a commit rebuilds. A commit that removes or moves that header
17934    /// is refused rather than leaving the index naming bytes that have gone.
17935    #[test]
17936    fn a_commit_that_strands_a_shared_message_record_is_refused() {
17937        let named = crate::sohm::SohmRecord {
17938            hash: 1,
17939            location: crate::sohm::SohmLocation::ObjectHeader {
17940                message_type: MessageType::Attribute.to_u16() as u8,
17941                creation_index: 0,
17942                address: 0x400,
17943            },
17944        };
17945        let moved = InvalidatedAddresses {
17946            removed: Vec::new(),
17947            moved: vec![0x400],
17948            base: BaseAddress::ZERO,
17949        };
17950        let err = screen_shared_message_records(std::slice::from_ref(&named), &moved).unwrap_err();
17951        assert_eq!(
17952            err.to_string(),
17953            Error::EditUnsupported(SHARED_MESSAGE_INDEX_NAMES_A_MOVED_OBJECT).to_string()
17954        );
17955
17956        let elsewhere = InvalidatedAddresses {
17957            removed: Vec::new(),
17958            moved: vec![0x800],
17959            base: BaseAddress::ZERO,
17960        };
17961        screen_shared_message_records(std::slice::from_ref(&named), &elsewhere).unwrap();
17962    }
17963
17964    /// A heap-stored record names no object header, so no address of it can go
17965    /// stale and the screen passes it. What a commit can spoil about one is its
17966    /// reference count, which the attribute paths refuse to change instead.
17967    #[test]
17968    fn a_heap_stored_shared_message_record_is_not_screened_for_addresses() {
17969        let heap = crate::sohm::SohmRecord {
17970            hash: 1,
17971            location: crate::sohm::SohmLocation::Heap {
17972                reference_count: 2,
17973                // The same eight bytes the address screen would refuse if it read
17974                // them as one.
17975                heap_id: 0x400u64.to_le_bytes(),
17976            },
17977        };
17978        let moved = InvalidatedAddresses {
17979            removed: Vec::new(),
17980            moved: vec![0x400],
17981            base: BaseAddress::ZERO,
17982        };
17983        screen_shared_message_records(&[heap], &moved).unwrap();
17984    }
17985
17986    /// A version 1 reference to a committed object becomes the version 2 form
17987    /// this crate writes everywhere else, naming the same address: the whole
17988    /// point of rewrapping is that the message still resolves to one copy.
17989    #[test]
17990    fn a_version_1_reference_is_rewrapped_as_the_modern_committed_form() {
17991        let mut v1 = vec![1u8, 0];
17992        v1.extend_from_slice(&[0u8; 6]); // reserved
17993        v1.extend_from_slice(&0x1111u64.to_le_bytes()); // local heap address
17994        v1.extend_from_slice(&0x5678u64.to_le_bytes()); // object header address
17995
17996        let modern = modernize_shared_reference(&v1, OFFSET_SIZE, LENGTH_SIZE).unwrap();
17997        assert_eq!(modern[0], 2, "the version this crate writes");
17998        assert_eq!(
17999            crate::shared_message::parse_shared_ref(&modern, OFFSET_SIZE, LENGTH_SIZE)
18000                .unwrap()
18001                .location,
18002            crate::shared_message::SharedLocation::ObjectHeader(0x5678)
18003        );
18004    }
18005
18006    /// A reference into the shared-message heap keeps its heap ID and the only
18007    /// version that encodes one. Re-encoding it as a committed reference would
18008    /// turn eight bytes of heap ID into a file address.
18009    #[test]
18010    fn a_heap_reference_is_rewrapped_as_a_heap_reference() {
18011        let mut v3 = vec![3u8, 1];
18012        v3.extend_from_slice(&[9, 8, 7, 6, 5, 4, 3, 2]);
18013
18014        let modern = modernize_shared_reference(&v3, OFFSET_SIZE, LENGTH_SIZE).unwrap();
18015        assert_eq!(modern, v3);
18016    }
18017
18018    /// The rewrapped record keeps the shared flag. Without it every reader
18019    /// decodes the reference as the attribute it stands for.
18020    #[test]
18021    fn a_rewrapped_shared_message_keeps_its_flag() {
18022        let mut region = OhRegion::empty(OhHeaderProps::PLAIN);
18023        region.push_shared(MessageType::Attribute, &[3, 1, 0, 0, 0, 0, 0, 0, 0, 0]);
18024        assert_eq!(region[3], MSG_FLAG_SHARED);
18025        assert!(region_has_shared_attr(&region).unwrap());
18026    }
18027
18028    #[test]
18029    fn reject_foreign_addresses_refuses_any_shared_message() {
18030        // A shared (SOHM) message of *any* type — here a Dataspace — stores a
18031        // source-file reference in place of its body, so a verbatim cross-file
18032        // copy must refuse it, not only shared datatypes/attributes. (A plain,
18033        // non-shared dataspace embeds no foreign address and is accepted.)
18034        let mut shared = message_record(MessageType::Dataspace, &[0u8; 8]);
18035        shared[3] = MSG_FLAG_SHARED; // set the message's shared flag
18036        let err = reject_foreign_addresses(&plain_region(shared)).unwrap_err();
18037        assert!(err.to_string().contains("shared"), "got: {err}");
18038
18039        let plain = message_record(MessageType::Dataspace, &[0u8; 8]);
18040        reject_foreign_addresses(&plain_region(plain)).unwrap();
18041    }
18042
18043    // ---- version 2 object-header record layout (issue #416) ----
18044
18045    /// Version-2 object-header message flag bit marking a message as one that
18046    /// must not be shared. The reference C library sets it on the Attribute
18047    /// Info message it writes, so a rewrite of that message has to keep it.
18048    const MSG_FLAG_DONT_SHARE: u8 = 0x04;
18049
18050    /// The layout of a header that tracks *and* indexes attribute creation
18051    /// order: object-header flag bits 2 and 3, and 6-byte message records.
18052    const TRACKED: OhRecordLayout = OhRecordLayout::from_header_flags(0x0C);
18053
18054    /// One compact Attribute message body for `name`.
18055    fn attr_body(name: &str, value: i64) -> Vec<u8> {
18056        crate::type_builders::build_attr_message(name, &AttrValue::I64(value))
18057            .serialize(LENGTH_SIZE)
18058    }
18059
18060    /// [`attr_region_in`] for a header carrying neither optional prefix block.
18061    fn attr_region(layout: OhRecordLayout, attrs: &[(&str, u16)], max: u16) -> OhRegion {
18062        attr_region_in(OhHeaderProps::with_layout(layout), attrs, max)
18063    }
18064
18065    /// A region belonging to a header with `props`, holding one Attribute message
18066    /// per `(name, creation index)`, preceded by the Attribute Info message a
18067    /// tracked object carries.
18068    fn attr_region_in(props: OhHeaderProps, attrs: &[(&str, u16)], max: u16) -> OhRegion {
18069        let layout = props.layout;
18070        let mut region = OhRegion::empty(props);
18071        let info = AttributeInfoMessage {
18072            max_creation_index: layout.tracks_creation_order().then_some(max),
18073            indexes_creation_order: layout.indexes_creation_order(),
18074            fractal_heap_address: None,
18075            btree_name_index_address: None,
18076            btree_creation_order_address: None,
18077        };
18078        let mut record = layout.record(MessageType::AttributeInfo, &info.serialize(OFFSET_SIZE));
18079        record[3] = MSG_FLAG_DONT_SHARE;
18080        region.push_bytes(&record);
18081        for (name, index) in attrs {
18082            let record = layout.record_with_creation_index(
18083                MessageType::Attribute,
18084                &attr_body(name, 1),
18085                *index,
18086            );
18087            region.push_bytes(&record);
18088        }
18089        region
18090    }
18091
18092    /// The attribute names a region carries, in the order its records hold them,
18093    /// each with the creation index its record declares.
18094    fn walk_attrs(region: &OhRegion) -> Vec<(String, Option<u16>)> {
18095        let mut out = Vec::new();
18096        let mut p = 0;
18097        while let Some((msg_type, body, body_end)) = region.next_message(p).unwrap() {
18098            if msg_type == MessageType::Attribute {
18099                let name = parse_compact_attr_name(region, p, body, body_end).unwrap();
18100                out.push((name, region.creation_index(p)));
18101            }
18102            p = body_end;
18103        }
18104        out
18105    }
18106
18107    /// The message walk steps by the width the header's flags declare: four
18108    /// bytes of record prefix without creation-order tracking, six with it.
18109    #[test]
18110    fn a_record_walk_steps_by_the_width_the_header_declares() {
18111        for layout in [OhRecordLayout::PLAIN, TRACKED] {
18112            let region = attr_region(layout, &[("alpha", 0), ("beta", 7)], 8);
18113            assert_eq!(
18114                walk_attrs(&region),
18115                vec![
18116                    (
18117                        "alpha".to_string(),
18118                        layout.tracks_creation_order().then_some(0)
18119                    ),
18120                    (
18121                        "beta".to_string(),
18122                        layout.tracks_creation_order().then_some(7)
18123                    ),
18124                ],
18125                "layout {layout:?}",
18126            );
18127        }
18128
18129        // Non-vacuity: the two widths are not interchangeable. Reading a tracked
18130        // region four bytes at a time misparses it from the first record on,
18131        // which is the defect this width exists to prevent.
18132        let tracked = attr_region(TRACKED, &[("alpha", 0), ("beta", 7)], 8);
18133        let misread = OhRegion::new(tracked.to_vec(), OhHeaderProps::PLAIN);
18134        let mut p = 0;
18135        let mut agreed = true;
18136        while let Ok(Some((_, _, body_end))) = misread.next_message(p) {
18137            p = body_end;
18138        }
18139        agreed &= p == tracked.len();
18140        assert!(
18141            !agreed,
18142            "a four-byte walk of a six-byte-record region must not come out even"
18143        );
18144    }
18145
18146    /// An emitted record carries the creation index only where the layout has
18147    /// the field, and the flags byte stays where every reader looks for it.
18148    #[test]
18149    fn an_emitted_record_carries_a_creation_index_only_where_the_header_tracks_one() {
18150        let body = [0xABu8; 5];
18151        let plain =
18152            OhRecordLayout::PLAIN.record_with_creation_index(MessageType::Attribute, &body, 9);
18153        assert_eq!(plain.len(), 4 + body.len());
18154        assert_eq!(&plain[4..], &body);
18155
18156        let tracked = TRACKED.record_with_creation_index(MessageType::Attribute, &body, 0x1234);
18157        assert_eq!(tracked.len(), 6 + body.len());
18158        assert_eq!(
18159            &tracked[..4],
18160            &plain[..4],
18161            "type, size and flags are shared"
18162        );
18163        assert_eq!(&tracked[4..6], &0x1234u16.to_le_bytes());
18164        assert_eq!(&tracked[6..], &body);
18165
18166        // Every non-attribute message the reference C library writes carries a
18167        // zero creation index, which is what the plain emitter passes.
18168        let group_info = TRACKED.record(MessageType::GroupInfo, &body);
18169        assert_eq!(&group_info[4..6], &0u16.to_le_bytes());
18170    }
18171
18172    /// A rebuilt header declares the record layout its bytes are written in, so
18173    /// re-reading it walks them the same way.
18174    #[test]
18175    fn a_rebuilt_header_declares_the_record_layout_it_used() {
18176        for layout in [OhRecordLayout::PLAIN, TRACKED] {
18177            let region = attr_region(layout, &[("alpha", 3)], 4);
18178            let header = build_v2_object_header(&region).unwrap();
18179            let (start, end, read_back) =
18180                oh_region_at(&header, 0, header.len() as u64).expect("the header parses");
18181            assert_eq!(
18182                read_back,
18183                OhHeaderProps::with_layout(layout),
18184                "the flags lost the layout"
18185            );
18186            let round_tripped =
18187                OhRegion::new(header[start as usize..end as usize].to_vec(), read_back);
18188            assert_eq!(
18189                walk_attrs(&round_tripped),
18190                walk_attrs(&region),
18191                "layout {layout:?}",
18192            );
18193        }
18194    }
18195
18196    // ---- the optional prefix blocks of a version 2 object header (PR #422) ----
18197
18198    /// Timestamps distinct enough that a field read at the wrong offset — or one
18199    /// copied from a neighbour — shows up as a different number.
18200    const FIXTURE_TIMES: ObjectTimes = ObjectTimes {
18201        access: 0x1111_1111,
18202        modification: 0x2222_2222,
18203        change: 0x3333_3333,
18204        birth: 0x4444_4444,
18205    };
18206
18207    /// A phase-change pair the reference C library would never write by default
18208    /// (its defaults are 8 and 6), so a rebuild that dropped the block and a
18209    /// rebuild that substituted the defaults both fail.
18210    const FIXTURE_PHASE: AttrPhaseChange = AttrPhaseChange {
18211        max_compact: 32,
18212        min_dense: 24,
18213    };
18214
18215    /// Hand-assemble the version 2 object header `props` describes around
18216    /// `region`'s bytes, with a 1-byte chunk-0 size field.
18217    ///
18218    /// Written out here rather than taken from [`build_v2_object_header`] so the
18219    /// parse is checked against bytes the emitter did not produce: an emitter and
18220    /// a parser that agreed on a wrong prefix layout would round-trip perfectly.
18221    fn v2_header_bytes(props: OhHeaderProps, region: &OhRegion) -> Vec<u8> {
18222        let mut buf = Vec::new();
18223        buf.extend_from_slice(b"OHDR");
18224        buf.push(2); // version
18225        buf.push(props.header_flags()); // size flags 0: a 1-byte length field
18226        if let Some(times) = props.times {
18227            buf.extend_from_slice(&times.to_bytes());
18228        }
18229        if let Some(phase) = props.attr_phase_change {
18230            buf.extend_from_slice(&phase.to_bytes());
18231        }
18232        buf.push(u8::try_from(region.len()).expect("the fixture region is under 256 bytes"));
18233        buf.extend_from_slice(region);
18234        let checksum = jenkins_lookup3(&buf);
18235        buf.extend_from_slice(&checksum.to_le_bytes());
18236        buf
18237    }
18238
18239    /// Every combination of the two optional prefix blocks and the record layout:
18240    /// the parse finds the message region, and a rebuild puts both blocks back
18241    /// with the flag bits that announce them.
18242    ///
18243    /// A dropped block is not a lost field alone — the chunk-0 size field sits
18244    /// *after* both, so the message walk is checked too.
18245    #[test]
18246    fn a_headers_optional_prefix_blocks_survive_a_rebuild() {
18247        for layout in [OhRecordLayout::PLAIN, TRACKED] {
18248            for times in [None, Some(FIXTURE_TIMES)] {
18249                for attr_phase_change in [None, Some(FIXTURE_PHASE)] {
18250                    let props = OhHeaderProps {
18251                        layout,
18252                        times,
18253                        attr_phase_change,
18254                    };
18255                    let region = attr_region_in(props, &[("alpha", 3), ("beta", 5)], 6);
18256                    let header = v2_header_bytes(props, &region);
18257
18258                    let (start, end, read_back) = oh_region_at(&header, 0, header.len() as u64)
18259                        .expect("the hand-built header parses");
18260                    assert_eq!(read_back, props, "the prefix lost a block");
18261                    let parsed =
18262                        OhRegion::new(header[start as usize..end as usize].to_vec(), read_back);
18263                    assert_eq!(
18264                        walk_attrs(&parsed),
18265                        walk_attrs(&region),
18266                        "the message region was located wrongly for {props:?}",
18267                    );
18268
18269                    let rebuilt = build_v2_object_header(&parsed).unwrap();
18270                    let (_, _, again) = oh_region_at(&rebuilt, 0, rebuilt.len() as u64)
18271                        .expect("the rebuilt header parses");
18272                    assert_eq!(
18273                        again.attr_phase_change, attr_phase_change,
18274                        "the rebuild lost the attribute phase-change thresholds",
18275                    );
18276                    assert_eq!(
18277                        again.times.is_some(),
18278                        times.is_some(),
18279                        "the rebuild changed whether the header stores times",
18280                    );
18281                    assert_eq!(again.layout, layout, "the rebuild lost the record layout");
18282                }
18283            }
18284        }
18285    }
18286
18287    /// The flag bits the emitter sets are the ones the format assigns: bit 4 for
18288    /// the phase-change block, bit 5 for the timestamps.
18289    #[test]
18290    fn the_optional_blocks_are_announced_by_their_own_flag_bits() {
18291        let with_times = OhHeaderProps {
18292            times: Some(FIXTURE_TIMES),
18293            ..OhHeaderProps::PLAIN
18294        };
18295        let with_phase = OhHeaderProps {
18296            attr_phase_change: Some(FIXTURE_PHASE),
18297            ..OhHeaderProps::PLAIN
18298        };
18299        assert_eq!(OhHeaderProps::PLAIN.header_flags(), 0);
18300        assert_eq!(with_times.header_flags(), 0x20);
18301        assert_eq!(with_phase.header_flags(), 0x10);
18302        assert_eq!(OhHeaderProps::PLAIN.optional_len(), 0);
18303        assert_eq!(with_times.optional_len(), 16);
18304        assert_eq!(with_phase.optional_len(), 4);
18305    }
18306
18307    /// A rebuild of a header that stores times moves the modification and change
18308    /// times to now and leaves the access and birth times alone.
18309    #[cfg(feature = "std")]
18310    #[test]
18311    fn a_rebuild_stamps_the_modification_and_change_times() {
18312        let props = OhHeaderProps {
18313            times: Some(FIXTURE_TIMES),
18314            ..OhHeaderProps::PLAIN
18315        };
18316        let region = attr_region_in(props, &[("alpha", 0)], 1);
18317
18318        let before = unix_time_now().expect("a std test build reads the clock");
18319        let header = build_v2_object_header(&region).unwrap();
18320        let after = unix_time_now().expect("a std test build reads the clock");
18321
18322        let (_, _, read_back) = oh_region_at(&header, 0, header.len() as u64).unwrap();
18323        let times = read_back.times.expect("the header still stores times");
18324        assert_eq!(
18325            (times.access, times.birth),
18326            (FIXTURE_TIMES.access, FIXTURE_TIMES.birth),
18327            "a rewrite is not an access and not a birth",
18328        );
18329        assert!(
18330            (before..=after).contains(&times.modification),
18331            "the modification time {} is outside [{before}, {after}]",
18332            times.modification,
18333        );
18334        assert!(
18335            (before..=after).contains(&times.change),
18336            "the change time {} is outside [{before}, {after}]",
18337            times.change,
18338        );
18339    }
18340
18341    /// A prefix that declares a block it does not carry is a malformed file, not
18342    /// a panic: the parse reads only what the buffer holds.
18343    #[test]
18344    fn a_prefix_truncated_inside_an_optional_block_is_refused() {
18345        let props = OhHeaderProps {
18346            times: Some(FIXTURE_TIMES),
18347            attr_phase_change: Some(FIXTURE_PHASE),
18348            ..OhHeaderProps::PLAIN
18349        };
18350        let region = attr_region_in(props, &[("alpha", 0)], 1);
18351        let header = v2_header_bytes(props, &region);
18352        // Every prefix length short of the chunk-0 size field, which is the last
18353        // thing the parse reads: 6 bytes of signature and flags, then 20 bytes of
18354        // optional blocks.
18355        for cut in 6..6 + 16 + 4 + 1 {
18356            assert!(
18357                oh_region_at(&header[..cut], 0, header.len() as u64).is_err(),
18358                "a {cut}-byte prefix must not parse as a whole header",
18359            );
18360        }
18361    }
18362
18363    /// A fresh attribute takes the object's next unused creation index, and the
18364    /// Attribute Info message records the one after it — the counter the
18365    /// reference C library hands out from.
18366    #[test]
18367    fn a_new_compact_attribute_takes_the_next_creation_index() {
18368        let region = attr_region(TRACKED, &[("alpha", 0), ("beta", 1)], 2);
18369        let out = set_attr_in_region(&region, "gamma", &AttrValue::I64(5)).unwrap();
18370        assert_eq!(
18371            walk_attrs(&out),
18372            vec![
18373                ("alpha".to_string(), Some(0)),
18374                ("beta".to_string(), Some(1)),
18375                ("gamma".to_string(), Some(2)),
18376            ],
18377        );
18378        let (record, _, info) = find_attribute_info(&out).unwrap().expect("an info message");
18379        assert_eq!(info.max_creation_index, Some(3));
18380        assert!(
18381            info.indexes_creation_order,
18382            "the index flag survives a rewrite"
18383        );
18384        assert_eq!(
18385            out[record + 3],
18386            MSG_FLAG_DONT_SHARE,
18387            "the rewritten record lost the message flags it carried"
18388        );
18389    }
18390
18391    /// Overwriting an attribute keeps the creation index it had, so an
18392    /// iteration by creation order does not reorder it.
18393    #[test]
18394    fn an_overwritten_compact_attribute_keeps_its_creation_index() {
18395        let region = attr_region(TRACKED, &[("alpha", 0), ("beta", 1)], 2);
18396        assert_eq!(attr_creation_index(&region, "alpha").unwrap(), Some(0));
18397        let out = set_attr_in_region(&region, "alpha", &AttrValue::I64(9)).unwrap();
18398        // The rewritten message moves to the end of the region — the editor
18399        // rebuilds rather than patches — but its creation index does not move.
18400        assert_eq!(
18401            walk_attrs(&out),
18402            vec![
18403                ("beta".to_string(), Some(1)),
18404                ("alpha".to_string(), Some(0))
18405            ],
18406        );
18407        let (_, _, info) = find_attribute_info(&out).unwrap().expect("an info message");
18408        assert_eq!(
18409            info.max_creation_index,
18410            Some(2),
18411            "an overwrite must not advance the counter"
18412        );
18413    }
18414
18415    /// A deletion leaves a gap and does not lower the recorded maximum, so the
18416    /// next attribute created cannot land in the middle of the order.
18417    #[test]
18418    fn a_deletion_leaves_the_creation_index_counter_where_it_was() {
18419        let region = attr_region(TRACKED, &[("alpha", 0), ("beta", 1), ("gamma", 2)], 3);
18420        let after = remove_attr_from_region(&region, "gamma", true).unwrap();
18421        assert_eq!(
18422            walk_attrs(&after),
18423            vec![
18424                ("alpha".to_string(), Some(0)),
18425                ("beta".to_string(), Some(1))
18426            ],
18427        );
18428        assert_eq!(
18429            next_attr_creation_index(&after).unwrap(),
18430            3,
18431            "the highest index still in use is 1, but the counter is 3"
18432        );
18433
18434        let out = put_attr_message(&after, "delta", &attr_body("delta", 1), None).unwrap();
18435        assert_eq!(attr_creation_index(&out, "delta").unwrap(), Some(3));
18436    }
18437
18438    /// A header that tracks the order but carries no Attribute Info message —
18439    /// which nothing this crate writes, and nothing the C library writes, but
18440    /// which the format permits — gets one recording a counter derived from the
18441    /// records themselves.
18442    #[test]
18443    fn a_tracked_header_without_an_info_message_gains_one() {
18444        let mut region = OhRegion::empty(OhHeaderProps::with_layout(TRACKED));
18445        for (name, index) in [("alpha", 0u16), ("beta", 4)] {
18446            let record = TRACKED.record_with_creation_index(
18447                MessageType::Attribute,
18448                &attr_body(name, 1),
18449                index,
18450            );
18451            region.push_bytes(&record);
18452        }
18453        assert!(find_attribute_info(&region).unwrap().is_none());
18454        ensure_attribute_info(&mut region).unwrap();
18455        let (_, _, info) = find_attribute_info(&region)
18456            .unwrap()
18457            .expect("an info message");
18458        assert_eq!(info.max_creation_index, Some(5));
18459        assert!(info.indexes_creation_order);
18460    }
18461
18462    /// Build a compact data-layout message body: version, class=0, 2-byte inline
18463    /// size, then the data.
18464    fn compact_layout_body(version: u8, data: &[u8]) -> Vec<u8> {
18465        let mut b = vec![version, 0];
18466        b.extend_from_slice(&(data.len() as u16).to_le_bytes());
18467        b.extend_from_slice(data);
18468        b
18469    }
18470
18471    #[test]
18472    fn rebuild_compact_layout_replaces_inline_data_only() {
18473        // A region with a Dataspace message, a compact Data Layout, and a trailing
18474        // Attribute message: rewriting the inline data must replace exactly the
18475        // layout's bytes and leave every other message verbatim.
18476        let mut region = message_record(MessageType::Dataspace, &[0xAB; 8]);
18477        region.extend_from_slice(&message_record(
18478            MessageType::DataLayout,
18479            &compact_layout_body(3, &[1, 2, 3, 4]),
18480        ));
18481        region.extend_from_slice(&message_record(MessageType::Attribute, &[0xCD; 5]));
18482
18483        let out = rebuild_compact_layout_region(&plain_region(region), &[9, 8, 7, 6]).unwrap();
18484
18485        // Same messages in the same order; only the layout's inline data changed.
18486        assert_eq!(
18487            region_types(&out),
18488            vec![
18489                MessageType::Dataspace,
18490                MessageType::DataLayout,
18491                MessageType::Attribute,
18492            ]
18493        );
18494        let mut p = 0;
18495        while let Some((mt, body, end)) = out.next_message(p).unwrap() {
18496            match mt {
18497                MessageType::Dataspace => assert_eq!(&out[body..end], &[0xAB; 8]),
18498                MessageType::DataLayout => {
18499                    assert_eq!(out[body], 3, "version preserved");
18500                    assert_eq!(out[body + 1], 0, "still compact");
18501                    let size = u16::from_le_bytes([out[body + 2], out[body + 3]]) as usize;
18502                    assert_eq!(size, 4);
18503                    assert_eq!(&out[body + 4..body + 4 + size], &[9, 8, 7, 6]);
18504                }
18505                MessageType::Attribute => assert_eq!(&out[body..end], &[0xCD; 5]),
18506                other => panic!("unexpected message {other:?}"),
18507            }
18508            p = end;
18509        }
18510    }
18511
18512    #[test]
18513    fn rebuild_compact_layout_refuses_non_compact() {
18514        // A contiguous (class 1) data layout is not compact, so the rebuild refuses
18515        // rather than corrupt it.
18516        let mut region = message_record(MessageType::DataLayout, &{
18517            let mut b = vec![3u8, 1]; // version 3, class 1 (contiguous)
18518            b.extend_from_slice(&0u64.to_le_bytes());
18519            b.extend_from_slice(&0u64.to_le_bytes());
18520            b
18521        });
18522        region.extend_from_slice(&message_record(MessageType::Dataspace, &[0; 8]));
18523        let err = rebuild_compact_layout_region(&plain_region(region), &[1, 2]).unwrap_err();
18524        assert!(err.to_string().contains("non-compact"), "got: {err}");
18525    }
18526
18527    #[test]
18528    fn a_refused_open_never_builds_the_image() {
18529        // The image is what may cost `O(file size)` — the mirror reads the whole
18530        // file — so every refusal has to come first. Asserting on the build
18531        // closure states that directly; measuring memory or elapsed time would
18532        // only correlate with it.
18533        use crate::writer::FileBuilder;
18534
18535        let dir = tempfile::tempdir().unwrap();
18536
18537        // One refusal from each family: the superblock's status flags (issue
18538        // #245), and an unsupported superblock version, which has always been
18539        // refused after the read this reorders.
18540        let flagged = dir.path().join("flagged.h5");
18541        let mut b = FileBuilder::new();
18542        b.create_dataset("d").with_i32_data(&[1, 2, 3]);
18543        b.write(&flagged).unwrap();
18544        let ancient = dir.path().join("ancient.h5");
18545        std::fs::copy(&flagged, &ancient).unwrap();
18546        for (path, version, flags) in [(&flagged, 3, SWMR_WRITE_FLAGS), (&ancient, 9, 0)] {
18547            let mut data = std::fs::read(path).unwrap();
18548            let off = signature::find_signature(&data).unwrap();
18549            let mut sb = Superblock::parse(&data, off).unwrap();
18550            sb.version = version;
18551            sb.consistency_flags = flags;
18552            let bytes = sb.serialize();
18553            data[off..off + bytes.len()].copy_from_slice(&bytes);
18554            std::fs::write(path, &data).unwrap();
18555        }
18556
18557        for path in [&flagged, &ancient] {
18558            let built = std::cell::Cell::new(false);
18559            let err = match WriteEngine::open_imaged(path, Some(FileLocking::Enabled), |h, len| {
18560                built.set(true);
18561                Ok(Box::new(HandleImage::new(
18562                    h,
18563                    len,
18564                    MetadataCacheConfig::disabled(),
18565                )))
18566            }) {
18567                Err(e) => e,
18568                Ok(_) => panic!("{} must be refused", path.display()),
18569            };
18570            assert!(
18571                !built.get(),
18572                "{} was refused with {err:?}, but the image was built first",
18573                path.display()
18574            );
18575        }
18576    }
18577
18578    #[test]
18579    fn a_stale_consistency_flag_is_refused_then_cleared_by_a_commit() {
18580        // A v3 file a crashed SWMR writer left flagged is refused by the editor
18581        // (issue #245) rather than edited under a writer the file still records.
18582        // On a v2 file, where the check is gated off to match the C library, the
18583        // editor opens — and the commit clears the stale flag rather than
18584        // re-emitting it, so the file stays properly closed for the C library
18585        // (issue #73).
18586        use crate::writer::FileBuilder;
18587
18588        let dir = tempfile::tempdir().unwrap();
18589        let path = dir.path().join("stale_flag.h5");
18590
18591        let mut b = FileBuilder::new();
18592        b.create_dataset("d").with_i32_data(&[1, 2, 3]);
18593        b.write(&path).unwrap();
18594
18595        // Simulate a crashed SWMR writer by stamping the on-disk write+SWMR flag
18596        // (0x05) into the superblock, recomputing its checksum.
18597        {
18598            let mut data = std::fs::read(&path).unwrap();
18599            let off = signature::find_signature(&data).unwrap();
18600            let mut sb = Superblock::parse(&data, off).unwrap();
18601            assert!(
18602                sb.version >= 2,
18603                "FileBuilder should emit a v2/v3 superblock"
18604            );
18605            sb.consistency_flags = 0x05;
18606            let bytes = sb.serialize();
18607            data[off..off + bytes.len()].copy_from_slice(&bytes);
18608            std::fs::write(&path, &data).unwrap();
18609            // Sanity: the stale flag is really set on disk now.
18610            assert_eq!(
18611                Superblock::parse(&data, off).unwrap().consistency_flags,
18612                0x05
18613            );
18614        }
18615
18616        // The editor refuses it while the flag stands.
18617        match WriteEngine::open_with_locking(&path, FileLocking::Enabled) {
18618            Err(Error::FileMarkedInUse(_)) => {}
18619            Err(e) => panic!("expected the flag refusal, got {e:?}"),
18620            Ok(_) => panic!("a flagged file must not be edited in place"),
18621        }
18622
18623        // The flag survives a *version-2* superblock, where the check is gated
18624        // off to match the C library — which is the one state that still carries
18625        // a stale flag into a commit, and so the one that keeps the healing below
18626        // load-bearing. (v2 and v3 superblocks share a byte layout, so restamping
18627        // the version is the whole difference.) A crashed C writer leaves plain
18628        // write access, without the SWMR bit.
18629        {
18630            let mut data = std::fs::read(&path).unwrap();
18631            let off = signature::find_signature(&data).unwrap();
18632            let mut sb = Superblock::parse(&data, off).unwrap();
18633            sb.version = 2;
18634            sb.consistency_flags = crate::file_lock::WRITE_ACCESS;
18635            let bytes = sb.serialize();
18636            data[off..off + bytes.len()].copy_from_slice(&bytes);
18637            std::fs::write(&path, &data).unwrap();
18638        }
18639
18640        // A clean edit-and-commit cycle heals it.
18641        {
18642            let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled)
18643                .expect("the gate skips a v2 superblock, so this opens");
18644            let mut b = DatasetBuilder::new("e");
18645            b.with_i32_data(&[4, 5]);
18646            s.stage_created_dataset("e", b).unwrap();
18647            s.commit().unwrap();
18648        }
18649
18650        let data = std::fs::read(&path).unwrap();
18651        let off = signature::find_signature(&data).unwrap();
18652        assert_eq!(
18653            Superblock::parse(&data, off).unwrap().consistency_flags,
18654            0,
18655            "commit must clear the stale consistency flag"
18656        );
18657    }
18658
18659    #[test]
18660    fn add_vlen_string_dataset_with_null_elements_via_edit_session() {
18661        // Regression test for a silent-corruption bug (issue #105): a
18662        // VL-string dataset added via the in-place edit engine used to commit `Ok(())`
18663        // without ever writing its global heap collection or patching its
18664        // placeholder references, so the dataset failed to read back. A null
18665        // element (no heap object at all, distinct from an empty string) must
18666        // stay untouched by the patch — only heap-backed elements'
18667        // placeholder addresses are resolved; exercising both keeps the mask
18668        // itself, not just the common all-`Bytes` case, under test.
18669        use crate::type_builders::VlStringElement;
18670        use crate::writer::FileBuilder;
18671
18672        let dir = tempfile::tempdir().unwrap();
18673        let path = dir.path().join("vlen_null.h5");
18674
18675        let mut b = FileBuilder::new();
18676        b.create_dataset("seed").with_i32_data(&[0]);
18677        b.write(&path).unwrap();
18678
18679        let datatype =
18680            crate::type_builders::make_vlen_string_type(crate::datatype::CharacterSet::Utf8);
18681        let elements = vec![
18682            VlStringElement::Bytes(b"alpha".to_vec()),
18683            VlStringElement::Null,
18684            VlStringElement::Bytes(b"gamma".to_vec()),
18685        ];
18686
18687        {
18688            let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
18689            let mut b = DatasetBuilder::new("labels");
18690            b.with_vlen_string_elements(datatype, &elements).unwrap();
18691            s.stage_created_dataset("labels", b).unwrap();
18692            s.commit().unwrap();
18693        }
18694
18695        let file = crate::reader::File::open(&path).unwrap();
18696        let ds = file.dataset("labels").unwrap();
18697        assert_eq!(
18698            ds.read_string().unwrap(),
18699            vec!["alpha".to_string(), String::new(), "gamma".to_string()]
18700        );
18701    }
18702
18703    #[test]
18704    fn edit_session_root_group_base_address_overflow_is_rejected() {
18705        // The edit-path sibling of issue #137. A userblock file has a nonzero base
18706        // address that `WriteEngine::open` adds to the stored root-group address.
18707        // A crafted address of HADDR_UNDEF must be rejected rather than overflow
18708        // (panicking in debug, wrapping in release).
18709        use crate::writer::FileBuilder;
18710
18711        let dir = tempfile::tempdir().unwrap();
18712        let path = dir.path().join("edit_root_overflow.h5");
18713
18714        const UB: u64 = 512;
18715        let mut b = FileBuilder::new();
18716        b.with_userblock(UB);
18717        b.create_dataset("d").with_i32_data(&[1, 2, 3]);
18718        b.write(&path).unwrap();
18719
18720        // Rewrite the stored (base-relative) root-group address to HADDR_UNDEF,
18721        // recomputing the superblock checksum via `serialize`. The base address
18722        // still equals the superblock offset, so the file stays editable and the
18723        // editor reaches the `root_group_address + base` normalization.
18724        let mut data = std::fs::read(&path).unwrap();
18725        let off = signature::find_signature(&data).unwrap();
18726        let mut sb = Superblock::parse(&data, off).unwrap();
18727        assert_eq!(
18728            sb.base_address,
18729            BaseAddress::new(UB),
18730            "userblock file must have base == UB"
18731        );
18732        sb.root_group_address = u64::MAX;
18733        let bytes = sb.serialize();
18734        data[off..off + bytes.len()].copy_from_slice(&bytes);
18735        std::fs::write(&path, &data).unwrap();
18736
18737        let err = WriteEngine::open_with_locking(&path, FileLocking::Enabled)
18738            .err()
18739            .expect("open must fail");
18740        match err {
18741            Error::Format(FormatError::OffsetOverflow { offset, length }) => {
18742                assert_eq!(offset, u64::MAX);
18743                assert_eq!(length, UB);
18744            }
18745            other => panic!("expected root-group address overflow, got {other:?}"),
18746        }
18747    }
18748
18749    use tempfile::tempdir;
18750
18751    // -----------------------------------------------------------------------
18752    // Bounded sessions: the same engine over a `HandleImage`, which holds no
18753    // whole-file mirror. These came across from the standalone bounded engine
18754    // deleted in issue #198; what they cover is unchanged, but they now exercise
18755    // the shared code the mirror sessions use.
18756    // -----------------------------------------------------------------------
18757
18758    /// Build a rank-1 unlimited chunked i32 dataset `d` seeded with `0..n`.
18759    fn build_appendable(path: &Path, n: i32, chunk: u64) {
18760        let data: Vec<i32> = (0..n).collect();
18761        let mut b = crate::writer::FileBuilder::new();
18762        b.create_dataset("d")
18763            .with_i32_data(&data)
18764            .with_shape(&[n as u64])
18765            .with_maxshape(&[u64::MAX])
18766            .with_chunks(&[chunk]);
18767        b.write(path).unwrap();
18768    }
18769
18770    fn open_bounded_session(path: &Path) -> WriteEngine {
18771        WriteEngine::open_rw_with_strategy(
18772            path,
18773            crate::source::MetadataCacheConfig::disabled(),
18774            FileLocking::Enabled,
18775            MemoryStrategy::Bounded,
18776        )
18777        .unwrap()
18778    }
18779
18780    fn dataset_addr(engine: &WriteEngine) -> u64 {
18781        crate::group_v2::resolve_path_any_from_source(&engine.image(), engine.superblock(), "d")
18782            .unwrap()
18783    }
18784
18785    /// Crash consistency on a bounded session: stop the append after only the
18786    /// first `max_phase` durability phases (simulating a crash at that boundary)
18787    /// and assert the reopened file reads either the old length (phases 1-3) or
18788    /// the new one (phase 4), never a torn view. Layouts cover a partial trailing
18789    /// chunk (relocated tail) and a chunk-aligned start.
18790    #[test]
18791    fn bounded_append_crash_consistency_partial_tail_prefix() {
18792        let dir = tempdir().unwrap();
18793        for (case, (n, chunk, add)) in [(0usize, (6i32, 4u64, 5i32)), (1, (8, 2, 6))] {
18794            let base = dir.path().join(std::format!("base_{case}.h5"));
18795            build_appendable(&base, n, chunk);
18796            for max_phase in 1u8..=4 {
18797                let p = dir.path().join(std::format!("crash_{case}_{max_phase}.h5"));
18798                std::fs::copy(&base, &p).unwrap();
18799                {
18800                    let mut engine = open_bounded_session(&p);
18801                    let addr = dataset_addr(&engine);
18802                    let mut b = AppendBuilder::new();
18803                    b.append_i32(&(n..n + add).collect::<Vec<_>>());
18804                    engine
18805                        .append_inplace_gathered(AppendTarget::Header(addr), &b, max_phase)
18806                        .unwrap();
18807                    // Dropping the engine simulates the crash: no further phases,
18808                    // no close barrier.
18809                }
18810                let expected_len = if max_phase == 4 { n + add } else { n };
18811                let got = crate::File::open(&p)
18812                    .unwrap()
18813                    .dataset("d")
18814                    .unwrap()
18815                    .read_i32()
18816                    .unwrap();
18817                assert_eq!(
18818                    got,
18819                    (0..expected_len).collect::<Vec<_>>(),
18820                    "case {case} phase {max_phase}"
18821                );
18822            }
18823        }
18824    }
18825
18826    /// The batching loop only honors `max_phase < 4` on its first batch, and a
18827    /// full multi-batch append leaves every batch fully committed: after a large
18828    /// append the file reads the complete sequence.
18829    #[test]
18830    fn bounded_multi_batch_append_commits_every_batch() {
18831        let dir = tempdir().unwrap();
18832        let p = dir.path().join("multibatch.h5");
18833        build_appendable(&p, 5, 512);
18834        let total = 700_000i32;
18835        {
18836            let mut engine = open_bounded_session(&p);
18837            let addr = dataset_addr(&engine);
18838            let mut b = AppendBuilder::new();
18839            b.append_i32(&(5..total).collect::<Vec<_>>());
18840            engine
18841                .append_inplace_gathered(AppendTarget::Header(addr), &b, 4)
18842                .unwrap();
18843        }
18844        let got = crate::File::open(&p)
18845            .unwrap()
18846            .dataset("d")
18847            .unwrap()
18848            .read_i32()
18849            .unwrap();
18850        assert_eq!(got.len(), total as usize);
18851        assert!(got.iter().enumerate().all(|(i, &v)| v == i as i32));
18852    }
18853
18854    /// A bounded session batches; a mirror session does not. The distinction is
18855    /// a deliberate trade — bounded peak memory against whole-call crash
18856    /// atomicity — so it is asserted rather than left to the batching code's
18857    /// arithmetic.
18858    #[test]
18859    fn only_a_bounded_session_batches_a_large_append() {
18860        let dir = tempdir().unwrap();
18861        let p = dir.path().join("batching.h5");
18862        build_appendable(&p, 8, 4);
18863
18864        let bounded_batch = {
18865            let mut engine = open_bounded_session(&p);
18866            engine
18867                .append_geometry(AppendTarget::Path("d"))
18868                .unwrap()
18869                .full_batch_elems
18870        };
18871        let mirror_batch = {
18872            let mut engine = WriteEngine::open_with_locking(&p, FileLocking::Enabled).unwrap();
18873            engine
18874                .append_geometry(AppendTarget::Path("d"))
18875                .unwrap()
18876                .full_batch_elems
18877        };
18878
18879        assert_eq!(
18880            mirror_batch,
18881            u64::MAX,
18882            "a mirror session must take the whole append as one crash-atomic batch"
18883        );
18884        assert!(
18885            bounded_batch < u64::MAX,
18886            "a bounded session must cap a batch, got {bounded_batch}"
18887        );
18888        assert_eq!(
18889            bounded_batch % 4,
18890            0,
18891            "a batch must be a whole number of chunks"
18892        );
18893    }
18894
18895    /// A persisting file appended through a bounded session and dropped WITHOUT
18896    /// `finalize_persist` (the true-crash case) still reads back every durable
18897    /// append. Dropping the engine releases the exclusive lock, so the reopen is
18898    /// portable (no leaked lock). The finalize-at-close path is covered by the
18899    /// `tests/bounded_append.rs` integration tests.
18900    #[test]
18901    fn bounded_persist_append_without_finalize_is_readable() {
18902        let dir = tempdir().unwrap();
18903        let p = dir.path().join("persist_crash.h5");
18904        let mut b = crate::writer::FileBuilder::new();
18905        b.with_file_space_strategy(crate::FileSpaceStrategy::FsmAggr, true, 1);
18906        b.create_dataset("d")
18907            .with_i32_data(&(0..6).collect::<Vec<i32>>())
18908            .with_shape(&[6])
18909            .with_maxshape(&[u64::MAX])
18910            .with_chunks(&[4]);
18911        b.write(&p).unwrap();
18912        {
18913            let mut engine = open_bounded_session(&p);
18914            assert!(engine.persist.is_some(), "persist state is armed at open");
18915            let addr = dataset_addr(&engine);
18916            let mut ab = AppendBuilder::new();
18917            ab.append_i32(&(6..20).collect::<Vec<_>>());
18918            engine
18919                .append_inplace_gathered(AppendTarget::Header(addr), &ab, 4)
18920                .unwrap();
18921            // Drop without finalizing: models a true crash and releases the lock.
18922        }
18923        let got = crate::File::open(&p)
18924            .unwrap()
18925            .dataset("d")
18926            .unwrap()
18927            .read_i32()
18928            .unwrap();
18929        assert_eq!(got, (0..20).collect::<Vec<_>>());
18930    }
18931
18932    /// A bounded session grows a PAGED persisting file and is killed before
18933    /// finalize (models a crash), leaving the file non-page-aligned. Reopening it
18934    /// must not panic, and the next append must re-align the crashed tail page
18935    /// before writing raw data (so no page mixes metadata and raw); a clean close
18936    /// then re-page-aligns the file and every row reads back.
18937    #[test]
18938    fn bounded_paged_reopen_after_crash_realigns_and_stays_readable() {
18939        let dir = tempdir().unwrap();
18940        let p = dir.path().join("paged_crash.h5");
18941        let mut b = crate::writer::FileBuilder::new();
18942        b.with_file_space_strategy(crate::FileSpaceStrategy::Page, true, 0)
18943            .with_file_space_page_size(4096);
18944        b.create_dataset("d")
18945            .with_i32_data(&(0..64).collect::<Vec<i32>>())
18946            .with_shape(&[64])
18947            .with_maxshape(&[u64::MAX])
18948            .with_chunks(&[64]);
18949        b.write(&p).unwrap();
18950
18951        // Grow enough to force extensible-array index growth, so the last write of
18952        // the session is metadata and the tail page is a partial metadata page.
18953        {
18954            let mut engine = open_bounded_session(&p);
18955            let addr = dataset_addr(&engine);
18956            let mut ab = AppendBuilder::new();
18957            ab.append_i32(&(64..2000).collect::<Vec<_>>());
18958            engine
18959                .append_inplace_gathered(AppendTarget::Header(addr), &ab, 4)
18960                .unwrap();
18961            // Drop without finalize: models a crash and releases the OS lock.
18962        }
18963        assert_ne!(
18964            std::fs::metadata(&p).unwrap().len() % 4096,
18965            0,
18966            "a crashed (un-finalized) paged session leaves the file non-page-aligned"
18967        );
18968
18969        // Reopen must not panic on the non-aligned file; the next append re-aligns
18970        // the crashed tail page, and finalize re-page-aligns the whole file.
18971        {
18972            let mut engine = open_bounded_session(&p);
18973            let addr = dataset_addr(&engine);
18974            let mut ab = AppendBuilder::new();
18975            ab.append_i32(&(2000..2500).collect::<Vec<_>>());
18976            engine
18977                .append_inplace_gathered(AppendTarget::Header(addr), &ab, 4)
18978                .unwrap();
18979            engine.finalize_persist().unwrap();
18980            engine.barrier().unwrap();
18981        }
18982        assert_eq!(
18983            std::fs::metadata(&p).unwrap().len() % 4096,
18984            0,
18985            "reopen + append + finalize re-aligns the paged file"
18986        );
18987        let got = crate::File::open(&p)
18988            .unwrap()
18989            .dataset("d")
18990            .unwrap()
18991            .read_i32()
18992            .unwrap();
18993        assert_eq!(got, (0..2500).collect::<Vec<_>>());
18994    }
18995
18996    /// A staged commit on a bounded session must stay bounded: it may read the
18997    /// metadata it edits, but never the file's bulk. Measured rather than
18998    /// asserted from the design — the engine is shared with the mirror sessions
18999    /// now, and a single slice-taking read added anywhere on the commit path
19000    /// would silently make a bounded open cost as much as a mirrored one.
19001    #[test]
19002    fn a_bounded_commit_reads_far_less_than_the_file() {
19003        use std::sync::Arc;
19004        use std::sync::atomic::{AtomicU64, Ordering};
19005
19006        let dir = tempdir().unwrap();
19007        let p = dir.path().join("bulk.h5");
19008        // ~8 MiB of chunked data, so "reads the whole file" and "reads only the
19009        // metadata" differ by orders of magnitude rather than by a margin.
19010        let rows = 2_000_000i32;
19011        build_appendable(&p, rows, 8192);
19012        let file_len = std::fs::metadata(&p).unwrap().len();
19013        assert!(file_len > 4 << 20, "file is only {file_len} bytes");
19014
19015        let read_bytes = Arc::new(AtomicU64::new(0));
19016        {
19017            let mut engine =
19018                WriteEngine::open_bounded_counting(&p, Arc::clone(&read_bytes)).unwrap();
19019            engine.create_group("g").unwrap();
19020            engine.commit().unwrap();
19021        }
19022        let read = read_bytes.load(Ordering::Relaxed);
19023
19024        assert!(
19025            read > 0,
19026            "the commit read nothing, so the test proves nothing"
19027        );
19028        // Measured at 310 bytes here. The bound is loose enough to survive a
19029        // changed header layout and still orders of magnitude below the file.
19030        assert!(
19031            read < 64 << 10,
19032            "a bounded commit read {read} bytes of a {file_len}-byte file"
19033        );
19034    }
19035
19036    /// An in-place append leaves a partially-filled **raw** page, so the next
19037    /// commit's metadata must pad it rather than pack into it.
19038    ///
19039    /// This is what keeps [`PagedEdit::begin`] reachable from [`EditStore`] now
19040    /// that an append allocates raw pages only: the append's job is to record that
19041    /// the tail page turned raw, and the commit's job is to act on it. It is also
19042    /// the interleaving that a single session-level page tracker makes possible —
19043    /// with a tracker per engine, the commit path could not see what the append
19044    /// path had done, which is why the whole-file editor refused an in-place append
19045    /// to a paged file at all (issue #198).
19046    #[test]
19047    fn a_commit_after_an_append_pads_the_raw_page_the_append_left() {
19048        const PAGE: u64 = 4096;
19049        let dir = tempdir().unwrap();
19050        let p = dir.path().join("paged_interleave.h5");
19051        let mut b = crate::writer::FileBuilder::new();
19052        b.with_file_space_strategy(crate::FileSpaceStrategy::Page, true, 0)
19053            .with_file_space_page_size(PAGE);
19054        b.create_dataset("d")
19055            .with_i32_data(&(0..64).collect::<Vec<i32>>())
19056            .with_shape(&[64])
19057            .with_maxshape(&[u64::MAX])
19058            .with_chunks(&[64]);
19059        b.write(&p).unwrap();
19060
19061        let mut engine = WriteEngine::open_with_locking(&p, FileLocking::Enabled).unwrap();
19062        let mut ab = AppendBuilder::new();
19063        ab.append_i32(&(64..2000).collect::<Vec<_>>());
19064        engine
19065            .append_inplace_gathered(AppendTarget::Path("d"), &ab, 4)
19066            .unwrap();
19067
19068        assert_eq!(
19069            engine.paged.as_ref().unwrap().last,
19070            Some(PageType::Raw),
19071            "the append must record that the tail page now holds raw data"
19072        );
19073        assert_ne!(
19074            engine.image.len() % PAGE,
19075            0,
19076            "the append must leave a partially-filled page for the commit to pad"
19077        );
19078
19079        engine.create_group("g").unwrap();
19080        engine.commit().unwrap();
19081
19082        // The commit padded the raw tail before laying down metadata, and folded
19083        // that padding into the raw list (`meta_pad`/`raw_pad` are cleared into the
19084        // free lists as part of the paged tail).
19085        let pg = engine.paged.as_ref().expect("the file is paged");
19086        let raw_free = pg.raw.sections();
19087        assert!(
19088            !raw_free.is_empty(),
19089            "the commit packed metadata into the raw page the append left open"
19090        );
19091        for (addr, len) in raw_free {
19092            assert_eq!(
19093                (addr + len) % PAGE,
19094                0,
19095                "padding {addr}+{len} does not reach a page boundary"
19096            );
19097        }
19098
19099        drop(engine);
19100        assert_eq!(
19101            crate::File::open(&p)
19102                .unwrap()
19103                .dataset("d")
19104                .unwrap()
19105                .read_i32()
19106                .unwrap(),
19107            (0..2000).collect::<Vec<_>>()
19108        );
19109    }
19110
19111    /// An in-place append to a paged file must allocate **only raw pages** — the
19112    /// chunk data and the extensible-array blocks indexing it alike.
19113    ///
19114    /// The reclaim path (`chunked_storage_spans`) reports both halves of a chunked
19115    /// dataset as raw free space, because that is where this crate places them. An
19116    /// append that put its index blocks in a metadata page instead would make the
19117    /// reclaim advertise metadata-page bytes for raw reuse, mixing the page a paged
19118    /// file exists to keep homogeneous. Measured here rather than through the
19119    /// reference C library, which reads a mixed-page file without complaint: an
19120    /// interop test proves interop and says nothing about segregation.
19121    #[test]
19122    fn an_inplace_append_to_a_paged_file_allocates_only_raw_pages() {
19123        const PAGE: u64 = 4096;
19124        let dir = tempdir().unwrap();
19125        let p = dir.path().join("paged_raw.h5");
19126        let mut b = crate::writer::FileBuilder::new();
19127        b.with_file_space_strategy(crate::FileSpaceStrategy::Page, true, 0)
19128            .with_file_space_page_size(PAGE);
19129        b.create_dataset("d")
19130            .with_i32_data(&(0..64).collect::<Vec<i32>>())
19131            .with_shape(&[64])
19132            .with_maxshape(&[u64::MAX])
19133            .with_chunks(&[64]);
19134        b.write(&p).unwrap();
19135
19136        let mut engine = WriteEngine::open_with_locking(&p, FileLocking::Enabled).unwrap();
19137        let before = engine.image().len();
19138        // Two appends, each large enough to grow the extensible-array index, so the
19139        // run allocates index blocks as well as chunk data.
19140        for range in [64..2000, 2000..4000] {
19141            let mut ab = AppendBuilder::new();
19142            ab.append_i32(&range.collect::<Vec<_>>());
19143            engine
19144                .append_inplace_gathered(AppendTarget::Path("d"), &ab, 4)
19145                .unwrap();
19146        }
19147
19148        let pg = engine.paged.as_ref().expect("the file is paged");
19149        assert_eq!(
19150            pg.last,
19151            Some(PageType::Raw),
19152            "the append left the tail page holding something other than raw data"
19153        );
19154        assert!(
19155            pg.meta_pad.is_empty() && pg.raw_pad.is_empty(),
19156            "an in-place append switched page type: meta_pad={:?} raw_pad={:?}",
19157            pg.meta_pad,
19158            pg.raw_pad
19159        );
19160
19161        // Not vacuous: the append really did allocate index structure above the
19162        // pre-append end-of-file, which is what would have opened a metadata page.
19163        let addr = crate::group_v2::resolve_path_any_from_source(
19164            &engine.image(),
19165            engine.superblock(),
19166            "d",
19167        )
19168        .unwrap();
19169        let spans = engine
19170            .chunked_storage_spans(addr.to_usize().unwrap())
19171            .expect("a chunked dataset has reclaimable spans");
19172        let fresh = spans.iter().filter(|&&(a, _, _)| a >= before).count();
19173        assert!(
19174            fresh > 0,
19175            "the append allocated nothing above {before}, so the assertion above proves nothing"
19176        );
19177        assert!(
19178            spans
19179                .iter()
19180                .all(|&(_, _, class)| class == FreeClass::Page(PageType::Raw)),
19181            "the reclaim tags every chunked span raw; a metadata or dead tag here would \
19182             need the placement rule above to change with it"
19183        );
19184    }
19185
19186    /// Write a small file of `tables` unlimited chunked datasets, paged when
19187    /// asked, for the write-gathering tests below.
19188    fn gather_fixture(path: &std::path::Path, tables: usize, paged: bool) {
19189        use crate::writer::FileBuilder;
19190        let mut b = FileBuilder::new();
19191        if paged {
19192            // Deliberately *not* DEFAULT_GATHER_PAGE: a paged fixture at the
19193            // default page size cannot tell a session that reads the file's page
19194            // size from one that assumes the default.
19195            b.with_file_space_strategy(FileSpaceStrategy::Page, true, 1)
19196                .with_file_space_page_size(16 * 1024);
19197        }
19198        for t in 0..tables {
19199            b.create_dataset(&std::format!("t{t}"))
19200                .with_i32_data(&(0..256).collect::<Vec<_>>())
19201                .with_shape(&[256])
19202                .with_maxshape(&[u64::MAX])
19203                .with_chunks(&[64]);
19204        }
19205        b.write(path).unwrap();
19206    }
19207
19208    /// Run the same appends and the same commit on `session`, and report what
19209    /// each cost in writes: the in-place appends, then the staged commit that
19210    /// follows them. They are counted apart because the gathering earns its keep
19211    /// in only one of them — see the caller. The file the workload leaves is the
19212    /// other half of what the callers compare, and they read it off the path
19213    /// themselves.
19214    fn gather_workload(session: &mut WriteEngine) -> (u64, u64) {
19215        let before = session.image.issued_writes();
19216        for round in 0..4 {
19217            for t in 0..4 {
19218                session
19219                    .append_inplace_i32_phased(&std::format!("t{t}"), &[round; 64], 4)
19220                    .unwrap();
19221            }
19222        }
19223        for t in 0..4 {
19224            let mut db = crate::type_builders::DatasetBuilder::new(&std::format!("n{t}"));
19225            db.with_f64_data(&[2.5f64; 32]).with_shape(&[32]);
19226            session
19227                .stage_created_dataset(&std::format!("/n{t}"), db)
19228                .unwrap();
19229        }
19230        let after_appends = session.image.issued_writes();
19231        session.commit().unwrap();
19232        (
19233            after_appends - before,
19234            session.image.issued_writes() - after_appends,
19235        )
19236    }
19237
19238    /// Gathering a session's writes lowers what it costs and changes nothing
19239    /// about what it produces (issue #288).
19240    ///
19241    /// Both halves matter and neither implies the other. A gatherer that dropped
19242    /// a run, wrote one twice in the wrong order, or filled the space between two
19243    /// runs sharing a page with zeros would lower the count exactly as well — so
19244    /// the two files are compared **byte for byte**, which is the only assertion
19245    /// a wrong merge cannot pass. And a gatherer that merged nothing would keep
19246    /// them identical, which is what the count is for.
19247    ///
19248    /// The comparison is against this same engine with the gathering turned off,
19249    /// rather than against a recorded number: how many writes a commit needs is
19250    /// an implementation detail that should be free to fall further.
19251    #[test]
19252    fn gathering_writes_costs_fewer_of_them_and_changes_no_byte() {
19253        use tempfile::tempdir;
19254
19255        let dir = tempdir().unwrap();
19256        for paged in [false, true] {
19257            let straight = dir.path().join(std::format!("straight_{paged}.h5"));
19258            let gathered = dir.path().join(std::format!("gathered_{paged}.h5"));
19259            gather_fixture(&straight, 4, paged);
19260            gather_fixture(&gathered, 4, paged);
19261
19262            let mut a = WriteEngine::open_with_locking(&straight, FileLocking::Enabled).unwrap();
19263            a.set_sync_policy(SyncPolicy::OnClose);
19264            a.image
19265                .set_write_buffering(WriteBuffering::Unbuffered)
19266                .unwrap();
19267            let (straight_appends, straight_commit) = gather_workload(&mut a);
19268            a.force_sync().unwrap();
19269            drop(a);
19270
19271            let mut b = WriteEngine::open_with_locking(&gathered, FileLocking::Enabled).unwrap();
19272            b.set_sync_policy(SyncPolicy::OnClose);
19273            let (gathered_appends, gathered_commit) = gather_workload(&mut b);
19274            b.force_sync().unwrap();
19275            drop(b);
19276
19277            // The commit tail is where the gathering earns its keep, and the half
19278            // to assert a ratio on. Measured: 2 writes against 10 unpaged, 4
19279            // against 16 paged. A commit rebuilds a group, repoints a root and
19280            // re-homes the free-space managers, all inside one phase and all into
19281            // a handful of pages, which is what merging within a barrier is for.
19282            // Merging *across* barriers goes further and is what an explicit page
19283            // buffer does; `a_page_buffer_holds_dirty_pages_across_operations`
19284            // measures that.
19285            assert!(
19286                gathered_commit * 3 < straight_commit,
19287                "paged={paged}: gathering must cost meaningfully fewer writes for a \
19288                 commit, but cost {gathered_commit} against {straight_commit}"
19289            );
19290            // The appends are the other half, and since issue #307 they are a
19291            // near-tie: 88 against 92 unpaged, 92 against 92 paged. Publishing a
19292            // checksummed structure is one write from the engine now rather than
19293            // two the gatherer had to rejoin, so the buffering has almost nothing
19294            // left to merge here — before that fix this half was 92 against 144.
19295            // What is still worth pinning is that it never costs *more*. Note the
19296            // limit of that: a publish write made wider still merges the same way,
19297            // so widening one to the whole structure it sits in changes no count
19298            // here or anywhere — `a_publish_writes_from_the_byte_it_changed` is
19299            // what holds that, by counting bytes rather than writes.
19300            assert!(
19301                gathered_appends <= straight_appends,
19302                "paged={paged}: gathering must not cost more writes for the appends, \
19303                 but cost {gathered_appends} against {straight_appends}"
19304            );
19305            assert_eq!(
19306                std::fs::read(&straight).unwrap(),
19307                std::fs::read(&gathered).unwrap(),
19308                "paged={paged}: gathering changed the file it produced"
19309            );
19310        }
19311    }
19312
19313    /// A session merges writes within the page its *file* was laid out on, and
19314    /// falls back to the format default only when the file is not paged.
19315    ///
19316    /// Asserted directly because the fixtures cannot assert it indirectly: the
19317    /// merge quantum is only visible in which writes coalesce, and any fixture
19318    /// built at the default page size makes the two answers identical. Reading
19319    /// the file's page size is the whole point of resolving this after the open
19320    /// rather than at it.
19321    #[test]
19322    fn the_merge_page_follows_the_file_rather_than_the_default() {
19323        use tempfile::tempdir;
19324
19325        let dir = tempdir().unwrap();
19326        let paged = dir.path().join("paged.h5");
19327        let plain = dir.path().join("plain.h5");
19328        gather_fixture(&paged, 1, true);
19329        gather_fixture(&plain, 1, false);
19330
19331        let s = WriteEngine::open_with_locking(&paged, FileLocking::Enabled).unwrap();
19332        assert_eq!(
19333            s.gather_page_size(),
19334            16 * 1024,
19335            "a paged file merges within its own file-space page"
19336        );
19337        assert_ne!(
19338            16 * 1024,
19339            DEFAULT_GATHER_PAGE,
19340            "the fixture must not be built at the default, or the assertion above \
19341             holds for a session that ignores the file entirely"
19342        );
19343        drop(s);
19344
19345        let s = WriteEngine::open_with_locking(&plain, FileLocking::Enabled).unwrap();
19346        assert_eq!(
19347            s.gather_page_size(),
19348            DEFAULT_GATHER_PAGE,
19349            "an unpaged file has no page size of its own"
19350        );
19351    }
19352
19353    /// Writing the status-flags byte on a file with a userblock changes that byte
19354    /// and nothing else.
19355    ///
19356    /// `set_consistency_flags` serializes the whole superblock to rewrite one
19357    /// byte of it, and the root address it carries is held absolute in memory but
19358    /// stored relative to the base address. Getting that wrong repoints the root
19359    /// past the end of the file, so a call that promised to touch a flag silently
19360    /// makes the file unreadable.
19361    ///
19362    /// Driven directly rather than through a caller, because for a long time
19363    /// neither caller could reach a non-zero base: SWMR refuses a userblock
19364    /// outright, and a page buffer required persisted free space, which is
19365    /// declined for a non-zero base. Both were refusals about something else that
19366    /// settled this as a side effect — and the day one of them moved is the day a
19367    /// test routed through it would have gone quiet. Issue #357 moved the second,
19368    /// so `a_page_buffer_marks_a_userblock_file_without_repointing_its_root`
19369    /// exercises the same conversion through a real session; this one stays as
19370    /// the direct statement of the rule.
19371    #[test]
19372    fn a_status_flag_write_leaves_a_userblock_files_root_alone() {
19373        use crate::writer::FileBuilder;
19374        use tempfile::tempdir;
19375
19376        let dir = tempdir().unwrap();
19377        let path = dir.path().join("userblock.h5");
19378        let mut b = FileBuilder::new();
19379        b.with_userblock(4096);
19380        b.create_dataset("d")
19381            .with_i32_data(&[1, 2, 3, 4])
19382            .with_shape(&[4]);
19383        b.write(&path).unwrap();
19384        let before = std::fs::read(&path).unwrap();
19385
19386        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
19387        assert_eq!(
19388            s.superblock.base_address,
19389            BaseAddress::new(4096),
19390            "the fixture must have a userblock, or this test holds for a file \
19391             whose absolute and relative roots are the same number"
19392        );
19393        // Up and back down, which is the sequence a marked session performs.
19394        s.set_consistency_flags(file_lock::WRITE_ACCESS).unwrap();
19395        s.set_consistency_flags(0).unwrap();
19396        s.force_sync().unwrap();
19397        drop(s);
19398
19399        assert_eq!(
19400            std::fs::read(&path).unwrap(),
19401            before,
19402            "raising and clearing the flags must leave the file as it was"
19403        );
19404        assert_eq!(
19405            crate::reader::File::open(&path)
19406                .unwrap()
19407                .dataset("d")
19408                .unwrap()
19409                .read_i32()
19410                .unwrap(),
19411            vec![1, 2, 3, 4],
19412            "and the file must still read"
19413        );
19414    }
19415
19416    /// The budget a caller asks for is the budget the gatherer is given, so a
19417    /// session that outruns it flushes rather than growing without bound.
19418    ///
19419    /// `set_page_buffer_size` spends most of its length on refusals, and the one
19420    /// line that does the work hands `max_bytes` on. Passing `usize::MAX` instead
19421    /// fails nothing else in the suite: every other page-buffer test runs a
19422    /// workload smaller than the budget, so it holds everything to the end either
19423    /// way and the file it leaves is identical. What that would cost is a
19424    /// long-running session holding the whole of what it wrote in memory, which
19425    /// is the one thing the budget is for.
19426    ///
19427    /// Asserted as "some writes went out before the session ended" rather than as
19428    /// a count, since how many a 4 MiB append needs is free to change.
19429    #[test]
19430    fn a_page_buffered_session_flushes_when_it_outruns_its_budget() {
19431        use tempfile::tempdir;
19432
19433        let dir = tempdir().unwrap();
19434        let path = dir.path().join("outrun.h5");
19435        gather_fixture(&path, 1, true);
19436
19437        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
19438        s.set_sync_policy(SyncPolicy::OnClose);
19439        s.set_page_buffer_size(WRITE_GATHER_BYTES).unwrap();
19440        let before = s.image.issued_writes();
19441        // Four times the budget, in one append, so nothing but the budget can
19442        // account for a write going out before the teardown below.
19443        s.append_inplace_i32_phased("t0", &vec![7i32; 1 << 20], 4)
19444            .unwrap();
19445        let during = s.image.issued_writes() - before;
19446        s.force_sync().unwrap();
19447        s.release_status_flags().unwrap();
19448        drop(s);
19449
19450        assert!(
19451            during > 0,
19452            "a session that wrote four times its budget issued nothing until it \
19453             closed, so the budget it was given is not the one it asked for"
19454        );
19455        assert_eq!(
19456            crate::reader::File::open(&path)
19457                .unwrap()
19458                .dataset("t0")
19459                .unwrap()
19460                .read_i32()
19461                .unwrap()
19462                .len(),
19463            256 + (1 << 20),
19464            "and the flushed append must still read back whole"
19465        );
19466    }
19467
19468    /// A page-buffer budget below the 1 MiB a session already gathers under
19469    /// issues *more* writes — on the workload a page buffer is sold for, and on
19470    /// the one it is worst at.
19471    ///
19472    /// This is the price the public property quotes for a small budget, so it
19473    /// belongs in the suite rather than in a comment. Such a budget was refused
19474    /// outright until issue #391, on the strength of exactly this measurement;
19475    /// what changed is who decides, not what it costs. A claim about a cost that
19476    /// nothing checks is one that quietly stops being true, and a caller sizing a
19477    /// buffer against a memory cap is who pays for that.
19478    ///
19479    /// The C library charges nothing for a small buffer: `H5PB_write` sends any
19480    /// I/O of a page or more straight to the driver. Nothing bypasses this
19481    /// gatherer, so the budget is also the point at which a long run is flushed
19482    /// and restarted — which is why the second workload is one long run.
19483    ///
19484    /// Asserted as an **ordering** rather than as counts. The counts are
19485    /// deterministic on one target and are quoted on the public property, but how
19486    /// many writes a commit needs is free to fall; what must not change is which
19487    /// budget issues fewer (issues #357, #391).
19488    #[test]
19489    fn a_smaller_budget_issues_more_writes() {
19490        use tempfile::tempdir;
19491
19492        let dir = tempdir().unwrap();
19493        let fixture = |path: &std::path::Path, tables: usize| {
19494            use crate::writer::FileBuilder;
19495            let mut b = FileBuilder::new();
19496            b.with_file_space_strategy(FileSpaceStrategy::Page, true, 1)
19497                .with_file_space_page_size(4096);
19498            for t in 0..tables {
19499                b.create_dataset(&std::format!("t{t}"))
19500                    .with_i32_data(&(0..256).collect::<Vec<_>>())
19501                    .with_shape(&[256])
19502                    .with_maxshape(&[u64::MAX])
19503                    .with_chunks(&[64]);
19504            }
19505            b.write(path).unwrap();
19506        };
19507
19508        // The public path, which now accepts every budget down to this fixture's
19509        // 4 KiB page — so the arms differ in the budget a caller asked for and in
19510        // nothing else.
19511        let run = |path: &std::path::Path, budget: usize, workload: &dyn Fn(&mut WriteEngine)| {
19512            let mut s = WriteEngine::open_with_locking(path, FileLocking::Enabled).unwrap();
19513            s.set_sync_policy(SyncPolicy::OnClose);
19514            s.set_page_buffer_size(budget).unwrap();
19515            let before = s.image.issued_writes();
19516            workload(&mut s);
19517            s.force_sync().unwrap();
19518            s.release_status_flags().unwrap();
19519            let issued = s.image.issued_writes() - before;
19520            drop(s);
19521            issued
19522        };
19523
19524        // Many small scattered writes — what the property is for — and one long
19525        // contiguous run, which is where a small budget collapses.
19526        let workload = |scattered: bool| {
19527            move |s: &mut WriteEngine| {
19528                if scattered {
19529                    for round in 0..4 {
19530                        for t in 0..8 {
19531                            s.append_inplace_i32_phased(&std::format!("t{t}"), &[round; 64], 4)
19532                                .unwrap();
19533                        }
19534                    }
19535                } else {
19536                    s.append_inplace_i32_phased("t0", &vec![7i32; 1 << 20], 4)
19537                        .unwrap();
19538                }
19539            }
19540        };
19541
19542        for (label, tables, scattered) in [("scattered", 8, true), ("one long run", 1, false)] {
19543            let workload = workload(scattered);
19544            let at = |budget: usize| {
19545                let path = dir
19546                    .path()
19547                    .join(std::format!("{}_{budget}.h5", label.replace(' ', "_")));
19548                fixture(&path, tables);
19549                run(&path, budget, &workload)
19550            };
19551            let ample = at(WRITE_GATHER_BYTES);
19552            let smallest = at(4096);
19553            for smaller in [4096, 64 * 1024] {
19554                let below = at(smaller);
19555                assert!(
19556                    ample <= below,
19557                    "{label}: a {smaller}-byte budget issued {below} writes against \
19558                     {ample} at {WRITE_GATHER_BYTES}, so a smaller budget is not the \
19559                     trade the property documents"
19560                );
19561            }
19562            // Not vacuous: a gatherer that ignored `max_bytes` outright would
19563            // report the same count at every budget and satisfy the ordering
19564            // above without honoring any of them. One page is a quarter of the
19565            // smallest step measured here, so a workload that does not separate
19566            // there is one this assertion cannot speak for.
19567            assert!(
19568                smallest > ample,
19569                "{label}: the budget changed nothing — {smallest} writes at 4 KiB \
19570                 against {ample} at 1 MiB — so this workload cannot say which \
19571                 budget is better"
19572            );
19573        }
19574    }
19575
19576    /// A page-buffered session on a file with a userblock raises and clears its
19577    /// crash mark without disturbing the root address that mark's superblock
19578    /// rewrite carries — and the userblock's own bytes stay put.
19579    ///
19580    /// The caller-side half of
19581    /// `a_status_flag_write_leaves_a_userblock_files_root_alone`, reachable only
19582    /// since issue #357 scoped the persisted-free-space refusal to paged files.
19583    /// A userblock file persists no free space whatever its creation properties
19584    /// asked for, so before that the mark could not be raised on one at all, and
19585    /// `set_consistency_flags`'s base conversion had no caller that needed it.
19586    #[test]
19587    fn a_page_buffer_marks_a_userblock_file_without_repointing_its_root() {
19588        use crate::writer::FileBuilder;
19589        use tempfile::tempdir;
19590
19591        let dir = tempdir().unwrap();
19592        let path = dir.path().join("ub_buffered.h5");
19593        let mut b = FileBuilder::new();
19594        b.with_userblock(4096);
19595        b.create_dataset("t0")
19596            .with_i32_data(&(0..256).collect::<Vec<_>>())
19597            .with_shape(&[256]);
19598        b.write(&path).unwrap();
19599        let userblock_before = std::fs::read(&path).unwrap()[..4096].to_vec();
19600
19601        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
19602        assert_eq!(
19603            s.superblock.base_address,
19604            BaseAddress::new(4096),
19605            "the fixture must have a userblock, or this test says nothing about \
19606             the conversion it exists for"
19607        );
19608        s.set_sync_policy(SyncPolicy::OnClose);
19609        s.set_page_buffer_size(1 << 20)
19610            .expect("an unpaged userblock file must accept a page buffer");
19611        // The mark is the point: a session that accepted the property and raised
19612        // nothing would pass every assertion below, since the conversion under
19613        // test runs only when the flags byte is written, and `raise_crash_mark`
19614        // is the only thing that writes it here. Asserted in memory rather than
19615        // off the disk because the session holds an exclusive OS lock on the
19616        // file, which is mandatory on Windows.
19617        assert_eq!(
19618            s.held_status_flags,
19619            file_lock::WRITE_ACCESS,
19620            "a page-buffered session must hold the crash mark"
19621        );
19622        // A staged commit rather than an in-place append: in-place appending is
19623        // refused on a userblock file for reasons of its own, which is a
19624        // different subject and would only mask this one.
19625        for t in 0..3 {
19626            let mut db = crate::type_builders::DatasetBuilder::new(&std::format!("n{t}"));
19627            db.with_f64_data(&[2.5f64; 32]).with_shape(&[32]);
19628            s.stage_created_dataset(&std::format!("/n{t}"), db).unwrap();
19629        }
19630        s.commit().unwrap();
19631        s.force_sync().unwrap();
19632        s.release_status_flags().unwrap();
19633        drop(s);
19634
19635        assert_eq!(
19636            &std::fs::read(&path).unwrap()[..4096],
19637            &userblock_before[..],
19638            "the page-buffered session rewrote the userblock"
19639        );
19640        let f = crate::reader::File::open(&path).unwrap();
19641        assert_eq!(
19642            f.dataset("t0").unwrap().read_i32().unwrap(),
19643            (0..256).collect::<Vec<_>>(),
19644            "the commit must have left the original dataset alone"
19645        );
19646        for t in 0..3 {
19647            assert_eq!(
19648                f.dataset(&std::format!("n{t}"))
19649                    .unwrap()
19650                    .read_f64()
19651                    .unwrap(),
19652                vec![2.5f64; 32],
19653                "n{t}: the committed dataset must read back"
19654            );
19655        }
19656    }
19657
19658    /// A page buffer keeps dirty pages across operations, so a workload that
19659    /// touches the same pages again and again pays for them once rather than
19660    /// once per operation (issue #288) — and still produces the same file.
19661    ///
19662    /// This is what the default gathering deliberately does *not* do, so the
19663    /// comparison is against that default rather than against no gathering at
19664    /// all: what is being measured is the second reduction, not the first.
19665    ///
19666    /// Run on a paged file **and an unpaged one**. `H5Pset_page_buffer_size`
19667    /// requires the paged allocator because the C page buffer is a page cache
19668    /// whose `min_meta_perc`/`min_raw_perc` reservations count pages that
19669    /// allocator keeps segregated by kind; this is a write gatherer, which needs
19670    /// only a window to merge within, and
19671    /// [`gather_page_size`](WriteEngine::gather_page_size) supplies one either
19672    /// way. The unpaged arm is what says so (issue #357), and it is the arm that
19673    /// matters most in practice: unpaged is the default strategy, so before this
19674    /// the property was reachable only from a file deliberately created paged.
19675    #[test]
19676    fn a_page_buffer_holds_dirty_pages_across_operations() {
19677        use tempfile::tempdir;
19678
19679        let dir = tempdir().unwrap();
19680        let run = |path: &std::path::Path, page_buffer: usize| {
19681            let mut s = WriteEngine::open_with_locking(path, FileLocking::Enabled).unwrap();
19682            s.set_sync_policy(SyncPolicy::OnClose);
19683            if page_buffer != 0 {
19684                s.set_page_buffer_size(page_buffer).unwrap();
19685            }
19686            let (appends, commit) = gather_workload(&mut s);
19687            // The teardown `File::close` and `FileInner::drop` perform, in their
19688            // order. A bare engine owes it too — it is what takes the crash mark
19689            // down — and nothing does it for one: this crate deliberately keeps
19690            // teardown writes off `WriteEngine::drop`, so that dropping a probe
19691            // session (as the bounded/mirrored dispatch does) writes nothing.
19692            // Without it the buffered file ends still marked, and the byte
19693            // comparison below is what says so.
19694            s.force_sync().unwrap();
19695            s.release_status_flags().unwrap();
19696            drop(s);
19697            appends + commit
19698        };
19699
19700        for paged in [true, false] {
19701            let label = if paged { "paged" } else { "unpaged" };
19702            let per_op = dir.path().join(std::format!("{label}_per_op.h5"));
19703            let buffered = dir.path().join(std::format!("{label}_buffered.h5"));
19704            gather_fixture(&per_op, 4, paged);
19705            gather_fixture(&buffered, 4, paged);
19706
19707            let per_op_writes = run(&per_op, 0);
19708            let buffered_writes = run(&buffered, 1 << 20);
19709
19710            assert!(
19711                buffered_writes * 4 < per_op_writes,
19712                "{label}: a page buffer must cost meaningfully fewer writes than the \
19713                 per-operation default, but cost {buffered_writes} against {per_op_writes}"
19714            );
19715            assert_eq!(
19716                std::fs::read(&per_op).unwrap(),
19717                std::fs::read(&buffered).unwrap(),
19718                "{label}: a page buffer changed the file it produced"
19719            );
19720        }
19721    }
19722
19723    /// A budget below the 1 MiB a session already gathers under is honored end
19724    /// to end, and leaves the same file 1 MiB leaves (issue #391).
19725    ///
19726    /// The floor this replaces was a write-count argument with no correctness
19727    /// claim behind it, so the assertion that carries the weight is the byte
19728    /// comparison rather than the read-back: a buffer that flushes and restarts
19729    /// more often must produce the *same* file, not merely a readable one.
19730    ///
19731    /// Run through the public API — create, append, commit, close, reopen —
19732    /// because `File::create_with_options` is where the refusal used to fire, on
19733    /// a pair it had already decided it could not reopen with. A 16 KiB-paged
19734    /// file at 256 KiB is the case the issue reported.
19735    #[test]
19736    fn a_page_buffer_below_the_gather_budget_writes_the_same_file() {
19737        use tempfile::tempdir;
19738
19739        let dir = tempdir().unwrap();
19740        let run = |path: &std::path::Path, budget: usize| -> Vec<i32> {
19741            let file = crate::reader::File::create_with_options(
19742                path,
19743                crate::FileCreateProperties::new()
19744                    .with_file_space_strategy(FileSpaceStrategy::Page, true, 1)
19745                    .with_file_space_page_size(16 * 1024),
19746                crate::FileAccessProperties::new()
19747                    .with_sync_policy(SyncPolicy::OnClose)
19748                    .with_page_buffer_size(budget),
19749            )
19750            .unwrap();
19751            file.root()
19752                .create_dataset("d", |b| {
19753                    b.with_i32_data(&(0..64).collect::<Vec<i32>>())
19754                        .with_shape(&[64])
19755                        .with_maxshape(&[u64::MAX])
19756                        .with_chunks(&[64]);
19757                })
19758                .unwrap();
19759            file.commit().unwrap();
19760            for round in 0..8i32 {
19761                let mut ds = file.dataset("d").unwrap();
19762                ds.append(&vec![round; 64]).unwrap();
19763            }
19764            file.root()
19765                .create_dataset("added", |b| {
19766                    b.with_f64_data(&[2.5f64; 32]).with_shape(&[32]);
19767                })
19768                .unwrap();
19769            file.commit().unwrap();
19770            file.close().unwrap();
19771
19772            let reopened = crate::reader::File::open(path).unwrap();
19773            assert_eq!(
19774                reopened.dataset("added").unwrap().read_f64().unwrap(),
19775                vec![2.5f64; 32],
19776                "the dataset committed through the buffer must read back"
19777            );
19778            reopened.dataset("d").unwrap().read_i32().unwrap()
19779        };
19780
19781        let small = dir.path().join("small_budget.h5");
19782        let ample = dir.path().join("ample_budget.h5");
19783        let from_small = run(&small, 256 * 1024);
19784        let from_ample = run(&ample, 1 << 20);
19785
19786        let mut expected: Vec<i32> = (0..64).collect();
19787        for round in 0..8i32 {
19788            expected.extend(std::iter::repeat_n(round, 64));
19789        }
19790        assert_eq!(
19791            from_small, expected,
19792            "a 256 KiB page buffer did not append what it was given"
19793        );
19794        assert_eq!(
19795            from_ample, expected,
19796            "and neither did the 1 MiB one, so the file comparison below would be \
19797             two arms agreeing on the wrong answer"
19798        );
19799        assert_eq!(
19800            std::fs::read(&small).unwrap(),
19801            std::fs::read(&ample).unwrap(),
19802            "a 256 KiB page buffer produced a different file from a 1 MiB one"
19803        );
19804    }
19805
19806    /// A barrier issues what has been gathered under **every** policy, so the
19807    /// bytes a publish point names are on the disk before the publish point is.
19808    ///
19809    /// Gathered writes are issued in address order, and the superblock lives at
19810    /// address 0 — so a commit whose barriers issued nothing would put its new
19811    /// root pointer on the disk *first* and the content it names last. A write
19812    /// that then failed, or a process that died mid-flush, would leave a
19813    /// superblock naming bytes that are not in the file, where before this
19814    /// gathering existed it left the previous file intact.
19815    ///
19816    /// `SyncPolicy::Always` cannot see this: its barriers are `fsync`s, which
19817    /// flush on their way out. Every crash-consistency test in this crate runs on
19818    /// that default, which is exactly why nothing caught it (issue #288). So this
19819    /// runs on `OnClose`, and asserts the order rather than a count — a count
19820    /// would be satisfied by a session that issued everything at the wrong time.
19821    ///
19822    /// It covers two of the three ordering sites: `barrier` (the commit tail) and
19823    /// `EditStore::sync` (the append phases). The third is `barrier_data`, which
19824    /// `EditStore::sync` now shares rather than duplicates, so it is correct by
19825    /// identity rather than by argument. It is covered too, one test over:
19826    /// mutating its `OnClose` arm to `Ok(())` fails
19827    /// `sync_policy_governs_the_persisting_and_flag_barriers` and nothing else.
19828    #[test]
19829    fn a_barrier_orders_the_publish_point_last_under_every_policy() {
19830        use tempfile::tempdir;
19831
19832        let dir = tempdir().unwrap();
19833        for policy in [SyncPolicy::Always, SyncPolicy::OnClose] {
19834            let path = dir.path().join(std::format!("order_{policy:?}.h5"));
19835            gather_fixture(&path, 1, false);
19836
19837            let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
19838            s.set_sync_policy(policy);
19839
19840            let mut db = crate::type_builders::DatasetBuilder::new("added");
19841            db.with_f64_data(&[2.5f64; 64]).with_shape(&[64]);
19842            s.stage_created_dataset("/added", db).unwrap();
19843            let before = s.image.issued_write_order().len();
19844            s.commit().unwrap();
19845
19846            let order = s.image.issued_write_order()[before..].to_vec();
19847            let superblock = order
19848                .iter()
19849                .position(|&(at, _)| at == s.sb_sig_off as u64)
19850                .unwrap_or_else(|| panic!("{policy:?}: the commit never wrote the superblock"));
19851            let content = order
19852                .iter()
19853                .rposition(|&(at, _)| at != s.sb_sig_off as u64)
19854                .unwrap_or_else(|| panic!("{policy:?}: the commit wrote nothing but a superblock"));
19855            assert!(
19856                superblock > content,
19857                "{policy:?}: the superblock was issued at position {superblock} of \
19858                 {order:?}, ahead of content at {content} — a failure in that window \
19859                 leaves a root pointing at bytes that are not in the file"
19860            );
19861
19862            // The same for the append engine's four phases, whose publish point is
19863            // the dataspace dimension in the object header — a *low* address, with
19864            // the chunk bytes it makes visible at end-of-file. Stated as "the last
19865            // write is not the highest one", which is precisely what a single
19866            // address-ordered flush of the whole append would make it.
19867            let before = s.image.issued_write_order().len();
19868            s.append_inplace_i32_phased("t0", &[7; 64], 4).unwrap();
19869            let order = s.image.issued_write_order()[before..].to_vec();
19870            let highest = order
19871                .iter()
19872                .map(|&(at, _)| at)
19873                .max()
19874                .expect("the append wrote");
19875            assert!(
19876                order.last().expect("the append wrote").0 < highest,
19877                "{policy:?}: the append's last write is its highest-addressed one, so \
19878                 the whole append went out in address order and the dimension that \
19879                 publishes the new rows preceded the chunk bytes: {order:?}"
19880            );
19881        }
19882    }
19883
19884    /// The page buffer produces the same bytes through the **bounded** backing,
19885    /// which is the one `File::open_rw` actually picks for a latest-format file.
19886    ///
19887    /// The test above drives the whole-file mirror, whose reads come from memory
19888    /// and so never meet the pending writes at all. The bounded image has no
19889    /// mirror: every read it serves goes to the disk and is then patched with
19890    /// whatever is still gathered, so it is the backing where a wrong overlay
19891    /// silently plans the next edit against bytes that are neither on the disk nor
19892    /// in the buffer. Byte identity against the same session without the buffer is
19893    /// the assertion a wrong overlay cannot pass.
19894    #[test]
19895    fn a_page_buffer_changes_no_byte_through_the_bounded_backing() {
19896        use crate::reader::File;
19897        use tempfile::tempdir;
19898
19899        let dir = tempdir().unwrap();
19900        let run = |name: &str, page_buffer: usize| {
19901            let path = dir.path().join(name);
19902            gather_fixture(&path, 4, true);
19903            {
19904                let props = crate::FileAccessProperties::new()
19905                    .with_sync_policy(SyncPolicy::OnClose)
19906                    .with_memory_strategy(MemoryStrategy::Bounded)
19907                    .with_page_buffer_size(page_buffer);
19908                let file = File::open_rw_with_options(&path, props).unwrap();
19909                assert_eq!(
19910                    file.edit_backing(),
19911                    Some(crate::EditBacking::Bounded),
19912                    "{name}: this must exercise the mirrorless backing"
19913                );
19914                let root = file.root();
19915                for round in 0..4u8 {
19916                    for t in 0..4 {
19917                        let mut ds = file.dataset(&std::format!("t{t}")).unwrap();
19918                        ds.append(&[i32::from(round); 64]).unwrap();
19919                    }
19920                }
19921                for t in 0..4 {
19922                    root.create_dataset(&std::format!("n{t}"), |b| {
19923                        b.with_f64_data(&[2.5f64; 32]).with_shape(&[32]);
19924                    })
19925                    .unwrap();
19926                }
19927                file.commit().unwrap();
19928                root.set_attr("tag", crate::AttrValue::I32(1)).unwrap();
19929                file.commit().unwrap();
19930                file.close().unwrap();
19931            }
19932            std::fs::read(&path).unwrap()
19933        };
19934
19935        assert_eq!(
19936            run("bounded_plain.h5", 0),
19937            run("bounded_buffered.h5", 1 << 20),
19938            "a page buffer changed the file the bounded backing produced"
19939        );
19940    }
19941
19942    /// An operation that has returned has put its bytes in the operating system,
19943    /// whatever the [`SyncPolicy`] says.
19944    ///
19945    /// This is what makes the default gathering free rather than a trade: the
19946    /// bytes are held only *inside* a commit or an append, never across one. It
19947    /// is asserted as "a forced sync afterwards finds nothing left to write",
19948    /// because the alternative — reading the file through a second handle — is
19949    /// what the session's own exclusive lock exists to prevent, mandatorily so on
19950    /// Windows.
19951    #[test]
19952    fn a_finished_operation_has_nothing_left_to_write() {
19953        use tempfile::tempdir;
19954
19955        let dir = tempdir().unwrap();
19956        let path = dir.path().join("finished_op.h5");
19957        gather_fixture(&path, 1, false);
19958
19959        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
19960        // The policy that issues no `fsync` at all, so the ordering barriers are
19961        // the only thing that can be draining the buffer.
19962        s.set_sync_policy(SyncPolicy::OnClose);
19963
19964        let before_append = s.image.issued_writes();
19965        s.append_inplace_i32_phased("t0", &[7; 64], 4).unwrap();
19966        let after_append = s.image.issued_writes();
19967        assert!(
19968            after_append > before_append,
19969            "the append issued nothing at all, so the equality below holds for a \
19970             session that did no work"
19971        );
19972        s.force_sync().unwrap();
19973        assert_eq!(
19974            s.image.issued_writes(),
19975            after_append,
19976            "a finished append left writes in this process's memory"
19977        );
19978
19979        let mut db = crate::type_builders::DatasetBuilder::new("added");
19980        db.with_f64_data(&[1.5f64; 8]).with_shape(&[8]);
19981        s.stage_created_dataset("/added", db).unwrap();
19982        s.commit().unwrap();
19983        let after_commit = s.image.issued_writes();
19984        assert!(
19985            after_commit > after_append,
19986            "the commit issued nothing at all"
19987        );
19988        s.force_sync().unwrap();
19989        assert_eq!(
19990            s.image.issued_writes(),
19991            after_commit,
19992            "a finished commit left writes in this process's memory"
19993        );
19994
19995        // And the bytes are the ones they were meant to be — an image that issued
19996        // writes at the right moments but the wrong contents passes everything
19997        // above.
19998        drop(s);
19999        let f = crate::reader::File::open(&path).unwrap();
20000        assert_eq!(f.dataset("t0").unwrap().read_i32().unwrap().len(), 320);
20001        assert_eq!(
20002            f.dataset("added").unwrap().read_f64().unwrap(),
20003            vec![1.5f64; 8]
20004        );
20005    }
20006
20007    /// Overwriting the root group is refused by name.
20008    ///
20009    /// The refusal lives in `stage_dataset_write` rather than in the commit,
20010    /// because the staged dataset is flattened as it is staged and the root's
20011    /// empty path would otherwise be reported as a dataset with no name. No
20012    /// public entry point can reach it — `Dataset::write_staged` runs off a
20013    /// resolved dataset path — so this is where it stays covered.
20014    #[test]
20015    fn overwriting_the_root_group_is_refused_at_staging() {
20016        use crate::writer::FileBuilder;
20017        use tempfile::tempdir;
20018
20019        let dir = tempdir().unwrap();
20020        let path = dir.path().join("root_overwrite.h5");
20021        let mut b = FileBuilder::new();
20022        b.create_dataset("d").with_i32_data(&[1]);
20023        b.write(&path).unwrap();
20024
20025        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20026        let mut db = crate::type_builders::DatasetBuilder::new("whatever");
20027        db.with_i32_data(&[1]);
20028        let err = s.stage_dataset_write("/", db).unwrap_err();
20029        assert!(
20030            matches!(&err, Error::EditUnsupported(m) if m.contains("root group")),
20031            "unexpected error: {err:?}"
20032        );
20033        assert!(!s.has_staged_edits());
20034    }
20035
20036    /// Unallocated storage is refused by this engine rather than materialized.
20037    ///
20038    /// The whole-file writer leaves such a dataset's data address undefined
20039    /// (issue #293); this engine appends into an existing layout and has no
20040    /// equivalent, so the flag has to be answered rather than dropped. Dropping
20041    /// it would write out the grid of fill values the caller asked not to have,
20042    /// and every value assertion would pass.
20043    ///
20044    /// The message is checked, not just the failure: the arm below this one
20045    /// refuses a dataset with no data for the opposite reason — the caller forgot
20046    /// it — and reporting that one here would send a caller looking for a bug in
20047    /// their own code.
20048    #[test]
20049    fn staging_unallocated_storage_into_an_existing_file_is_refused() {
20050        use crate::writer::FileBuilder;
20051        use tempfile::tempdir;
20052
20053        let dir = tempdir().unwrap();
20054        let path = dir.path().join("unallocated.h5");
20055        let mut b = FileBuilder::new();
20056        b.create_dataset("d").with_i32_data(&[1]);
20057        b.write(&path).unwrap();
20058
20059        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20060        let mut db = crate::type_builders::DatasetBuilder::new("sparse");
20061        db.with_unallocated_storage(make_i32_type(), &[1000]);
20062        db.with_chunks(&[100]);
20063        let err = s.stage_created_dataset("/sparse", db).unwrap_err();
20064        assert!(
20065            matches!(&err, Error::EditUnsupported(m) if m.contains("unallocated storage")),
20066            "unexpected error: {err:?}"
20067        );
20068        assert!(!s.has_staged_edits());
20069    }
20070
20071    /// The same obligation on the one operation that does not go through
20072    /// [`commit`](WriteEngine::commit): the free-space finalize a session owes at
20073    /// teardown.
20074    ///
20075    /// Its two callers force a sync straight after it, so nothing observable
20076    /// breaks when it forgets — which is exactly why it needs its own test. It is
20077    /// `pub(crate)`, and the next caller would inherit the omission silently.
20078    #[test]
20079    fn finalize_persist_has_nothing_left_to_write() {
20080        use crate::writer::FileBuilder;
20081        use tempfile::tempdir;
20082
20083        let dir = tempdir().unwrap();
20084        let path = dir.path().join("persisting.h5");
20085        let mut b = FileBuilder::new();
20086        b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 1);
20087        b.create_dataset("t0")
20088            .with_i32_data(&(0..256).collect::<Vec<_>>())
20089            .with_shape(&[256])
20090            .with_maxshape(&[u64::MAX])
20091            .with_chunks(&[64]);
20092        b.write(&path).unwrap();
20093
20094        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20095        s.set_sync_policy(SyncPolicy::OnClose);
20096        // Grow the file past the managers, which is what gives the finalize
20097        // something to re-home.
20098        s.append_inplace_i32_phased("t0", &[7; 64], 4).unwrap();
20099
20100        s.finalize_persist().unwrap();
20101        let after = s.image.issued_writes();
20102        s.force_sync().unwrap();
20103        assert_eq!(
20104            s.image.issued_writes(),
20105            after,
20106            "finalize_persist returned with writes still gathered"
20107        );
20108    }
20109
20110    /// [`SpaceAccounting::reusable_free_space`] stays coalesced once an in-place
20111    /// append holds a reserve (issue #387).
20112    ///
20113    /// The reserve is drawn *out of* the session's free list, so what is left of
20114    /// the hole it came from sits immediately beside it — and as the append
20115    /// spends the reserve down, the spent end walks toward that remainder until
20116    /// the two abut exactly. Reporting them as two regions would break the
20117    /// field's documented contract ("no two regions touch or overlap") in the one
20118    /// way that misleads: a caller sizing an allocation against the largest
20119    /// region would be told nothing that big fits when it does.
20120    ///
20121    /// Sixteen chunk-sized appends, checked after every one, because the abutment
20122    /// appears only at a particular point in spending a batch rather than at the
20123    /// first append.
20124    #[test]
20125    fn the_append_reserve_is_reported_as_one_coalesced_free_list() {
20126        use crate::writer::FileBuilder;
20127        use tempfile::tempdir;
20128
20129        /// Elements of the dataset the delete vacates: three reserve batches, so
20130        /// the hole outlives several draws.
20131        const VICTIM: usize = (APPEND_RESERVE_BYTES as usize * 3) / 4;
20132        /// Elements per chunk: 64 KiB, so sixteen appends spend a whole batch.
20133        const CHUNK: u64 = 16384;
20134
20135        let dir = tempdir().unwrap();
20136        let path = dir.path().join("reserve_coalesced.h5");
20137        let mut b = FileBuilder::new();
20138        b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 1);
20139        b.create_dataset("t0")
20140            .with_i32_data(&[0i32])
20141            .with_shape(&[1])
20142            .with_maxshape(&[u64::MAX])
20143            .with_chunks(&[CHUNK]);
20144        b.create_dataset("victim")
20145            .with_i32_data(&vec![7i32; VICTIM]);
20146        b.create_dataset("ceiling").with_i32_data(&[9i32, 9, 9]);
20147        b.write(&path).unwrap();
20148
20149        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20150        s.set_sync_policy(SyncPolicy::OnClose);
20151        s.delete("victim").unwrap();
20152        s.commit().unwrap();
20153
20154        let batch: Vec<i32> = (0..CHUNK as i32).collect();
20155        let mut reserved_ever = false;
20156        for round in 0..16 {
20157            s.append_inplace_i32_phased("t0", &batch, 4).unwrap();
20158            reserved_ever |= !s.reserved.is_empty();
20159            let acct = s.space_accounting();
20160            let regions = &acct.reusable_free_space;
20161            for w in regions.windows(2) {
20162                let ((a_addr, a_len), (b_addr, _)) = (w[0], w[1]);
20163                assert!(
20164                    a_addr + a_len < b_addr,
20165                    "round {round}: [{a_addr}, {}) and [{b_addr}, ..) touch or overlap, \
20166                     though reusable_free_space is documented as fully coalesced: {regions:?}",
20167                    a_addr + a_len
20168                );
20169            }
20170            assert_eq!(
20171                acct.reusable_free_bytes,
20172                regions.iter().map(|&(_, len)| len).sum::<u64>(),
20173                "round {round}: the total must be the summed length of the regions"
20174            );
20175        }
20176        assert!(
20177            reserved_ever,
20178            "no reserve was ever held, so this measured the plain free list"
20179        );
20180    }
20181
20182    /// A persisting session gives its **unspent** append reserve back to the
20183    /// on-disk managers before it closes (issue #387).
20184    ///
20185    /// An append on such a file takes a batch out of the managers before it may
20186    /// spend any of it, and takes a whole batch however little the append needs.
20187    /// What is left over is space nothing occupies, so leaving it out of the
20188    /// managers would turn every appending session into a leak of up to
20189    /// [`APPEND_RESERVE_BYTES`] — invisible from inside the session, since its own
20190    /// accounting still counts the reserve as reusable.
20191    ///
20192    /// Measured from outside the session for that reason: the persisted free
20193    /// space after the close, against what it was before the append.
20194    #[test]
20195    fn an_unspent_append_reserve_goes_back_to_the_managers() {
20196        use crate::writer::FileBuilder;
20197        use tempfile::tempdir;
20198
20199        /// Elements of the dataset the delete below vacates. Its blocks are the
20200        /// hole the append draws from, so it has to exceed one reserve batch or
20201        /// nothing is drawn and this measures the old behaviour.
20202        const VICTIM: usize = (APPEND_RESERVE_BYTES as usize * 2) / 4;
20203
20204        let dir = tempdir().unwrap();
20205        let path = dir.path().join("reserve_return.h5");
20206        let mut b = FileBuilder::new();
20207        b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 1);
20208        b.create_dataset("t0")
20209            .with_i32_data(&(0..256).collect::<Vec<_>>())
20210            .with_shape(&[256])
20211            .with_maxshape(&[u64::MAX])
20212            .with_chunks(&[64]);
20213        b.create_dataset("victim")
20214            .with_i32_data(&vec![7i32; VICTIM]);
20215        // Above the hole, so the delete leaves an interior region rather than a
20216        // run reaching end-of-file that the commit truncates away.
20217        b.create_dataset("ceiling").with_i32_data(&[9i32, 9, 9]);
20218        b.write(&path).unwrap();
20219
20220        let persisted = |p: &std::path::Path| -> u64 {
20221            crate::reader::File::open(p)
20222                .unwrap()
20223                .persisted_free_space()
20224                .iter()
20225                .map(|&(_, len)| len)
20226                .sum()
20227        };
20228
20229        {
20230            let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20231            s.set_sync_policy(SyncPolicy::OnClose);
20232            s.delete("victim").unwrap();
20233            s.commit().unwrap();
20234        }
20235        let before = persisted(&path);
20236        assert!(
20237            before > APPEND_RESERVE_BYTES,
20238            "the fixture must leave more than one reserve batch on disk, not {before} bytes"
20239        );
20240
20241        // A few kilobytes of append against a megabyte of reserve.
20242        let appended: Vec<i32> = (0..1024).collect();
20243        {
20244            let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20245            s.set_sync_policy(SyncPolicy::OnClose);
20246            s.append_inplace_i32_phased("t0", &appended, 4).unwrap();
20247            s.finalize_persist().unwrap();
20248        }
20249
20250        let after = persisted(&path);
20251        // What the append actually placed: its chunks and the index blocks beside
20252        // them, plus the manager rewrite's own churn. Generous, and still an order
20253        // of magnitude under the batch that would go missing.
20254        let spent = 64 * 1024;
20255        assert!(
20256            after + spent >= before,
20257            "the session reserved {APPEND_RESERVE_BYTES} bytes and spent a few of them, \
20258             so the managers should still describe nearly all of the {before} they did \
20259             before — they describe {after}"
20260        );
20261        assert!(
20262            after < before,
20263            "the append placed bytes inside the hole, so the managers must describe \
20264             less than the {before} they did (they describe {after})"
20265        );
20266
20267        let f = crate::reader::File::open(&path).unwrap();
20268        let mut want = (0..256).collect::<Vec<i32>>();
20269        want.extend_from_slice(&appended);
20270        assert_eq!(f.dataset("t0").unwrap().read_i32().unwrap(), want);
20271        assert_eq!(f.dataset("ceiling").unwrap().read_i32().unwrap(), [9, 9, 9]);
20272    }
20273
20274    /// A persisting session's fixture for the draw tests: `t0` to append onto,
20275    /// `victims` datasets of `victim_elems` `i32`s each, every one followed by a
20276    /// small keeper so that deleting the victims leaves that many *separate*
20277    /// holes, and a `ceiling` above them all so none of the holes is trailing.
20278    fn fragmented_persisting_fixture(
20279        path: &std::path::Path,
20280        victims: usize,
20281        victim_elems: usize,
20282    ) -> WriteEngine {
20283        use crate::writer::FileBuilder;
20284
20285        let mut b = FileBuilder::new();
20286        b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 1);
20287        b.create_dataset("t0")
20288            .with_i32_data(&[0i32])
20289            .with_shape(&[1])
20290            .with_maxshape(&[u64::MAX])
20291            .with_chunks(&[64]);
20292        for v in 0..victims {
20293            b.create_dataset(&std::format!("victim{v}"))
20294                .with_i32_data(&vec![7i32; victim_elems]);
20295            b.create_dataset(&std::format!("keeper{v}"))
20296                .with_i32_data(&[v as i32]);
20297        }
20298        b.create_dataset("ceiling").with_i32_data(&[9i32, 9, 9]);
20299        b.write(path).unwrap();
20300
20301        let mut s = WriteEngine::open_with_locking(path, FileLocking::Enabled).unwrap();
20302        s.set_sync_policy(SyncPolicy::OnClose);
20303        for v in 0..victims {
20304            s.delete(&std::format!("victim{v}")).unwrap();
20305        }
20306        s.commit().unwrap();
20307        s
20308    }
20309
20310    /// One draw takes every hole the append fits in, not only the largest, so a
20311    /// file fragmented into small holes pays one manager rewrite for all of them
20312    /// rather than one per hole (issue #413).
20313    ///
20314    /// Three holes of a few chunks each, each far under a batch, separated by
20315    /// live objects so they cannot coalesce. One chunk-sized append then has to
20316    /// leave the reserve holding runs from more than one of them: a draw that
20317    /// took the largest hole alone would have served the chunk just as well and
20318    /// held exactly one run, so the count is what tells the two apart.
20319    #[test]
20320    fn a_draw_gathers_every_hole_the_append_fits_in() {
20321        use tempfile::tempdir;
20322
20323        let dir = tempdir().unwrap();
20324        let path = dir.path().join("draw_gathers.h5");
20325        let mut s = fragmented_persisting_fixture(&path, 3, 4 * 64);
20326        assert!(
20327            s.free.sections().len() >= 3,
20328            "the fixture must leave three separate holes, not {:?}",
20329            s.free.sections()
20330        );
20331        let before = s.free.sections();
20332
20333        s.append_inplace_i32_phased("t0", &[1i32; 64], 4).unwrap();
20334
20335        let held = s.reserved.sections();
20336        // A run per hole, less what the chunk and the rewrite's tail took out of
20337        // them: the chunk out of one, the tail out of at most one more.
20338        assert!(
20339            held.len() >= 2,
20340            "one draw should have gathered runs from several of the holes {before:?}, \
20341             not only {held:?}"
20342        );
20343        let drawn: u64 = held.iter().map(|&(_, len)| len).sum();
20344        assert!(
20345            drawn > before.iter().map(|&(_, len)| len).max().unwrap(),
20346            "the reserve holds {drawn} bytes, no more than the largest hole alone"
20347        );
20348    }
20349
20350    /// The manager rewrite that publishes a draw places its own tail inside the
20351    /// space the draw is taking when the draw has emptied the free list, rather
20352    /// than appending it (issue #413).
20353    ///
20354    /// On a flat file the draw and the tail are served from the same list, and
20355    /// a draw takes every hole the append fits in, so the case is the ordinary
20356    /// one, not a corner: without the fallback every draw appended one tail at
20357    /// end-of-file and freed the previous one, a persisting file's whole growth
20358    /// under churn once the floor on the draw was gone. Measured as the file's
20359    /// length across the append, which a tail landing anywhere inside leaves
20360    /// alone.
20361    ///
20362    /// Measured as the tail's own address rather than as an unchanged length: the
20363    /// rewrite supersedes the fixture's tail, which sat at end-of-file, so
20364    /// releasing that trailing run leaves the file *shorter* than it started
20365    /// (issue #418). An appended tail would land at exactly the pre-append
20366    /// end-of-file, which is the case both assertions rule out.
20367    #[test]
20368    fn a_draws_rewrite_places_its_tail_inside_the_draw() {
20369        use tempfile::tempdir;
20370
20371        let dir = tempdir().unwrap();
20372        let path = dir.path().join("draw_tail.h5");
20373        let mut s = fragmented_persisting_fixture(&path, 1, 8 * 64);
20374        let len_before = s.image.len();
20375
20376        s.append_inplace_i32_phased("t0", &[1i32; 64], 4).unwrap();
20377
20378        assert!(
20379            !s.reserved.is_empty(),
20380            "no reserve was drawn, so this measured an ordinary end-of-file append"
20381        );
20382        let ext = s.superblock.superblock_extension_address.unwrap();
20383        assert!(
20384            ext < len_before,
20385            "the rewrite appended its tail at {ext}, the pre-append end-of-file \
20386             {len_before}, instead of placing it in the hole it was publishing as taken"
20387        );
20388        assert!(
20389            s.image.len() <= len_before,
20390            "a rewrite that placed its tail inside the file must not have grown it \
20391             (was {len_before}, now {})",
20392            s.image.len()
20393        );
20394    }
20395
20396    /// One in-place append costs a small constant number of writes even where
20397    /// nothing gathers them — the case the Extensible-Array header's six
20398    /// statistics were written separately for.
20399    ///
20400    /// Under gathering, six adjacent writes and one merge into the same page
20401    /// write, so the whole suite is blind to which one the engine made. The SWMR
20402    /// writer is the regime where it still shows: it gathers nothing by design,
20403    /// so every write the engine makes is a syscall. Stated as a ceiling on the
20404    /// count rather than as an exact figure, since what an append needs is free to
20405    /// fall — and it has: for the append this measures, the first into a dataset,
20406    /// 12 before issue #307 published each checksummed structure in one write and
20407    /// 8 after; an append that reuses the data block that one allocates costs 5.
20408    /// Six statistics written singly puts it five over; a checksum written apart
20409    /// from the value it covers, four.
20410    #[test]
20411    fn an_unbuffered_append_costs_a_small_constant_number_of_writes() {
20412        use tempfile::tempdir;
20413
20414        let dir = tempdir().unwrap();
20415        let path = dir.path().join("swmr_cost.h5");
20416        gather_fixture(&path, 1, false);
20417
20418        let mut s = WriteEngine::open_swmr_writer(&path, SyncPolicy::OnClose).unwrap();
20419        let before = s.image.issued_writes();
20420        s.append_inplace_i32_phased("t0", &[7; 64], 4).unwrap();
20421        let cost = s.image.issued_writes() - before;
20422
20423        assert!(
20424            cost > 0,
20425            "the append issued nothing, so the ceiling below proves nothing"
20426        );
20427        assert!(
20428            cost <= 10,
20429            "an unbuffered append costs {cost} writes; the array header's six \
20430             statistics belong in one write, not six, and each checksum belongs in \
20431             the write that changed what it covers (measured at 8)"
20432        );
20433    }
20434
20435    /// A SWMR writer gathers nothing: its readers follow the ordered phases as
20436    /// they become visible, and a phase that has not reached the operating system
20437    /// is a phase the reader cannot see.
20438    ///
20439    /// Stopping inside the durability sequence is what makes this selective. A
20440    /// completed append ends with a barrier, so it would land on disk under
20441    /// either setting; only a stop *inside* the sequence distinguishes a writer
20442    /// that gathers from one that does not. The file is read through a second handle, which SWMR
20443    /// permits precisely because it takes no lock.
20444    #[test]
20445    fn the_swmr_writer_holds_no_write_back() {
20446        use tempfile::tempdir;
20447
20448        let dir = tempdir().unwrap();
20449        let path = dir.path().join("swmr.h5");
20450        gather_fixture(&path, 1, false);
20451
20452        let mut s = WriteEngine::open_swmr_writer(&path, SyncPolicy::OnClose).unwrap();
20453        let before = std::fs::read(&path).unwrap().len();
20454        // Phase 1 only: the chunk bytes and the superblock's end-of-file.
20455        s.append_inplace_i32_phased("t0", &[7; 64], 1).unwrap();
20456
20457        assert!(
20458            std::fs::read(&path).unwrap().len() > before,
20459            "a SWMR reader must see the phase-1 chunk bytes as soon as they are written"
20460        );
20461    }
20462
20463    /// The `fsync` cadence belongs to whoever the fapl says: every durability
20464    /// point in the engine — an immediate append's ordered barriers, a commit's
20465    /// barrier and repoint, the barrier a commit issues after truncating, the
20466    /// same-length-overwrite fast path, and the barrier `close` issues — answers
20467    /// to the session's [`SyncPolicy`], while `force_sync` (the explicit
20468    /// `File::sync`) answers to nobody (issue #263).
20469    ///
20470    /// The counts are compared as a table across the two policies rather than
20471    /// pinned to literals: how many `fsync`s a commit costs is an implementation
20472    /// detail that may fall, but that `OnClose` costs *none* and `Always` costs
20473    /// some at each of those points is the contract. The file is read back
20474    /// under both, since a skipped barrier must cost durability and nothing else
20475    /// — the bytes have already reached the operating system.
20476    ///
20477    /// Every stage here is a *distinct* barrier site. A commit tail that syncs
20478    /// twice does not stand in for the fast path that syncs once, nor for the
20479    /// post-truncate barrier that only a shrinking commit reaches: each is its
20480    /// own `self.barrier()` call that a later edit could regress to a bare
20481    /// `self.image.sync_all()` on its own — verified by mutating each site
20482    /// separately and watching this fail. The persisting tails and the
20483    /// consistency-flag write are covered by the test below.
20484    ///
20485    /// One site is left uncovered: the version 0/1 repoint branch of the
20486    /// non-persisting tail. A pre-v2 file cannot be produced by this crate's own
20487    /// writer — the crosscheck tests get one from the C library — and the
20488    /// counting image is a bounded one, which such a file needs the mirror
20489    /// instead of. Covering it needs a checked-in fixture and a second counting
20490    /// opener; it is named here so the gap is a known one rather than an
20491    /// assumed-covered one.
20492    #[test]
20493    fn sync_policy_governs_every_barrier() {
20494        use crate::writer::FileBuilder;
20495        use std::sync::Arc;
20496        use std::sync::atomic::{AtomicU64, Ordering};
20497        use tempfile::tempdir;
20498
20499        let dir = tempdir().unwrap();
20500        // [after an immediate append, after a staged commit, after a same-length
20501        // overwrite, after a commit that truncates, after the close barrier,
20502        // after an explicit sync].
20503        let run = |name: &str, policy: SyncPolicy| -> [u64; 5] {
20504            let path = dir.path().join(name);
20505            let mut b = FileBuilder::new();
20506            b.create_dataset("d")
20507                .with_i32_data(&(0..8).collect::<Vec<_>>())
20508                .with_shape(&[8])
20509                .with_maxshape(&[u64::MAX])
20510                .with_chunks(&[4]);
20511            b.write(&path).unwrap();
20512
20513            let syncs = Arc::new(AtomicU64::new(0));
20514            let mut s = WriteEngine::open_sync_counting(&path, policy, Arc::clone(&syncs)).unwrap();
20515            s.append_inplace_i32_phased("d", &[8, 9, 10, 11], 4)
20516                .unwrap();
20517            let after_append = syncs.load(Ordering::Relaxed);
20518            let mut db = crate::type_builders::DatasetBuilder::new("added");
20519            db.with_f64_data(&[2.5f64; 8]).with_shape(&[8]);
20520            s.stage_created_dataset("/added", db).unwrap();
20521            s.commit().unwrap();
20522            let after_commit = syncs.load(Ordering::Relaxed);
20523
20524            // Same-length value overwrite: the commit fast path, which patches
20525            // the bytes where they lie and syncs without repointing anything.
20526            let mut ow = crate::type_builders::DatasetBuilder::new("added");
20527            ow.with_f64_data(&[4.5f64; 8]).with_shape(&[8]);
20528            s.stage_dataset_write("/added", ow).unwrap();
20529            s.commit().unwrap();
20530            let after_overwrite = syncs.load(Ordering::Relaxed);
20531
20532            // A delete whose freed run reaches end-of-file, so the commit
20533            // truncates and takes the barrier that only a shrinking commit does.
20534            s.delete("/added").unwrap();
20535            s.commit().unwrap();
20536            let after_truncate = syncs.load(Ordering::Relaxed);
20537
20538            s.force_sync().unwrap();
20539            let after_forced = syncs.load(Ordering::Relaxed);
20540
20541            // Release the exclusive OS lock before reading the file back; those
20542            // locks are mandatory on Windows.
20543            drop(s);
20544            let f = crate::reader::File::open(&path).unwrap();
20545            assert_eq!(
20546                f.dataset("d").unwrap().read_i32().unwrap(),
20547                (0..12).collect::<Vec<_>>(),
20548                "the append must land under {policy:?}"
20549            );
20550            assert!(
20551                f.dataset("added").is_err(),
20552                "the deleting commit must land under {policy:?}"
20553            );
20554            [
20555                after_append,
20556                after_commit,
20557                after_overwrite,
20558                after_truncate,
20559                after_forced,
20560            ]
20561        };
20562
20563        let always = run("always.h5", SyncPolicy::Always);
20564        assert!(always[0] > 0, "an immediate append syncs under Always");
20565        assert!(always[1] > always[0], "so does a commit");
20566        assert!(
20567            always[2] > always[1],
20568            "so does the same-length-overwrite fast path"
20569        );
20570        assert!(
20571            always[3] > always[2],
20572            "so does a commit that truncates the file"
20573        );
20574        assert_eq!(
20575            always[4],
20576            always[3] + 1,
20577            "and a forced sync is exactly one more"
20578        );
20579
20580        let deferred = run("on_close.h5", SyncPolicy::OnClose);
20581        assert_eq!(
20582            &deferred[..4],
20583            &[0, 0, 0, 0],
20584            "OnClose must leave every one of those in-session barriers unissued"
20585        );
20586        assert_eq!(
20587            deferred[4], 1,
20588            "a forced sync is issued whatever the policy says — it is what the \
20589             teardown path and File::sync both take"
20590        );
20591    }
20592
20593    /// The two barrier sites the table above cannot reach: the commit tail of a
20594    /// file that *persists* its free space (a different tail, with its own
20595    /// barrier and repoint, plus the manager re-homing `close` owes), and the
20596    /// superblock consistency-flag write a SWMR session makes on open and close.
20597    ///
20598    /// Both are `self.barrier()`/`self.barrier_data()` calls on paths the
20599    /// ordinary non-persisting session never executes, so without this a
20600    /// regression at either would pass the whole suite (issue #263).
20601    #[test]
20602    fn sync_policy_governs_the_persisting_and_flag_barriers() {
20603        use crate::writer::FileBuilder;
20604        use std::sync::Arc;
20605        use std::sync::atomic::{AtomicU64, Ordering};
20606        use tempfile::tempdir;
20607
20608        let dir = tempdir().unwrap();
20609        // [after a commit on a persisting file, after the flag write, after the
20610        // close barrier and its manager re-homing].
20611        let run = |name: &str, strategy: FileSpaceStrategy, policy: SyncPolicy| -> [u64; 3] {
20612            let path = dir.path().join(name);
20613            let mut b = FileBuilder::new();
20614            b.create_dataset("d")
20615                .with_i32_data(&(0..8).collect::<Vec<_>>())
20616                .with_shape(&[8])
20617                .with_maxshape(&[u64::MAX])
20618                .with_chunks(&[4]);
20619            b.create_dataset("victim")
20620                .with_f64_data(&[1.5f64; 64])
20621                .with_shape(&[64]);
20622            // A paged file takes the page-aware tail, a different pair of barrier
20623            // calls from the plain persisting one; `0` asks for the default page
20624            // size, which the plain strategy ignores.
20625            b.with_file_space_strategy(
20626                strategy,
20627                true,
20628                if strategy == FileSpaceStrategy::Page {
20629                    0
20630                } else {
20631                    1
20632                },
20633            );
20634            b.write(&path).unwrap();
20635
20636            let syncs = Arc::new(AtomicU64::new(0));
20637            let mut s = WriteEngine::open_sync_counting(&path, policy, Arc::clone(&syncs)).unwrap();
20638            assert!(
20639                s.persist.is_some(),
20640                "the fixture must persist its free space, or this tests the wrong tail"
20641            );
20642            // A delete on a persisting file takes `commit_persisting`: the free
20643            // space is recorded on disk rather than truncated away.
20644            s.delete("/victim").unwrap();
20645            s.commit().unwrap();
20646            let after_commit = syncs.load(Ordering::Relaxed);
20647
20648            s.set_consistency_flags(0).unwrap();
20649            let after_flags = syncs.load(Ordering::Relaxed);
20650            // The flag write's barrier orders as well as syncs, like the other
20651            // two. Its production callers all force a sync straight after, which
20652            // is why nothing else would notice it stopping — the same reason
20653            // `finalize_persist_has_nothing_left_to_write` exists.
20654            let issued = s.image.issued_writes();
20655            s.force_sync().unwrap();
20656            assert_eq!(
20657                s.image.issued_writes(),
20658                issued,
20659                "{policy:?}: the consistency-flag write was left gathered"
20660            );
20661
20662            // An immediate append leaves the on-disk managers mid-file, which is
20663            // the debt `finalize_persist` settles with a second commit tail at
20664            // close — the writes no earlier `sync` can have covered.
20665            s.append_inplace_i32_phased("d", &[8, 9, 10, 11], 4)
20666                .unwrap();
20667            let before_close = syncs.load(Ordering::Relaxed);
20668            s.finalize_persist().unwrap();
20669            let after_close = syncs.load(Ordering::Relaxed) - before_close;
20670
20671            drop(s);
20672            let f = crate::reader::File::open(&path).unwrap();
20673            assert!(
20674                f.dataset("victim").is_err(),
20675                "the delete must land under {policy:?}"
20676            );
20677            assert_eq!(
20678                f.dataset("d").unwrap().read_i32().unwrap(),
20679                (0..12).collect::<Vec<_>>(),
20680                "and so must the append under {policy:?}"
20681            );
20682            [after_commit, after_flags, after_close]
20683        };
20684
20685        // Both persisting tails: the plain one and the page-aware one, which is a
20686        // separate pair of barrier calls reached only by a paged file.
20687        for (label, strategy) in [
20688            ("fsmaggr", FileSpaceStrategy::FsmAggr),
20689            ("paged", FileSpaceStrategy::Page),
20690        ] {
20691            let always = run(&format!("{label}_always.h5"), strategy, SyncPolicy::Always);
20692            assert!(
20693                always[0] > 0,
20694                "the {label} persisting commit tail syncs under Always"
20695            );
20696            assert!(
20697                always[1] > always[0],
20698                "so does the consistency-flag write a SWMR session makes ({label})"
20699            );
20700            assert!(
20701                always[2] > 0,
20702                "so does the manager re-homing close owes ({label})"
20703            );
20704
20705            assert_eq!(
20706                run(
20707                    &format!("{label}_on_close.h5"),
20708                    strategy,
20709                    SyncPolicy::OnClose
20710                ),
20711                [0, 0, 0],
20712                "OnClose must leave the {label} persisting tail, the flag write, and the \
20713                 close-time manager re-homing with no fsync at all"
20714            );
20715        }
20716    }
20717
20718    /// A write that alters the file and *then* reports failure is a real device
20719    /// behaviour — it is the one `TornWriteImage` models — so a `write_at`
20720    /// returning an error does not mean its bytes missed the disk. When the
20721    /// write in question is the superblock repoint, that makes "did this commit
20722    /// publish?" unanswerable from inside the engine, and every rollback
20723    /// unsound: undoing the values would tear a commit that did land.
20724    ///
20725    /// So the flag that gates the rollback is raised *before* the publish is
20726    /// issued, not after it returns, and this pins that. The commit here does
20727    /// publish — `/extra` is readable afterwards, which is only possible
20728    /// through the new root — while its `commit` returns the write's error.
20729    /// Rolling back under it would leave `/nums` holding the pre-commit value
20730    /// inside a tree that is otherwise entirely the new one (issue #344).
20731    #[test]
20732    fn a_commit_whose_publish_write_fails_rolls_nothing_back() {
20733        use crate::writer::FileBuilder;
20734        use tempfile::tempdir;
20735
20736        let dir = tempdir().unwrap();
20737        let path = dir.path().join("torn_publish.h5");
20738        let mut b = FileBuilder::new();
20739        b.create_dataset("nums").with_i32_data(&[1, 2, 3]);
20740        b.write(&path).unwrap();
20741
20742        // The superblock sits at offset 0 on a file with no userblock.
20743        let mut s = WriteEngine::open_torn_writes(&path, 0..48).unwrap();
20744        s.stage_dataset_write("/nums", {
20745            let mut db = crate::type_builders::DatasetBuilder::new("");
20746            db.with_i32_data(&[9, 9, 9]);
20747            db
20748        })
20749        .unwrap();
20750        s.stage_created_dataset("/extra", {
20751            let mut db = crate::type_builders::DatasetBuilder::new("");
20752            db.with_i32_data(&[42]);
20753            db
20754        })
20755        .unwrap();
20756        let failed = s.commit();
20757        assert!(
20758            matches!(failed, Err(Error::Io(_))),
20759            "the publish write is what failed: {failed:?}"
20760        );
20761        drop(s);
20762
20763        let f = crate::reader::File::open(&path).unwrap();
20764        assert_eq!(
20765            f.dataset("extra").unwrap().read_i32().unwrap(),
20766            vec![42],
20767            "the publish landed despite reporting failure, so this commit is live"
20768        );
20769        assert_eq!(
20770            f.dataset("nums").unwrap().read_i32().unwrap(),
20771            vec![9, 9, 9],
20772            "a rollback under a commit that did publish would tear it"
20773        );
20774    }
20775
20776    /// Two hard links to one dataset are two paths over one data block, and the
20777    /// commit's duplicate guard is by path — so one commit can journal the same
20778    /// address twice, the second entry holding what the first write put there.
20779    /// The undo therefore has to replay newest-first; oldest-first finishes on a
20780    /// value from the very batch being rolled back (issue #344).
20781    ///
20782    /// A same-length overwrite through either link is deliberately allowed —
20783    /// it rewrites the block every link sees, so unlike a relocating write it
20784    /// needs no single-hard-link rule (`write_dataset_shared_hard_link_crosscheck`
20785    /// is the interop half of that). Which is what makes this reachable.
20786    ///
20787    /// The second link is made with the reference C library because this crate
20788    /// has no API that creates one: the file is a committed one, see
20789    /// `crates/crosscheck/tests/c_test_data.rs`.
20790    #[test]
20791    fn an_undo_replays_two_hard_links_to_one_block_newest_first() {
20792        use tempfile::tempdir;
20793
20794        let dir = tempdir().unwrap();
20795        let path = crate::test_data::copy("c/hard_link_undo.h5", dir.path());
20796
20797        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20798        // Fail the commit in its tail, after both writes have landed.
20799        s.superblock.superblock_extension_address = Some(0);
20800        for (name, data) in [("/aa", [9, 9, 9]), ("/bb", [8, 8, 8])] {
20801            s.stage_dataset_write(name, {
20802                let mut db = crate::type_builders::DatasetBuilder::new("");
20803                db.with_i32_data(&data);
20804                db
20805            })
20806            .unwrap();
20807        }
20808        // A second kind of edit, so the batch takes the full commit path and has
20809        // a tail to fail in.
20810        s.stage_created_dataset("/extra", {
20811            let mut db = crate::type_builders::DatasetBuilder::new("");
20812            db.with_i32_data(&[42]);
20813            db
20814        })
20815        .unwrap();
20816        assert!(s.commit().is_err());
20817        drop(s);
20818
20819        let f = crate::reader::File::open(&path).unwrap();
20820        assert_eq!(
20821            f.dataset("aa").unwrap().read_i32().unwrap(),
20822            vec![1, 2, 3],
20823            "the block both links share must hold the value it held before"
20824        );
20825        assert_eq!(f.dataset("bb").unwrap().read_i32().unwrap(), vec![1, 2, 3]);
20826    }
20827
20828    /// The chunked in-place path (`WritePlan::InPlaceChunks`) journals one entry
20829    /// per chunk, and — when the chunks shrink — the rewritten chunk index
20830    /// beside them. A partial restore would leave the index recording the new
20831    /// sizes against the old bytes, so this is the case where the rollback has
20832    /// most to put back (issue #344).
20833    ///
20834    /// Filtered, and overwritten with data that compresses to almost nothing, so
20835    /// every chunk's recorded size changes and the index write is part of what
20836    /// is rolled back rather than an unchanged block.
20837    #[test]
20838    fn a_failed_commit_puts_back_every_chunk_it_overwrote() {
20839        use crate::writer::FileBuilder;
20840        use tempfile::tempdir;
20841
20842        let dir = tempdir().unwrap();
20843        let path = dir.path().join("chunked_undo.h5");
20844        let original: Vec<i32> = (0..4096i32)
20845            .map(|i| i.wrapping_mul(2_654_435_761u32 as i32) ^ i)
20846            .collect();
20847        let mut b = FileBuilder::new();
20848        b.create_dataset("grid")
20849            .with_i32_data(&original)
20850            .with_shape(&[4096])
20851            .with_chunks(&[512])
20852            .with_deflate(6);
20853        b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
20854        b.write(&path).unwrap();
20855
20856        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20857        s.superblock.superblock_extension_address = Some(0);
20858        s.stage_dataset_write("/grid", {
20859            // Values only: an overwrite that restated the chunking or the
20860            // filter would be refused as asking for more than an overwrite
20861            // (issue #318). The layout comes from the dataset already there.
20862            let mut db = crate::type_builders::DatasetBuilder::new("");
20863            db.with_i32_data(&vec![0i32; 4096]);
20864            db
20865        })
20866        .unwrap();
20867        s.stage_created_dataset("/extra", {
20868            let mut db = crate::type_builders::DatasetBuilder::new("");
20869            db.with_i32_data(&[42]);
20870            db
20871        })
20872        .unwrap();
20873        assert!(s.commit().is_err());
20874        drop(s);
20875
20876        let f = crate::reader::File::open(&path).unwrap();
20877        assert_eq!(
20878            f.dataset("grid").unwrap().read_i32().unwrap(),
20879            original,
20880            "every chunk, and the index sizing them, must read as it did before"
20881        );
20882    }
20883
20884    /// A commit clears the journal on entry, so it can never replay entries it
20885    /// did not write itself (issue #344).
20886    ///
20887    /// Only an attempt that *unwound* leaves any — a panic caught by the session
20888    /// lock, which recovers from poisoning — and the state it leaves is what
20889    /// this constructs directly rather than by finding something to panic in:
20890    /// an entry naming an address whose contents have moved on. Replaying it
20891    /// would write bytes captured before the unwind over whatever lives there
20892    /// now, and the entry clear on the way *out* cannot help, because that is
20893    /// the step the unwind skipped.
20894    ///
20895    /// `superseded_heaps` two fields up clears at entry for the same reason and
20896    /// says so; this is that rule applied to the journal.
20897    #[test]
20898    fn a_commit_does_not_replay_a_journal_it_did_not_write() {
20899        use crate::writer::FileBuilder;
20900        use tempfile::tempdir;
20901
20902        let dir = tempdir().unwrap();
20903        let path = dir.path().join("stale_journal.h5");
20904        let mut b = FileBuilder::new();
20905        b.create_dataset("nums").with_i32_data(&[1, 2, 3]);
20906        b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
20907        b.write(&path).unwrap();
20908
20909        let nums_block = {
20910            let f = crate::reader::File::open(&path).unwrap();
20911            match f.dataset("nums").unwrap().layout().unwrap() {
20912                crate::Layout::Contiguous {
20913                    address: Some(a), ..
20914                } => a as usize,
20915                other => panic!("expected a contiguous dataset: {other:?}"),
20916            }
20917        };
20918
20919        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20920        // Exactly what an attempt that unwound past `commit` leaves behind.
20921        let stale: Vec<u8> = [7i32, 7, 7].iter().flat_map(|v| v.to_le_bytes()).collect();
20922        s.inplace_undo.push((nums_block, stale));
20923
20924        // An unrelated commit, failed in its tail so that it rolls back at all.
20925        s.superblock.superblock_extension_address = Some(0);
20926        s.stage_created_dataset("/extra", {
20927            let mut db = crate::type_builders::DatasetBuilder::new("");
20928            db.with_i32_data(&[42]);
20929            db
20930        })
20931        .unwrap();
20932        assert!(s.commit().is_err());
20933        drop(s);
20934
20935        let f = crate::reader::File::open(&path).unwrap();
20936        assert_eq!(
20937            f.dataset("nums").unwrap().read_i32().unwrap(),
20938            vec![1, 2, 3],
20939            "the commit replayed a journal entry that was not its own"
20940        );
20941    }
20942
20943    /// Putting the values back is a write like any other, so it has to be
20944    /// ordered like one: a restore left sitting in the gathering buffer can be
20945    /// issued after a later write to the same block, which is the reordering
20946    /// hazard write gathering introduced (issue #288).
20947    ///
20948    /// Measured as a difference rather than a total, so it states the rule and
20949    /// not one commit's arithmetic: a refused commit with nothing to put back
20950    /// issues no barrier at all — it fails before the one it would have made —
20951    /// and the same refusal carrying a value overwrite issues exactly the one
20952    /// that orders the restore.
20953    #[test]
20954    fn a_rollback_orders_the_values_it_puts_back() {
20955        use crate::writer::FileBuilder;
20956        use std::sync::Arc;
20957        use std::sync::atomic::{AtomicU64, Ordering};
20958        use tempfile::tempdir;
20959
20960        let mut counted = Vec::new();
20961        for with_overwrite in [false, true] {
20962            let dir = tempdir().unwrap();
20963            let path = dir.path().join("rollback_syncs.h5");
20964            let mut b = FileBuilder::new();
20965            b.create_dataset("nums").with_i32_data(&[1, 2, 3]);
20966            b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
20967            b.write(&path).unwrap();
20968
20969            let syncs = Arc::new(AtomicU64::new(0));
20970            let mut s =
20971                WriteEngine::open_sync_counting(&path, SyncPolicy::Always, Arc::clone(&syncs))
20972                    .unwrap();
20973            // Fail the commit in its tail, after the apply phase has written.
20974            s.superblock.superblock_extension_address = Some(0);
20975            if with_overwrite {
20976                s.stage_dataset_write("/nums", {
20977                    let mut db = crate::type_builders::DatasetBuilder::new("");
20978                    db.with_i32_data(&[9, 9, 9]);
20979                    db
20980                })
20981                .unwrap();
20982            }
20983            s.stage_created_dataset("/extra", {
20984                let mut db = crate::type_builders::DatasetBuilder::new("");
20985                db.with_i32_data(&[42]);
20986                db
20987            })
20988            .unwrap();
20989            let before = syncs.load(Ordering::Relaxed);
20990            assert!(s.commit().is_err());
20991            counted.push(syncs.load(Ordering::Relaxed) - before);
20992        }
20993
20994        assert_eq!(
20995            counted[0], 0,
20996            "a refusal with nothing to put back reaches no barrier"
20997        );
20998        assert_eq!(
20999            counted[1],
21000            counted[0] + 1,
21001            "a refusal that put values back must order them: {counted:?}"
21002        );
21003    }
21004
21005    /// The journal reads back what it is about to overwrite, so a bounded
21006    /// commit's reads scale with the value being replaced rather than staying
21007    /// near the metadata-only figure
21008    /// [`a_bounded_commit_reads_far_less_than_the_file`] pins for a commit that
21009    /// overwrites nothing.
21010    ///
21011    /// This is the cost of the guarantee in issue #344 and it is charged even
21012    /// when the commit succeeds, since whether the bytes will be needed is not
21013    /// known until the attempt ends. Stated as a rule — reads land between the
21014    /// payload and a small multiple of it — rather than as one target's exact
21015    /// count, so it holds wherever the suite runs.
21016    #[test]
21017    fn a_bounded_commit_reads_the_value_it_is_replacing() {
21018        use crate::writer::FileBuilder;
21019        use std::sync::Arc;
21020        use std::sync::atomic::{AtomicU64, Ordering};
21021        use tempfile::tempdir;
21022
21023        let dir = tempdir().unwrap();
21024        let p = dir.path().join("journal_reads.h5");
21025        let data: Vec<i32> = (0..250_000).collect();
21026        let payload = (data.len() * 4) as u64;
21027        let mut b = FileBuilder::new();
21028        b.create_dataset("nums").with_i32_data(&data);
21029        b.write(&p).unwrap();
21030
21031        let read_bytes = Arc::new(AtomicU64::new(0));
21032        let mut engine = WriteEngine::open_bounded_counting(&p, Arc::clone(&read_bytes)).unwrap();
21033        engine
21034            .stage_dataset_write("/nums", {
21035                let mut db = crate::type_builders::DatasetBuilder::new("");
21036                db.with_i32_data(&data);
21037                db
21038            })
21039            .unwrap();
21040        let before = read_bytes.load(Ordering::Relaxed);
21041        engine.commit().unwrap();
21042        let read = read_bytes.load(Ordering::Relaxed) - before;
21043
21044        assert!(
21045            read >= payload,
21046            "the journal must read the whole value it replaces: {read} of {payload}"
21047        );
21048        assert!(
21049            read < payload + (64 << 10),
21050            "and not much more than it: {read} against {payload}"
21051        );
21052    }
21053}
21054
21055#[cfg(test)]
21056mod staged_query_tests {
21057    //! The queries a handle asks about objects a session has staged and not
21058    //! committed (issue #392).
21059
21060    use super::*;
21061    use crate::type_builders::DatasetBuilder;
21062    use tempfile::tempdir;
21063
21064    /// A session over a file holding one dataset, `existing`.
21065    fn open_session(path: &Path) -> WriteEngine {
21066        let mut b = crate::writer::FileBuilder::new();
21067        b.create_dataset("existing").with_i32_data(&[1, 2, 3]);
21068        b.write(path).unwrap();
21069        WriteEngine::open_rw_with_strategy(
21070            path,
21071            crate::source::MetadataCacheConfig::disabled(),
21072            FileLocking::Enabled,
21073            MemoryStrategy::Mirrored,
21074        )
21075        .unwrap()
21076    }
21077
21078    fn i32_dataset(data: &[i32]) -> DatasetBuilder {
21079        let mut b = DatasetBuilder::new("");
21080        b.with_i32_data(data);
21081        b
21082    }
21083
21084    fn kind(e: &WriteEngine, path: &str) -> Option<StagedKind> {
21085        e.staged_object(path).map(|o| o.kind)
21086    }
21087
21088    fn child_names(e: &WriteEngine, parent: &str) -> Vec<(String, StagedKind)> {
21089        e.staged_children(parent)
21090            .into_iter()
21091            .map(|c| (c.name, c.kind))
21092            .collect()
21093    }
21094
21095    #[test]
21096    fn a_staged_creation_is_named_at_every_level_of_its_path() {
21097        let dir = tempdir().unwrap();
21098        let mut e = open_session(&dir.path().join("q.h5"));
21099        e.create_group("a").unwrap();
21100        let mut col = DatasetBuilder::new("");
21101        col.with_i32_data(&[1, 2])
21102            .with_maxshape(&[u64::MAX])
21103            .with_chunks(&[2]);
21104        e.stage_created_dataset("a/b/col", col).unwrap();
21105
21106        assert_eq!(kind(&e, "a"), Some(StagedKind::Group));
21107        assert_eq!(kind(&e, "a/b/col"), Some(StagedKind::Dataset));
21108        // `a/b` was never named: the commit creates it on the way to `col`, and
21109        // a session cannot tell from its staged set whether such a group is one
21110        // it is adding or one the file already holds. Naming it is the way to
21111        // address it.
21112        assert_eq!(kind(&e, "a/b"), None);
21113        // The root always exists, an on-disk object is not staged, and neither
21114        // is a name nothing reaches.
21115        assert_eq!(kind(&e, ""), None);
21116        assert_eq!(kind(&e, "existing"), None);
21117        assert_eq!(kind(&e, "a/missing"), None);
21118
21119        assert_eq!(
21120            child_names(&e, ""),
21121            vec![("a".to_string(), StagedKind::Group)]
21122        );
21123        assert_eq!(
21124            child_names(&e, "a/b"),
21125            vec![("col".to_string(), StagedKind::Dataset)]
21126        );
21127        // `b` under `a` is the unnamed intermediate again.
21128        assert!(child_names(&e, "a").is_empty());
21129        assert!(child_names(&e, "existing").is_empty());
21130
21131        // Named outright, it answers at every level.
21132        e.create_group("a/b").unwrap();
21133        assert_eq!(kind(&e, "a/b"), Some(StagedKind::Group));
21134        assert_eq!(
21135            child_names(&e, "a"),
21136            vec![("b".to_string(), StagedKind::Group)]
21137        );
21138    }
21139
21140    #[test]
21141    fn a_staged_dataset_reports_what_its_builder_settled() {
21142        let dir = tempdir().unwrap();
21143        let mut e = open_session(&dir.path().join("q.h5"));
21144        let mut col = DatasetBuilder::new("");
21145        col.with_i32_data(&[1, 2, 3, 4])
21146            .with_maxshape(&[u64::MAX])
21147            .with_chunks(&[2])
21148            .with_deflate(4);
21149        e.stage_created_dataset("col", col).unwrap();
21150        e.stage_created_dataset("plain", i32_dataset(&[5])).unwrap();
21151
21152        let meta = e.staged_dataset_meta("col").unwrap();
21153        assert_eq!(meta.dimensions, vec![4]);
21154        assert_eq!(meta.maxshape, Some(vec![u64::MAX]));
21155        assert_eq!(meta.datatype.type_size(), 4);
21156        assert!(meta.chunked);
21157        assert_eq!(meta.filters, vec![(1u16, false)]);
21158
21159        // A fixed-shape, unfiltered dataset is contiguous and reports no
21160        // maximum, the way `Dataset::maxshape` reports an on-disk one.
21161        let plain = e.staged_dataset_meta("plain").unwrap();
21162        assert_eq!(plain.dimensions, vec![1]);
21163        assert_eq!(plain.maxshape, None);
21164        assert!(!plain.chunked);
21165        assert!(plain.filters.is_empty());
21166
21167        // Only datasets answer.
21168        e.create_group("g").unwrap();
21169        assert!(e.staged_dataset_meta("g").is_none());
21170        assert!(e.staged_dataset_meta("existing").is_none());
21171    }
21172
21173    #[test]
21174    fn a_deletion_hides_nothing_until_a_creation_replaces_it() {
21175        let dir = tempdir().unwrap();
21176        let mut e = open_session(&dir.path().join("q.h5"));
21177        e.delete("existing").unwrap();
21178        // The object is still in the file, and still readable, so a handle must
21179        // not be told it is staged.
21180        assert_eq!(kind(&e, "existing"), None);
21181        assert!(e.staged_children("").is_empty());
21182
21183        e.stage_created_dataset("existing", i32_dataset(&[9]))
21184            .unwrap();
21185        let staged = e.staged_object("existing").unwrap();
21186        assert_eq!(staged.kind, StagedKind::Dataset);
21187        assert!(staged.replaces_link, "the same commit removes the link");
21188        assert_eq!(
21189            e.staged_dataset_meta("existing").unwrap().dimensions,
21190            vec![1]
21191        );
21192        assert!(e.staged_children("")[0].replaces_link);
21193    }
21194
21195    #[test]
21196    fn a_creation_colliding_with_a_surviving_link_is_refused_where_it_is_staged() {
21197        let dir = tempdir().unwrap();
21198        let mut e = open_session(&dir.path().join("q.h5"));
21199        // No deletion beside it, so this creation collides with a link the
21200        // commit keeps. That is the commit's own refusal, raised where the call
21201        // is made so no handle onto the creation is ever handed back: such a
21202        // handle would have addressed the file's `existing` while claiming to
21203        // address the new one.
21204        let err = e
21205            .stage_created_dataset("existing", i32_dataset(&[9]))
21206            .unwrap_err();
21207        assert!(
21208            matches!(&err, Error::EditUnsupported(m) if m.contains("already exists")),
21209            "got: {err}"
21210        );
21211        // The same rule for a group, which is also the kind change a listing
21212        // would otherwise misreport as turning a dataset into a group.
21213        assert!(e.create_group("existing").is_err());
21214
21215        // Nothing was staged, so the file's own object is still what the name
21216        // means, and the session has nothing left to refuse at commit time.
21217        assert_eq!(kind(&e, "existing"), None);
21218        assert!(e.staged_dataset_meta("existing").is_none());
21219        assert!(e.staged_children("").is_empty());
21220        e.commit().unwrap();
21221    }
21222
21223    #[test]
21224    fn a_second_creation_at_a_staged_path_is_refused_where_it_is_staged() {
21225        let dir = tempdir().unwrap();
21226        let mut e = open_session(&dir.path().join("q.h5"));
21227        e.stage_created_dataset("fresh", i32_dataset(&[1, 2, 3]))
21228            .unwrap();
21229
21230        // The staged set is indexed by path and keeps the first record there, so
21231        // a second creation would be staged behind the first and the handle this
21232        // call hands back would answer for the first one's shape and datatype.
21233        let err = e
21234            .stage_created_dataset("fresh", i32_dataset(&[9]))
21235            .unwrap_err();
21236        assert!(
21237            matches!(&err, Error::EditUnsupported(m) if m.contains("already stages")),
21238            "got: {err}"
21239        );
21240        // Refused, not replaced: the first creation is exactly as it was, and it
21241        // is still the only thing staged at that name.
21242        assert_eq!(e.staged_dataset_meta("fresh").unwrap().dimensions, vec![3]);
21243        assert_eq!(
21244            child_names(&e, ""),
21245            vec![("fresh".to_string(), StagedKind::Dataset)]
21246        );
21247
21248        // The two kinds collide with each other for the same reason: one path
21249        // cannot name both, and whichever the index found first is what every
21250        // handle onto it would report.
21251        assert!(e.create_group("fresh").is_err());
21252        e.create_group("g").unwrap();
21253        assert!(e.stage_created_dataset("g", i32_dataset(&[9])).is_err());
21254        assert_eq!(kind(&e, "g"), Some(StagedKind::Group));
21255
21256        e.commit().unwrap();
21257    }
21258
21259    #[test]
21260    fn a_replacement_is_staged_once_and_a_withdrawal_frees_the_name_again() {
21261        let dir = tempdir().unwrap();
21262        let mut e = open_session(&dir.path().join("q.h5"));
21263        // A creation over a link this session deletes is a replacement, which
21264        // the file arm admits — but only one of them, since the second would
21265        // still be staged behind the first.
21266        e.delete("existing").unwrap();
21267        e.stage_created_dataset("existing", i32_dataset(&[9]))
21268            .unwrap();
21269        assert!(
21270            e.stage_created_dataset("existing", i32_dataset(&[8, 8]))
21271                .is_err()
21272        );
21273        assert_eq!(
21274            e.staged_dataset_meta("existing").unwrap().dimensions,
21275            vec![1]
21276        );
21277
21278        // Deleting a staged creation withdraws it, which is what puts the name
21279        // back within reach of another creation.
21280        e.delete("existing").unwrap();
21281        e.stage_created_dataset("existing", i32_dataset(&[8, 8]))
21282            .unwrap();
21283        assert_eq!(
21284            e.staged_dataset_meta("existing").unwrap().dimensions,
21285            vec![2]
21286        );
21287        e.commit().unwrap();
21288    }
21289
21290    #[test]
21291    fn a_group_staged_twice_is_one_group_and_stays_allowed() {
21292        let dir = tempdir().unwrap();
21293        let mut e = open_session(&dir.path().join("q.h5"));
21294        // Two group creations at one path name one node: the commit builds a
21295        // single group from them, so a handle onto either addresses it and
21296        // nothing is misreported. Re-staging is how attributes and children are
21297        // added to a group already staged.
21298        e.create_group("g").unwrap();
21299        e.create_group("g").unwrap();
21300        assert_eq!(kind(&e, "g"), Some(StagedKind::Group));
21301        assert_eq!(
21302            child_names(&e, ""),
21303            vec![("g".to_string(), StagedKind::Group)]
21304        );
21305        e.stage_created_dataset("g/inner", i32_dataset(&[7]))
21306            .unwrap();
21307        e.commit().unwrap();
21308    }
21309
21310    #[test]
21311    fn a_creation_under_a_deleted_group_the_session_does_not_rebuild_shadows_nothing() {
21312        // `delete("g")` carries `g/inner` away with it, but only a commit that
21313        // builds `g` again can put anything back under that path — and this one
21314        // does not, so the commit refuses the batch. Until then the file's own
21315        // `g/inner` is what the name means: calling the staged creation beside
21316        // it a replacement would make a live, readable dataset answer
21317        // `NotCommitted` and vanish from its group's listing (issue #392).
21318        let dir = tempdir().unwrap();
21319        let path = dir.path().join("prefix.h5");
21320        let mut b = crate::writer::FileBuilder::new();
21321        let mut g = b.create_group("g");
21322        g.create_dataset("inner").with_i32_data(&[1]);
21323        b.add_group(g.finish());
21324        b.write(&path).unwrap();
21325
21326        let mut e = WriteEngine::open_rw_with_strategy(
21327            &path,
21328            crate::source::MetadataCacheConfig::disabled(),
21329            FileLocking::Enabled,
21330            MemoryStrategy::Mirrored,
21331        )
21332        .unwrap();
21333        e.delete("g").unwrap();
21334        // The name `g/inner` is still taken: a deletion of `g` alone hands it
21335        // over to nobody, so this is the collision it looks like rather than a
21336        // replacement.
21337        let err = e
21338            .stage_created_dataset("g/inner", i32_dataset(&[9]))
21339            .unwrap_err();
21340        assert!(
21341            matches!(&err, Error::EditUnsupported(m) if m.contains("already exists")),
21342            "got: {err}"
21343        );
21344        assert_eq!(kind(&e, "g/inner"), None, "the file's own dataset");
21345
21346        // A name the file does not hold stages, and is not a replacement of
21347        // anything; the commit refuses the batch for the overlap itself.
21348        e.stage_created_dataset("g/other", i32_dataset(&[9]))
21349            .unwrap();
21350        assert!(
21351            !e.staged_children("g")[0].replaces_link,
21352            "nothing rebuilds `g`, so its names are not handed over"
21353        );
21354        assert!(e.commit().is_err(), "a deletion overlapping an addition");
21355    }
21356
21357    #[test]
21358    fn a_deleted_group_rebuilt_in_the_same_commit_hands_its_names_over() {
21359        // The other side of that rule: `g` is replaced, so a creation under it
21360        // owns its name from the moment it is staged and the object the commit
21361        // removes is no longer what the path means.
21362        let dir = tempdir().unwrap();
21363        let path = dir.path().join("replaced.h5");
21364        let mut b = crate::writer::FileBuilder::new();
21365        let mut g = b.create_group("g");
21366        g.create_dataset("inner").with_i32_data(&[1]);
21367        b.add_group(g.finish());
21368        b.write(&path).unwrap();
21369
21370        let mut e = WriteEngine::open_rw_with_strategy(
21371            &path,
21372            crate::source::MetadataCacheConfig::disabled(),
21373            FileLocking::Enabled,
21374            MemoryStrategy::Mirrored,
21375        )
21376        .unwrap();
21377        e.delete("g").unwrap();
21378        e.create_group("g").unwrap();
21379        e.stage_created_dataset("g/inner", i32_dataset(&[9]))
21380            .unwrap();
21381        assert_eq!(kind(&e, "g/inner"), Some(StagedKind::Dataset));
21382        assert!(e.staged_object("g/inner").unwrap().replaces_link);
21383        assert!(e.staged_children("g")[0].replaces_link);
21384        e.commit().unwrap();
21385    }
21386
21387    #[test]
21388    fn the_root_cannot_be_deleted() {
21389        // Nothing links to the root, so there is no link to remove — and an
21390        // empty path is a prefix of every other, so a deletion staged there
21391        // would make every creation in the session look like a replacement.
21392        let dir = tempdir().unwrap();
21393        let mut e = open_session(&dir.path().join("q.h5"));
21394        for path in ["", "/"] {
21395            let err = e.delete(path).unwrap_err();
21396            assert!(
21397                matches!(&err, Error::EditUnsupported(m) if m.contains("root group")),
21398                "got: {err}"
21399            );
21400        }
21401        e.stage_created_dataset("fresh", i32_dataset(&[1])).unwrap();
21402        assert!(
21403            !e.staged_object("fresh").unwrap().replaces_link,
21404            "no deletion was staged, so this replaces nothing"
21405        );
21406    }
21407
21408    #[test]
21409    fn an_append_onto_a_staged_dataset_grows_the_pending_creation() {
21410        let dir = tempdir().unwrap();
21411        let mut e = open_session(&dir.path().join("q.h5"));
21412        e.stage_created_dataset("col", i32_dataset(&[1, 2]))
21413            .unwrap();
21414
21415        let mut b = AppendBuilder::new();
21416        b.append_i32(&[3, 4]);
21417        e.stage_dataset_append_pending("col", b).unwrap();
21418        assert_eq!(e.staged_dataset_meta("col").unwrap().dimensions, vec![4]);
21419        // Folded into the creation rather than queued beside it, so the commit
21420        // has one dataset to write and no append to apply to it.
21421        assert!(e.staged.appends.is_empty());
21422
21423        e.commit().unwrap();
21424        let addr = crate::group_v2::resolve_path_any_from_source(&e.image(), e.superblock(), "col")
21425            .unwrap();
21426        assert!(addr > 0);
21427    }
21428
21429    #[test]
21430    fn an_append_through_a_handle_onto_the_replaced_object_is_refused() {
21431        let dir = tempdir().unwrap();
21432        let mut e = open_session(&dir.path().join("q.h5"));
21433        e.delete("existing").unwrap();
21434        e.stage_created_dataset("existing", i32_dataset(&[100]))
21435            .unwrap();
21436
21437        // `stage_dataset_append` is the entry point a handle onto the object in
21438        // the file uses. Growing the replacement under it would silently move
21439        // the elements to another object.
21440        let mut b = AppendBuilder::new();
21441        b.append_i32(&[200]);
21442        assert!(matches!(
21443            e.stage_dataset_append("existing", b),
21444            Err(Error::EditUnsupported(_))
21445        ));
21446        assert_eq!(
21447            e.staged_dataset_meta("existing").unwrap().dimensions,
21448            vec![1],
21449            "the refusal must leave the replacement alone"
21450        );
21451        assert!(e.staged.appends.is_empty());
21452    }
21453
21454    #[test]
21455    fn an_append_the_staged_dataset_cannot_carry_is_refused_without_changing_it() {
21456        let dir = tempdir().unwrap();
21457        let mut e = open_session(&dir.path().join("q.h5"));
21458        e.stage_created_dataset("col", i32_dataset(&[1, 2]))
21459            .unwrap();
21460
21461        let mut wrong_type = AppendBuilder::new();
21462        wrong_type.append_f64(&[1.0]);
21463        assert!(matches!(
21464            e.stage_dataset_append_pending("col", wrong_type),
21465            Err(Error::AppendUnsupported(_))
21466        ));
21467
21468        let mut partial = AppendBuilder::new();
21469        partial.append_raw(&[0u8, 1, 2]);
21470        assert!(matches!(
21471            e.stage_dataset_append_pending("col", partial),
21472            Err(Error::AppendUnsupported(_))
21473        ));
21474
21475        // A finite maximum is a promise the commit would refuse to break.
21476        let mut capped = DatasetBuilder::new("");
21477        capped
21478            .with_i32_data(&[1, 2])
21479            .with_maxshape(&[3])
21480            .with_chunks(&[2]);
21481        e.stage_created_dataset("capped", capped).unwrap();
21482        let mut over = AppendBuilder::new();
21483        over.append_i32(&[3, 4]);
21484        assert!(matches!(
21485            e.stage_dataset_append_pending("capped", over),
21486            Err(Error::AppendUnsupported(_))
21487        ));
21488
21489        // Nothing the refusals touched changed.
21490        assert_eq!(e.staged_dataset_meta("col").unwrap().dimensions, vec![2]);
21491        assert_eq!(e.staged_dataset_meta("capped").unwrap().dimensions, vec![2]);
21492    }
21493
21494    #[test]
21495    fn an_append_onto_a_committed_dataset_still_stages_an_append() {
21496        let dir = tempdir().unwrap();
21497        let mut e = open_session(&dir.path().join("q.h5"));
21498        let mut b = AppendBuilder::new();
21499        b.append_i32(&[4]);
21500        e.stage_dataset_append("existing", b).unwrap();
21501        assert_eq!(e.staged.appends.len(), 1);
21502
21503        // And so does one made through a handle whose creation was committed
21504        // between the caller's check and this lock.
21505        let mut b = AppendBuilder::new();
21506        b.append_i32(&[5]);
21507        e.stage_dataset_append_pending("existing", b).unwrap();
21508        assert_eq!(e.staged.appends.len(), 2);
21509    }
21510
21511    #[test]
21512    fn deleting_a_staged_creation_withdraws_it() {
21513        let dir = tempdir().unwrap();
21514        let mut e = open_session(&dir.path().join("q.h5"));
21515        e.stage_created_dataset("col", i32_dataset(&[1, 2]))
21516            .unwrap();
21517        e.delete("col").unwrap();
21518        // Withdrawn, not queued for a deletion the commit could not perform.
21519        assert_eq!(kind(&e, "col"), None);
21520        assert!(e.staged.deletes.is_empty());
21521        assert!(!e.has_staged_edits());
21522        e.commit().unwrap();
21523    }
21524
21525    #[test]
21526    fn deleting_a_staged_group_withdraws_its_staged_subtree() {
21527        let dir = tempdir().unwrap();
21528        let mut e = open_session(&dir.path().join("q.h5"));
21529        e.create_group("g").unwrap();
21530        e.create_group("g/inner").unwrap();
21531        e.stage_created_dataset("g/inner/col", i32_dataset(&[1]))
21532            .unwrap();
21533        e.set_group_attr("g", "kind", AttrValue::I64(1)).unwrap();
21534
21535        e.delete("g").unwrap();
21536        assert_eq!(kind(&e, "g"), None);
21537        assert_eq!(kind(&e, "g/inner"), None);
21538        assert_eq!(kind(&e, "g/inner/col"), None);
21539        assert!(
21540            !e.has_staged_edits(),
21541            "the attribute and the subtree go with the group"
21542        );
21543        e.commit().unwrap();
21544    }
21545
21546    #[test]
21547    fn deleting_a_staged_replacement_leaves_the_plain_deletion() {
21548        let dir = tempdir().unwrap();
21549        let mut e = open_session(&dir.path().join("q.h5"));
21550        e.delete("existing").unwrap();
21551        e.stage_created_dataset("existing", i32_dataset(&[9]))
21552            .unwrap();
21553        // Changing one's mind about the replacement leaves the deletion of the
21554        // object in the file, which is what was asked for first.
21555        e.delete("existing").unwrap();
21556        assert_eq!(kind(&e, "existing"), None);
21557        assert_eq!(e.staged.deletes.len(), 1);
21558        e.commit().unwrap();
21559        assert!(
21560            crate::group_v2::resolve_path_any_from_source(&e.image(), e.superblock(), "existing",)
21561                .is_err()
21562        );
21563    }
21564
21565    #[test]
21566    fn a_batch_that_fails_leaves_the_staged_index_matching_the_staged_set() {
21567        let dir = tempdir().unwrap();
21568        let mut e = open_session(&dir.path().join("q.h5"));
21569        e.stage_created_dataset("kept", i32_dataset(&[1])).unwrap();
21570        let refused: Result<(), Error> = e.stage_atomically(|s| {
21571            s.create_group("gone")?;
21572            s.stage_created_dataset("gone/col", i32_dataset(&[2]))?;
21573            Err(Error::EditUnsupported("refused on purpose"))
21574        });
21575        assert!(refused.is_err());
21576        // The index must forget what the rewind dropped, or a lookup would hand
21577        // back a handle onto an entry that is no longer there.
21578        assert_eq!(kind(&e, "gone"), None);
21579        assert_eq!(kind(&e, "gone/col"), None);
21580        assert_eq!(kind(&e, "kept"), Some(StagedKind::Dataset));
21581        assert!(e.staged_children("gone").is_empty());
21582
21583        // And it must forget them for good: an index entry left behind would be
21584        // found by the *next* creation at that path and keep it pointing at the
21585        // position the rewind dropped, so the retry would stage a dataset no
21586        // lookup could see.
21587        // Staged behind an unrelated creation, so the retry lands at a
21588        // *different* position than the one the rewind dropped: an index entry
21589        // left behind would still name the old one.
21590        e.create_group("other").unwrap();
21591        e.stage_created_dataset("other/col", i32_dataset(&[3]))
21592            .unwrap();
21593        e.create_group("gone").unwrap();
21594        e.stage_created_dataset("gone/col", i32_dataset(&[2]))
21595            .unwrap();
21596        assert_eq!(kind(&e, "gone"), Some(StagedKind::Group));
21597        assert_eq!(kind(&e, "gone/col"), Some(StagedKind::Dataset));
21598        assert_eq!(
21599            e.staged_dataset_meta("gone/col").unwrap().dimensions,
21600            vec![1]
21601        );
21602        e.commit().unwrap();
21603    }
21604
21605    #[test]
21606    fn an_edit_that_changes_staged_work_is_refused_inside_a_batch() {
21607        let dir = tempdir().unwrap();
21608        let mut e = open_session(&dir.path().join("q.h5"));
21609        e.stage_created_dataset("col", i32_dataset(&[1, 2]))
21610            .unwrap();
21611        // `rewind` undoes a batch by truncating, which is exact only while
21612        // staging appends. Both operations that do otherwise say so here rather
21613        // than leaving half of themselves behind in a refused batch.
21614        let folded: Result<(), Error> = e.stage_atomically(|s| {
21615            let mut b = AppendBuilder::new();
21616            b.append_i32(&[3]);
21617            s.stage_dataset_append_pending("col", b)
21618        });
21619        assert!(matches!(folded, Err(Error::EditUnsupported(_))));
21620        let withdrawn: Result<(), Error> = e.stage_atomically(|s| s.delete("col"));
21621        assert!(matches!(withdrawn, Err(Error::EditUnsupported(_))));
21622        assert_eq!(e.staged_dataset_meta("col").unwrap().dimensions, vec![2]);
21623    }
21624}
21625
21626#[cfg(test)]
21627mod object_header_wrap_tests {
21628    use super::*;
21629
21630    /// A region whose messages cannot be walked is reported, not asserted away.
21631    ///
21632    /// The `debug_assert!(false)` this replaced split the behavior by build
21633    /// profile: a test build panicked, and a release build wrote the header with
21634    /// no Attribute Info message — the zero-`num_attrs` defect the normalization
21635    /// exists to prevent. Asserted as an `Err` because that is the one answer
21636    /// both profiles can give.
21637    #[test]
21638    fn an_unwalkable_region_is_refused_rather_than_wrapped() {
21639        // One version 2 header message — type byte, 2-byte size, flags byte —
21640        // whose size field claims far more body than the region holds.
21641        let mut region = vec![0x0Cu8]; // Attribute
21642        region.extend_from_slice(&0xFFFFu16.to_le_bytes());
21643        region.push(0); // flags
21644        region.extend_from_slice(&[0u8; 4]); // a body far shorter than declared
21645
21646        let err = build_v2_object_header(&OhRegion::new(region, OhHeaderProps::PLAIN)).unwrap_err();
21647        assert!(
21648            matches!(err, Error::EditUnsupported(_)),
21649            "an unwalkable region gave {err:?}"
21650        );
21651    }
21652
21653    /// The walkable case still normalizes: a header carrying inline attributes
21654    /// comes back with the Attribute Info message that declares their count.
21655    #[test]
21656    fn a_walkable_region_still_gains_its_attribute_info() {
21657        let body = [0u8; 8];
21658        let mut region = vec![0x0Cu8]; // Attribute
21659        region.extend_from_slice(&(body.len() as u16).to_le_bytes());
21660        region.push(0); // flags
21661        region.extend_from_slice(&body);
21662
21663        let oh =
21664            build_v2_object_header(&OhRegion::new(region.clone(), OhHeaderProps::PLAIN)).unwrap();
21665        assert_eq!(&oh[..4], b"OHDR");
21666        assert!(
21667            oh.len() > 8 + region.len() + 4,
21668            "the wrapped header did not grow by an Attribute Info message"
21669        );
21670    }
21671}